> 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.
This appears to come from dressing up like Elton John in a feather suit and hiring a marketing team.
Is there something in this website that feels unnecessary? It seems like a good format of sharing high quality information.
This looks like a full bug into a complete root escalation of a kernel. That's hard to do and deserving of praise. The fact that we have a writeup organized like this is awesome.
-------
This is sort of the expert level stuff that I thought HackerNews would most enjoy.
CVE numbers are for boring professionals.
Why? This is a better resource in every way: https://cgit.freebsd.org/src/commit/?id=000d5b52c19ff3858a6f...
It details the actual problem instead of showing off tired stack exploit tricks.
I mean that is the whole point of a NAS OS. It gives you a GUI and you don't have to worry about the rest.
I don't understand why you're being so defensive about this.
git log -S suggests 4cd93df95e697942adf0ff038fc8f357cbb07cf9, which looks more likely: https://cgit.freebsd.org/src/commit/?id=4cd93df95e697942adf0... - though not to say you don't want the later commit too. I'm sure you do.
Case in point: what's "tired" about the stack exploitation techniques they're using here?
And, while you're not right, even stipulating that you were, what would that matter? How is anyone better off with less explanation of a vulnerability?
These complaints aren't about what's better or worse for the user community; they're about people trying to put vulnerability researchers in their place.
I'm more interested in the why than the how.
I suppose people with different overall goals will see that differently.
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 FreeBSD Security Team published FreeBSD-SA-26:18.setcred on 2026-05-21, and patches have been issued for all currently supported branches. Users of 14.3, 14.4 and 15.0 should update to 14.3-RELEASE-p14, 14.4-RELEASE-p5 or 15.0-RELEASE-p9 respectively.
On FreeBSD 15.0 the surrounding code differed enough from 14.4 that the chain primitives developed here did not lift the overflow into a working LPE; on that branch the bug remained a kernel panic triggered by any unprivileged user. 15.0 is now patched as well.
01 / LPE - SMAP & SMEP enabled
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
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.
FreeBSD-SA-26:18.setcred was published on 2026-05-21 and patches have been issued for every currently supported branch. If your system is at or above the patchlevel listed below, you are not affected.
| Patched (update available) | FreeBSD 14.3-RELEASE β fixed in 14.3-RELEASE-p14 (2026-05-20)FreeBSD 14.4-RELEASE β fixed in 14.4-RELEASE-p5 (2026-05-20)FreeBSD 15.0-RELEASE β fixed in 15.0-RELEASE-p9 (2026-05-20)FreeBSD stable/14 β fixed in tree (2026-05-20) FreeBSD stable/15 β fixed in tree (2026-01-06) |
| Vulnerable + exploitable if unpatched | Any 14.3 / 14.4 system below the patchlevels listed above (working LPE under SMAP & SMEP) |
| Vulnerable, panic only if unpatched | Any 15.0 system below 15.0-RELEASE-p9 (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 affected | FreeBSD main (fixed in commit 000d5b5, 2025-11-27)FreeBSD 13.x and earlier ( setcred(2) not present) |
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).
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.
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.
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.
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.
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.
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).
The thread has effective root for VFS operations. From here, installing persistence or spawning a privileged shell is a routine post-exploitation step.
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.
Patched. The FreeBSD Security Team published FreeBSD-SA-26:18.setcred on 2026-05-21 with the assigned identifier CVE-2026-45250. Patches landed across all supported branches on 2026-05-20.
| stable/15 | 2026-01-06 13:34:30 UTC (15.0-STABLE) |
| releng/15.0 | 2026-05-20 19:39:28 UTC (15.0-RELEASE-p9) |
| stable/14 | 2026-05-20 19:37:54 UTC (14.4-STABLE) |
| releng/14.4 | 2026-05-20 19:39:54 UTC (14.4-RELEASE-p5) |
| releng/14.3 | 2026-05-20 19:40:32 UTC (14.3-RELEASE-p14) |
The underlying fix is the main-branch commit 000d5b52c19ff3858a6f0cbb405d47713c4267a4 from 2025-11-27 ("setcred(2): Fix a panic on too many groups from latest commit"), which 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 original commit message does not mention the stack overflow; the fix appears to be an unintentional side effect of the refactoring. The Security Team has now backported it to every supported branch.
Use freebsd-update fetch install on a binary installation, or rebuild from the SA's source patch on a custom kernel. Reboot is required. There is no clean userland mitigation for unpatched systems: 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.