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.
Recommended fix
--- 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)
PoC verification
Evidence pack
findings/poc/DF-2739 Β· 16 files| File | Type | Description | Size | |
|---|---|---|---|---|
| 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):
kldstatshowsrefs=0for a loaded file (observable in the run log).- A later
kldloadof a module thatMODULE_DEPENDs on it bumpsrefs0β1 (kern_linker.c:1636) β the dependent becomes the sole holder of a reference that was already consumed once. - The original user's
kldunloadthen takes the full-teardown path (refs == 1, not therefs > 1fast path) and frees the linker_file while the dependent'sdeps[]still points at it. - Unloading the dependent recurses into
linker_file_unload(freed_file)(kern_linker.c:544-545): read offreed->refs,TAILQ_REMOVEon freed memory, indirect call throughfreed->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.
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) andTAILQ_REMOVE(&linker_files, file, link)(:540) β two pointer writes into the freed chunk plus list-head surgery; - indirect call:
file->ops->unload(file)(:556) andfile->ops->lookup_setetc. β 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
userrefshandling insys_kldunload(:853-856) is symmetric and not the cause: refs underflow is observable before any user accounting. MODULE_DEPENDrefcounting 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_unloadframe is exactly the dependency recursion at :544-545 β not an unrelated list bug.
Fix verification
fixedfix.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"]
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.
No comments yet.