UAF/double-free race: in-flight cuse_client_open vs final cuse_server_unref (async destroy_dev, refs++ after the final decrement)
Summary
cuse_client_open loads dev->si_drv1 unlocked, dereferences the server, then takes pcs->mtx and does refs++. The final unref decrements to 0 with no re-check and tears down: free_dev NULLs si_drv1, calls destroy_dev - ASYNCHRONOUS on DragonFly - and kfrees pcsd/pcs without draining in-flight opens. An open that loaded si_drv1 before the NULL write but incremented refs after the final drop proceeds on the dying server: TAILQ_INSERT into freed pcs, and its error path triggers a SECOND full teardown (double TAILQ_REMOVE, double kfrees). Unprivileged open side against world-accessible cuse nodes; heap corruption on a hit. HONESTLY NOT REPRODUCED after 120+300 hammer rounds on the 6-vCPU guest (window too narrow) - trace sound, verdict not_reproduced. dying-flag + global-lock handshake fix.
Discussion (0)
PoC verification
Evidence pack
findings/poc/DF-4148 Β· 7 files| File | Type | Description | Size | |
|---|---|---|---|---|
| cuse_race.c | β | 4.5 KB | view raw | |
| build.sh | β | 104 B | view raw | |
| run.log | β | 333 B | view raw | |
| run.2.log | β | 490 B | view raw | |
| fix.diff | β | 2.3 KB | view raw | |
| VERDICT.md | β | 3.8 KB | β raw | |
| verdict.json | β | 3.4 KB | view raw |
DF-4148 β cuse client-open vs server-teardown UAF/double-free race
What (code trace β not reproduced on the guest)
cuse_client_open() (cuse.c:1497-1531) loads pcsd = dev->si_drv1
unlocked (1509), then dereferences pcsd->server/pcsd->user_dev
(1511-1512), takes pcs->mtx and does pcs->refs++ (1521).
The final cuse_server_unref() (cuse.c:685-732) decrements refs to 0
under pcs->mtx (692) with no re-check afterwards and tears down:
cuse_server_free_dev()NULLssi_drv1underpcs->mtx(668-669), callsdestroy_dev(pcsd->kern_dev)(680) β on DragonFly this is asynchronous (devfs_destroy_devβlwkt_sendmsg,sys/vfs/devfs/devfs_core.c:755-763; the "destroy device synchronously" comment atcuse.c:679is wrong for DFly) β and thenkfree(pcsd)(682) without waiting for in-flightcuse_client_opendispatches;kfree(pcs)at 731 after the last unlock at 723.
Interleaving A (refs race): a client open that loaded si_drv1 before the
NULL write but executes refs++ after the final --refs (0β1) proceeds on
the dying server: TAILQ_INSERT_TAIL(&pcs->hcli, pcc) (1561) into memory
that is kfree'd at 731, and since is_closing was already set its error
path (1564-1574) runs devfs_clear_cdevpriv β cuse_client_free β
cuse_server_unref (refs 1β0) β a second full teardown: second
TAILQ_REMOVE from cuse_server_head (already removed at 701, stale
tqe pointers β global-list corruption), re-walk of hdev racing thread 1
β double destroy_dev/kfree(pcsd), and double kfree(pcs).
Interleaving B (pcsd UAF): open loads pcsd, is preempted before pcs =
pcsd->server; teardown completes kfree(pcsd) (682) β open dereferences
freed memory, then cuse_server_lock() on freed/uninited pcs->mtx
(lockmgr writes into freed memory).
Trigger surface: an unprivileged user can hammer open() on a world-writable
cuse device node; the server side must be exiting at the same moment (daemon
restart/crash). Both sides were driven as root in the PoC below.
Guest attempt β honest result
cuse_race.c: 6 cycler processes doing tight open/close loops against a
pumper that services OPEN/CLOSE commands for ~1 s and then closes /dev/cuse
to start the teardown (whose cuse_server_free wait-loop polls refs at 1 Hz,
so each round's teardown races the cyclers' in-flight opens).
- run 1: 120 rounds β
all 120 rounds done, guest alive - run 2: 300 rounds β
all 300 rounds done, guest alive
No panic, no guest death in 420 teardown races β not_reproduced.
The per-round hit window is only a few Β΅s wide against a teardown that
itself takes ~Β΅s; the trace stands but the practical hit-rate on this
6-vCPU guest was too low. Two supporting observations were recorded:
a pumper parking in cuse_server_free's wait loop for >60 s mid-run
(transient ref oscillation), and β during harness debugging β wedged
D-state clients that keep an inherited /dev/cuse fd alive, which also
demonstrates that teardown of a cuse server with blocked clients never
completes (operational DoS amplifier, see DF-4149).
Fix
fix.diff (authored, not guest-validated β no reproducer exists):
- serialize the final decrement and the open-side increment under
cuse_global_mtxand add adyingflag set before teardown starts;cuse_client_openchecksdying/NULL under the same lock before touchingpcsd->serveror bumping refs; - NULL
si_drv1incuse_server_free_devundercuse_global_mtx(matching the open-side read).
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
not_testablefix.diff authored (dying flag set under cuse_global_mtx before teardown; open-side si_drv1 load + dying check + refs++ under the same lock; si_drv1 NULLed under cuse_global_mtx in cuse_server_free_dev) but not built/validated on the guest because the race itself did not reproduce - there is no behavioral baseline to diff against.
fix.diff in this directory; logic-level review only
Confirmed kernel references
Detail
Evidence (decisive lines)
VERDICT.md (full interleaving trace with path:line); run.log / run.2.log (120 + 300 clean rounds, 'guest alive'); cuse_race.c
PoC changes
Harness iterated twice: v1's pump loop never exited (self-deadlock) and children inherited /dev/cuse keeping dead servers alive; v2 closes the inherited fd in children, bounds the pump with SIGALRM + wall-clock deadline, uses unique per-round devnames, and treats EINVAL/ENOENT/ENXIO as end-of-round.
Verified recommended fix
Serialize the final refs decrement and the open-side increment under cuse_global_mtx with a dying flag; NULL si_drv1 under the same lock before freeing pcsd (see fix.diff).
Verdict
Code-level race proven by trace but not triggered on the guest in 420 teardown-vs-open rounds. cuse_client_open (cuse.c:1509-1521) reads dev->si_drv1 unlocked and does pcs->refs++ under pcs->mtx; the final cuse_server_unref (cuse.c:692) decrements refs to 0 with no re-check and frees pcsd/pcs after an ASYNCHRONOUS destroy_dev (devfs_core.c:755-763 - the 'synchronously' comment at cuse.c:679 is wrong on DFly). A racy open lands refs 0->1 on the dying server, inserts its client into freed pcs, and its is_closing error path runs a SECOND full teardown: double TAILQ_REMOVE from cuse_server_head, double kfree(pcsd)/kfree(pcs). The hammer (6 cycler processes doing open/close at full speed vs 1-second pumper + 1 Hz teardown poll) completed 120 + 300 rounds with the guest alive and /dev/cuse still serviceable. The window is a few microseconds wide; the trace is sound but the practical hit-rate on this 6-vCPU guest was too low to demonstrate. Supporting observations: a pumper parked >60s in cuse_server_free's wait loop mid-run (refs oscillation), and harness-induced wedges showing teardown with blocked clients never completes.
No comments yet.