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

sys_rtprio() NULL lwp dereference on processes in the fork (SIDL) window β€” unprivileged EFAULT read of &((struct lwp*)0)->lwp_rtprio; privileged raw kernel write to 0x198 panics the kernel

Field Value
ID DF-2791
Status new
Severity Low
CVSS 3.1 CVSS:3.1/AV:L/AC:H/PR:H/UI:N/S:U/C:N/I:N/A:H
CWE CWE-476 NULL Pointer Dereference
File sys/kern/kern_resource.c
Lines 704 (window kern_fork.c:491β†’848; pfind kern_proc.c:524)
Area kern
Confidence certain
Discovered 2026-08-31
Pass 2 (GLM 5.3 second pass)
Bucket memcorrupt
Reported pending
Known CVE none
CVE match novel

Summary

sys_rtprio() uses FIRST_LWP_IN_PROC(p) with no NULL check, unlike its sibling sys_lwp_rtprio(). Between proc_add_allproc() and the first lwp insertion in lwp_fork2 β€” a window containing vm_fork() and several M_WAITOK allocations β€” a process is on allproc with p_stat=SIDL and an empty lwp tree, and pfind() returns it (only SZOMB is skipped). With lp==NULL: RTP_LOOKUP copyout reads absolute 0x198 (EFAULT, contained by pcb_onfault); RTP_SET executes lp->lwp_rtprio=rtp β€” a raw kernel-mode store to unmappable 0x198 with pcb_onfault==NULL β†’ panic. Reachable in practice because LWKT drops all tokens on deschedule: an unprivileged sibling pthread churning mmap/munmap makes fork1's vm_fork block on the parent vm_map token mid-window, dropping p2->p_token.

Threat model & preconditions

Any local user can observe the NULL deref (RTP_LOOKUP EFAULT on a pid caught mid-fork); a privileged caller (root / SYSCAP_NOSCHED) racing RTP_SET against fork reliably panics. Unpriv callers cannot reach the write; no unpriv→root path (fixed, unmappable write address).

Proof of contest

VERIFIED on the stock guest (findings/poc/DF-2791/rtprio_sidl.c): 20k-entry forker + token-churning pthread + pid-predicting sprayer β†’ 3Γ— EFAULT in 105 syscalls as uid 1001; as root with RTP_SET the guest panicked <50s with Stopped at sys_rtprio+0x1b0: movl %eax,0x198(%rdx). Fix (NULL check + LWPHOLD, mirroring sys_lwp_rtprio) validated on a rebuilt kernel: EFAULT=0 over 1,015,185 probes, root race survived 65s+.

See findings/poc/DF-2791/fix.diff (validated).

References

  • DF-2753 (the same SIDL publication window, kern_fork.c side)

Timeline

  • 2026-08-31 Discovered during pass-2 audit of kern_resource.c (GLM 5.3); both legs reproduced + fix validated same run.

Discussion (0)

No comments yet.

PoC verification

Evidence pack

findings/poc/DF-2791 Β· 14 files
FileTypeDescriptionSize
rtprio_sidl.c β€” 5.6 KB view raw
build.sh β€” 92 B view raw
run.sh β€” 175 B view raw
run.log β€” 391 B view raw
panic.txt β€” 684 B view raw
panic_full_serial.log β€” 2.2 KB view raw
fixed_lookup.log β€” 144 B view raw
fixed_set.log β€” 169 B view raw
env.txt β€” 253 B view raw
fix.diff β€” 742 B view raw
README.md β€” 2.8 KB ↓ raw
VERDICT.md β€” 4.6 KB ↓ raw
manifest.json β€” 1.3 KB view raw
verdict.json β€” 4.4 KB view raw

DF-2791 β€” sys_rtprio() NULL lwp deref on a process in its fork (SIDL) window

What

