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

UAF in targclose: softc kfree'd before periph's targdtor runs

Field Value
ID DF-1045
Status new
Severity Medium
CVSS 3.1 CVSS:3.1/AV:L/AC:H/PR:H/UI:N/S:U/C:H/I:H/A:H
CWE CWE-416 Use After Free
File sys/bus/cam/scsi/scsi_target.c
Lines 207-248 (targclose, esp. 238-245), 527-553 (targdtor), 216-222 (early-exit), 442-448 (targenable UAF)
Area bus/cam/scsi (CAM SCSI target-mode driver /dev/targN)
Confidence likely
Discovered 2026-07-14
Reported pending
Known CVE none
CVE match dfly_specific

Summary

targclose() calls kfree(softc) (scsi_target.c:240) before cam_periph_release(periph) (line 245). The final release synchronously invokes camperiphfree() β†’ targdtor() (cam_periph.c:370-374, 600-601), which unconditionally dereferences periph->softc (still pointing at the freed softc) and walks softc->user_ccb_queue and softc->abort_queue (lines 534, 541-548, 550). A second manifestation: a privileged user who issues TARGIOCDISABLE then close() takes the early-exit at line 216 that kfrees softc while softc->periph is still registered; a subsequent TARGIOCENABLE on the same path makes targenable retrieve del_softc = periph->softc (freed) at line 446 and dereference it at lines 447-448 β†’ targdtor β†’ UAF.

Root cause

Success-path UAF

targclose:229 cam_periph_acquire bumps refcount 0β†’1; targclose:235 cam_periph_invalidate only sets CAM_PERIPH_INVALID (refcount==1 so camperiphfree is not called β€” see cam_periph.c:573-578); targclose:240 kfree(softc) frees the softc while periph->softc (assigned in targctor:521) still points at it; targclose:245 cam_periph_release drops refcount 1β†’0 with INVALID set, so camperiphfree runs synchronously (cam_periph.c:370-374) and calls targdtor. targdtor:534 does softc = (struct targ_softc *)periph->softc; (dangling), then reads softc->user_ccb_queue.tqh_first (line 541), writes the queue head via TAILQ_REMOVE (line 542), passes softc to targfreeccb (line 543), and writes softc->periph = NULL (line 550) β€” all use-after-free.

targdisable (called just above at targclose:231) populates those queues via abort_all_pending (lines 1084, 1088, and aborted CCBs return through targdone:842), so user_ccb_queue is typically non-empty when targdtor runs.

Early-exit UAF (disable-then-close)

targclose:216-222 triggers when softc->state lacks TARG_STATE_LUN_ENABLED but softc->periph != NULL (the post-TARGIOCDISABLE state). It kfrees softc with no periph cleanup, leaving periph->softc dangling in the still-registered periph. targenable:442-456 then does del_softc = (struct targ_softc *)periph->softc; (freed) and dereferences del_softc->state (line 447) and del_softc->periph (line 448) before calling cam_periph_invalidate(del_softc->periph) which invokes targdtor on the freed softc.

Threat model & preconditions

  • Attacker position: Local user holding SYSCAP_RESTRICTEDROOT (required by targopen:177 β€” real root outside chroot/jail; auto-disabled in jails/chroots per sys/caps.h:123-126).
  • Privileges gained or impact: Kernel use-after-free with attacker-influenced heap timing β€” at minimum a deterministic kernel panic on close (system-wide DoS), and with slab grooming an arbitrary kernel-memory read/write primitive, because TAILQ_FIRST / TAILQ_REMOVE in targdtor interpret attacker-controlled bytes in the reused slab as queue head + ccb_h pointers, and then targfreeccb (lines 1012-1013, 1019/1026/1030) calls kfree on ccb->ccb_h.targ_descr and on the ccb itself β€” a free-of-attacker-controlled-address primitive.
  • Required config or capabilities: A CAM path to a SCSI HBA advertising target-mode support (PIT_PROCESSOR flag, checked at targenable:435) so that TARGIOCENABLE succeeds and populates softc->periph β€” needed for the success-path variant. The disable-then-close variant only needs TARGIOCENABLE to have ever succeeded once on the path.
  • Reachability: Open /dev/targN (requires RESTRICTEDROOT) β†’ TARGIOCENABLE on a target-capable HBA β†’ either close (success-path UAF) or TARGIOCDISABLE+close (early-exit UAF). The cross-instance variant (process A disable+close, process B enable on same path) is the most reliable because no CCBs in flight are needed.

