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

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)

No comments yet.

PoC verification

Evidence pack

findings/poc/DF-0884 Β· 17 files
FileTypeDescriptionSize
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
README.md readme build/run/expected + reproduce
↓ download 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.

VERDICT.md verdict full mechanism walkthrough, harness proof, impact ceiling, fix validation
↓ download raw

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)

  1. read(2) takes the vnode SHARED β€” vfs_vnops.c:751 vn_lock(vp, LK_SHARED | LK_RETRY) inside vn_read(). The directory vnode is held shared for the whole read.

  2. The shared->exclusive upgrade is dead code β€” smbfs_io.c:201-204 c 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 */ The lockstatus() call that would detect the real shared mode is commented out; lks is hardcoded to LK_EXCLUSIVE, so if (lks == LK_SHARED) is always false and the vn_lock(LK_UPGRADE) is never reached. Object-code confirmation: in the unpatched smbfs.ko, smbfs_readvnode's VDIR branch contains 0 vn_islocked and 0 vn_lock calls β€” the optimizer elided the dead upgrade entirely (disasm_evidence.txt).

  3. smbfs_readvdir mutates per-vnode directory state β€” smbfs_io.c:78-174. It reads and writes np->n_dirseq and np->n_dirofs (lines 118-141) and drives the SMB find context through smbfs_findopen / smbfs_findnext / smbfs_findclose. Because step 2 left the lock shared, two read(2) callers can execute this body concurrently.

  4. The race -> write-UAF: - Thread A enters smbfs_readvdir, takes the reopen branch (smbfs_io.c:118-135), allocates ctx_A via smbfs_findopen (smbfs_smb.c:1170-1193, kmalloc at :1177), stores it in np->n_dirseq (smbfs_io.c:132), and calls smbfs_findnext(ctx_A, ...) (smbfs_io.c:137 or :151). - smbfs_findnext() (smbfs_smb.c:1196) blocks for a full SMB network round-trip inside smbfs_findnextLM1 / smbfs_findnextLM2 (smbfs_smb.c:854 / :1049; the blocking smbfs_smb_search at :868 / smb_t2_request). Thread A is parked in network I/O still holding the ctx_A pointer. - Thread B enters smbfs_readvdir concurrently (the lock is still shared). B's offset != np->n_dirofs (A advanced n_dirofs at smbfs_io.c:124/138/154), so B takes the reopen branch and calls smbfs_findclose(np->n_dirseq, &scred) at smbfs_io.c:121 β€” which is A's ctx_A. - smbfs_findclose() (smbfs_smb.c:1224-1236) frees ctx_A->f_rname (:1233) and then kfree(ctx, M_SMBFSDATA) at :1234. - Thread A resumes from the blocked smbfs_findnext() and writes ctx->f_attr.fa_ino at smbfs_smb.c:1220, then reads it back at smbfs_io.c:157 (vop_write_dirent(..., ctx->f_attr.fa_ino, ...)). Both are accesses through the now-freed ctx_A -> write/read UAF on a freed smbfs_fctx.

  5. The getdents(2) path is NOT affected β€” smbfs_vnops.c:725 smbfs_readdir() unconditionally takes vn_lock(vp, LK_EXCLUSIVE | LK_RETRY | LK_FAILRECLAIM) before calling smbfs_readvnode, so two getdents(2) callers cannot race. Only the read(2) path (smbfs_read -> smbfs_readvnode) is vulnerable, because vn_read holds 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.diff to /usr/src/sys/vfs/smbfs/smbfs_io.c (patch -p1, hunk #1 succeeded at line 199).
  • Rebuilt smbfs.ko from /usr/src/sys/vfs/smbfs (fix_build.log): -Werror clean, rc=0.
  • Object-code proof (disasm_evidence.txt): smbfs_readvnode VDIR branch went from vn_islocked=0 / vn_lock=0 (unpatched) to vn_islocked=1 / vn_lock=2 (patched).
  • Installed the patched smbfs.ko to /boot/kernel/smbfs.ko, kldload rc=0, kldunload rc=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

fixed
baseline reproduced→ patch + rebuild →patched clean

VALIDATED. 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.
↓ fix.diffDragonFly 6.5-DEVELOPMENT #0 (base kernel unchanged; fix lives entirely in the rebuilt smbfs.ko module, hot-swapped at /boot/kernel/smbfs.ko, kldload rc=0)

Confirmed kernel references

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).