sys_rtprio() (sys/kern/kern_resource.c:704) takes FIRST_LWP_IN_PROC(p) with no NULL check, unlike its sibling sys_lwp_rtprio() which checks every lookup. A process is observable with zero lwps between proc_add_allproc() (sys/kern/kern_fork.c:491) and the first lwp_rb_tree_RB_INSERT() in lwp_fork2() (sys/kern/kern_fork.c:848), and pfind() returns such processes (it only skips SZOMB β€” sys/kern/kern_proc.c:524). With lp == NULL:

  • RTP_LOOKUP β†’ copyout(&lp->lwp_rtprio, …) reads absolute address 0x198 inside copyout's pcb_onfault window β†’ converted to EFAULT (sys/platform/pc64/x86_64/trap.c:985-995). Observable by any unprivileged user.
  • RTP_SET (privileged only; unpriv is stopped at kern_resource.c:718-723) β†’ lp->lwp_rtprio = rtp is a raw kernel-mode write to 0x198 with pcb_onfault == NULL β†’ "Fatal user address access from kernel mode" β†’ panic.

Race window opener (fully unprivileged): fork1 holds p2->p_token across the window, but LWKT drops all of a thread's tokens when it deschedules (lwkt_relalltokens, sys/kern/lwkt_token.c:539-558). The window contains vm_fork(), which must acquire the parent's vm_map token; a pthread in the forking process churning mmap/munmap makes the forking thread block on that token mid-window, dropping p2->p_token long enough for another CPU's rtprio() to acquire it and observe the empty lwp tree.

Build

cc -O2 -pthread -o rtprio_sidl rtprio_sidl.c

Run

Unprivileged EFAULT leg (harmless, returns 0 on first hits):

./rtprio_sidl lookup

Privileged panic leg (root; kills the guest within ~a minute):

./rtprio_sidl set

Expected output

lookup (unprivileged, baseline kernel):

[hit] pid <N> EFAULT after 105 syscalls (NULL lwp deref inside copyout)
...
REPRODUCED: rtprio(RTP_LOOKUP) hit the SIDL zero-lwp window

set (root, baseline kernel) β€” serial console:

Fatal user address access from kernel mode from rtprio_sidl at ffffffff806543d0
Fatal trap 12: page fault while in kernel mode
fault virtual address  = 0x198
fault code             = supervisor write data, page not present
Stopped at sys_rtprio+0x1b0: movl %eax,0x198(%rdx)

With fix.diff applied (patched kernel): lookup reports EFAULT=0 over >10^6 probes and returns 1 ("NOT reproduced"); set-mode survives indefinitely.

Fix validation (performed 2026-09-01)

  • baseline kernel #0: lookup β†’ 3 EFAULT in 105 syscalls; set β†’ panic (sys_rtprio+0x1b0 movl %eax,0x198(%rdx), see panic.txt).
  • fix.diff applied to guest /usr/src, make nativekernel && make installkernel, reboot into kernel #1:
  • lookup β†’ syscalls=1015185 ok=261860 EFAULT=0 … NOT reproduced
  • set (root, 65 s) β†’ guest alive, no panic.
VERDICT.md
↓ download raw

DF-2791 VERDICT

Finding: sys_rtprio() missing FIRST_LWP_IN_PROC(p) NULL check (sys/kern/kern_resource.c:704) β€” NULL lwp pointer dereference against a process caught in its fork (SIDL) window.

Status: REPRODUCED (both legs). Severity Low (privileged panic / unprivileged EFAULT).

