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

cam_periph_alloc() TOCTOU on the borrowed cam_periph_find() pointer: camperiphfree() can free the periph between find() and the unlocked NEW_DEV_FOUND/deferred_callback stores (UAF write), and camperiphfree() can see the flag set before deferred_callback is published (NULL/garbage indirect call)

Summary

cam_periph_find returns a borrowed pointer after dropping the buses lock; alloc then reads/writes periph->flags/deferred_callback/deferred_ac with NO lock - the dying INVALID periph's last release can complete camperiphfree (TAILQ_REMOVE + kfree) inside the window so alloc's stores land in freed memory; symmetrically camperiphfree can observe flag=1 with callback still NULL and call it. Hot-plug/LUN-replacement racing the final close. Bounded guest race attempted (200 create/destroy cycles x 4000 open/close churns, 90s): no crash - window is a handful of instructions; stands on the lock-domain proof. One-buses-lock-hold + store-callback-before-flag fix (compilable diff in pack).

Discussion (0)

No comments yet.

PoC verification

Evidence pack

findings/poc/DF-5328 Β· 6 files
FileTypeDescriptionSize
README.md β€” 2.7 KB ↓ raw
VERDICT.md β€” 3.1 KB ↓ raw
race_alloc.sh β€” 982 B view raw
race_fdchurn.c β€” 346 B view raw
fix.diff β€” 2.6 KB view raw
verdict.json β€” 2.6 KB view raw

DF-5328 evidence pack β€” cam_periph_alloc() TOCTOU on the borrowed cam_periph_find() pointer: camperiphfree() can free the periph between find() and the NEW_DEV_FOUND/deferred_callback stores

Finding (one line)

cam_periph_alloc() (sys/bus/cam/cam_periph.c:176-190) calls cam_periph_find() β€” which returns a borrowed pointer with no reference and drops the buses lock before returning β€” and then reads and writes periph->flags (CAM_PERIPH_NEW_DEV_FOUND) and periph->deferred_callback / deferred_ac with no lock held; the last cam_periph_release() on the dying INVALID periph can complete camperiphfree() (cam_periph.c:581-632: TAILQ_REMOVE -> chcleanup/cdcleanup -> kfree(softc) -> kfree(periph)) inside that window, so alloc's stores land in freed memory (UAF write), and symmetrically camperiphfree()'s CAM_PERIPH_NEW_DEV_FOUND test at cam_periph.c:604 can see the flag set before deferred_callback is stored, calling a NULL/garbage function pointer at cam_periph.c:625.

Why it is real (code proof)

  • cam_periph_find() (cam_periph.c:292-316) takes xpt_lock_buses(), finds, unlocks, returns the raw pointer. No refcount.
  • cam_periph_alloc() :176 periph = cam_periph_find(path, name); then :178-183 touches periph->flags / periph->deferred_callback / periph->deferred_ac unlocked.
  • camperiphfree() runs under xpt_lock_buses() from cam_periph_release()'s slow path (:368-377) or cam_periph_invalidate() (:573-578) and frees the periph (:630). Nothing serializes it against alloc's unlocked stores.
  • Precondition: a periph of the same driver on the same path that is CAM_PERIPH_INVALID but not yet freed (refcount > 0, e.g. held by an open fd or an in-flight probe) whose last reference drops exactly while a rescan rediscovers the LUN (hot-plug / LUN replacement / rescan racing close).

Verification status: NOT reproduced on the guest (window measured in

a few instructions; see VERDICT.md for the honest analysis)

The interleaving requires the freeing thread to complete TAILQ_REMOVE+dtor+kfree between alloc's find() return and its first flag store β€” a window of a handful of instructions on the allocating thread. A statistical race (rig-driven invalidate/rescan loops) gives an estimated <<1e-3 hit rate per cycle and could not demonstrate it within the run budget. The finding stands on the code-level proof above (same borrowed-pointer pattern FreeBSD has since restructured).

Reproduce attempt rig

evilcd2.c provides debug.evilcd.invalidate (AC_LOST_DEVICE) and debug.evilcd.rescan (XPT_SCAN_BUS) to drive create/destroy cycles; race_alloc.sh loops open/close/invalidate/rescan against /dev/ch0. No crash/ anomalies observed in bounded runs.

VERDICT.md
↓ download raw

DF-5328 VERDICT

