FatGid - FreeBSD 14.x kernel LPE

Lobsters Hottest News

Summary

A kernel stack buffer overflow in FreeBSD 14.x's setcred(2) system call allows any unprivileged local user to escalate privileges to root, even with SMAP/SMEP enabled. The bug is not yet fixed in stable branches.

<p><a href="https://lobste.rs/s/yy3xax/fatgid_freebsd_14_x_kernel_lpe">Comments</a></p>
Original Article
View Cached Full Text

Cached at: 05/21/26, 02:15 PM

# FatGid - FreeBSD 14.x kernel LPE Source: [https://fatgid.io/](https://fatgid.io/) ## Summary A kernel stack buffer overflow exists in the`setcred\(2\)`system call introduced in FreeBSD 14\.x\. The overflow occurs before any privilege check, allowing any unprivileged local user to trigger arbitrary behaviour ranging from a kernel panic to full local privilege escalation\. Working LPE exploits against an amd64 GENERIC kernel both without SMAP/SMEP and with SMAP/SMEP enabled have been developed and are described below\. The SMAP/SMEP\-safe variant requires only that`zfs\.ko`be loaded \-\- the case on every FreeBSD installation with a ZFS pool\. The root cause is a single*sizeof*type error in`kern\_setcred\_copyin\_supp\_groups\(\)`\(`sys/kern/kern\_prot\.c`\)\. The bug was silently fixed in the`main`branch on 2025\-11\-27 \(commit`000d5b52c19ff3858a6f0cbb405d47713c4267a4`\) as a side effect of a broader function refactoring\. The fix has**not**been backported to`stable/14`or`releng/14\.4`\. FreeBSD 14\.4\-RELEASE remains vulnerable\. FreeBSD 15\.0 still carries the`sizeof\(\*groups\)`typo and is therefore vulnerable, but the surrounding code differs enough from 14\.4 that the chain primitives developed here do not lift the overflow into a working LPE on that branch\. On 15\.0 the bug remains a kernel panic triggered by any unprivileged user\. ## Impact 01 / LPE \- SMAP & SMEP enabled ### Modern\-kernel root, no info\-leak A single`setcred\(2\)`syscall lifts an unprivileged shell to uid=0 on a kernel with SMAP and SMEP enabled\. No kernel info\-leak primitive is required\. This is the headline result\. 02 / LPE \- no mitigations ### Legacy\-kernel root Same single syscall, on a kernel without SMAP/SMEP\. Useful as a stepping stone and as a reference for the`amd64\_syscall\+0x155`chain primitive that both techniques share\. ![FatGid exploit running on FreeBSD 14.4-RELEASE-p3 amd64: build, install, trigger, root shell.](https://fatgid.io/assets/poc.png)Full chain on FreeBSD 14\.4\-RELEASE\-p3 amd64 \(current patchset\)\. Unprivileged user, build, run, root shell\. ## Am I affected? Vulnerable \+ exploitableFreeBSD 14\.4\-RELEASE \(confirmed\) FreeBSD stable/14Vulnerable, panic onlyFreeBSD 15\.0\(same source\-level typo, but the surrounding code differs enough from 14\.4 that no chain primitive we know of lifts the overflow into a working LPE\)Not affectedFreeBSD main \(fixed in commit`000d5b5`, 2025\-11\-27\) FreeBSD 13\.x and earlier \(`setcred\(2\)`not present\) ## Vulnerability details File:`sys/kern/kern\_prot\.c` Function:`kern\_setcred\_copyin\_supp\_groups\(\)` Lines: 528\-533 The function signature uses a double pointer for the`groups`argument: ``` static int kern_setcred_copyin_supp_groups(struct setcred *const wcred, const u_int flags, gid_t *const smallgroups, gid_t **const groups) ``` Because`groups`has type`gid\_t \*\*`, the expression`sizeof\(\*groups\)`evaluates to`sizeof\(gid\_t \*\) == 8`on LP64, rather than the intended`sizeof\(gid\_t\) == 4`\. This sizeof expression is used in two places: ``` /* line 528-530: allocation */ *groups = wcred->sc_supp_groups_nb < CRED_SMALLGROUPS_NB ? smallgroups : malloc((wcred->sc_supp_groups_nb + 1) * sizeof(*groups), M_TEMP, M_WAITOK); /* sizeof(*groups) == 8 */ /* line 532-533: copyin */ error = copyin(wcred->sc_supp_groups, *groups + 1, wcred->sc_supp_groups_nb * sizeof(*groups)); /* sizeof(*groups) == 8 */ ``` The allocation on the heap path is 2× oversized, which is safe\. However, for the stack path \(when`sc\_supp\_groups\_nb < CRED\_SMALLGROUPS\_NB == 16`\),`\*groups`is set to`smallgroups`, a`gid\_t\[CRED\_SMALLGROUPS\_NB\]`array declared as a local variable in the caller`user\_setcred\(\)`: ``` gid_t smallgroups[CRED_SMALLGROUPS_NB]; /* 16 * 4 = 64 bytes */ ``` The copyin destination is`\*groups \+ 1 == &smallgroups\[1\]`, which leaves`15 \* 4 == 60`bytes of usable space\. The copyin copies`sc\_supp\_groups\_nb \* sizeof\(\*groups\) == sc\_supp\_groups\_nb \* 8`bytes\. With the maximum stack\-path value of`sc\_supp\_groups\_nb == 15`: ``` Bytes written: 15 * 8 = 120 Buffer capacity: 15 * 4 = 60 Overflow: 60 bytes past the end of smallgroups[] ``` The overflow is written with fully attacker\-controlled data from user space \(`wcred\-\>sc\_supp\_groups`points to an attacker\-supplied buffer\)\. ### Trigger path and privilege\-check ordering The overflow happens in`kern\_setcred\_copyin\_supp\_groups\(\)`, which is called from`user\_setcred\(\)`at line 604 \-\-**before the privilege check**\. The privilege check \(`priv\_check\_cred\(PRIV\_CRED\_SETCRED\)`\) does not occur until`kern\_setcred\(\)`is called at line 623, and within that function at line 813\. Any local user can trigger the overflow by issuing: ``` setcred(SETCREDF_SUPP_GROUPS, &wcred, sizeof(wcred)) ``` with`wcred\.sc\_supp\_groups\_nb == 15`and`wcred\.sc\_supp\_groups`pointing to a`15 \* 8 == 120`\-byte user\-space buffer\. ### LPE technique \(no SMAP, no SMEP\) The 60\-byte overflow corrupts every callee\-saved register slot in`user\_setcred\(\)`'s prologue except saved RBP\. Compiler ordering on 14\.4 GENERIC places the corruption window at`\[rbp \- 0x40 \.\. \-0x05\]`: ``` buf[60..67] mac.m_buflen buf[68..75] mac.m_string buf[76..83] td pointer spill <- controls kern_setcred(td=...) buf[84..91] saved rbx buf[92..99] saved r12 <- propagates up the stack buf[100..107] saved r13 buf[108..115] saved r14 buf[116..119] low 32 bits of saved r15 ``` The crucial observation is that`sys\_setcred\(\)`'s prologue saves only`rbp/r14/rbx`\-\- it does**not**save`r12`\. The corrupted`r12`popped by`user\_setcred\(\)`'s epilogue therefore propagates unchanged through`sys\_setcred\(\)`up to`amd64\_syscall\(\)`, which at`\+0x155`uses it as if it were the live`td\_proc`pointer: ``` ffffffff8105b6e5: mov rcx, [r12 + 0x3f8] ; r12 fully controlled ffffffff8105b6ed: mov rdi, rbx ; rdi = real curthread ffffffff8105b6f0: mov esi, eax ; esi = setcred retval ffffffff8105b6f2: call [rcx + 0xc8] ; INDIRECT CALL ``` This is a two\-level indirect call entirely controlled by the attacker:`\*\(r12\+0x3f8\)`supplies`rcx`, and`\*\(rcx\+0xc8\)`is the call target\. Without SMAP, the kernel happily dereferences user\-mode pointers, so both indirections can be satisfied by fake structures placed in user memory\. Without SMEP, the indirect call may target user\-space code\. The published no\-SMAP exploit constructs a fake`struct sysentvec`whose`sv\_set\_syscall\_retval`slot \(offset`0xc8`\) points to user\-space shellcode\. The shellcode reads`gs:\[0\]`for the real curthread, restores`r12`, then zeroes`cr\_uid/cr\_ruid/cr\_svuid/cr\_rgid/cr\_svgid`on the real`td\_ucred`and returns\. ### LPE technique \(SMAP/SMEP, no info\-leak\) The chain primitive at`amd64\_syscall\+0x155`reaches its target with`rcx = K1`\(an attacker\-chosen 8\-byte value\)\. If the target gadget writes`rcx \+ 1`to`td\-\>td\_ucred`, the current thread's credential pointer is now set to any address we choose \-\- and if that address happens to lie inside a kernel buffer we control \(a heap\-resident`pargs`slab\), the fake credential we planted there immediately takes effect\. The gadget lives inside`zfs\.ko`, in`ZSTD\_initCStream\_advanced`: ``` push rbp; mov rbp, rsp push r15; push r14; push rbx sub rsp, 0x38 mov rbx, rdx mov r14, rsi mov r15, rdi ; r15 = arg1 = real_td (from chain) mov rax, [rip + __stack_chk_guard] mov [rbp - 0x20], rax ; canary spill xor eax, eax cmp dword ptr [rbp + 0x2c], 0 lea rdx, [rcx + 1] ; rdx = K1 + 1 cmovne rax, rdx test rcx, rcx mov dword ptr [rdi + 0x430], 0 cmovne rax, rdx ; rcx != 0 (always) -> rax = K1 + 1 mov qword ptr [rdi + 0x180], rax ; *** td->td_ucred = K1+1 *** ``` The two`cmovne`instructions both fire whenever`rcx \!= 0`\. The function continues with stores into`td\+0x10\.\.0x3c`which corrupt`TAILQ\_ENTRY`scheduler\-link fields with garbage drawn from`amd64\_syscall`'s stack frame, then performs its canary check and returns\. Empirically the corruption is survivable until the thread next reaches the scheduler\. ### Fake ucred placement \(parent's pargs slab\) `setproctitle\(2\)`is exposed to unprivileged users; the kernel allocates a 256\-byte slot in the PARGS UMA zone and copies up to 244 user bytes verbatim into the`ar\_args`field\. The parent process's pargs slab`P\_base`becomes our`fake\_ucred`: ``` slot offset field value +0x20 cr_ref 0x7fffffff (high; defeats crfree) +0x28 cr_users 0x7fffffff +0x2c cr_flags 0 +0x60 cr_uid 0 +0x64 cr_ruid 0 +0x68 cr_svuid 0 +0x6c cr_ngroups 1 +0x88 cr_prison &prison0 (real kernel symbol) +0xb0 cr_groups &prison0 (TRICK: see note) +0xb8 cr_agroups 1 +0xc7 call target ZSTD_initCStream_advanced ``` **cr\_groups trick:**setting`cr\_groups = &prison0`makes`cred\-\>cr\_groups\[0\]`read the first 4 bytes of`struct prison`, which is`pr\_id = 0 = wheel gid`\. This lets the in\-kernel`groupmember\(0, cred\)`check inside the VFS chmod path return 1 without a NULL dereference\. ### K1 placement \(child's pargs slab\) The chain primitive reads`K1`via`mov rcx, \[r12 \+ 0x3f8\]`; we want`K1 = P\_base \- 1`so that`K1\+1 = P\_base`is our fake ucred\. We can't write`P\_base \- 1`back into the parent's slab \(it already contains fake ucred fields\), and we can't use the td\_name trick: UMA\-heap addresses always have a NUL byte at byte offset 4 of`P\_base \- 1`, which truncates`thr\_set\_name`'s`strlcpy`\. Solution: fork a CHILD process that does its own`setproctitle`with the qword`P\_base \- 1`placed at offset`0xd0`of its own pargs\. The chain then sets`r12 = C\_base \+ 0xd0 \- 0x3f8`, so that`\[r12 \+ 0x3f8\] = qword at \(C\_base \+ 0xd0\) = K1`\. The parent must not exit and must not setproctitle again before the child triggers the chain \-\- otherwise pargs is freed and`P\_base`may be reused\. ### Self\-resolving kernel symbols The exploit resolves`ZSTD\_initCStream\_advanced`and`&prison0`at runtime through the unprivileged`kldnext\(2\)`\+`kldsym\(2\)`interface, so a single binary is portable across the entire 14\.4 patchset\. The kernel image itself contains a different ZSTD library with incompatible struct offsets, so for the ZSTD lookup we skip`fileid=1`\(kernel\) and pick the symbol from a loaded module \(zfs\.ko on a typical server\)\. ### Post\-exploit The thread has effective root for VFS operations\. From here, installing persistence or spawning a privileged shell is a routine post\-exploitation step\. ## Proof of concept Full source for the no\-SMAP and the SMAP/SMEP\-safe LPE, together with the supporting build pipeline, is publicly available: A typical run on a current FreeBSD 14\.4\-RELEASE\-p3 amd64 guest, as the unprivileged user, looks like the screenshot above\. ## Fix status The vulnerability was inadvertently fixed in the`main`branch on 2025\-11\-27 by commit`000d5b52c19ff3858a6f0cbb405d47713c4267a4`\(*"setcred\(2\): Fix a panic on too many groups from latest commit"*\)\. The commit refactored`kern\_setcred\_copyin\_supp\_groups\(\)`into`user\_setcred\_copyin\_supp\_groups\(\)`, changing the`groups`argument from`gid\_t \*\*`to a local`gid\_t \*`, and replacing both`sizeof\(\*groups\)`occurrences with`sizeof\(gid\_t\)`\. The commit message does not mention the stack overflow; the fix appears to be an unintentional side effect of the refactoring\. The fix has**not**been merged into`stable/14`or`releng/14\.4`\. FreeBSD 14\.4\-RELEASE and the current`stable/14`branch remain vulnerable as of the date of this report\. The FreeBSD Security Team has issued an advisory tracked as[FreeBSD\-SA\-26:18\.setcred](https://www.freebsd.org/security/advisories/FreeBSD-SA-26:18.setcred.asc), and the issue has been assigned[CVE\-2026\-45250](https://nvd.nist.gov/vuln/detail/CVE-2026-45250)\. ### Mitigation If you are running FreeBSD 14\.4\-RELEASE or stable/14, cherry\-pick commit`000d5b5`into your local kernel tree and rebuild\. There is no clean userland mitigation: restricting`setcred\(2\)`would break the FreeBSD\-native API for which it was added, and the SMAP/SMEP\-safe chain runs entirely in\-kernel so dropping`setuid`bits or disabling`kldload`doesn't help\.

Similar Articles

CVE-2026-45257: LPE in FreeBSD via kTLS-RX

Lobsters Hottest

A critical local privilege escalation vulnerability in FreeBSD (CVE-2026-45257) allows an unprivileged user to write arbitrary data into the page cache of any readable file, bypassing file permissions and flags, leading to full root compromise. The bug affects default installations of FreeBSD 13.0 and later via unsafe composition of sendfile, KTLS, and in-kernel AES-GCM decryption.

Dirtyfrag: Universal Linux LPE

Hacker News Top

A report titled 'Dirty Frag' details a universal Linux Local Privilege Escalation (LPE) vulnerability that allows root access on major distributions by chaining two kernel bugs. The disclosure notes that due to a broken embargo, no patches currently exist for this critical security issue.