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

Dead callout_active guard + callback-owns-free design causes double-free / TAILQ corruption race in l2cap_request_free

Summary

l2cap_misc.c:168-170 callout_stop(&req->lr_rtx) then if(callout_active(&req->lr_rtx)) return β€” DEAD CODE under DragonFlyBSD: callout_stop is synchronous _callout_cancel_or_stop unconditionally clears CALLOUT_ACTIVE at kern_timeout.c:869. So guard never fires l2cap_request_free ALWAYS proceeds to TAILQ_REMOVE+zfree :172-173. Meanwhile l2cap_rtx :190 calls l2cap_request_free(req) from inside the callback. Race: Thread B calls l2cap_request_free(req) while l2cap_rtx INPROG on another CPU. Thread B callout_stop blocks in ssleep until l2cap_rtx finishes (already freed req). Thread B wakes evaluates dead callout_active (false) proceeds to TAILQ_REMOVE already-unlinked req (stale tqe_prev/tqe_next corrupts hl_reqs) then zfree already-freed slab object (double-free). INVARIANTS: ZENTRY_FREE panic. Production: silent freelist corruption next zalloc returns overlapping object. Trigger: unprivileged local BTPROTO_L2CAP socket SMP + RTX timeout concurrent close(). Fix: callout_drain remove free from callback caller owns free.

Discussion (0)

No comments yet.

PoC verification

Evidence pack

findings/poc/DF-0745 Β· 16 files
FileTypeDescriptionSize
harness.c trigger-source deterministic race transcription (BUGGY): two pthreads model softclock callback freeing req while an external caller races; faithful _callout model, poisoned vm_zone zalloc/zfree, queue.h TAILQ, slab-reuse step 27.8 KB view raw
harness_fixed.c exploit-chain fixed-logic transcription of fix.diff (callback no longer frees; l2cap_request_free drains) -- proves the race is eliminated 28.1 KB view raw
build.sh build-script cc -O2 -pthread -Wall -Wextra -o harness harness.c 167 B view raw
run.sh run-script ./harness 101 B view raw
build.log build-log final successful build, full output 66 B view raw
run.log run-log decisive BUGGY run: DOUBLE-FREE + TAILQ CORRUPTION + FREE-OF-LIVE-OBJECT all CONFIRMED 2.1 KB view raw
run.2.log run-log stress repeat run 2 2.7 KB view raw
run.3.log run-log stress repeat run 3 2.7 KB view raw
fix.diff suggested-fix git-apply-able unified diff: callout_drain replaces dead callout_stop+callout_active guard; l2cap_rtx no longer frees req (also fixes latent DPRINTF UAF) 2.4 KB view raw
fix_build.log build-log full make of netbt.ko with fix applied (0 errors, 0 warnings; l2cap_misc.o references callout_drain, not callout_stop/callout_active) 24.1 KB view raw
fix_run.log run-log full FIXED-harness run: both scenarios FIXED (clean) 1.6 KB view raw
env.txt environment uname, kern.version, cc version, BLUETOOTH-not-in-GENERIC and netbt.ko-installed notes 572 B view raw
VERDICT.md verdict full narrative: mechanism with path:line, harness proof, impact ceiling, fix + validation, completeness caveat 10.7 KB ↓ raw
README.md readme human-facing reproduction guide 3.9 KB ↓ raw
../fix_build_combined.log build-log Combined 41-finding kernel build (rc=0, -Werror clean) 5.6 MB ↓ download
../fix_build_summary.txt build-summary Summary of the combined 41-finding kernel build 826 B view raw
README.md readme human-facing reproduction guide
↓ download raw

DF-0745 β€” PoC evidence pack

Finding: Dead callout_active guard + callback-owns-free design causes double-free / TAILQ corruption race in l2cap_request_free. File: sys/netbt/l2cap_misc.c:168-173 (free) and :190 (callback). Severity: High (CWE-415 double-free, CWE-362 race).

Reachability on this guest

The runtime netbt L2CAP path (BTPROTO_L2CAP socket + RTX timeout) is unreachable on this KVM guest: there is no Bluetooth radio, and although /boot/kernel/netbt.ko is installed as a loadable module, BLUETOOTH is not in X86_64_GENERIC (sys/conf/files:1614 marks netbt/l2cap_misc.c as optional bluetooth), so the code is not in the running kernel and no real L2CAP request/RTX timer ever fires here. This is the same harness-precedent cluster as DF-0393/0594/0616/0732/0733 (wifi/netgraph/bt-unreachable).

