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

eventhandler_deregister() performs no tag/list validation β€” wrong-list or stale tag deterministically corrupts both lists' tail sentinels (write-after-free, cross-list handler aliasing, double free); tag==NULL silently frees an entire shared list

Field Value
ID DF-2973
Status new
Severity Low
CVSS 3.1 CVSS:3.1/AV:L/AC:L/PR:H/UI:N/S:U/C:N/I:H/A:H
CWE CWE-20 / CWE-416 / CWE-672
File sys/kern/subr_eventhandler.c
Lines 123-133 (TAILQ semantics: queue.h:646-660)
Area kern
Confidence certain
Discovered 2026-09-02
Pass 2 (GLM 5.3 second pass)
Bucket memcorrupt
Reported pending
Known CVE none
CVE match novel

Summary

eventhandler_deregister(list, tag) executes TAILQ_REMOVE + kfree on pure faith (:123-126). Because TAILQ_REMOVE only updates the head when the element is LAST, deregistering a tag that is the last entry of its real list B through a different list A (i) redirects A->tqh_last into B's live entry and (ii) leaves B->tqh_last dangling at the entry being freed, then frees it. The next registration on B performs *(B->tqh_last)=new — a heap-pointer write into the freed chunk (write-after-free); the next registration on A aliases B's traversal into A's entries (B executes A's handlers). A stale/double tag performs an unlink+kfree of freed memory: idempotent link writes plus a double free. ep==NULL is a documented mode that frees EVERY entry of a globally shared list, dangling all other consumers' saved tags. No in-tree trigger found (all 30+ call sites audited; nearest misses: oce_if.c stale softc tags on failed attach, if_vmx.c wrong-list register that is at least self-consistent at deregister). Reachable by any KLD/driver mistake: a single wrong-name DEREGISTER or a double-deregister in an error path yields deterministic kernel heap corruption — on INVARIANTS kernels a guaranteed panic inside the next eventhandler_register; on production kernels a silent write-after-free into a freed 40-byte M_EVENTHANDLER chunk plus cross-list handler aliasing and a double free. Trigger requires kernel code (root to load a KLD), hence Low despite the deterministic corruption primitive. VERIFIED via KLD PoC on stock INVARIANTS guest: two corruption signatures then deterministic 'panic: Bad tailq NEXT(...)->tqh_last != NULL' inside eventhandler_register. No user→root route (kernel-API misuse, not an unprivileged syscall). Fix: membership validation under the existing token before touching memory (row diff).

Timeline

  • 2026-09-02 Discovered during pass-2 audit of subr_eventhandler.c (GLM 5.3); corruption reproduced deterministically via KLD.

Discussion (0)

No comments yet.

PoC verification

Evidence pack

findings/poc/DF-2973 Β· 14 files
FileTypeDescriptionSize
df2973.c β€” 7.1 KB view raw
Makefile β€” 52 B ↓ download
build.sh β€” 531 B view raw
run.sh β€” 358 B view raw
build.log β€” 5.5 KB view raw
run.log β€” 2.6 KB view raw
run.fixed.log β€” 1.1 KB view raw
boot_excerpt.txt β€” 2.6 KB view raw
panic.txt β€” 880 B view raw
env.txt β€” 394 B view raw
fixbuild.log β€” 1.3 KB view raw
fix.diff β€” 955 B view raw
VERDICT.md β€” 5.1 KB ↓ raw
README.md β€” 2.5 KB ↓ raw

DF-2973 β€” eventhandler_deregister() performs no tag/list validation

Target: sys/kern/subr_eventhandler.c:116-136 Class: memory corruption (API-contract / latent consumer-misuse enabler) Trigger: any kernel code (in-tree driver bug, out-of-tree KLD, or the documented-but-dangerous tag == NULL "wipe" mode) that calls eventhandler_deregister() with a tag that is not currently linked into the supplied list.

Build

In-guest (KLD):

sh findings/poc/DF-2973/build.sh      # transfers df2973.c + Makefile, runs make