Proof of concept

PoC source: findings/poc/DF-1045/tgt_uaf.c and findings/poc/DF-1045/README.md

Success-path variant (single-user):

/* tgt_uaf.c β€” exercises the targclose success-path UAF */
int main(void) {
    int fd = open("/dev/targ0", O_RDWR);
    if (fd < 0) { perror("open /dev/targ0"); return 1; }
    struct ioc_enable_lun lun = { 0 };
    lun.path_id   = /* real bus id from `camcontrol devlist` */;
    lun.target_id = /* target id assignable on the HBA */;
    lun.lun_id    = 0;
    if (ioctl(fd, TARGIOCENABLE, &lun) != 0) {
        perror("TARGIOCENABLE (need real target-capable HBA)");
        /* fallthrough β€” variant 2 still works without this succeeding */
    }
    /* close() -> targclose -> targdisable (aborts any pending ATIOs back to
     * user_ccb_queue) -> kfree(softc) -> cam_periph_release -> targdtor ->
     * TAILQ_FIRST(&softc->user_ccb_queue) on freed memory. */
    close(fd);
    return 0;
}

Cross-instance variant (no CCBs in flight): process A on tty1 β€” open(/dev/targ0) + TARGIOCENABLE(path X) + TARGIOCDISABLE + close β€” close takes the early-exit at line 216 and kfrees softc while periph->softc stays set on path X. Process B on tty2 β€” open(/dev/targ1) + TARGIOCENABLE(path X) β€” targenable:442 cam_periph_find returns A's periph, line 446 reads del_softc from freed periph->softc, line 448 cam_periph_invalidate β†’ targdtor on A's freed softc β†’ panic.

Build & run

cc -o tgt_uaf tgt_uaf.c -I/usr/src/sys
sudo ./tgt_uaf

Expected output

Fatal trap 12: page fault while in kernel mode
fault virtual address   = 0x<address drawn from freed M_TARG slab>
targdtor(...) at scsi_target.c:541     (TAILQ_FIRST on freed softc->user_ccb_queue)
camperiphfree(...) at cam_periph.c:370
cam_periph_release(...) at cam_periph.c:600
targclose(...) at scsi_target.c:245
...

On INVARIANTS / DEBUG kernels with slab poisoning, the fault virtual address will contain poison bytes (e.g. 0xDE…). On a stock kernel, slab grooming can convert the UAF into a free-of-attacker-controlled-address primitive via targfreeccb.

To make the bug self-evident without heap grooming, add KASSERT(periph->softc != NULL, ("targdtor: softc UAF")); immediately before line 534 and rebuild the kernel β€” the assertion fires deterministically on the first close.

Impact

Local kernel UAF with a free-of-controlled-address primitive. Requires RESTRICTEDROOT to open the device, so a local unprivileged user cannot trigger this directly. The practical exposure is: (1) self-DoS by a privileged user (or buggy privileged target daemon) on close, and (2) a capability-restricted root daemon that still holds RESTRICTEDROOT (e.g. a SCSI target daemon such as the scsi_target(4) userland) that is compromised could turn this into full kernel compromise β€” escalation from compromised user-space root daemon to kernel mode.

Make targdtor the sole place softc is freed (the canonical periph-dtor-owns-softc pattern, matching scsi_sa.c:sacleanup called from sadtor). Remove the direct kfree(softc) from targclose, split the early-exit branch so the "disabled-via-ioctl but periph still registered" case actually tears the periph down via cam_periph_invalidate/release (which invokes targdtor and frees softc safely), and add kfree(softc, M_TARG) to the end of targdtor after the queue draining.

--- a/sys/bus/cam/scsi/scsi_target.c
+++ b/sys/bus/cam/scsi/scsi_target.c
@@ -210,18 +210,42 @@ targclose(struct dev_close_args *ap)
    struct targ_softc     *softc;
    struct cam_periph     *periph;
    int    error;

    softc = (struct targ_softc *)dev->si_drv1;
