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\.
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\.