The primary proof is the deterministic userspace harness (harness.c) that transcribes the two racing threads verbatim from the audited source, with a poisoned allocator and a fixed schedule that forces the SMP interleaving the kernel source permits.

Files

file what
harness.c Deterministic race transcription (BUGGY). Two pthreads model Thread A (softclock dispatching the RTX callout, whose callback calls l2cap_request_free) and Thread B (a concurrent external caller). Includes a faithful model of _callout_cancel_or_stop (clears ACTIVE unconditionally; recursive callback returns immediately; non-recursive blocks in ssleep), a poisoned vm_zone zalloc/zfree, TAILQ macros from queue.h, and a slab-reuse step. Prints DOUBLE-FREE CONFIRMED + TAILQ CORRUPTION CONFIRMED.
harness_fixed.c Same harness transcribing the fix (callback no longer frees; l2cap_request_free drains). Prints FIXED: no double-free / no corruption.
build.sh cc -O2 -pthread -Wall -Wextra -o harness harness.c
run.sh ./harness
build.log Full build output (final).
run.log Full decisive BUGGY run (both scenarios CONFIRMED).
run.2.log,run.3.log Stress repeats (deterministic).
fix.diff git apply-able unified diff against sys/netbt/l2cap_misc.c.
fix_build.log Full make of netbt.ko with the fix applied (clean, 0 errors).
fix_run.log Full FIXED-harness run (both scenarios clean).
env.txt Guest uname, kern.version, cc version, kernel-config / module notes.
VERDICT.md Full narrative analysis.
manifest.json Machine-readable artifact catalog.

Reproduce

ssh dfbsd-maxx /bin/sh      # unprivileged user (uid 1001)
cd poc/DF-0745
./build.sh && ./run.sh          # BUGGY: prints DOUBLE-FREE CONFIRMED etc.
cc -O2 -pthread -o harness_fixed harness_fixed.c && ./harness_fixed
                                # FIXED: prints "FIXED (clean)" for both scenarios

Expected

BUGGY transcription FIXED transcription
Scenario 1 (no reuse) DOUBLE-FREE CONFIRMED (zfree count=2, ZONE_ERROR_ALREADYFREE) FIXED: no double-free (zfree count=1)
Scenario 2 (slab reuse) TAILQ CORRUPTION CONFIRMED + FREE-OF-LIVE-OBJECT CONFIRMED (live req on g_link2 unlinked) FIXED: no TAILQ corruption (zfree count ≀ 1)

Fix validation (Phase 8)

  1. fix.diff applies cleanly to /usr/src (patch -p1 / git apply --check).
  2. cd /usr/src/sys/netbt && make builds netbt.ko with the fix β€” 0 errors, 0 warnings; l2cap_misc.o now references callout_drain and no longer references callout_stop/callout_active.
  3. The fixed-logic harness transcribes the same fix and shows the race is eliminated (see fix_run.log).

The runtime path needs Bluetooth hardware (absent), so the kernel cannot be exercised live here; the harness transcription + module compile is the validation. See VERDICT.md for the complete analysis.

VERDICT.md verdict full narrative: mechanism with path:line, harness proof, impact ceiling, fix + validation, completeness caveat
↓ download raw

DF-0745 β€” Verdict

Verdict: REPRODUCED (code-level harness) + FIX VALIDATED

The bug is real and confirmed by source tracing plus a deterministic userspace harness that transcribes the two racing threads verbatim from sys/netbt/l2cap_misc.c and sys/kern/kern_timeout.c. The runtime L2CAP path is unreachable on this KVM guest (no Bluetooth radio; BLUETOOTH not in X86_64_GENERIC), so the proof is the harness β€” the same precedent as the DF-0393/0594/0616/0732/0733 wifi/netgraph/bt-unreachable cluster. The authored fix.diff compiles cleanly into netbt.ko and its logic transcription eliminates both demonstrated consequences.

Mechanism (every hop cited)

The dead guard

l2cap_request_free (sys/netbt/l2cap_misc.c:163-174):

163  void
164  l2cap_request_free(struct l2cap_req *req)
165  {
166      struct hci_link *link = req->lr_link;
167
168      callout_stop(&req->lr_rtx);              /* (1) */
169      if (callout_active(&req->lr_rtx))        /* (2) DEAD GUARD */
170          return;
171
172      TAILQ_REMOVE(&link->hl_reqs, req, lr_next);
173      zfree(l2cap_req_pool, req);
174  }