-   if ((softc->periph == NULL) ||
-       (softc->state & TARG_STATE_LUN_ENABLED) == 0) {
+   if (softc->periph == NULL) {
+       /* Never enabled: no periph to tear down, free softc directly. */
        devfs_clone_bitmap_put(&DEVFS_CLONE_BITMAP(targ), dev->si_uminor);
        destroy_dev(dev);
        kfree(softc, M_TARG);
        return (0);
    }
+   if ((softc->state & TARG_STATE_LUN_ENABLED) == 0) {
+       /*
+        * Disabled via TARGIOCDISABLE: periph is still registered.
+        * Tear it down so targdtor drains any user_ccb_queue and
+        * abort_queue entries and frees softc.  Must not kfree(softc)
+        * here directly β€” targdtor still references periph->softc.
+        */
+       periph = softc->periph;
+       dev->si_drv1 = 0;
+       devfs_clone_bitmap_put(&DEVFS_CLONE_BITMAP(targ), dev->si_uminor);
+       destroy_dev(dev);
+       cam_periph_acquire(periph);
+       cam_periph_lock(periph);
+       cam_periph_invalidate(periph);
+       cam_periph_unlock(periph);
+       cam_periph_release(periph); /* -> targdtor -> kfree(softc) */
+       return (0);
+   }

    /*
     * Acquire a hold on the periph so that it doesn't go away before
     * we are ready at the end of the function.
     */
    periph = softc->periph;
    cam_periph_acquire(periph);
    cam_periph_lock(periph);
    error = targdisable(softc);
    if (error == CAM_REQ_CMP) {
        dev->si_drv1 = 0;
        if (softc->periph != NULL) {
            cam_periph_invalidate(softc->periph);
            softc->periph = NULL;
        }
-       destroy_dev(dev);   /* eats the open ref */
        devfs_clone_bitmap_put(&DEVFS_CLONE_BITMAP(targ), dev->si_uminor);
-       kfree(softc, M_TARG);
+       destroy_dev(dev);   /* eats the open ref */
+       /* softc is freed by targdtor via cam_periph_release below. */
    } else {
        release_dev(dev);
    }
    cam_periph_unlock(periph);
    cam_periph_release(periph);

    return (error);
 }
@@ -545,6 +569,7 @@ targdtor(struct cam_periph *periph)

    softc->periph = NULL;
    softc->path = NULL;
+   kfree(softc, M_TARG);
    periph->softc = NULL;
 }

With this patch, softc is freed exactly once and only after targdtor has finished draining user_ccb_queue and abort_queue; periph->softc is cleared immediately afterward. targclose never touches softc after cam_periph_release() returns. All three close paths (never-enabled, disabled-via-ioctl, enabled-then-closed) are now use-after-free free.

References

Timeline

  • 2026-07-14 Discovered during automated audit.

Discussion (0)

No comments yet.

PoC verification

Evidence pack

findings/poc/DF-1045 Β· 17 files
FileTypeDescriptionSize
tgt_uaf.c trigger-source original reviewer-written PoC (opens /dev/targN) 3.6 KB view raw
test_targ.c trigger-source fix-validation test (opens /dev/targ base + TARGIOCENABLE on ATA) 1.1 KB view raw
build.sh build-script cc -o tgt_uaf tgt_uaf.c 444 B view raw
run.sh run-script runs the PoC, documents /dev/targ absence on default kernel 848 B view raw
fix.diff suggested-fix git-apply-able fix: make targdtor sole owner of softc kfree 1.8 KB view raw
VERDICT.md verdict full source-level UAF trace + fix validation details 8.2 KB ↓ raw
README.md readme original reviewer README 3.2 KB ↓ raw
build.log build-log PoC build output (compiles cleanly) 104 B view raw
run.log run-log PoC run on default kernel (open fails, no /dev/targ) 336 B view raw
env.txt environment guest env: uname, kern.version, no targ symbols, no /dev/targ, camcontrol devlist 1.1 KB view raw
fix_build.log build-log full nativekernel+installkernel build of patched kernel with device targ 5.7 MB ↓ download
fix_run.log run-log patched #1 kernel: open+TARGIOCENABLE(ATA)+close β€” no panic (bug path unreachable) 692 B view raw
patched_env.txt environment patched #1 kernel version + sha256 305 B view raw
run_patched_maxx.log run-log PoC as maxx on patched kernel (open fails: EPERM) 48 B view raw
run_patched_root.log run-log PoC as root on patched kernel 5 B view 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 original reviewer README
↓ download raw

