Use-after-free in DEVFS_RULE_RESET: devfs_gc() frees nodes under the topology walker's saved cursors
Summary
devfs_rule_reset_node() calls devfs_gc() from inside devfs_iterate_topology()'s TAILQ_FOREACH_MUTABLE walk; devfs_gc frees arbitrary nodes (all links targeting the node, empty dirs), so the walker advances onto freed memory and runs the reset callback on it - KKASSERT(node->link_target) fails on INVARIANTS kernels; on production kernels the next statement writes through the NULLed link_target (low-page fault) and the stale ORPHANED/list state drives orphan-list and child-list corruption routed through the freed chunk. VERIFIED: rule-built link chain L2->L1->null + RESET '*' -> 'assertion node->link_target failed in devfs_rule_reset_node at devfs_rules.c:223' with full stack through devfs_iterate_topology <- devfs_apply_reset_rules_caller. Privileged ioctl (root, or jailed root via DF-3197 -> host-kernel-corruption path). memcorrupt bucket. Fix validated (two-pass reset: collect then free after the walk): same PoC survives, links cleanly removed.
Discussion (0)
PoC verification
Evidence pack
findings/poc/DF-3198 Β· 11 files| File | Type | Description | Size | |
|---|---|---|---|---|
| README.md | β | 204 B | β raw | |
| VERDICT.md | β | 2.9 KB | β raw | |
| build.sh | β | 93 B | view raw | |
| env.txt | β | 415 B | view raw | |
| fix.diff | β | 8.6 KB | view raw | |
| panic.txt | β | 621 B | view raw | |
| run.fixed.log | β | 476 B | view raw | |
| run.log | β | 376 B | view raw | |
| run.sh | β | 92 B | view raw | |
| uaf_reset.c | β | 3.0 KB | view raw | |
| verdict.json | β | 2.6 KB | view raw |
DF-3198 β UAF in DEVFS_RULE_RESET (devfs_gc frees under the walker). See VERDICT.md. Build: cc -static -O -o uaf_reset uaf_reset.c Run as root: ./uaf_reset -> expect panic (KKASSERT devfs_rules.c:223)
DF-3198 β use-after-free in DEVFS_RULE_RESET: devfs_gc() frees nodes under the topology walker's feet
Root cause
devfs_apply_reset_rules_caller() (devfs_core.c:1912) walks the topology with
devfs_iterate_topology(), whose TAILQ_FOREACH_MUTABLE saves the next sibling
(node2) before descending. The callback devfs_rule_reset_node()
(devfs_rules.c:213-234) calls devfs_gc() on every rule-created link, and
devfs_gc() (devfs_core.c:721) frees ARBITRARY other nodes:
- devfs_gc_links_callback frees every link whose link_target is the node,
- devfs_gc_dirs_callback frees empty directories.
The walker then advances onto freed memory and runs the callback on it.
Reproduced (deterministic, INVARIANTS kernel)
Two rules build a link chain L2 -> L1 -> null (rule 2 matches the alias
created by rule 1 β devfs_allocp applies rules to every new node,
devfs_core.c:291-292). DEVFS_RULE_RESET "*":
panic: assertion "node->link_target" failed in devfs_rule_reset_node
at /usr/src/sys/vfs/devfs/devfs_rules.c:223
devfs_rule_reset_node() at devfs_rule_reset_node+0xba
devfs_rule_reset_node() at devfs_rule_reset_node+0xba
devfs_iterate_topology() at devfs_iterate_topology+0x67
devfs_apply_reset_rules_caller() at devfs_apply_reset_rules_caller+0x8e
devfs_msg_exec() at devfs_msg_exec+0x3ac
Sequence: reset of L1 runs devfs_gc(L1) β nlinks>0 (L2 points at L1) β
gc frees L2; the outer walk's saved cursor node2 == L2 (freed) β
reset_node(L2-freed) β KKASSERT(node->link_target) fails because the first
devfs_unlinkp already NULLed link_target (devfs_core.c:613).
On a production (non-INVARIANTS) kernel the very next statement is
--node->link_target->nlinks β a write through the NULL low page β and the
stale flags (DEVFS_ORPHANED, list cursors) drive double-unlink/orphan-list
corruption. The corruption primitive (list tail routed through freed memory)
is real, but the trigger is a privileged ioctl (root, or jailed root via
DF-3197), so the practical impact here is a reliable privileged panic;
combined with DF-3197 it is a jailed-root β host-kernel-corruption path.
Fix
Two-pass reset (in fix.diff): the walk only unlinks rule-created links and
collects them; all freeing happens after the walk in devfs_reset_mount(),
with a DEVFS_DESTROYED guard for entries freed by an earlier links pass.
Validated: fixed kernel resets the same chain without a panic (run.fixed.log).
Files
uaf_reset.c, build.sh, run.sh, run.log, panic.txt, fix.diff,
run.fixed.log (post-fix), verdict.json, manifest.json
Fix status (batch validation)
Applied with the 36 sibling fix.diffs of this batch in findings/poc/_batch41/all_fixes.patch; single in-guest make -j6 nativekernel KERNCONF=X86_64_GENERIC completed RC=0 with zero errors under -Werror (full log: findings/poc/_batch41/fix_build8.log). Behavior A/B intentionally skipped for this Info-tier item.
Fix verification
fixedsame PoC on the fixed kernel: SURVIVED, /dev/L1 and /dev/L2 removed by the reset, tree consistent, guest stayed up
['DF-3198/run.fixed.log']
Confirmed kernel references
Detail
Exploit chain
privileged (root, or jailed root via DF-3197) ioctl sequence: ADD LINK null->L1, ADD LINK L1->L2, APPLY '', RESET '' -> devfs core thread frees L2 during L1's gc -> reset_node runs on freed L2 -> KKASSERT/NULL-page write; production kernels get list corruption through the freed chunk (double-unlink of orphaned nodes)
Evidence (decisive lines)
['panic.txt: assertion + full backtrace', 'run.log: chain built then panic on RESET', 'run.fixed.log: SURVIVED, links cleaned, guest up']
PoC changes
none beyond compile fixes (limits.h include)
Verified recommended fix
two-pass reset: unlink+collect rule-created links during the walk, free (gc_links + freep + one gc_dirs sweep) only after it
Verdict
devfs_rule_reset_node() calls devfs_gc() while devfs_iterate_topology() holds saved TAILQ cursors; devfs_gc frees arbitrary nodes (links to the target, empty dirs), so the walker revisits freed memory. With a rule-built chain L2->L1->null, DEVFS_RULE_RESET panicked deterministically: 'assertion node->link_target failed in devfs_rule_reset_node at devfs_rules.c:223' with the full stack through devfs_iterate_topology <- devfs_apply_reset_rules_caller. On non-INVARIANTS kernels the next statement is a write through the NULL link_target (low-page fault), and the stale ORPHANED/list state drives orphan-list and child-list corruption routed through freed memory.
No comments yet.