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

linker_file_unload() veto path drops the caller's reference on a failed unload β€” refcount underflow on a live linker_file β†’ premature teardown under a live dependent β†’ use-after-free

Field Value
ID DF-2739
Status new
Severity Medium
CVSS 3.1 CVSS:3.1/AV:L/AC:L/PR:H/UI:N/S:C/C:H/I:H/A:H
CWE CWE-911 Improper Update of Reference Count (β†’CWE-416)
File sys/kern/kern_linker.c
Lines 514-520 (fast path :494, teardown :540-549, deps++ :1636)
Area kern
Confidence certain
Discovered 2026-08-30
Pass 2 (GLM 5.3 second pass)
Bucket memcorrupt
Reported pending
Known CVE none
CVE match novel

Summary

When a module vetoes an unload (module_unload()!=0), linker_file_unload() releases llf_lock and then executes file->refs--, dropping the caller's reference even though the unload failed and the file remains live. kldstat then reports refs=0 for a loaded file. A subsequent kldload of a module that MODULE_DEPENDs on it bumps refs 0β†’1, so the dependent is the sole holder of a reference already consumed once; the original user's next kldunload sees refs==1, skips the fast path, and fully tears the file down β€” freeing the linker_file while the dependent's deps[] still points at it. Unloading the dependent then recurses into linker_file_unload(freed): read of freed->refs, TAILQ_REMOVE on freed memory, and an indirect call through freed->ops->unload β€” deterministic UAF read/write/indirect-call on an M_LINKER chunk (same zone as link_elf's elf_file/symtab allocations, trivially groomable).

Threat model & preconditions

Privileged (kldload/kldunload gated by SYSCAP_NOKLD) but deterministic kernel memory corruption on the standard kld management path: any driver whose MOD_UNLOAD returns EBUSY while busy (the normal veto idiom) turns routine kldunload retries into UAF. On INVARIANTS it panics; on production kernels it is silent heap corruption with an RIP-controllable indirect call. No unpriv route (the SYSCAP gate is the hard blocker).

Proof of concept

Reproduced on the stock INVARIANTS guest with pure syscalls (findings/poc/DF-2739/): vetoa.ko (MOD_UNLOAD returns EBUSY under a sysctl, MODULE_VERSION 1) β†’ kldunload β†’ EBUSY with refs observed 0 β†’ kldload depb.ko (MODULE_DEPEND on vetoa) β†’ refs 1 β†’ veto off, kldunload β†’ vetoa fully torn down while depb->deps[0] dangles β†’ kldunload(depb) β†’ panic: Bad link elm ... next->prev != elm with nested linker_file_unload frames. Fix (remove the veto-path refs--) validated on a rebuilt kernel: refs 1β†’1β†’2β†’1, vetoa survives the dependent, clean unload, no panic.

--- a/sys/kern/kern_linker.c
+++ b/sys/kern/kern_linker.c
@@ -514,8 +514,11 @@ linker_file_unload(linker_file_t file)
    if ((error = module_unload(mod)) != 0) {
        KLD_DPF(FILE, ("linker_file_unload: module %p vetoes unload\n",
               mod));
+       /*
+        * The unload failed, so the caller's reference must
+        * survive.  Do NOT drop it here.
+        */
        lockmgr(&llf_lock, LK_RELEASE);
-       file->refs--;
        goto out;
    }

(validated on a rebuilt guest kernel)

Timeline

  • 2026-08-30 Discovered during pass-2 audit of kern_linker.c (GLM 5.3); deterministic panic reproduced + fix validated same run.

Discussion (0)

No comments yet.

PoC verification

Evidence pack

findings/poc/DF-2739 Β· 16 files
FileTypeDescriptionSize
README.md β€” 2.7 KB ↓ raw
VERDICT.md β€” 5.2 KB ↓ raw
poc.c β€” 3.4 KB view raw
vetoa_mod/vetoa.c β€” 956 B view raw
vetoa_mod/Makefile β€” 49 B ↓ download
depb_mod/depb.c β€” 749 B view raw
depb_mod/Makefile β€” 47 B ↓ download
Makefile β€” 220 B ↓ download
build.sh β€” 338 B view raw
run.sh β€” 640 B view raw
build.log β€” 522 B view raw
panic.txt β€” 876 B view raw
env.txt β€” 370 B view raw
fix.diff β€” 425 B view raw
manifest.json β€” 1.7 KB view raw
verdict.json β€” 5.5 KB view raw