DF-1045 PoC β€” SCSI target driver targclose UAF

Trigger

Two variants of a single UAF in targclose, both reachable from any local user holding SYSCAP_RESTRICTEDROOT (i.e. real root outside jail/chroot, which is what targopen:177 requires).

Variant A β€” success-path UAF

open(/dev/targN) + TARGIOCENABLE(path_id, target_id, lun_id) on a target-capable HBA, then close(). The close sequence is:

targclose β†’ targdisable (aborts any pending ATIOs back to user_ccb_queue) β†’ kfree(softc) at line 240 β†’ cam_periph_release β†’ targdtor at line 528 β†’ TAILQ_FIRST(&softc->user_ccb_queue) on freed memory at line 541.

Variant B β€” early-exit / cross-instance UAF (no CCBs in flight)

Process A: open + TARGIOCENABLE + TARGIOCDISABLE + close. Close takes the early-exit at line 216 (state lacks TARG_STATE_LUN_ENABLED, periph != NULL) and kfrees softc while leaving periph->softc dangling in the still-registered periph.

Process B: open(/dev/targM) + TARGIOCENABLE(same path X). targenable at line 446 retrieves del_softc = periph->softc (already freed by A) and at line 447 dereferences del_softc->state β†’ page fault.

Build & run

cc -o tgt_uaf tgt_uaf.c
sudo ./tgt_uaf <path_id> <target_id> <lun_id>                 # variant A
sudo ./tgt_uaf <path_id> <target_id> <lun_id> --disable-then-close   # variant B step 1
sudo ./tgt_uaf <path_id> <target_id> <lun_id>                 # variant B step 2

path_id, target_id, lun_id come from camcontrol devlist and must match a path on a SCSI HBA whose driver advertises the PIT_PROCESSOR flag (so that TARGIOCENABLE succeeds). Examples that work in DragonFlyBSD: ahc(4), ahd(4) with target mode, or mpt(4).

Expected output

Fatal trap 12: page fault while in kernel mode
fault virtual address   = 0x<address drawn from freed M_TARG slab>
targdtor(...) at scsi_target.c:541
camperiphfree(...) at cam_periph.c:370
cam_periph_release(...) at cam_periph.c:600
targclose(...) at scsi_target.c:245
spec_strategy(...) at ...
... close() syscall path ...

On INVARIANTS / DEBUG kernels with slab poisoning, the fault virtual address will contain poison bytes (e.g. 0xDE…). On a stock kernel, slab grooming can convert the UAF into a free-of-attacker-controlled-address primitive via targfreeccb (which calls kfree on ccb->ccb_h.targ_descr and on the ccb itself).

Making the bug self-evident

If you cannot run a live HBA target-mode test, add the following KASSERT to sys/bus/cam/scsi/scsi_target.c just before line 534 and rebuild:

KASSERT(periph->softc != NULL, ("targdtor: softc UAF"));

The assertion fires deterministically on the first close() after a successful TARGIOCENABLE.

Kernel references

VERDICT.md verdict full source-level UAF trace + fix validation details
↓ download raw

DF-1045 β€” VERDICT

Verdict

NOT REPRODUCED on guest (latent source-level UAF, confirmed by line-by-line trace). The bug is real β€” a use-after-free in targclose where kfree(softc) runs before cam_periph_release(periph) which synchronously invokes targdtor on the freed softc β€” but it is not triggerable on this guest for two independent reasons, each sufficient on its own:

  1. The targ driver is not compiled into the default kernel. sys/conf/files line 45: bus/cam/scsi/scsi_target.c optional targ. X86_64_GENERIC does not contain device targ (verified: grep -c 'device targ' = 0). No /dev/targ* device exists, no targ.ko module exists, and nm /boot/kernel/kernel | grep -w targclose returns empty. The vulnerable code is simply absent from the running kernel.

  2. Even with the driver compiled in, no target-capable SCSI HBA is present. The QEMU guest only exposes ATA buses (ata0, ata1 with a QEMU DVD-ROM). targenable() at scsi_target.c:435 rejects paths where (cpi->target_sprt & PIT_PROCESSOR) == 0, and ATA SIMs never set PIT_PROCESSOR. So TARGIOCENABLE always fails before softc->periph is populated, and targclose always takes the safe "never enabled" early-exit branch.