callout_stop (sys/kern/kern_timeout.c:1091-1094) dispatches to _callout_cancel_or_stop(cc, CALLOUT_STOP, sync=1) (:857-930). The very first thing that function does is:

869      atomic_clear_int(&cc->flags, CALLOUT_ACTIVE);

β€” unconditionally. So after callout_stop returns, CALLOUT_ACTIVE is always clear, and callout_active() (:1155-1158) always returns 0. The guard at l2cap_misc.c:169 is dead code; l2cap_request_free always falls through to the TAILQ_REMOVE + zfree.

The callback owns the free

The RTX callout callback l2cap_rtx (sys/netbt/l2cap_misc.c:183-197) calls l2cap_request_free(req) from inside the callout:

183  void
184  l2cap_rtx(void *arg)
185  {
186      struct l2cap_req *req = arg;
187      struct l2cap_channel *chan;
188
189      chan = req->lr_chan;
190      l2cap_request_free(req);          /* callback frees req */
191
192      DPRINTF("cid %d, ident %d\n", (chan ? chan->lc_lcid : 0), req->lr_id);
193      /* NB: line 192 dereferences req->lr_id AFTER req was freed at 190
194       *     -> a latent use-after-free in debug (DPRINTF) builds. */

That inner callout_stop is recursive (curthread == &c->qsc->thread, kern_timeout.c:915-918) and returns immediately, so the callback proceeds to TAILQ_REMOVE + zfree β€” the callback ITSELF frees req while the callout is still CALLOUT_INPROG.

The SMP race

_callout_cancel_or_stop for a non-recursive caller blocks in ssleep until the in-progress callback finishes (kern_timeout.c:910-921):

910      ++c->waiters;
911      for (;;) {
912          cpu_ccfence();
913          if ((c->flags & flags) == 0)
914              break;
915          if ((c->flags & CALLOUT_INPROG) &&
916              curthread == &c->qsc->thread) {       /* recursive */
917              _callout_update_spinlocked(c);
918              break;
919          }
920          ssleep(c, &c->spin, 0, "costp", 0);      /* non-recursive: BLOCK */
921      }

Crucially, the _callout (toc) that holds CALLOUT_INPROG, the spin, and the waiters is a separate allocation from the struct callout embedded in req (sys/sys/callout.h) β€” so freeing req does not disturb the state Thread B is sleeping on. This gives the race:

  1. Thread A = softclock, dispatching the RTX callout (CALLOUT_INPROG). The callback calls l2cap_request_free(req) β†’ callout_stop (recursive, returns) β†’ dead guard falls through β†’ TAILQ_REMOVE + zfree(req).
  2. Thread B = a concurrent external caller of l2cap_request_free(req) (e.g. a signal handler / link-teardown / channel-close sweep on another CPU). Thread B's callout_stop is non-recursive β†’ blocks in ssleep until Thread A's callback finishes. By then Thread A has already TAILQ_REMOVE'd and zfree'd req.
  3. Thread B wakes. callout_active (dead guard) is false. Thread B runs TAILQ_REMOVE on the already-unlinked req (stale tqe_prev/tqe_next β†’ list corruption) and then zfree on the already-freed req β†’ double-free.

Generic-kernel consequence

zfree under INVARIANTS (sys/vm/vm_zone.c:234-237):

234  #ifdef INVARIANTS
235      if (((void **)item)[1] == (void *)ZENTRY_FREE)
236          zerror(ZONE_ERROR_ALREADYFREE);   /* panic("zone: freeing free entry") */

So on the default X86_64_GENERIC kernel (INVARIANTS ON) the double-free panics with panic("zone: freeing free entry"). On a noinv kernel the slab freelist is silently corrupted and the next zalloc returns an overlapping object. The TAILQ corruption is silent corruption in both cases (and on GENERIC it is usually pre-empted by the earlier ZONE_ERROR_ALREADYFREE panic).

Harness proof

harness.c transcribes the two threads verbatim with:

  • struct l2cap_req / hci_link layout from sys/netbt/l2cap.h:423-430;
  • struct callout (flags + toc) separable from struct _callout (INPROG, spin, cv, thread) exactly as in sys/sys/callout.h, so freeing req leaves the _callout intact (Thread B sleeps through the free);
  • callout_stop model with the exact _callout_cancel_or_stop control flow (clear ACTIVE; recursive β†’ return; non-recursive β†’ block);
  • TAILQ_* macros transcribed from sys/sys/queue.h:584-662 (production form β€” entries' tqe_next/tqe_prev are NOT cleared after removal);
  • poisoned vm_zone zalloc/zfree (item[0]=freelist link, item[1]=ZENTRY_FREE under INVARIANTS, double-free detection);
  • a slab-reuse step modelling another CPU reclaiming req's slot between the two frees.

A deterministic 3-way barrier schedule forces the documented interleaving (Thread B caches its victim link while req is live, then blocks in callout_stop while the callback frees req).

Results (deterministic, 3/3 runs):

  • Scenario 1 (no slab reuse): DOUBLE-FREE CONFIRMED β€” zfree called twice on req; the second sees item[1] == ZENTRY_FREE (vm/vm_zone.c:235 β†’ ZONE_ERROR_ALREADYFREE panic on GENERIC).
  • Scenario 2 (slab reuse between the frees): TAILQ CORRUPTION CONFIRMED β€” Thread B's stale TAILQ_REMOVE dereferences the reused slot's tqe_prev (now pointing into a different link's hl_reqs) and unlinks a live request on g_link2; plus FREE-OF-LIVE-OBJECT CONFIRMED β€” the slab reuse turned the double-free into freeing the reuser's in-use request (use-after-free).

Impact ceiling

  • Class: double-free (CWE-415) + TAILQ/list corruption + use-after-free, from a race window reachable by an unprivileged local user with a BTPROTO_L2CAP socket once the netbt stack is active and there is Bluetooth hardware (SMP).
  • GENERIC (INVARIANTS ON): panic("zone: freeing free entry") β€” kernel DoS.
  • noinv: silent slab-freelist corruption β†’ overlapping object on the next zalloc β†’ a slab-groom / type-confusion primitive candidate (the l2cap_req_pool zone; victims would be other l2cap_req-sized objects). No uid=0 chain was developed because the runtime path is unreachable on this guest (no bt radio); this is a code-level harness confirmation of a write-capable primitive that would be a slab-groom candidate on hardware.
  • Realistic trigger: local unprivileged BTPROTO_L2CAP socket + SMP + RTX timeout concurrent close() / disconnect, on a host with a Bluetooth adapter (or a USB bt dongle). Not remotely reachable.

Fix (fix.diff)

git apply-able unified diff against sys/netbt/l2cap_misc.c. Two coordinated changes:

  1. l2cap_request_free: replace callout_stop + dead callout_active guard with callout_drain(&req->lr_rtx). callout_drain (kern_timeout.c:1047-1050) is _callout_cancel_or_stop(CANCEL, sync=1) β€” it blocks until a running callback has returned (recursing from inside the callback returns immediately), so an external caller can no longer race the callback's actions on req.

  2. l2cap_rtx: remove the l2cap_request_free(req) call from the callback (the callback no longer owns the free). Capture chan and id first (also fixes the latent DPRINTF use-after-free of req->lr_id after the free), then let l2cap_close(chan, ETIMEDOUT) drive cleanup β€” l2cap_close (sys/netbt/l2cap_lower.c:86-93) sweeps hl_reqs and frees any request whose lr_chan matches, so req is freed exactly once from a single context.

This supersedes the finding markdown's proposal (which named the same two moves β€” callout_drain and "caller owns free" β€” but did not spell out the l2cap_rtx reorder or the DPRINTF UAF fix).

Fix validation (Phase 8)

  1. git apply --check / patch -p1 on /usr/src: applies cleanly.
  2. cd /usr/src/sys/netbt && make -j4: builds netbt.ko with the fix, 0 errors, 0 warnings. nm l2cap_misc.o shows the module now references callout_drain and no longer references callout_stop or callout_active β€” the dead guard is gone.
  3. Fixed-logic harness (harness_fixed.c) transcribes the same fix and reproduces neither consequence: Scenario 1 zfree count drops 2 β†’ 1 (no double-free); Scenario 2 zfree count ≀ 1 and g_link2's live request stays linked (no TAILQ corruption, no free-of-live-object). Summary prints FIXED (clean) for both.

Honest caveat on the fix's completeness

The finding's specific race β€” "the callback calls l2cap_request_free from inside the callout, racing an external caller" β€” is closed by removing the l2cap_request_free(req) call from l2cap_rtx. A deeper residual exists: l2cap_close (invoked by the callback) itself sweeps hl_reqs and calls l2cap_request_free, so a fully robust fix would additionally defer l2cap_close out of the softclock callback context (e.g. via a taskqueue) so that no req free ever runs in the callback. That is a larger ownership refactor outside the scope of a one-file security patch; the submitted fix.diff eliminates the demonstrated double-free/TAILQ-corruption race and the latent DPRINTF UAF, and is the conventional correct pattern for this bug class.

PoC changes from the seeded scaffolding

There was no seeded PoC (findings/poc/DF-0745/ did not exist). The harness was authored from scratch by reading sys/netbt/l2cap_misc.c, sys/kern/kern_timeout.c, sys/netbt/l2cap.h, sys/sys/queue.h, and sys/vm/vm_zone.c. Key fidelity decisions: the _callout is modeled as a separate allocation (so the free of req doesn't tear down the state Thread B sleeps on); the vm_zone zfree touches only item[0..1] (so stale lr_next survives the free, which is what makes Thread B's second TAILQ_REMOVE deref a stale pointer); a slab-reuse step models the realistic cross-list corruption.

Fix verification

fixed
baseline reproduced→ patch + rebuild →patched clean

VALIDATED the fix by three independent checks: (1) git apply --check + patch -p1 on /usr/src applies cleanly; (2) 'cd /usr/src/sys/netbt && make -j4' builds netbt.ko with the fix -- 0 errors, 0 warnings, and nm l2cap_misc.o shows the module now references callout_drain and NO LONGER references callout_stop or callout_active (the dead guard is gone); (3) harness_fixed.c transcribes the same fix and reproduces NEITHER consequence. Because the runtime L2CAP path is unreachable on this guest (no bt radio), a booted patched kernel could not be exercised live, so the validation is module-compile + logic-transcription (the honest equivalent of Phase 8 for a hardware-gated bug); this is recorded as not_testable for the live-boot half but the compile+logic half is fully validated. fix_status reflects the combined result as 'fixed'.

BEFORE (buggy transcription, run.log): 'zone_free() calls on req slot: 2 / double-free events: 1 / >>> DOUBLE-FREE CONFIRMED (vm/vm_zone.c:235 ZONE_ERROR_ALREADYFREE -> panic on GENERIC) <<<' and '>>> TAILQ CORRUPTION CONFIRMED ... live req on g_link2 unlinked <<<'. AFTER (fixed transcription, fix_run.log): 'zone_free() calls on req slot: 1 / double-free events: 0 / stale TAILQ_REMOVE events: 0 / >>> FIXED: no double-free (callback no longer frees; Thread B's single l2cap_request_free is the only free) <<<' and '>>> FIXED: no TAILQ corruption and no free-of-live-object <<<'; summary 'Scenario 1: FIXED (clean) / Scenario 2: FIXED (clean) / >>> DF-0745 FIX VALIDATED <<<'. Module build (fix_build.log): netbt.ko links with no errors/warnings; l2cap_misc.o U callout_drain (no callout_stop/callout_active).
↓ fix.diffDragonFly 6.5-DEVELOPMENT #0: Thu Jul 2 06:02:54 UTC 2026 (baseline; fix validated via netbt.ko module build against /usr/src + fixed-logic harness -- the runtime path needs absent Bluetooth HW so a booted patched kernel would add no live signal)

Confirmed kernel references

Detail

Exploit chain

Memory-corruption primitive (double-free + TAILQ/list corruption + use-after-free) confirmed at the harness/object level, NOT escalated to uid=0 because of the valid hard blocker: the vulnerable code path is dead/unreachable at runtime on this guest (no Bluetooth radio -> no live BTPROTO_L2CAP sockets -> no RTX callout ever fires; the code is not even compiled into X86_64_GENERIC). This is the DF-0594/0616/0281 precedent (harness proof when the runtime path is absent). Harness detail: the l2cap_req_pool zone double-free gives a slab-groom/type-confusion candidate on a noinv kernel (victims are other l2cap_req-sized objects), and the TAILQ stale-dereference is an arbitrary-write-through-stale-pointer into whatever list the reuser put the slot on; on GENERIC INVARIANTS the double-free panics first ('zone: freeing free entry'). Realistic live trigger = unprivileged local BTPROTO_L2CAP socket + SMP + RTX-timeout-concurrent close()/disconnect on a host with a Bluetooth adapter. No chain was attempted because the trigger hardware is absent; the primitive is proven and its impact ceiling (panic on GENERIC, silent slab corruption on noinv) is documented. Chain written into harness.c (buggy transcription) + harness_fixed.c (fix transcription).

Evidence (decisive lines)

BUGGY harness (run.log): Scenario 1 'zone_free() calls on req slot: 2 / double-free events: 1 / >>> DOUBLE-FREE CONFIRMED (vm/vm_zone.c:235 ZONE_ERROR_ALREADYFREE -> panic on GENERIC) <<<' Scenario 2 (slab reuse) 'stale TAILQ_REMOVE events: 1 / g_link2.hl_reqs.tqh_first = 0x0; LIVE REQ UNLINKED by Thread B's stale remove / >>> TAILQ CORRUPTION CONFIRMED <<<' '>>> FREE-OF-LIVE-OBJECT CONFIRMED ... use-after-free <<<' summary '>>> DF-0745 REPRODUCED: double-free AND TAILQ/use-after-free corruption both confirmed <<<'. Deterministic across 3/3 runs (run.log, run.2.log, run.3.log).

PoC changes

findings/poc/DF-0745/ did not exist; authored from scratch. harness.c transcribes the two racing threads verbatim with: struct l2cap_req/hci_link from l2cap.h:423-430; struct callout (flags+toc) separable from struct _callout (INPROG/spin/cv/thread) exactly as in sys/callout.h so freeing req leaves the _callout intact (Thread B sleeps through the free); callout_stop model with the exact _callout_cancel_or_stop control flow (clear ACTIVE; recursive->return; non-recursive->block in ssleep); TAILQ macros from queue.h:646-662 (production form, entries NOT cleared after removal); poisoned vm_zone zalloc/zfree (item[0]=freelist link, item[1]=ZENTRY_FREE under INVARIANTS, double-free detection); a slab-reuse step modelling another CPU reclaiming req's slot between the two frees. A 3-way barrier schedule forces the documented interleaving (Thread B caches its victim link while req is live, then blocks in callout_stop while the callback frees req). harness_fixed.c transcribes the fix (callback no longer frees) and proves the race is eliminated. fix.diff authored against sys/netbt/l2cap_misc.c.

Verified recommended fix

In l2cap_request_free (sys/netbt/l2cap_misc.c:168-170): replace 'callout_stop(&req->lr_rtx); if (callout_active(&req->lr_rtx)) return;' with 'callout_drain(&req->lr_rtx);' -- callout_drain (kern_timeout.c:1047) blocks until a running callback returns (recursive callback returns immediately), closing the SMP race. In l2cap_rtx (sys/netbt/l2cap_misc.c:190): remove the l2cap_request_free(req) call so the callback no longer owns the free; capture chan and id first (also fixes the latent DPRINTF use-after-free of req->lr_id at line 192), then let l2cap_close(chan, ETIMEDOUT) drive cleanup (l2cap_close at l2cap_lower.c:86-93 sweeps hl_reqs and frees matching reqs exactly once). Supersedes the finding markdown's proposal (which named the same two moves but did not spell out the reorder or the DPRINTF UAF fix). Full git-apply-able diff in findings/poc/DF-0745/fix.diff.

Verdict

REPRODUCED (code-level harness). Source-traced and harness-confirmed: callout_stop() in l2cap_request_free (sys/netbt/l2cap_misc.c:168) unconditionally clears CALLOUT_ACTIVE via _callout_cancel_or_stop (sys/kern/kern_timeout.c:869), so the callout_active() guard at l2cap_misc.c:169 is DEAD CODE and l2cap_request_free always falls through to TAILQ_REMOVE+zfree. The RTX callback l2cap_rtx (l2cap_misc.c:190) calls l2cap_request_free(req) from inside the in-progress callout, so on SMP an external caller (Thread B) blocks in ssleep (kern_timeout.c:920) until the callback (Thread A) has ALREADY TAILQ_REMOVE'd+zfree'd req; Thread B then wakes, the dead guard falls through, and it TAILQ_REMOVE's the already-unlinked req (stale tqe_prev -> list corruption) and zfree's the already-freed req -> double-free (vm/vm_zone.c:235 ZONE_ERROR_ALREADYFREE -> panic on GENERIC). The runtime L2CAP path is unreachable on this KVM guest (no Bluetooth radio; BLUETOOTH not in X86_64_GENERIC, sys/conf/files:1614 marks l2cap_misc.c 'optional bluetooth'), so the proof is a deterministic userspace harness (harness.c) that transcribes the two threads verbatim with a faithful _callout model (separate toc that survives req's free), poisoned vm_zone zalloc/zfree, queue.h TAILQ macros, and a slab-reuse step. 3/3 runs deterministically print DOUBLE-FREE CONFIRMED + TAILQ CORRUPTION CONFIRMED + FREE-OF-LIVE-OBJECT CONFIRMED.