Root cause chain (all source-verified)

  1. pfind() returns any non-SZOMB process on allproc (sys/kern/kern_proc.c:522-531). A freshly-forked process is inserted into allproc at proc_add_allproc() (sys/kern/kern_fork.c:491) with p_stat = SIDL (kern_fork.c:458) and an empty p_lwp_tree (RB_INIT at kern_fork.c:468).
  2. The first lwp enters the tree only inside lwp_fork2() (kern_fork.c:848), after vm_fork() (kern_fork.c:675) and several M_WAITOK allocations. p_stat becomes SACTIVE even later, in start_forked_proc() (kern_fork.c:950). So SIDL β‰  zero-lwp alone, but SIDL ∧ pre-848 ⇔ zero lwp, and pfind does not filter SIDL.
  3. sys_rtprio() (kern_resource.c:701-704) takes p->p_token and dereferences FIRST_LWP_IN_PROC(p) with no NULL check: * RTP_LOOKUP: copyout(&lp->lwp_rtprio, uap->rtp, …) (:707) β€” reads address 0x198. copyout executes under pcb_onfault, and trap_pfault() for a kernel-mode fault on a user-range address returns to the onfault label when one is registered (sys/platform/pc64/x86_64/trap.c:985-995) β†’ syscall returns EFAULT. * RTP_SET: lp->lwp_rtprio = rtp; (:748) β€” a plain 4-byte kernel store to 0x198; no onfault protection; kernel-mode fault on a user-range address with pcb_onfault == NULL is fatal (trap.c:917-926) β†’ panic.
  4. Reachability of the token: fork1 holds p2->p_token across the whole window, but DragonFly LWKT tokens are all released when the owning thread deschedules (lwkt_relalltokens, sys/kern/lwkt_token.c:539-558 β€” the same property the comment at kern_resource.c:602-606 relies on). vm_fork() must acquire the parent's vm_map token; a sibling pthread churning mmap/munmap makes the forking thread block on that token in the middle of the window, dropping p2->p_token. The sprayer (a second process predicting the sequential pids) then acquires p->p_token, sees the empty tree, and hits the NULL.
  5. Privilege gates: * RTP_LOOKUP has no credential check before the copyout β€” any local user can trigger the NULL read (result: EFAULT only). * RTP_SET for unprivileged callers is stopped at kern_resource.c:718-723 ("can't set someone else's" when caps_priv_check(NOSCHED) fails and uap->pid != 0; pid==0 is curproc which always has an lwp). Hence the write leg requires root / SYSCAP_NOSCHED β€” severity stays Low.