A third, lesser gate: targopen() at scsi_target.c:177 requires SYSCAP_RESTRICTEDROOT (real root outside jail/chroot), so this is not a local-unprivileged→kernel bug regardless.

Mechanism (source-level UAF β€” confirmed by trace)

The UAF is in the success path of targclose (scsi_target.c:207-248):

  1. targclose:228 β€” periph = softc->periph (non-NULL after successful TARGIOCENABLE).
  2. targclose:229 β€” cam_periph_acquire(periph) bumps refcount 0β†’1 (periph starts at refcount=0 per cam_periph_alloc at cam_periph.c:215).
  3. targclose:231 β€” targdisable(softc) aborts pending CCBs back to user_ccb_queue / abort_queue, clears TARG_STATE_LUN_ENABLED.
  4. targclose:235 β€” cam_periph_invalidate(softc->periph) sets CAM_PERIPH_INVALID. Since refcount==1, camperiphfree is NOT called yet (cam_periph.c:574: only if refcount==0).
  5. targclose:240 β€” kfree(softc, M_TARG) FREES THE SOFTC. periph->softc (set at targctor:521) is now dangling.
  6. targclose:245 β€” cam_periph_release(periph) enters the slow path (cam_periph.c:352-353: INVALID && refcount==1), decrements to 0, and calls camperiphfree (cam_periph.c:373).
  7. camperiphfree:600-601 β€” calls periph->periph_dtor(periph) = targdtor.
  8. targdtor:534 β€” softc = (struct targ_softc *)periph->softc β†’ dangling.
  9. targdtor:541 β€” TAILQ_FIRST(&softc->user_ccb_queue) reads freed memory.
  10. targdtor:542-543 β€” TAILQ_REMOVE + targfreeccb write/call on freed softc.
  11. targdtor:550 β€” softc->periph = NULL writes to freed memory.

This is a textbook UAF: free at step 5, dereference at steps 8-11.

A second manifestation (early-exit path) exists when the user does TARGIOCENABLE then TARGIOCDISABLE then close(): targclose:216-222 kfrees softc while periph->softc stays dangling in the still-registered periph. A subsequent TARGIOCENABLE on the same path via targenable:442-448 retrieves del_softc = periph->softc (freed) and dereferences it.

Why it cannot be triggered on this guest

Gate Status on guest Effect
device targ in X86_64_GENERIC ABSENT scsi_target.c not compiled; no /dev/targ
SCSI HBA with PIT_PROCESSOR ABSENT (only ATA ata0/ata1) TARGIOCENABLE fails at scsi_target.c:435
SYSCAP_RESTRICTEDROOT Present (root) Not a blocker for root

For the fix-validation boot, I added device targ to the config and rebuilt β€” this brought the targ driver online (/dev/targ appeared, targclose/targdtor symbols in kernel.debug). I then opened /dev/targ as root and issued TARGIOCENABLE on the ATA bus: it returned ENOTTY (ATA does not support target mode), so softc->periph stayed NULL and targclose took the safe "never enabled" early-exit. No panic, guest healthy. The bug path remained unreachable.

Exploit chain

Not applicable — no chain developed because the bug is not triggerable on this guest (valid Phase 6 blocker: the vulnerable code path is dead at runtime on this guest AND no harness can exercise it without absent hardware). The bug requires device targ in the kernel config AND a SCSI HBA advertising PIT_PROCESSOR (target mode) — neither is present. The primitive IS characterizable at the source level (free-of-attacker-influenced-address via targfreeccb if slab grooming shapes the reused M_TARG chunk), but the precondition (RESTRICTEDROOT + target HBA) means this is a root→kernel hardening gap, not a local-unpriv→kernel escalation.

Fix validation (Phase 8)