Status: NOT REPRODUCED (bounded guest attempt; code-proven race, confidence "likely")

The claim

cam_periph_alloc() (sys/bus/cam/cam_periph.c:176-190) uses the borrowed, unlocked cam_periph_find() result:

periph = cam_periph_find(path, name);      /* buses lock dropped inside */
if (periph != NULL) {
    if ((periph->flags & CAM_PERIPH_INVALID) != 0
     && (periph->flags & CAM_PERIPH_NEW_DEV_FOUND) == 0) {
        periph->flags |= CAM_PERIPH_NEW_DEV_FOUND;   /* unlocked WRITE */
        periph->deferred_callback = ac_callback;     /* unlocked WRITE */
        periph->deferred_ac = code;
        return (CAM_REQ_INPROG);

camperiphfree() (cam_periph.c:581-632, from cam_periph_release() :368-377 or cam_periph_invalidate() :573-578) removes the periph from the units list and ultimately kfree(periph) at :630. Two races:

(a) UAF write: the last release completes camperiphfree between alloc's find() return and its flag store -> the three stores land in freed memory (M_CAMPERIPH, 144 bytes). (b) NULL/garbage indirect call: camperiphfree() reads CAM_PERIPH_NEW_DEV_FOUND at :604 under the buses lock, but the flag was stored BEFORE deferred_callback in alloc's unlocked sequence -> camperiphfree can see flag=1 with callback still NULL -> periph->deferred_callback(NULL, ...) at :625 -> panic/garbage call.

Why the guest attempt could not hit it

The interleaving window is the handful of instructions between find()'s unlock-return and the first flag store on the allocating thread, and the freeing thread must complete TAILQ_REMOVE + dtor + kfree inside it. Precondition: a same-driver INVALID periph with a live last reference (open fd / in-flight probe) on the exact path being rescanned.

Rig attempt (race_alloc.sh, race_fdchurn.c): ~200 invalidate+rescan cycles interleaved with ~4000 open/close churns on /dev/ch0 across 90 s on the vulnerable kernel. No crash, no anomalies, refcount stable at 0 at quiesce. Expected hit rate per cycle is far below 1e-3; the bounded attempt is documented as evidence of diligence, not as refutation β€” the code path stands on the lock-domain analysis above (FreeBSD restructured this same area; the borrowed-pointer pattern is the defect).

Reproduce attempt

cd /root/df5327 && sh race_alloc.sh   # ~90 s bounded loop

(rig sysctls debug.evilcd.invalidate / .rescan drive create/destroy)

Fix

fix.diff: perform the lookup and the deferred bookkeeping under one xpt_lock_buses() hold (serializing with camperiphfree's TAILQ_REMOVE/flag read) and store deferred_callback/deferred_ac BEFORE setting CAM_PERIPH_NEW_DEV_FOUND. Not kernel-rebuilt (race not reproduced; patch is advisory from the analysis).

Guest

DragonFly dfbsd 6.5-DEVELOPMENT (EVIL kernel), evilcd2.ko rig.

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_testable
baseline no→ patch + rebuild →patched clean

fix.diff authored from the analysis; not kernel-validated (race not reproduced at runtime)

fix.diff; VERDICT.md lock-domain analysis
↓ fix.diffper-fix-DF-5328

Confirmed kernel references

Detail

Evidence (decisive lines)

VERDICT.md (full interleaving analysis), race.log (bounded attempt, no anomalies), README.md

PoC changes

race rig built from the evilcd2 rig (invalidate/rescan sysctls already present); no seed existed

Verified recommended fix

perform the units-list lookup and the deferred bookkeeping under one xpt_lock_buses() hold, storing deferred_callback/deferred_ac BEFORE the NEW_DEV_FOUND flag (fix.diff)

Verdict

cam_periph_alloc() touches periph->flags/deferred_callback/deferred_ac unlocked on the borrowed cam_periph_find() result; camperiphfree() (buses-locked remove + dtor + kfree, plus the flag read at :604) is not serialized against those stores. Bounded guest race (200 invalidate/rescan cycles x 4000 open/close churns, 90s) produced no hit -- the window is a few instructions wide and requires the dying periph's last reference to drop exactly as a rescan rediscovers the LUN (hot-plug/close race). Finding stands on the lock-domain analysis; not demonstrated at runtime.