Run

sh findings/poc/DF-2973/run.sh        # kldload; MOD_LOAD performs all stages

Expected (baseline, unfixed kernel)

Deterministic (race-free, single-threaded) β€” first kldload:

DF2973[df2973] CONFIRMED: A->tqh_last redirected into B's LIVE entry b1 (…)
DF2973[df2973] CONFIRMED: B->tqh_last still points at FREED b2 (…) after its removal
panic: Bad tailq NEXT(…->tqh_last) != NULL, last 0xffffffffffffffff
eventhandler_register() at eventhandler_register+0x1f0
stage1() at stage1+0x11a
…

The two CONFIRMED lines are the corruption signatures (wrong-head TAILQ_REMOVE redirected list A's tail sentinel into list B's live entry and left list B's tail sentinel dangling at the just-freed entry). The panic is the next registration dereferencing the freed chunk through the dangling sentinel β€” caught by the QMD_TAILQ_CHECK_TAIL INVARIANT; on a non-INVARIANTS kernel the write (*(B->tqh_last) = b3) lands silently in freed heap memory (write-after-free) and the entry becomes unreachable (b3 invisible to traversal), with cross-list aliasing of live entries on the next append to A.

On the patched kernel (fix.diff): stage1's wrong-list deregistration is ignored with a console diagnostic, no corruption signatures, no panic, DF2973 TOTAL confirmed=0.

Stages

  1. wrong-list deregistration (eventhandler_deregister(A, b2) where b2 lives on list B and is B's last entry) β†’ sentinel corruption of both lists, write-after-free, cross-list aliasing.
  2. tag == NULL mass-wipe + re-deregister of a saved (now dangling) tag β†’ freed-memory TAILQ_REMOVE, head pollution, double kfree (provable by allocation aliasing: two registrations returning the same chunk).
  3. 2000Γ— stress repetitions of stage 1 with fresh names (volume/repeatability).

Stage 2 and 3 are never reached on the stock kernel because stage 1's corruption panics on the next registration; they are defense-in-depth for non-INVARIANTS kernels.

VERDICT.md
↓ download raw

DF-2973 VERDICT

Bottom line

REPRODUCED β€” deterministic kernel panic from unvalidated eventhandler_deregister() (memory-corruption primitive), fixed by the membership check in fix.diff and re-verified clean on a patched kernel.

  • status: reproduced (impact panic; underlying primitive is write-after-free + cross-list entry aliasing, caught deterministically by the stock kernel's TAILQ INVARIANT)
  • attempts: 1 (deterministic β€” no race, no retry)
  • confidence: certain

What the bug is

eventhandler_deregister() (sys/kern/subr_eventhandler.c:116-136) takes a list and a tag on pure faith:

if (ep != NULL) {
    TAILQ_REMOVE(&list->el_entries, ep, ee_link);   /* line 125 */
    kfree(ep, M_EVENTHANDLER);                      /* line 126 */
}

There is no check that ep is actually linked into list->el_entries, no protection against a stale (already-deregistered) tag, and the ep == NULL case silently frees every entry on a globally shared list (lines 129-133), dangling every other consumer's saved tag.

Why it corrupts memory (mechanism, queue.h-exact)

TAILQ_REMOVE(head, elm) only touches head when elm is the last element (sys/sys/queue.h:646-660): it sets head->tqh_last = elm->tqe_prev and then unlinks elm via elm's own linkage. Deregistering a tag that is the last entry of list B through list A therefore:

  1. redirects A->tqh_last to &b1->ee_link.tqe_next β€” a field inside B's live entry;
  2. leaves B->tqh_last pointing at the entry being freed;
  3. frees the entry.

The next TAILQ_INSERT_TAIL on B executes *(B->tqh_last) = new β€” a heap pointer written into a freed chunk β€” and the new entry is unreachable from B's traversal chain. The next TAILQ_INSERT_TAIL on A executes *(A->tqh_last) = new β€” writing into B's live entry b1, aliasing B's traversal into A's entries (B now executes A's handlers). A stale-tag (double) deregistration is an unlink+kfree of freed memory: idempotent link writes plus a double free (provably handing the same chunk to two subsequent registrations).

How it was proven (baseline run, stock INVARIANTS kernel #0)

KLD df2973.ko (this pack), single kldload, no concurrency:

DF2973[df2973] CONFIRMED: A->tqh_last redirected into B's LIVE entry b1 (0xfffff8008d3631e0)
DF2973[df2973] CONFIRMED: B->tqh_last still points at FREED b2 (0xfffff8008d3631b8) after its removal
panic: Bad tailq NEXT(0xfffff8008db442e0->tqh_last) != NULL, last 0xffffffffffffffff
eventhandler_register() at eventhandler_register+0x1f0 0xffffffff806989f0
stage1() at stage1+0x11a 0xffffffff8260012a
df2973_modevent() at df2973_modevent+0x33
linker_load_file.part.3()
Debugger("panic")

(panic.txt, run.log, boot_excerpt.txt.) The panic fires inside the next eventhandler_register() as it appends through B's dangling sentinel β€” the QMD_TAILQ_CHECK_TAIL INVARIANT catches the freed-chunk dereference (last 0xffffffffffffffff = the freed chunk's scavenged contents). On a non-INVARIANTS kernel the same store proceeds silently: a controlled kernel heap pointer written at offset 0 of a freed 40-byte M_EVENTHANDLER chunk, and the just-registered handler silently invisible to dispatch.

Reachability / threat (honest)

No in-tree caller currently passes a wrong or stale tag (audited every EVENTHANDLER_DEREGISTER/eventhandler_deregister call site β€” see finding JSON). The demonstrated trigger is a KLD, i.e. root or a driver-author mistake; nearest in-tree misses: oce_if.c:293-296 deregisters on attach-failure but leaves the softc tags stale-non-NULL (unreachable today because newbus does not detach a failed attach), and if_vmx.c:1857 registers its unconfig handler on vlan_config (self-consistent at deregister, functional bug only). Severity therefore Low, but the primitive is deterministic kernel memory corruption from a one-line API misuse, and the tag == NULL wipe mode turns any single consumer mistake into mass dangling tags whose later deregistrations all become freed-memory unlinks + double frees.

Fix validation

fix.diff adds a TAILQ_FOREACH membership check under the existing evlist_token; a foreign/stale tag logs eventhandler_deregister: tag %p not in list "%s" -- deregistration ignored and returns without touching memory.

  • baseline (stock kernel #0): panic above, reproduced=1
  • patched (make nativekernel in-guest, kernel #1 Fri Sep 4 11:31:24 UTC 2026): stage 1 wrong-list deregistration ignored with the diagnostic (eventhandler_deregister: tag %p not in list "%s" -- deregistration ignored β€” 2001Γ—), no corruption signatures, no panic, DF2973 TOTAL confirmed=0, module unloads cleanly (fix_baseline_reproduced=1, fix_patched_reproduced=0) β€” see run.fixed.log, fixbuild.log. (The β€” INCOMPLETE suffix after confirmed=0 is a cosmetic printf branch for confirmed < 4; the count is the signal. Two PoC predicates were tightened after the first patched run because they false-positived when the fix correctly ignores the removal β€” see poc_changes in verdict.json.)

Fix verification

fixed
baseline reproduced→ patch + rebuild →patched clean

fix.diff (TAILQ_FOREACH membership check under evlist_token, foreign/stale tags logged and ignored) applied to in-guest /usr/src, kernel rebuilt with make nativekernel and installed (#1 Fri Sep 4 11:31:24 UTC 2026). Exact same PoC re-run: zero panics, zero corruption signatures (TOTAL confirmed=0) across 2001 wrong-list deregistrations plus stage-2 stale-tag paths, module unloads cleanly. Baseline on stock #0 panicked deterministically. Behavior-preserving for all correct callers.

["run.fixed.log: 2001x 'eventhandler_deregister: tag ... not in list ... deregistration ignored' + 'DF2973 TOTAL confirmed=0' + UNLOAD-OK", "fixbuild.log: '>>> Kernel install for X86_64_GENERIC completed' + BUILD-OK", 'panic.txt: baseline panic on stock kernel #0']
↓ fix.diffDragonFly dfbsd 6.5-DEVELOPMENT DragonFly 6.5-DEVELOPMENT #1: Fri Sep 4 11:31:24 UTC 2026 root@dfbsd:/usr/obj/usr/src/sys/X86_64_GENERIC x86_64

Confirmed kernel references

Detail

Exploit chain

no unprivileged chain: trigger requires kernel code calling eventhandler_deregister with a wrong/stale tag (no in-tree instance; nearest misses oce_if.c:293-296 stale tags on failed attach, if_vmx.c:1857 unconfig-handler-on-vlan_config). Primitive if triggered: deterministic write-after-free into freed 40-byte M_EVENTHANDLER chunk + cross-list handler aliasing + double free; on this INVARIANTS kernel it manifests as a guaranteed panic inside eventhandler_register.

Evidence (decisive lines)

["panic.txt: two CONFIRMED corruption signatures then 'panic: Bad tailq NEXT(0xfffff8008db442e0->tqh_last) != NULL, last 0xffffffffffffffff' with stack eventhandler_register+0x1f0 <- stage1 <- kldload", "run.fixed.log: patched kernel #1, 2001 wrong-list deregistrations all rejected ('tag %p not in list ... deregistration ignored'), DF2973 TOTAL confirmed=0, UNLOAD-OK", 'boot_excerpt.txt: full serial-console capture of the baseline run', 'fixbuild.log: in-guest make nativekernel + installkernel (kernel #1, Fri Sep 4 11:31:24 UTC 2026)', 'VERDICT.md: full mechanism with queue.h:646-660 TAILQ_REMOVE semantics']

PoC changes

no seed existed; module written from scratch. After the first patched-kernel run, two PoC predicates were tightened because they false-positived when the fix correctly IGNORES the wrong-list removal (B-sentinel check now also requires b1->tqe_next==NULL proving b2 was actually unlinked; the tag==NULL wipe line is informational only, as fix.diff intentionally preserves that documented mode). The cosmetic 'β€” INCOMPLETE' suffix prints whenever confirmed<4 and is not a signal; the count is. Baseline panic was captured before the tightening; on the vulnerable kernel the tightened predicates fire identically (b2 IS unlinked there).

Verified recommended fix

Verify tag membership with TAILQ_FOREACH under evlist_token before TAILQ_REMOVE/kfree; log and ignore foreign/stale tags (see fix.diff).

Verdict

eventhandler_deregister() (sys/kern/subr_eventhandler.c:116-136) unlinks and kfree()s whatever tag it is handed with no membership validation. Deregistering a tag that is the LAST entry of its real list through a different list redirects the wrong head's tqh_last into a live foreign entry and leaves the victim list's tqh_last dangling at the chunk being freed; the next registration on the victim list then executes *(tqh_last)=new as a write-after-free (and the entry becomes dispatch-invisible), while the next registration on the wrong list writes into the foreign list's live entry, aliasing traversals across lists. A stale-tag (double) deregistration performs an idempotent unlink plus a double kfree (same chunk handed to two subsequent registrations). tag==NULL silently frees every entry of a globally shared list, dangling all other consumers' saved tags. Proven deterministically (no race) on the stock INVARIANTS kernel: the two corruption signatures print, then the very next eventhandler_register() panics in QMD_TAILQ_CHECK_TAIL dereferencing the freed chunk. No in-tree caller currently passes a wrong/stale tag (all call sites audited), so trigger is kernel-code misuse (KLD/driver bug) β€” Low severity, memcorrupt-bucket primitive.