fix_status: not_testable β€” the bug path cannot be exercised on this guest.

What was validated: 1. Fix applies cleanly β€” patch -p1 --forward < fix.diff succeeds (3 hunks). 2. Fix compiles β€” rebuilt X86_64_GENERIC + device targ with make nativekernel; scsi_target.c compiled with -Werror (no warnings), kernel linked successfully. targclose/targdtor symbols present in kernel.debug. 3. Patched kernel boots β€” #1 kernel (Jul 14 09:16:43) boots cleanly, /dev/targ active, guest healthy. 4. Source-level verification β€” patched targclose no longer has kfree(softc) in the success path or the disabled-via-ioctl early-exit; targdtor is now the sole place softc is freed (after queue draining, before clearing periph->softc).

What could NOT be validated: live reproduction of the UAF on the patched kernel (there is no target-capable HBA to make TARGIOCENABLE succeed, so the bug path that frees-then-dereferences softc is never entered).

The fix

The fix makes targdtor the sole owner of softc deallocation (the canonical periph-dtor-owns-softc pattern, matching scsi_sa.c:sadtor/sacleanup):

  1. targclose early-exit: split into two branches. - softc->periph == NULL (never enabled): kfree directly (safe β€” no periph). - softc->periph != NULL AND !(state & LUN_ENABLED) (disabled via ioctl): now tears down the periph via cam_periph_acquire β†’ cam_periph_invalidate β†’ cam_periph_release which invokes targdtor β†’ frees softc safely.

  2. targclose success path: removed kfree(softc); softc is freed by targdtor via the final cam_periph_release.

  3. targdtor: added kfree(softc, M_TARG) after queue draining and before clearing periph->softc = NULL.

This ensures softc is freed exactly once and only after targdtor has finished all queue operations. periph->softc is cleared immediately after the free.

PoC changes

  • tgt_uaf.c β€” unchanged (the reviewer-written trigger; compiles cleanly).
  • build.sh / run.sh β€” added reproducible build/run scripts.
  • test_targ.c β€” added a direct open(/dev/targ) + TARGIOCENABLE test for the fix-validation kernel (the original PoC opens /dev/targN which doesn't exist until the autoclone handler runs on /dev/targ).
  • fix.diff β€” authored the git-apply-able fix (supersedes finding proposal; same approach, cleaner implementation of the early-exit split).

Kernel references (confirmed)

Fix verification

not_testable
baseline no→ patch + rebuild →patched clean

not_testable (no SCSI target HW). Compile+boot validated: fix applies, compiles -Werror, boots #1. targdtor now sole owner of softc free.

Fix compiles+boots. targclose kfree removed. targdtor has kfree. No live repro possible (no PIT_PROCESSOR HW).
↓ fix.diffDragonFly 6.5-DEVELOPMENT #1: Tue Jul 14 09:16:43 UTC 2026

Confirmed kernel references

Detail

Exploit chain

none -- dead code (no device targ) + no SCSI target HW. Root-only (SYSCAP_RESTRICTEDROOT). Not a local-unpriv escalation.

Evidence (decisive lines)

Default kernel: no /dev/targ, no targclose in nm. Patched #1 (device targ+fix): open OK, TARGIOCENABLE ENOTTY (ATA no target mode), close clean. Bug path unreachable.

PoC changes

Authored from scratch: tgt_uaf.c + test_targ.c, fix.diff (targdtor owns softc deallocation, removes kfree from targclose), VERDICT.md, manifest.json.

Verified recommended fix

Make targdtor sole owner of softc free: (1) targclose early-exit split (periph==NULL->kfree, periph!=NULL&&!LUN_ENABLED->release); (2) remove kfree(softc) from success path; (3) add kfree(softc) to targdtor after queue drain. Full diff in findings/poc/DF-1045/fix.diff.

Verdict

NOT REPRODUCED -- latent UAF confirmed by trace but dead code on guest. (1) scsi_target.c is 'optional targ', no 'device targ' in GENERIC, no /dev/targ. (2) Even compiled in, QEMU guest has no target-capable SCSI HBA (PIT_PROCESSOR). targclose:240 kfree(softc) BEFORE cam_periph_release -> targdtor -> reads freed periph->softc at :534/:541.