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)
PoC verification
Evidence pack
findings/poc/DF-0745 Β· 16 files| File | Type | Description | Size | |
|---|---|---|---|---|
| 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 |
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)
fix.diffapplies cleanly to/usr/src(patch -p1/git apply --check).cd /usr/src/sys/netbt && makebuildsnetbt.kowith the fix β 0 errors, 0 warnings;l2cap_misc.onow referencescallout_drainand no longer referencescallout_stop/callout_active.- 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.
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:
- Thread A = softclock, dispatching the RTX callout (
CALLOUT_INPROG). The callback callsl2cap_request_free(req)βcallout_stop(recursive, returns) β dead guard falls through βTAILQ_REMOVE+zfree(req). - 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'scallout_stopis non-recursive β blocks inssleepuntil Thread A's callback finishes. By then Thread A has alreadyTAILQ_REMOVE'd andzfree'dreq. - Thread B wakes.
callout_active(dead guard) is false. Thread B runsTAILQ_REMOVEon the already-unlinkedreq(staletqe_prev/tqe_nextβ list corruption) and thenzfreeon the already-freedreqβ 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_linklayout fromsys/netbt/l2cap.h:423-430;struct callout(flags +toc) separable fromstruct _callout(INPROG, spin, cv, thread) exactly as insys/sys/callout.h, so freeingreqleaves the_calloutintact (Thread B sleeps through the free);callout_stopmodel with the exact_callout_cancel_or_stopcontrol flow (clear ACTIVE; recursive β return; non-recursive β block);TAILQ_*macros transcribed fromsys/sys/queue.h:584-662(production form β entries'tqe_next/tqe_prevare NOT cleared after removal);- poisoned
vm_zonezalloc/zfree(item[0]=freelist link, item[1]=ZENTRY_FREEunder 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βzfreecalled twice onreq; the second seesitem[1] == ZENTRY_FREE(vm/vm_zone.c:235βZONE_ERROR_ALREADYFREEpanic on GENERIC). - Scenario 2 (slab reuse between the frees):
TAILQ CORRUPTION CONFIRMEDβ Thread B's staleTAILQ_REMOVEdereferences the reused slot'stqe_prev(now pointing into a different link'shl_reqs) and unlinks a live request ong_link2; plusFREE-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_L2CAPsocket 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 (thel2cap_req_poolzone; victims would be otherl2cap_req-sized objects). Nouid=0chain 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_L2CAPsocket + SMP + RTX timeout concurrentclose()/ 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:
-
l2cap_request_free: replacecallout_stop+ deadcallout_activeguard withcallout_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 onreq. -
l2cap_rtx: remove thel2cap_request_free(req)call from the callback (the callback no longer owns the free). Capturechanandidfirst (also fixes the latentDPRINTFuse-after-free ofreq->lr_idafter the free), then letl2cap_close(chan, ETIMEDOUT)drive cleanup βl2cap_close(sys/netbt/l2cap_lower.c:86-93) sweepshl_reqsand frees any request whoselr_chanmatches, soreqis 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)
git apply --check/patch -p1on/usr/src: applies cleanly.cd /usr/src/sys/netbt && make -j4: buildsnetbt.kowith the fix, 0 errors, 0 warnings.nm l2cap_misc.oshows the module now referencescallout_drainand no longer referencescallout_stoporcallout_activeβ the dead guard is gone.- Fixed-logic harness (
harness_fixed.c) transcribes the same fix and reproduces neither consequence: Scenario 1zfreecount drops 2 β 1 (no double-free); Scenario 2zfreecount β€ 1 andg_link2's live request stays linked (no TAILQ corruption, no free-of-live-object). Summary printsFIXED (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
fixedVALIDATED 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).
Confirmed kernel references
- sys/netbt/l2cap_misc.c:163
- sys/netbt/l2cap_misc.c:168
- sys/netbt/l2cap_misc.c:169
- sys/netbt/l2cap_misc.c:172
- sys/netbt/l2cap_misc.c:173
- sys/netbt/l2cap_misc.c:183
- sys/netbt/l2cap_misc.c:190
- sys/netbt/l2cap_misc.c:192
- sys/kern/kern_timeout.c:869
- sys/kern/kern_timeout.c:915
- sys/kern/kern_timeout.c:920
- sys/kern/kern_timeout.c:1047
- sys/kern/kern_timeout.c:1091
- sys/vm/vm_zone.c:235
- sys/vm/vm_zone.c:890
- sys/netbt/l2cap_lower.c:86
- sys/sys/queue.h:646
- sys/netbt/l2cap.h:423
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.
No comments yet.