Use-after-free in smbfs_readvdir via dead-code vnode lock upgrade on read(2) of directory
Summary
smbfs_io.c:202 lks=LK_EXCLUSIVE hardcoded (lockstatus commented out). :203 if(lks==LK_SHARED) ALWAYS FALSE dead code. vn_read takes LK_SHARED (vfs_vnops.c:751). read(2) on VDIR reaches smbfs_readvdir under shared lock only. smbfs_readvdir mutates np->n_dirseq/n_dirofs (lines 118-141). Race: Thread A smbfs_findnext blocks on SMB I/O using ctx_old. Thread B smbfs_findclose kfree(ctx_old). Thread A resumes write-UAF on freed smbfs_fctx. Race window spans full SMB network round-trip. Unprivileged user with read access. getdents(2) path NOT affected (smbfs_readdir takes LK_EXCLUSIVE explicitly). Fix: unconditional vn_lock LK_UPGRADE.
Discussion (0)
PoC verification
Evidence pack
findings/poc/DF-0884 Β· 17 files| File | Type | Description | Size | |
|---|---|---|---|---|
| harness.c | trigger-source | deterministic race transcription: dead-code lock upgrade + findnext/findclose UAF with poisoned allocator, buggy|fixed modes | 19.3 KB | view raw |
| build.sh | build-script | cc -O2 -pthread -o harness harness.c | 181 B | view raw |
| run.sh | run-script | ./run.sh [buggy|fixed] | 182 B | view raw |
| build.log | build-log | final successful harness build, full output | 105 B | view raw |
| run.log | run-log | decisive buggy-mode run, full output incl UAF CONFIRMED | 798 B | view raw |
| run.2.log | run-log | buggy-mode run #2 (determinism check) | 769 B | view raw |
| run.3.log | run-log | buggy-mode run #3 (determinism check) | 769 B | view raw |
| run.fixed.log | run-log | fixed-mode run showing race eliminated | 806 B | view raw |
| fix.diff | suggested-fix | git-apply-able: replace dead lks=LK_EXCLUSIVE with lks=vn_islocked(vp) at smbfs_io.c:202 | 995 B | view raw |
| fix_build.log | build-log | patched smbfs.ko rebuild, -Werror clean, rc=0 | 19.9 KB | view raw |
| fix_run.log | run-log | fixed-mode harness after patched module built+installed | 806 B | view raw |
| disasm_evidence.txt | panic-signature | object-code proof: vn_islocked/vn_lock counts 0/0 unpatched -> 1/2 patched in smbfs_readvnode VDIR branch | 1.2 KB | view raw |
| env.txt | environment | uname, cc version, kern.version | 313 B | view raw |
| VERDICT.md | verdict | full mechanism walkthrough, harness proof, impact ceiling, fix validation | 10.2 KB | β raw |
| README.md | readme | build/run/expected + reproduce | 2.4 KB | β 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-0884 β PoC: smbfs_readvdir UAF via dead-code vnode lock upgrade
Verdict
REPRODUCED (UAF via deterministic harness), then FIX VALIDATED.
The bug is real: a dead-code vnode lock upgrade in smbfs_readvnode lets two
concurrent read(2) on a directory vnode run smbfs_readvdir() under a shared
lock, racing smbfs_findnext() against smbfs_findclose() and using-after-free
the smbfs_fctx. The authored fix.diff closes it: the patched smbfs.ko
restores vn_islocked() + the conditional vn_lock(LK_UPGRADE/DOWNGRADE) (the
dead code is gone at the object-code level), and the fixed-mode harness shows
the race is eliminated.
Reachability (harness, not live mount)
The live trigger requires a mounted SMB share (an SMB server reachable from the
kernel client). This isolated KVM guest has no network beyond QEMU user-mode NAT
to the host and no SMB server, so the bug is proved by a deterministic
harness that transcribes the exact code path (per the DF-0598 / DF-0599 smbfs
precedent). The harness models the SMB network round-trip inside
smbfs_findnext() as a controllable interleaving point and uses a poisoned
allocator to detect the write-UAF on the freed smbfs_fctx.
Build
./build.sh # cc -O2 -pthread -o harness harness.c
Run
./run.sh buggy # baseline: expect >>> UAF CONFIRMED ./run.sh fixed # fix model: expect >>> FIXED: no UAF
Expected (bug present)
[*] After Thread B ran: A's ctx=0x... freed? YES (poison=0xDD) [+] A wrote ctx->f_attr.fa_ino (0xcafebabe) into an object that was ALREADY freed by Thread B's smbfs_findclose() >>> UAF CONFIRMED: smbfs_findnext wrote through freed smbfs_fctx (dead-code lock upgrade lets two read(2) on a VDIR run smbfs_readvdir concurrently)
Deterministic: confirmed across 3 consecutive runs (run.log, run.2.log,
run.3.log).
Fix validation (Phase 8)
fix.diff was applied to /usr/src/sys/vfs/smbfs/smbfs_io.c, smbfs.ko was
rebuilt (fix_build.log, -Werror clean, rc=0), and the patched module was
installed + kldload-ed cleanly. Object-code proof (disasm_evidence.txt):
call in smbfs_readvnode VDIR branch |
unpatched | patched |
|---|---|---|
vn_islocked |
0 | 1 |
vn_lock |
0 | 2 (upgrade + downgrade) |
The fixed-mode harness (fix_run.log) shows the upgrade serializes the two
readers: >>> FIXED: no UAF.
See VERDICT.md for the full mechanism walkthrough and manifest.json for the
artifact catalog.
DF-0884 β VERDICT
Verdict: REPRODUCED (UAF via deterministic harness), then FIX VALIDATED
The bug is real and the mechanism is exactly as the finding describes. A
dead-code vnode lock upgrade in smbfs_readvnode lets two concurrent read(2)
on a directory vnode run smbfs_readvdir() under a shared lock, racing
smbfs_findnext() against smbfs_findclose() and using-after-free the
smbfs_fctx. The authored fix.diff restores vn_islocked() + the
conditional vn_lock(LK_UPGRADE/DOWNGRADE); the patched smbfs.ko is
object-code-confirmed to emit them and the fixed-mode harness shows the race is
eliminated.
The live trigger needs a mounted SMB share, which is not present on this
isolated KVM guest (no network beyond QEMU user-mode NAT to the host, no SMB
server). Per the smbfs harness precedent (DF-0598 / DF-0599), the bug is proved
deterministically by transcribing the exact code path into a userspace harness
that models the SMB network round-trip as a controllable interleaving point and
uses a poisoned allocator to detect the write-UAF on the freed smbfs_fctx.
Mechanism (every hop cited)
-
read(2) takes the vnode SHARED β
vfs_vnops.c:751vn_lock(vp, LK_SHARED | LK_RETRY)insidevn_read(). The directory vnode is held shared for the whole read. -
The shared->exclusive upgrade is dead code β
smbfs_io.c:201-204c if (vp->v_type == VDIR) { lks = LK_EXCLUSIVE;/*lockstatus(&vp->v_lock, td);*/ /* :202 */ if (lks == LK_SHARED) /* :203 ALWAYS FALSE */ vn_lock(vp, LK_UPGRADE | LK_RETRY); /* :204 DEAD CODE */ error = smbfs_readvdir(vp, uiop, cred); /* :205 runs SHARED */Thelockstatus()call that would detect the real shared mode is commented out;lksis hardcoded toLK_EXCLUSIVE, soif (lks == LK_SHARED)is always false and thevn_lock(LK_UPGRADE)is never reached. Object-code confirmation: in the unpatchedsmbfs.ko,smbfs_readvnode's VDIR branch contains 0vn_islockedand 0vn_lockcalls β the optimizer elided the dead upgrade entirely (disasm_evidence.txt). -
smbfs_readvdir mutates per-vnode directory state β
smbfs_io.c:78-174. It reads and writesnp->n_dirseqandnp->n_dirofs(lines 118-141) and drives the SMB find context throughsmbfs_findopen/smbfs_findnext/smbfs_findclose. Because step 2 left the lock shared, tworead(2)callers can execute this body concurrently. -
The race -> write-UAF: - Thread A enters
smbfs_readvdir, takes the reopen branch (smbfs_io.c:118-135), allocatesctx_Aviasmbfs_findopen(smbfs_smb.c:1170-1193,kmallocat:1177), stores it innp->n_dirseq(smbfs_io.c:132), and callssmbfs_findnext(ctx_A, ...)(smbfs_io.c:137or:151). -smbfs_findnext()(smbfs_smb.c:1196) blocks for a full SMB network round-trip insidesmbfs_findnextLM1/smbfs_findnextLM2(smbfs_smb.c:854/:1049; the blockingsmbfs_smb_searchat:868/smb_t2_request). Thread A is parked in network I/O still holding thectx_Apointer. - Thread B enterssmbfs_readvdirconcurrently (the lock is still shared). B'soffset != np->n_dirofs(A advancedn_dirofsatsmbfs_io.c:124/138/154), so B takes the reopen branch and callssmbfs_findclose(np->n_dirseq, &scred)atsmbfs_io.c:121β which is A'sctx_A. -smbfs_findclose()(smbfs_smb.c:1224-1236) freesctx_A->f_rname(:1233) and thenkfree(ctx, M_SMBFSDATA)at:1234. - Thread A resumes from the blockedsmbfs_findnext()and writesctx->f_attr.fa_inoatsmbfs_smb.c:1220, then reads it back atsmbfs_io.c:157(vop_write_dirent(..., ctx->f_attr.fa_ino, ...)). Both are accesses through the now-freedctx_A-> write/read UAF on a freedsmbfs_fctx. -
The getdents(2) path is NOT affected β
smbfs_vnops.c:725smbfs_readdir()unconditionally takesvn_lock(vp, LK_EXCLUSIVE | LK_RETRY | LK_FAILRECLAIM)before callingsmbfs_readvnode, so twogetdents(2)callers cannot race. Only theread(2)path (smbfs_read->smbfs_readvnode) is vulnerable, becausevn_readholds the lock shared and the upgrade is dead.
Harness proof (deterministic)
harness.c transcribes steps 1-4 into userspace:
- The vnode lock is a pthread_rwlock_t (shared = read-lock, exclusive =
write-lock).
- The SMB network round-trip inside smbfs_findnext is a controllable barrier:
Thread A blocks on a pthread_cond_t; the orchestrator releases it only
AFTER Thread B has run smbfs_findclose() on A's ctx, modelling the full
round-trip race window.
- The allocator is poisoned: every free() overwrites the object with 0xDD
and marks it freed, so Thread A's post-I/O write through the dangling ctx
is detected unambiguously.
Bug mode output (run.log):
[*] After Thread B ran: A's ctx=0x8005108c0 freed? YES (poison=0xDD) [*] Releasing Thread A ... it will now write ctx->f_attr.fa_ino THROUGH FREED MEMORY [+] A wrote ctx->f_attr.fa_ino (0xcafebabe) into an object that was ALREADY freed by Thread B's smbfs_findclose() >>> UAF CONFIRMED: smbfs_findnext wrote through freed smbfs_fctx ...
Deterministic across 3 runs (run.log, run.2.log, run.3.log).
Impact ceiling
The primitive is a write/read UAF on a freed struct smbfs_fctx
(kmalloc(sizeof(struct smbfs_fctx), M_SMBFSDATA, M_WAITOK | M_ZERO)). The
M_SMBFSDATA malloc type backs several smbfs objects of similar size
(smbfs_fctx, smbnode aux, names), so the freed slot is reusable by an attacker
who can drive concurrent allocations β the classic path to corrupting a victim
object's function pointer / refcount / credential pointer. On the default
GENERIC kernel (options INVARIANTS), kern_slaballoc.c's
chunk_mark_free / WEIRD_ADDR (0xdeadc0de) poisoning and magic checks would
trap the cross-type reuse / double-free and panic before grooming lands,
so the realistic on-GENERIC impact is panic / local DoS; a clean uid=0
escalation would require the noinv kernel (INVARIANTS off) and is therefore a
non-default-kernel result. No live escalation chain was developed because
the live trigger requires a mounted SMB share, which this isolated KVM guest
cannot provide (no SMB server reachable) β the harness transcribes the race
deterministically per the smbfs precedent.
Realistic preconditions: an unprivileged local user with read access to a
directory on a mounted SMB share (the share mounted by an admin or made
mountable via vfs.usermount=1 + a root-created creds/SMB image owned by the
attacker). Two threads issuing read(2) on the same directory fd reproduce
the race. The trigger is a normal syscall surface; no kldload, no setuid
helper, no non-default kernel required for the bug to fire.
PoC changes (what I authored)
The finding shipped no trigger source, so I authored harness.c from the cited
path. Iteration notes:
- First cut deadlocked in fixed mode: I modelled LK_UPGRADE as
pthread_rwlock_wrlock, but a thread already holding the read lock cannot
take the write lock on the same pthread rwlock (POSIX). I verified DragonFly's
lockmgr_upgrade (kern_lock.c:576-660) has an explicit anti-deadlock rule:
if another upgrade is pending, the caller releases its shared lock and
acquires exclusive normally (:616-625). I re-modelled the upgrade as
that same safe sequence (drop shared, take exclusive; reverse on exit), which
both avoids the pthread deadlock and faithfully represents the kernel's
no-deadlock upgrade.
- The verdict captures write-vs-free timing (g_a_write_saw_freed) rather than
the final freed state, because in fixed mode B legitimately frees A's ctx
only after A has finished β so "is ctx freed at the end" is true in both
modes. The UAF is specifically "A writes while ctx is already freed"; the
timing snapshot distinguishes the two cases crisply.
Fix (verified)
fix.diff (git-apply-able, git apply --check clean) replaces the dead
lks = LK_EXCLUSIVE; if (lks == LK_SHARED) with lks = vn_islocked(vp);
(vn_islocked at vfs_vnops.c:1136 is the idiomatic wrapper for
lockstatus(&vp->v_lock, curthread), which is exactly what the commented-out
code intended). This is correct for BOTH callers of smbfs_readvnode:
- smbfs_read (VOP_READ): lock is shared -> vn_islocked returns LK_SHARED
-> upgrade fires -> readvdir runs exclusive -> downgrade. Race closed.
- smbfs_readdir (VOP_READDIR, smbfs_vnops.c:725): lock is already
exclusive -> vn_islocked returns LK_EXCLUSIVE -> upgrade/downgrade are
skipped (the if (lks == LK_SHARED) guards them) -> no lock-state
disturbance for the readdir caller. This is why the fix must DETECT the
state rather than unconditionally upgrade: an unconditional LK_DOWNGRADE
on exit would incorrectly downgrade the exclusive lock smbfs_readdir
acquired, breaking its vn_unlock.
The finding's ## Recommended fix proposed an unconditional vn_lock
LK_UPGRADE; this refines it to vn_islocked() detection, which is
required for correctness with the second caller (smbfs_readdir).
Validation (Phase 8)
- Applied
fix.diffto/usr/src/sys/vfs/smbfs/smbfs_io.c(patch -p1, hunk #1 succeeded at line 199). - Rebuilt
smbfs.kofrom/usr/src/sys/vfs/smbfs(fix_build.log):-Werrorclean,rc=0. - Object-code proof (
disasm_evidence.txt):smbfs_readvnodeVDIR branch went fromvn_islocked=0 /vn_lock=0 (unpatched) tovn_islocked=1 /vn_lock=2 (patched). - Installed the patched
smbfs.koto/boot/kernel/smbfs.ko,kldloadrc=0,kldunloadrc=0 (valid module, valid symbol resolution). - Fixed-mode harness (
fix_run.log):>>> FIXED: no UAF.
Before / after
| smbfs.ko | vn_islocked in VDIR branch |
vn_lock in VDIR branch |
harness | |
|---|---|---|---|---|
| baseline | unpatched | 0 | 0 | >>> UAF CONFIRMED |
| patched | rebuilt w/ fix | 1 | 2 (upgrade+downgrade) | >>> FIXED: no UAF |
(No live SMB share is mountable on this guest, so module-compile + object-code comparison + logic-transcription harness is the validation level, matching the DF-0598 / DF-0599 smbfs precedent.)
Fix verification
fixedVALIDATED. The harness transcribes the unpatched logic -> UAF CONFIRMED (deterministic 3x), and the patched logic -> FIXED no UAF. The actual kernel fix was validated at the module level: fix.diff applied cleanly to /usr/src/sys/vfs/smbfs/smbfs_io.c, smbfs.ko rebuilt -Werror-clean (rc=0, fix_build.log), installed, and kldload-ed rc=0. Object-code before/after (disasm_evidence.txt): smbfs_readvnode VDIR branch went from vn_islocked=0/vn_lock=0 (unpatched, dead upgrade elided) to vn_islocked=1/vn_lock=2 (patched, upgrade+downgrade restored), proving the dead code is gone and the serialization is now emitted. No live SMB share is mountable on this isolated KVM guest, so module-compile + object-code comparison + logic-transcription harness is the validation level, matching the DF-0598/DF-0599 smbfs precedent.
baseline (unpatched smbfs.ko): harness buggy -> [*] A's ctx freed? YES (poison=0xDD) / >>> UAF CONFIRMED; disasm: vn_islocked=0 vn_lock=0 in VDIR branch. patched (rebuilt smbfs.ko w/ fix.diff): harness fixed -> [+] FIXED: at A's write instant, ctx was still owned by A / >>> FIXED: no UAF; disasm: vn_islocked=1 vn_lock=2 in VDIR branch; kldload rc=0, kldunload rc=0.
Confirmed kernel references
- sys/vfs/smbfs/smbfs_io.c:202
- sys/vfs/smbfs/smbfs_io.c:203
- sys/vfs/smbfs/smbfs_io.c:204
- sys/vfs/smbfs/smbfs_io.c:121
- sys/vfs/smbfs/smbfs_io.c:132
- sys/vfs/smbfs/smbfs_io.c:151
- sys/vfs/smbfs/smbfs_io.c:157
- sys/vfs/smbfs/smbfs_smb.c:1177
- sys/vfs/smbfs/smbfs_smb.c:1196
- sys/vfs/smbfs/smbfs_smb.c:1220
- sys/vfs/smbfs/smbfs_smb.c:1234
- sys/kern/vfs_vnops.c:751
- sys/vfs/smbfs/smbfs_vnops.c:725
- sys/kern/kern_lock.c:576
- sys/kern/vfs_vnops.c:1136
Detail
Exploit chain
none (non-default-mount precondition + INVARIANTS trap). The primitive is a write/read UAF on a freed struct smbfs_fctx (kmalloc(sizeof(smbfs_fctx), M_SMBFSDATA, M_ZERO)). The M_SMBFSDATA bucket is reusable by concurrent attacker allocations, so the theoretical escalation is corrupt-a-victim-object->forge/pivot->uid0; however on the default GENERIC kernel (options INVARIANTS) kern_slaballoc.c's chunk_mark_free/WEIRD_ADDR(0xdeadc0de) poisoning and magic checks trap cross-type slab reuse / the double-free and panic before grooming lands, so the realistic on-GENERIC impact is panic/local DoS. A clean uid0 escalation would require the noinv kernel (INVARIANTS off) and is a non-default-kernel result; per the bright-line rule it is reported as panic/corruption on GENERIC. Critically, the LIVE trigger needs a mounted SMB share, which this isolated KVM guest cannot provide (no SMB server reachable, no network beyond QEMU user-mode NAT to the host) -- so no live escalation chain was developed; the bug is proved deterministically via the transcribed harness (findings/poc/DF-0884/harness.c) per the DF-0598/DF-0599 smbfs precedent. Valid hard blocker reached: live trigger unreachable on this guest; primitive characterized at harness level (write-UAF on freed fctx confirmed by poisoned allocator observing the 0xDD-poisoned-then-overwritten object).
Evidence (decisive lines)
run.log decisive lines: [*] After Thread B ran: A's ctx=0x8005108c0 freed? YES (poison=0xDD) / [*] Releasing Thread A ... it will now write ctx->f_attr.fa_ino THROUGH FREED MEMORY / [+] A wrote ctx->f_attr.fa_ino (0xcafebabe) into an object that was ALREADY freed by Thread B's smbfs_findclose() / >>> UAF CONFIRMED: smbfs_findnext wrote through freed smbfs_fctx (dead-code lock upgrade lets two read(2) on a VDIR run smbfs_readvdir concurrently) / --- object-code proof (disasm_evidence.txt) --- unpatched smbfs_readvnode VDIR branch: vn_islocked=0 vn_lock=0 (dead upgrade elided) / patched smbfs_readvnode VDIR branch: vn_islocked=1 vn_lock=2 (upgrade+downgrade restored)
PoC changes
Authored findings/poc/DF-0884/harness.c from scratch (finding shipped no trigger source). Deterministic transcription of the dead-code lock upgrade + the findnext/findclose UAF race with a poisoned allocator; the SMB network round-trip is modelled as a controllable pthread cond-var interleaving point. Two modes: 'buggy' (transcribes smbfs_io.c:202-204 as-shipped) prints UAF CONFIRMED; 'fixed' (transcribes the vn_islocked upgrade) prints FIXED no UAF. One iteration: first cut deadlocked in fixed mode because pthread rwlocks cannot model a read->write upgrade in the same thread; verified DragonFly lockmgr_upgrade (kern_lock.c:616-625) has an anti-deadlock fallback (release shared, acquire exclusive normally) and re-modelled the upgrade as that same safe sequence. Also authored build.sh/run.sh, VERDICT.md, README.md, manifest.json, fix.diff, and saved full untrimmed logs.
Verified recommended fix
In sys/vfs/smbfs/smbfs_io.c smbfs_readvnode(), replace the dead lks = LK_EXCLUSIVE;/*lockstatus(&vp->v_lock, td);*/ with lks = vn_islocked(vp); so the existing if (lks == LK_SHARED) vn_lock(vp, LK_UPGRADE|LK_RETRY) fires for the read(2) caller (lock shared via vn_read) and is correctly skipped for the getdents(2) caller (smbfs_readdir already holds LK_EXCLUSIVE; an unconditional upgrade+downgrade would corrupt its lock state). vn_islocked (vfs_vnops.c:1136) is the idiomatic wrapper for lockstatus(&vp->v_lock, curthread) -- exactly what the commented-out code intended. REFINES the finding proposal (which suggested an unconditional upgrade): state detection via vn_islocked is required for correctness with the second caller. Full git-apply-able diff in findings/poc/DF-0884/fix.diff.
Verdict
REPRODUCED. The dead-code lock upgrade at smbfs_io.c:202-204 is confirmed at source AND object-code level: lks=LK_EXCLUSIVE is hardcoded (the lockstatus() call is commented out), so if (lks == LK_SHARED) at :203 is always false and the vn_lock(LK_UPGRADE) at :204 is dead. The unpatched smbfs.ko disassembles to 0 vn_islocked and 0 vn_lock calls in smbfs_readvnode's VDIR branch (the optimizer elided the dead upgrade). vn_read takes the vnode SHARED (vfs_vnops.c:751), so two concurrent read(2) on a directory run smbfs_readvdir() under a shared lock, racing smbfs_findnext() (which blocks for a full SMB round-trip holding ctx) against smbfs_findclose() (smbfs_io.c:121 -> kfree at smbfs_smb.c:1234); thread A resumes and writes ctx->f_attr.fa_ino (smbfs_smb.c:1220) through freed memory -> write/read UAF on freed smbfs_fctx. The getdents path is NOT affected (smbfs_readdir takes LK_EXCLUSIVE unconditionally at smbfs_vnops.c:725). Confirmed deterministically via a harness (poisoned allocator + controllable SMB-round-trip interleaving point): 3 consecutive runs print UAF CONFIRMED (run.log/run.2.log/run.3.log).
No comments yet.