What was run (guest dfbsd 6.5-DEVELOPMENT, X86_64_GENERIC #0, 6 vCPU)

  • Unprivileged leg: /tmp/rtprio_sidl lookup as uid 1001 β†’ 3 Γ— EFAULT on pid 10115 within 105 syscalls (run.log), including on the very first probes. Two earlier variants without the vm_map-token churn thread produced 0 hits (memory-pressure filler pressures user pages, not the token path) β€” the churn thread is what makes the forker deschedule mid-window.
  • Privileged leg: /tmp/rtprio_sidl set as root β†’ guest panicked in DDB within ~50 s. Serial console (panic.txt / panic_full_serial.log): Fatal user address access from kernel mode from rtprio_sidl, fault virtual address = 0x198, supervisor write data, Stopped at sys_rtprio+0x1b0: movl %eax,0x198(%rdx) β€” disassembly matches lp->lwp_rtprio = rtp at kern_resource.c:748 with lp == NULL, and 0x198 == offsetof(struct lwp, lwp_rtprio).

Exploitability ceiling

  • Unprivileged: EFAULT side-channel only (the read is contained by copyout's onfault; the source address 0x198 resolves against the attacker's own user map and page 0 is unmappable). No memory disclosure, no control flow.
  • Privileged (root or SYSCAP_NOSCHED): reliable kernel panic β€” a 4-byte write of semi-controlled data (type/prio u_int16 pair) at fixed address 0x198, which is unmappable user space, so it is a crash, not a controlled write. Low: requires an already-privileged caller.

Fix validation

Authored fix.diff (NULL check returning ESRCH + LWPHOLD/LWPRELE around the blocking copyout, mirroring sys_lwp_rtprio). Applied to the guest's /usr/src copy, make nativekernel -j6 + make installkernel, reboot into kernel #1:

  • lookup: syscalls=1015185 ok=261860 EFAULT=0 ESRCH=753325 β†’ EFAULT leg gone (fixed_lookup.log).
  • set (root, β‰₯65 s): guest alive, no panic (fixed_set.log) β†’ panic leg gone.

fix_status: fixed.

Fix verification

fixed
baseline reproduced→ patch + rebuild →patched clean

fix.diff (NULL check -> ESRCH + LWPHOLD/LWPRELE) applied to guest /usr/src, make nativekernel -j6 + make installkernel, reboot into kernel #1. Baseline behaviors gone: lookup 0 EFAULT over 1,015,185 probes (was 3 in 105); root RTP_SET race ran 65+s with the guest alive (baseline panicked <50s).

['fixed_lookup.log', 'fixed_set.log', 'fix.diff']
↓ fix.diffDragonFly dfbsd 6.5-DEVELOPMENT DragonFly 6.5-DEVELOPMENT #1: Tue Sep 1 10:01:19 UTC 2026 root@dfbsd:/usr/obj/usr/src/sys/X86_64_GENERIC x86_64

Confirmed kernel references

Detail

Exploit chain

unpriv: fork-bomber (p_thread churn via mmap/munmap widens the SIDL zero-lwp window) + pid-predicting rtprio(RTP_LOOKUP) sprayer -> kernel NULL deref read contained by copyout onfault -> EFAULT observability only. priv (root/SYSCAP_NOSCHED): same race with RTP_SET -> 4-byte semi-controlled write at fixed unmappable user address 0x198 -> fatal trap -> panic. No path to uid0: the write address is not mappable (page 0) and not controllable.

Evidence (decisive lines)

["run.log: '[hit] pid 10115 EFAULT after 97/100/103 syscalls' + 'REPRODUCED' (unpriv leg, kernel #0)", "panic.txt: 'Fatal user address access from kernel mode from rtprio_sidl', 'fault virtual address = 0x198', 'supervisor write data', 'Stopped at sys_rtprio+0x1b0: movl %eax,0x198(%rdx)' (priv leg, kernel #0)", "fixed_lookup.log: 'syscalls=1015185 ok=261860 EFAULT=0' on fix.diff-patched kernel #1", 'fixed_set.log: root RTP_SET race >=65s on patched kernel #1, guest alive', 'VERDICT.md: full path:line trace of the SIDL window and token-drop mechanism']

PoC changes

Original seed sketch assumed the SIDL window was reachable by mere forking; in fact fork1 holds p2->p_token and only deschedules drop it. Added a sibling pthread churning mmap/munmap so vm_fork()'s vm_map-token acquisition blocks mid-window (this made the race hit on the first attempt after 0 hits across two earlier variants), fixed zombie reaping (maxprocperuid EAGAIN), pid prediction from a shared ring buffer, and added signal.h/pthread linkage.

Verified recommended fix

In sys_rtprio(), return ESRCH when FIRST_LWP_IN_PROC(p) is NULL and LWPHOLD/LWPRELE the lwp across the blocking copyout, mirroring sys_lwp_rtprio() (fix.diff, validated).

Verdict

sys_rtprio() (kern_resource.c:704) dereferences FIRST_LWP_IN_PROC(p) without a NULL check. A process is pfind()-visible with zero lwps during its fork/SIDL window (allproc insertion at kern_fork.c:491 until the first lwp tree insert at kern_fork.c:848), and fork1's p_token is dropped whenever the forking thread deschedules (lwkt_relalltokens), which an unprivileged mmap/munmap churn thread in the forker forces inside vm_fork(). Unprivileged rtprio(RTP_LOOKUP) then reads &((struct lwp*)0)->lwp_rtprio (0x198) inside copyout -> EFAULT (3 hits in 105 syscalls as uid 1001). Privileged rtprio(RTP_SET) executes the raw store lp->lwp_rtprio = rtp at 0x198 with pcb_onfault == NULL -> 'Fatal user address access from kernel mode' -> panic: verified on the serial console ('Stopped at sys_rtprio+0x1b0: movl %eax,0x198(%rdx)'). Unprivileged callers cannot reach the write (blocked at kern_resource.c:718-723), so impact ceiling for a non-root user is the EFAULT side channel; the panic requires an already-privileged caller β€” severity Low.