DF-2739 β€” linker_file_unload() veto-path refcount underflow β†’ premature teardown β†’ UAF

What

sys/kern/kern_linker.c:linker_file_unload(). When a module vetoes the unload (module_unload(mod) != 0), the function releases llf_lock and then unconditionally executes file->refs-- (kern_linker.c:517-518) β€” dropping the caller's reference even though the unload failed and the file stays live on linker_files. The reference count is driven to 0 on a live object.

Consequences (all deterministic, syscall-only):

  1. kldstat shows refs=0 for a loaded file (observable in the run log).
  2. A later kldload of a module that MODULE_DEPENDs on it bumps refs 0β†’1 (kern_linker.c:1636) β€” the dependent becomes the sole holder of a reference that was already consumed once.
  3. The original user's kldunload then takes the full-teardown path (refs == 1, not the refs > 1 fast path) and frees the linker_file while the dependent's deps[] still points at it.
  4. Unloading the dependent recurses into linker_file_unload(freed_file) (kern_linker.c:544-545): read of freed->refs, TAILQ_REMOVE on freed memory, indirect call through freed->ops β€” use-after-free read/write/indirect-call.

Reproduce (on the DragonFly guest, as root)

cd /tmp/kldtest
csh build.sh      # builds vetoa.ko, depb.ko, ./poc
./poc             # run.sh

Expected on a vulnerable kernel (decisive lines from run.log):

== step 2: ... kldunload(4) = -1 errno=16 (Device busy)
   after vetoed unload        vetoa.ko   : id=4 refs=0     <-- refs==0, file LIVE
== step 3: ... dep loaded; vetoa refs     vetoa.ko   : id=4 refs=1
== step 4: ... kldunload(4) = 0           <-- premature teardown under live dependent
   after second unload        vetoa.ko   : NOT LOADED
*** BUG REPRODUCED ... ***
== step 5: ... -> panic
panic: Bad link elm 0xfffff8008bb64280 next->prev != elm
linker_file_unload() at linker_file_unload+0x478
linker_file_unload() at linker_file_unload+0x478
linker_file_unload() at linker_file_unload+0x14e
sys_kldunload() at sys_kldunload+0x81

The two nested linker_file_unload frames are the dependency-unload recursion (kern_linker.c:545) operating on the freed object.

Fix

fix.diff β€” one-line removal of the erroneous file->refs-- on the veto path (the caller keeps its reference when the unload fails). Validated on a rebuilt kernel: see VERDICT.md.

Trigger privilege

kldload(2)/kldunload(2) are gated by caps_priv_check_self(SYSCAP_NOKLD) (kern_linker.c:794, :841) β€” the deterministic trigger requires root. The bug is a kernel-robustness / memory-corruption defect on the privileged kld management path, not an unprivileged escalation.

VERDICT.md
↓ download raw

DF-2739 VERDICT β€” REPRODUCED (kernel memory corruption / UAF, panic proof)

Bottom line

The veto path in linker_file_unload() (sys/kern/kern_linker.c:514-520) drops the caller's reference when a module vetoes the unload. This is a refcount underflow on a live linker_file that deterministically leads to a premature full teardown underneath a live dependent module and then a use-after-free when the dependent is unloaded. Reproduced end-to-end on the stock INVARIANTS guest with nothing but kldload(2)/kldunload(2)/kldstat(2) syscalls and two purpose-built KLDs (vetoa.ko, depb.ko).

How it reproduces (baseline run, run.log + panic.txt)

