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)
PoC verification
Evidence pack
findings/poc/DF-2973 Β· 14 files| File | Type | Description | Size | |
|---|---|---|---|---|
| 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
- wrong-list deregistration (
eventhandler_deregister(A, b2)whereb2lives on list B and is B's last entry) β sentinel corruption of both lists, write-after-free, cross-list aliasing. tag == NULLmass-wipe + re-deregister of a saved (now dangling) tag β freed-memoryTAILQ_REMOVE, head pollution, doublekfree(provable by allocation aliasing: two registrations returning the same chunk).- 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.
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(impactpanic; 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:
- redirects
A->tqh_lastto&b1->ee_link.tqe_nextβ a field inside B's live entry; - leaves
B->tqh_lastpointing at the entry being freed; - 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 nativekernelin-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) β seerun.fixed.log,fixbuild.log. (Theβ INCOMPLETEsuffix afterconfirmed=0is a cosmetic printf branch forconfirmed < 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 β seepoc_changesin verdict.json.)
Fix verification
fixedfix.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']
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.
No comments yet.