# 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:

```c
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.)