step syscall sequence kernel state (kern_linker.c) observed
1 kldload("/tmp/kldtest/vetoa.ko") linker_load_file β†’ refs=1, userrefs=1 (sys_kldload:820) id=4 refs=1
2 sysctl kern.vetoa_veto=1; kldunload(4) module handler returns EBUSY β†’ module_unload(mod)!=0 β†’ file->refs-- at :518 with the file still on linker_files; sys_kldunload restores userrefs (:856) EBUSY, kldstat: refs=0, file loaded
3 kldload("/tmp/kldtest/depb.ko") (MODULE_DEPEND(depb, vetoa,1,1,1)) linker_load_dependencies β†’ modlist_lookup2("vetoa") hit β†’ lfdep->refs++ (:1636) 0β†’1 refs=1
4 sysctl kern.vetoa_veto=0; kldunload(4) refs==1 β†’ NOT the refs>1 fast path β†’ full teardown of vetoa; TAILQ_REMOVE(&linker_files,...), kfree(file) β€” while depb->deps[0]==vetoa kldunload=0, kldfind(vetoa)=ENOENT, depb still loaded
5 kldunload(5) linker_file_unload(depb) β†’ dep loop linker_file_unload(file->deps[0]) (:544-545) on the freed object β†’ freed->refs read, TAILQ_REMOVE on freed memory, freed->ops->unload indirect call panic: Bad link elm ... next->prev != elm, backtrace shows the nested linker_file_unload frames

Decisive console lines (panic.txt):

vetoa: MOD_UNLOAD vetoed -> EBUSY
vetoa: MOD_UNLOAD accepted
depb: MOD_UNLOAD accepted
panic: Bad link elm 0xfffff8008bb64280 next->prev != elm
cpuid = 0
linker_file_unload() at linker_file_unload+0x478
linker_file_unload() at linker_file_unload+0x478
linker_file_unload() at linker_file_unload+0x14e
sys_kldunload() at sys_kldunload+0x81

Primitive characterization

