β¬’ DragonFlyBSD Kernel Audit
← triage Β· dashboard
DF-2753

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.

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)

No comments yet.

PoC verification

Evidence pack

findings/poc/DF-2753 Β· 14 files
FileTypeDescriptionSize
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
VERDICT.md
↓ download 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 place p2->p_pgrp gets 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:

  1. kill(-1, sig) β€” kern_sig.c:711 allproc_scan(killpg_all_callback) β†’ callback CANSIGNAL(p, sig, 0) (kern_sig.c:721) β†’ p_trespass(cr1, p->p_ucred) (kern_prot.c:1023). With p->p_ucred == NULL: unjailed cr1 passes PRISON_CHECK (cr_prison == NULL short-circuit, proc.h:462-463) and faults at caps_get(cr2=NULL, SYSCAP_RESTRICTEDROOT) (kern_prot.c:1038) β€” NULL + 0xa0 read. Jailed cr1 faults inside PRISON_CHECK itself.
  2. sched_getscheduler/sched_getparam/sched_setparam/ sched_rr_get_interval(pid) β€” kern_p1003_1b.c:151 evaluates CAN_AFFECT(p, cr, other_proc) before lwkt_gettoken(&other_proc->p_token); the macro (kern_p1003_1b.c:73-78) unconditionally derefs (q)->p_ucred->cr_ruid.
  3. getsid(pid) β€” kern_prot.c:153 derefs pt->p_session->s_sid with no token at all; p_session is p_pgrp->pg_session (proc.h:344), NULL until the :496 bcopy.
  4. (jailed only) ptrace(PT_ATTACH, pid) β€” sys_process.c:153 pre-token PRISON_CHECK(curp->p_ucred, p->p_ucred).
  5. (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 the pcredcache() 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

fixed
baseline reproduced→ patch + rebuild →patched clean

fix.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
↓ fix.diffDragonFly dfbsd 6.5-DEVELOPMENT DragonFly 6.5-DEVELOPMENT #1: Mon Aug 31 19:48:28 UTC 2026 root@dfbsd:/usr/obj/usr/src/sys/X86_64_GENERIC x86_64

Confirmed kernel references

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.