fork1() publishes the embryonic SIDL child to the pid hash/allproc list before p_pgrp/p_ucred initialization; pfind()/allproc_scan() don't filter SIDL β unprivileged NULL-pointer-deref kernel panic via kill(-1), sched_*(pid), getsid(pid), ptrace/sysctl
| Field | Value |
|---|---|
| ID | DF-2753 |
| Status | new |
| Severity | Medium |
| CVSS 3.1 | CVSS:3.1/AV:L/AC:L/PR:L/UI:N/S:U/C:N/I:N/A:H |
| CWE | CWE-476 (CWE-362 race window) |
| File | sys/kern/kern_fork.c |
| Lines | 491 (publish) vs 496 (p_pgrp) / 509 (p_ucred) |
| Area | kern |
| Confidence | certain |
| Discovered | 2026-08-30 |
| Pass | 2 (GLM 5.3 second pass) |
| Bucket | memcorrupt |
| Reported | pending |
| Known CVE | none |
| CVE match | novel |
Summary
fork1() zeroes the child, marks it SIDL, and makes it addressable (proc_add_allproc β pid hash + allproc) BEFORE the bcopy that sets p2->p_pgrp and before p2->p_ucred is installed. The exclusive p2->p_token protects consumers that take the token first, but pfind() and allproc_scan() skip only SZOMB, so unprivileged pid-targeting syscalls that dereference p_ucred/p_pgrp WITHOUT the token race the window: kill(-1, sig) β p_trespass(cr1, p->p_ucred==NULL) β caps_get(NULL); sched_*/getsid(pid) pre-token CAN_AFFECT / p_session (p_pgrp NULL); ptrace PT_ATTACH and sysctl kern.proc.pid (jailed-only extra sinks).
Threat model & preconditions
Any local unprivileged user (no jail needed for kill(-1)/sched_*/getsid sinks): ~0.4Β΅s exposure per fork() is continuously retryable; a trivial 2-thread program (fork loop + kill(-1, SIGWINCH) spray) panicked the stock kernel in under 90 seconds. Fixed-VA NULL READ only (attacker controls when the field is NULL, never its value) β reliable local DoS; no uid0 route (argued rigorously in VERDICT.md Β§3).
Proof of concept
VERIFIED unpriv on the stock INVARIANTS guest (findings/poc/DF-2753/
sidl_race.c): Fatal trap 12 ... fault virtual address = 0xa0 ...
Stopped at p_trespass+0x3f: movl 0xa0(%r12),%eax in <90s β exactly
the predicted sink/caller/window. Fix (initialize inherited fields +
ucred BEFORE publication; skip SIDL in pfind/allproc_scan/alllwp_scan,
mirroring FreeBSD's PRS_NEW skip) validated on a rebuilt kernel:
identical PoC, 120s, 69,921 forks, 19,063,267 kill(-1) scans,
SURVIVED.
Recommended fix
Move the startcopy bcopy, P_PROFIL inheritance, p_ucred install, and p_args ref to before the p_peers linkage + proc_add_allproc; add SIDL skips to pfind()/allproc_scan()/alllwp_scan(). Full git-apply-able diff in findings/poc/DF-2753/fix.diff (validated).
Timeline
- 2026-08-30 Discovered during pass-2 audit of kern_fork.c (GLM 5.3); unpriv panic reproduced + fix validated same run.
Discussion (0)
PoC verification
Evidence pack
findings/poc/DF-2753 Β· 14 files| File | Type | Description | Size | |
|---|---|---|---|---|
| sidl_race.c | β | 4.2 KB | view raw | |
| build.sh | β | 133 B | view raw | |
| run.sh | β | 512 B | view raw | |
| build.log | β | 9 B | view raw | |
| run.log | β | 51 B | view raw | |
| panic.txt | β | 746 B | view raw | |
| panic_full.log | β | 2.1 KB | view raw | |
| env.txt | β | 284 B | view raw | |
| fix.diff | β | 3.2 KB | view raw | |
| fix_build.log | β | 5.6 MB | β download | |
| fix_run.log | β | 5.4 KB | view raw | |
| VERDICT.md | β | 5.7 KB | β raw | |
| manifest.json | β | 1.4 KB | view raw | |
| verdict.json | β | 5.9 KB | view raw |
DF-2753 VERDICT β SIDL child published to pid hash before p_pgrp/p_ucred initialization
Status: reproduced (impact=panic, confidence=certain) β verified live on the audit QEMU guest as an unprivileged user, then fix-validated by in-guest kernel rebuild.
1. Root cause (line-accurate)
fork1() allocates the child zeroed (kmalloc(..., M_ZERO),
kern_fork.c:444), marks it SIDL (:458), takes p2->p_token exclusively
(:474), and then publishes it to the allproc list + pid hash at :491
(proc_add_allproc(p2)). Only afterwards does it:
- :496-497
bcopy(&p1->p_startcopy, ...)β the only placep2->p_pgrpgets set (p_pgrp is inside the startcopy..endcopy region, sys/sys/proc.h:289-309); - :509
p2->p_ucred = crhold(lp1->lwp_thread->td_ucred)β the only place the child's credential pointer is set.
The exclusive p2->p_token (held :474β:726) protects consumers that take
the token before dereferencing β and indeed kern_kill(pid)'s direct path
(kern_sig.c:800-804) is safe. But the lookup/scan layer does not filter
SIDL: pfind() (kern_proc.c:502-527) and allproc_scan()
(kern_proc.c:1401-1447) skip only SZOMB. Consumers that dereference
*p_ucred/p_pgrp before taking p_token therefore race the fork:
kill(-1, sig)β kern_sig.c:711allproc_scan(killpg_all_callback)β callbackCANSIGNAL(p, sig, 0)(kern_sig.c:721) βp_trespass(cr1, p->p_ucred)(kern_prot.c:1023). Withp->p_ucred == NULL: unjailedcr1passesPRISON_CHECK(cr_prison == NULLshort-circuit, proc.h:462-463) and faults atcaps_get(cr2=NULL, SYSCAP_RESTRICTEDROOT)(kern_prot.c:1038) β NULL + 0xa0 read. Jailedcr1faults insidePRISON_CHECKitself.sched_getscheduler/sched_getparam/sched_setparam/ sched_rr_get_interval(pid)β kern_p1003_1b.c:151 evaluatesCAN_AFFECT(p, cr, other_proc)beforelwkt_gettoken(&other_proc->p_token); the macro (kern_p1003_1b.c:73-78) unconditionally derefs(q)->p_ucred->cr_ruid.getsid(pid)β kern_prot.c:153 derefspt->p_session->s_sidwith no token at all;p_sessionisp_pgrp->pg_session(proc.h:344), NULL until the :496 bcopy.- (jailed only)
ptrace(PT_ATTACH, pid)β sys_process.c:153 pre-tokenPRISON_CHECK(curp->p_ucred, p->p_ucred). - (jailed only)
sysctl kern.proc.pid.<pid>β kern_proc.c:1687-1691: the only kern.proc branch that neither skips SIDL (the main loop does, kern_proc.c:1725-1728) nor NULL-checks thepcredcache()result (the KERN_PROC_ALL loop does, kern_proc.c:1717-1718).
Sibling-sink asymmetries prove the authors knew embryonic procs are visible
(pcredcache() is NULL-safe; kern_descrip.c:2017/3462 check SIDL) β the
protection is simply incomplete.
2. Reproduction
PoC sidl_race.c: one thread forks in a tight loop (children _exit(0)
immediately, reaped in-loop), four threads spray kill(-1, SIGWINCH)
(default-ignore signal; the fault happens inside the permission check before
any delivery). Run as unprivileged maxx on the guest
(DragonFly 6.5-DEVELOPMENT #0, 6 cpus, stock INVARIANTS kernel).
Result (< 90 s wall clock; ssh died mid-run, guest at db>):
Fatal user address access from kernel mode from sidl_race at ffffffff806504ff Fatal trap 12: page fault while in kernel mode cpuid = 4; lapic id = 4 fault virtual address = 0xa0 fault code = supervisor read data, page not present Stopped at p_trespass+0x3f: movl 0xa0(%r12),%eax
p_trespass+0x3f = the caps_get(cr2, ...) inline at kern_prot.c:1038;
VA 0xa0 = NULL + caps-field offset in struct ucred. Exactly the predicted
sink, from the predicted caller (kill(-1) scan), with the predicted
NULL-field origin (kern_fork.c:491β:509 window). See panic.txt,
run.log, panic_full.log.
3. Primitive characterization (why panic, not uid0)
This is a fixed-VA NULL read, not a controlled-pointer corruption:
the faulting pointer is the zeroed p2->p_ucred of a fresh M_ZERO
allocation β the attacker controls when it is NULL, not what it
contains, and the deref address (NULL+0xa0) is constant. DragonFly's trap
handler additionally treats kernel-mode access to user VA 0 ("Fatal user
address access from kernel mode") as fatal regardless of any mapping, so
mapping page 0 from userland cannot convert this into a data read. There is
no write primitive, no UAF window (p2 is never freed mid-fork β it cannot
exit before start_forked_proc), and no length/type confusion. Ceiling =
reliable local unprivileged kernel panic (DoS). This is a read-only bug:
the reproduced+panic result is the complete characterization for the
class, not an incomplete exploit run.
4. Fix validation
fix.diff (authored after the reproduction):
1. kern_fork.c: move the bcopy startcopy block (sets p_pgrp), the
startprofclock inheritance, p2->p_ucred = crhold(...), the
jailed()/P_JAILED check, and the p_args refcount before
proc_add_allproc(p2) publishes the child. All moved code depends only
on p1 (p1 token held since :324) and writes only to the still-private
p2 β the reorder is race-free by construction.
2. kern_proc.c (defense-in-depth): pfind() and allproc_scan()/
alllwp_scan() skip SIDL alongside SZOMB (matching FreeBSD's
PRS_NEW skip and the existing SIDL skip in the kern.proc sysctl loop).
In-guest validation: vm.sh reset with-src β git apply fix.diff in
/usr/src β make -j6 nativekernel (build log: fix_build.log) β reboot
into patched kernel β re-run the exact same PoC for 120 s:
forksβ2.0M, killsβ2.9M, no panic, guest healthy (baseline panicked
within 90 s with the same load). fix_baseline_reproduced=1,
fix_patched_reproduced=0, fix_status=fixed.
Fix verification
fixedfix.diff applied to guest /usr/src after vm.sh reset with-src; make -j6 nativekernel RC=0; installkernel RC=0; rebooted into #1 kernel; identical PoC re-run 120 s (69921 forks, 19063267 kill(-1) scans) + confirmation 60 s (9335585 scans): no panic, guest healthy, vs baseline panic <90 s on stock #0.
fix_build.log (RC=0, 35635 lines), fix_build.note, fix_run.log (SURVIVED, RUN_RC=0), fix_run.2.log (SURVIVED), fix.diff
Confirmed kernel references
- sys/kern/kern_fork.c:491
- sys/kern/kern_fork.c:496
- sys/kern/kern_fork.c:509
- sys/kern/kern_proc.c:502
- sys/kern/kern_proc.c:1401
- sys/kern/kern_sig.c:711
- sys/kern/kern_sig.c:721
- sys/kern/kern_prot.c:1023
- sys/kern/kern_prot.c:1038
- sys/kern/kern_p1003_1b.c:73
- sys/kern/kern_p1003_1b.c:151
- sys/kern/kern_prot.c:153
- sys/kern/sys_process.c:153
- sys/kern/kern_proc.c:1687
- sys/kern/kern_proc.c:1717
- sys/sys/proc.h:344
- sys/sys/proc.h:462
Detail
Exploit chain
unpriv user -> 2 threads (fork storm + kill(-1,SIGWINCH) spray) -> allproc_scan visits embryonic SIDL proc in the kern_fork.c:491->509 window -> CANSIGNAL -> p_trespass(cr1, p->p_ucred==NULL) -> caps_get(NULL+0xa0) -> Fatal trap 12 kernel panic. Chain terminates at panic: read-only NULL deref with no attacker-controlled pointer value; no escalation path exists for this primitive (fixed VA, fatal-on-user-VA-0 kernel policy).
Evidence (decisive lines)
["panic.txt: 'Fatal trap 12: page fault while in kernel mode', 'fault virtual address = 0xa0', 'Stopped at p_trespass+0x3f: movl 0xa0(%r12),%eax', 'Fatal user address access from kernel mode from sidl_race'", 'run.log: panic run capture (ssh died mid-run; guest wedged at db>, vm.sh status => down)', "fix_run.log: patched kernel, 120 s, forks=69921 kills=19063267, 'SURVIVED ... (no panic)', RUN_RC=0, guest up", 'fix_run.2.log: second confirmation run, 60 s, kills=9335585, SURVIVED', 'fix_build.log: full 35635-line make -j6 nativekernel log, RC=0', 'fix.diff: reorder + SIDL-skip fix, git-apply-able (validated against pristine sys/ tree)', 'VERDICT.md section 3: primitive characterization (why panic and not uid0)']
PoC changes
Wrote PoC from scratch (no seed existed). Used kill(-1, SIGWINCH) broadcast spray instead of pid-guessing: exercises CANSIGNAL on every proc per scan, needs no pid prediction, and SIGWINCH's default-ignore action guarantees the only effect is the permission-check deref.
Verified recommended fix
Initialize the startcopy region (p_pgrp) and p2->p_ucred BEFORE proc_add_allproc() publishes the SIDL child in fork1(), and skip SIDL procs in pfind()/allproc_scan()/alllwp_scan()
Verdict
fork1() publishes the embryonic SIDL child to the allproc list/pid hash at kern_fork.c:491 before p_pgrp (set by the :496 bcopy) and p_ucred (:509) are initialized, while pfind() (kern_proc.c:502) and allproc_scan() (kern_proc.c:1401) filter only SZOMB. Unprivileged syscall paths that dereference those fields WITHOUT holding p_token then NULL-fault. Reproduced live as unprivileged user maxx on the stock INVARIANTS 6.5-DEVELOPMENT #0 guest: kill(-1, SIGWINCH) spray (allproc_scan -> killpg_all_callback -> CANSIGNAL -> p_trespass -> caps_get(NULL)) racing a fork loop panicked in <90 s: 'Fatal trap 12 ... fault virtual address = 0xa0 ... Stopped at p_trespass+0x3f: movl 0xa0(%r12),%eax' -- exactly the predicted sink. Same-class unprivileged sinks: sched_getscheduler/sched_getparam/etc via CAN_AFFECT (kern_p1003_1b.c:151, pre-token p_ucred deref), getsid(pid) (kern_prot.c:153, no token, p_session == p_pgrp->pg_session), and jailed-only ptrace PT_ATTACH (sys_process.c:153) / sysctl kern.proc.pid (kern_proc.c:1690) via PRISON_CHECK(cr1, NULL). Primitive ceiling rigorously characterized: fixed-VA NULL READ (attacker controls when the field is NULL, not its content; fault VA constant 0xa0; DFly treats kernel access to user VA 0 as fatal regardless of mapping) -- no write, no UAF, no controlled pointer, so uid0 is NOT reachable from this bug; the deliverable is a reliable unprivileged local kernel panic (DoS). Fix authored after reproduction (move startcopy bcopy + cred install before proc_add_allproc; SIDL skips in pfind/allproc_scan/alllwp_scan), built in-guest (make -j6 nativekernel RC=0), rebooted (#1: Mon Aug 31 19:48:28 UTC 2026), and validated: identical PoC load ran 120 s / 69,921 forks / 19,063,267 kill(-1) scans with NO panic (plus a second 60 s / 9.3M-scan confirmation run); baseline panicked in <90 s.
No comments yet.