Freed object: struct linker_file (~200 bytes, M_LINKER kmalloc zone β€” the same zone used by link_elf's elf_file, pathname and symbol-table allocations, so zone grooming by the (privileged) attacker is straightforward). The UAF gives:

  • write: file->refs-- on the freed chunk (fast path, :494-497) and TAILQ_REMOVE(&linker_files, file, link) (:540) β€” two pointer writes into the freed chunk plus list-head surgery;
  • indirect call: file->ops->unload(file) (:556) and file->ops->lookup_set etc. β€” control flow through a pointer read from the freed chunk on non-INVARIANTS builds;
  • read: strlen(lf->filename), lf->deps[] walk.

On the INVARIANTS guest the TAILQ invariant check converts this into an immediate panic (proof). On production (non-INVARIANTS) builds this is silent heap corruption fully controllable by the already-privileged caller.

Why it stops short of uid=0

The only trigger entry points are kldunload(2)/kldload(2), both gated by caps_priv_check_self(SYSCAP_NOKLD) (sys/kern/kern_linker.c:794, :841); there is no unprivileged path that reaches the veto branch (netgraph's auto-load paths are root-gated at ngc_attach, sys/netgraph7/socket/ng_socket.c:182). So the honest impact ceiling is: deterministic kernel UAF on the privileged kld management path β€” kernel robustness/memory-corruption, not an unprivileged escalation. (Hard blocker: privilege gate at :841, verified by tracing every linker_file_unload caller.)

Fix validation

fix.diff removes the erroneous file->refs-- (the caller keeps its reference when unload fails). Applied to the guest's /usr/src, kernel rebuilt with make -j6 nativekernel KERNCONF=X86_64_GENERIC + installkernel, guest rebooted into #1: Mon Aug 31 12:46:55 UTC 2026, exact same PoC re-run (run.fixed.log):

step baseline (stock #0) patched (#1)
2 vetoed unload EBUSY, refs=0, file live EBUSY, refs=1
3 depb load vetoa refs=1 vetoa refs=2
4 second unload vetoa torn down (kldfind ENOENT, depb dangling) fast path refs 2β†’1, vetoa stays loaded
5 depb unload panic: Bad link elm … next->prev != elm (nested linker_file_unload frames) clean unload, returns 0, guest up

Bad behavior eliminated; both modules end cleanly loadable/unloadable (kldstat tail in run.fixed.log).

Cross-checks that rule out alternative explanations

  • The userrefs handling in sys_kldunload (:853-856) is symmetric and not the cause: refs underflow is observable before any user accounting.
  • MODULE_DEPEND refcounting itself is correct on both load and unload when no veto occurs (control runs without the veto: clean load/unload cycles).
  • The panic backtrace's double linker_file_unload frame is exactly the dependency recursion at :544-545 β€” not an unrelated list bug.

Fix verification

fixed
baseline reproduced→ patch + rebuild →patched clean

fix.diff applied to guest /usr/src, kernel rebuilt (make -j6 nativekernel KERNCONF=X86_64_GENERIC) and installed, guest rebooted into #1. Exact same PoC re-run: step-2 EBUSY leaves refs=1 (no underflow), step-3 refs=2, step-4 kldunload takes the refs>1 fast path and vetoa STAYS loaded (kldstat refs=1), step-5 dependent unload is clean. No panic, no corruption, guest remains up. Bad behavior eliminated.

["run.fixed.log (vetoa.ko refs sequence 1 -> 1 -> 2 -> 1, 'poc exit: 0', kldstat tail shows both modules loaded and unloadable)", "VERDICT.md 'Fix validation' section"]
↓ fix.diffDragonFly dfbsd 6.5-DEVELOPMENT DragonFly 6.5-DEVELOPMENT #1: Mon Aug 31 12:46:55 UTC 2026 root@dfbsd:/usr/obj/usr/src/sys/X86_64_GENERIC x86_64

Confirmed kernel references

Detail

Exploit chain

root: kldload vetoa.ko (refs=1,userrefs=1) -> kern.vetoa_veto=1 -> kldunload (module vetoes; kernel refs-- -> refs=0, file live) -> kldload depb.ko (MODULE_DEPEND vetoa; refs 0->1) -> veto off -> kldunload vetoa (refs==1 -> FULL TEARDOWN under live dependent; depb->deps[0] dangles) -> kldunload depb -> linker_file_unload(freed vetoa) -> TAILQ_REMOVE on freed chunk -> panic (INVARIANTS) / silent heap corruption + controllable ops->unload indirect call on production builds. Privilege gate: SYSCAP_NOKLD (root-only trigger).

Evidence (decisive lines)

['run.log β€” baseline: refs=0 after vetoed unload, premature teardown at step 4, hang/panic at step 5 (ssh timed out as guest died)', "panic.txt β€” 'panic: Bad link elm 0xfffff8008bb64280 next->prev != elm' + nested linker_file_unload frames from sys_kldunload", 'run.fixed.log β€” patched kernel: refs stays 1 after veto, refs=2 after dep load, vetoa survives step-4 unload (refs=1), clean depb unload, guest up', 'build.log / build2 kernel install in VERDICT.md', 'fix.diff β€” one-line removal of the veto-path refs--']

PoC changes

Seed had no runnable PoC. Authored: vetoa.ko (module whose MOD_UNLOAD returns EBUSY while sysctl kern.vetoa_veto=1), depb.ko (MODULE_DEPEND on vetoa), poc.c (syscall orchestration + kldstat refs observability). Fixed the bug-marker heuristic after fix validation (key on kldfind ENOENT, not on kldunload success).

Verified recommended fix

Remove the file->refs-- on the module-veto path of linker_file_unload(): the caller's reference must survive a failed unload (fix.diff, validated).

Verdict

REPRODUCED end-to-end on the stock INVARIANTS guest with pure syscalls (kldload(2)/kldunload(2)/kldstat(2)) and two purpose-built KLDs. linker_file_unload()'s module-veto path (sys/kern/kern_linker.c:517-518) drops the caller's reference even though the unload failed, leaving a live linker_file with refs==0 (directly observable via kldstat: 'vetoa.ko refs=0' while loaded). A subsequent MODULE_DEPEND load bumps refs 0->1 (kern_linker.c:1636); the original user's kldunload then takes the full-teardown path and frees the file while the dependent's deps[] still references it. Unloading the dependent recurses into linker_file_unload(freed) (kern_linker.c:544-545) -> use-after-free: read of freed->refs, TAILQ_REMOVE on freed memory, indirect call via freed->ops. Guest panicked with 'Bad link elm ... next->prev != elm' and a backtrace showing the nested linker_file_unload frames. Primitive: deterministic UAF read+write+indirect-call on an M_LINKER chunk (~200B, groomable via the linker's own allocations). uid=0 escalation is NOT reachable: both kldload(2) and kldunload(2) are gated by caps_priv_check_self(SYSCAP_NOKLD) (kern_linker.c:794,:841) β€” the hard blocker is privilege, verified by tracing every linker_file_unload caller. Fix validated on a rebuilt kernel (make nativekernel): with the erroneous refs-- removed, refs never underflows (1->2->1), vetoa survives the dependent, dependent unload is clean, no panic, guest stays up.