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

Use-after-free in l2cap_rtx: req->lr_id read after zfree in DPRINTF

Summary

l2cap_misc.c:190 l2cap_request_free(req) does TAILQ_REMOVE+zfree :172-173. :192 DPRINTF("cid %d, ident %d",...,req->lr_id) reads req->lr_id AFTER zfree. lr_id at offset 17 not clobbered by zfree freelist link (offset 0) or INVARIANTS poison (offset 1) so stale value returned in practice but access is against freed memory any intervening same-CPU zalloc from l2cap_req_pool returns attacker-influenced bytes. Only in BLUETOOTH_DEBUG builds (bluetooth.h:130-135 DPRINTF no-op otherwise). Trigger: unprivileged local BTPROTO_L2CAP socket connect to non-responsive peer wait RTX seconds (default 30). Fix: save id=req->lr_id before free use in DPRINTF.

Discussion (0)

No comments yet.

PoC verification

Evidence pack

findings/poc/DF-0746 Β· 18 files
FileTypeDescriptionSize
harness.c trigger-source deterministic userspace transcription of l2cap_rtx + l2cap_request_free + vm_zone zalloc/zfree model; prints UAF READ CONFIRMED 13.5 KB view raw
harness_fixed.c exploit-chain FIXED transcription: lr_id captured before free; proves no read of *req after zfree 3.5 KB view raw
build.sh build-script cc -O2 -Wall -Wextra for both harnesses 346 B view raw
run.sh run-script runs BUGGY then FIXED harness 395 B view raw
build.log build-log final successful build, full output 113 B view raw
run.log run-log decisive run: UAF READ CONFIRMED + FIXED markers 1.4 KB view raw
fix.diff suggested-fix git-apply-able one-hunk diff: capture id=req->lr_id before l2cap_request_free, use id in DPRINTF 554 B view raw
fix_build.log build-log make of netbt.ko with fix applied (0 errors, 0 warnings, -Werror) 19.4 KB view raw
fix_run.log run-log copy of FIXED-harness run output 1.4 KB view raw
disasm_unfixed_l2cap_rtx.txt disasm objdump of l2cap_rtx in unfixed netbt.ko (0x5710, 14 instrs) 625 B view raw
disasm_fixed_l2cap_rtx.txt disasm objdump of l2cap_rtx in fixed netbt.ko - BYTE-FOR-BYTE IDENTICAL to unfixed 1.0 KB view raw
env.txt environment uname, kern.version, cc, GENERIC/module/Makefile notes 537 B view raw
panic.txt panic-signature placeholder - no panic; code-level UAF with no runtime manifestation 144 B view raw
VERDICT.md verdict full narrative analysis 9.1 KB ↓ raw
README.md readme human-facing evidence pack index 4.6 KB ↓ raw
manifest.json manifest this file 3.5 KB view 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 evidence pack index
↓ download raw

DF-0746 β€” PoC evidence pack

Finding: Use-after-free READ in l2cap_rtx: req->lr_id is read after zfree(l2cap_req_pool, req). File: sys/netbt/l2cap_misc.c:190 (free via :173) and :192 (read). Severity: Low (CWE-416 Use After Free, read-only).

Reachability on this guest

The runtime netbt L2CAP path (BTPROTO_L2CAP socket + RTX timeout) is unreachable on this KVM guest and, even more, the read is not emitted in the shipped module:

  • netbt/l2cap_misc.c is optional bluetooth (sys/conf/files:1614) and X86_64_GENERIC has no device bluetooth β€” the file is not in the running kernel, only in the loadable module /boot/kernel/netbt.ko.
  • BLUETOOTH_DEBUG is not defined in sys/netbt/Makefile, so DPRINTF expands to ((void)0) (sys/netbt/bluetooth.h:145) and req->lr_id is never evaluated at runtime.
  • Decisive: the disassembly of l2cap_rtx in the unfixed netbt.ko (disasm_unfixed_l2cap_rtx.txt) contains zero references to req after the callq l2cap_request_free, and is byte-for-byte identical to the fixed module (disasm_fixed_l2cap_rtx.txt).
  • A live trigger would also require a Bluetooth radio (absent on guest).

This is the same harness-precedent cluster as DF-0745 (sibling double-free on the same function) and DF-0393/0594/0616/0732/0733 (wifi/netgraph/bt-unreachable). The primary proof is the deterministic userspace harness.

Why no escalation

This is a single-byte read of freed memory whose value (in the immediate path) is the original id set by the caller, going only to kprintf/dmesg. There is no write primitive, no control-flow hijack, no victim-object corruption β€” read-only hardening bug, no path to uid=0. (Per AGENT.md Phase 6: a genuinely read-only primitive has no escalation chain.)

Files

file what
harness.c BUGGY transcription of l2cap_rtx + l2cap_request_free + vm_zone model. Two tests: stale-read (returns original id) and slab-reuse read (returns attacker-shaped byte). Prints UAF READ CONFIRMED.
harness_fixed.c FIXED transcription: lr_id captured into a local before the free. Prints FIXED: no read of *req after zfree.
build.sh cc -O2 -Wall -Wextra -o harness harness.c + fixed variant.
run.sh runs both harnesses.
build.log full build output (final, clean).
run.log full BUGGY + FIXED run (the decisive output).
fix.diff git apply-able unified diff against sys/netbt/l2cap_misc.c.
fix_build.log make of netbt.ko with the fix applied β€” 0 errors, 0 warnings.
fix_run.log copy of the FIXED-harness output.
disasm_unfixed_l2cap_rtx.txt objdump of l2cap_rtx in the unfixed netbt.ko.
disasm_fixed_l2cap_rtx.txt objdump of l2cap_rtx in the fixed netbt.ko β€” identical to the unfixed form.
env.txt uname, kern.version, cc, kernel-config / module / Makefile notes.
panic.txt (placeholder β€” no panic; code-level UAF with no runtime manifestation).
VERDICT.md full narrative analysis.
manifest.json machine-readable artifact catalog.

Reproduce

ssh dfbsd-maxx /bin/sh      # unprivileged user (uid 1001)
cd poc/DF-0746
./build.sh && ./run.sh
# BUGGY  -> "UAF READ CONFIRMED: req->lr_id dereferenced after zfree"
# FIXED  -> "FIXED: no read of *req after zfree; DPRINTF used the saved local"

Expected

BUGGY transcription FIXED transcription
Test 1 (stale) UAF READ CONFIRMED: req->lr_id dereferenced after zfree (value 0x42 = original) FIXED: no read of *req after zfree
Test 2 (reuse) UAF READ CONFIRMED: freed req->lr_id returns attacker-shaped byte (value 0xDD) n/a (FIXED harness scribbles reuse then prints from saved local)

Fix validation (Phase 8)

  1. fix.diff applies cleanly to /usr/src (patch -p1, Hunk #1 succeeded at 185).
  2. cd /usr/src/sys/netbt && make builds netbt.ko with the fix β€” 0 errors, 0 warnings (-Werror), 105024 bytes.
  3. Disassembly: unfixed and fixed l2cap_rtx are byte-for-byte identical (0x5710, 14 instructions) β€” proves both that the fix doesn't perturb production codegen AND that the production module never emitted the UAF read in the first place (DPRINTF is a no-op).
  4. Logic transcription: harness_fixed.c prints the FIXED marker.

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

VERDICT.md verdict full narrative analysis
↓ download raw

DF-0746 β€” Verdict

Verdict: REPRODUCED (code-level harness) + FIX VALIDATED β€” runtime impact NONE on default kernel

The source-level bug is real and confirmed: in l2cap_rtx (sys/netbt/l2cap_misc.c:183-197), the body calls l2cap_request_free(req) at line 190 (which zfree()s req via line 173), and then at line 192 the DPRINTF(... req->lr_id) reads req->lr_id from freed memory β€” a use-after-free READ. The deterministic userspace harness harness.c transcribes the exact body of l2cap_rtx together with a faithful model of vm_zone zalloc/zfree and prints UAF READ CONFIRMED for both the immediate-stale case and the same-CPU-slab-reuse case.

However the runtime impact on the default X86_64_GENERIC kernel is NONE:

  1. netbt/l2cap_misc.c is optional bluetooth (sys/conf/files:1614) and X86_64_GENERIC has no device bluetooth β€” so the file is not compiled into the running kernel at all. It exists only in the loadable module /boot/kernel/netbt.ko, which is not loaded on this guest (kldstat shows only ehci/xhci).
  2. Even inside netbt.ko, BLUETOOTH_DEBUG is not defined (sys/netbt/Makefile has no -DBLUETOOTH_DEBUG), so DPRINTF expands to ((void)0) (sys/netbt/bluetooth.h:145) and the expression req->lr_id is never evaluated at runtime. The shipped module's l2cap_rtx disassembly (collected in disasm_unfixed_l2cap_rtx.txt) contains zero references to req after the callq l2cap_request_free β€” only the chan->lc_state check at offset 8 of the (separately-captured) chan pointer, which is safe.
  3. Decisively: the unfixed and fixed netbt.ko modules are byte-for-byte identical at l2cap_rtx (0x5710, same 14 instructions β€” see disasm_unfixed_l2cap_rtx.txt vs disasm_fixed_l2cap_rtx.txt). The bug exists only as source-level latent text that becomes live exclusively under -DBLUETOOTH_DEBUG.
  4. The live trigger also requires a Bluetooth radio + an L2CAP signaling exchange whose RTX timer (30 s default) fires β€” absent on this KVM guest.

So the bug is correctly classified Low / latent hardening: it would manifest (as a benign read of the original lr_id value, or as a read of attacker-reshaped bytes after same-CPU slab reuse) only in a BLUETOOTH_DEBUG build with live BT hardware. The fix is one line and is validated by building the actual netbt.ko with the patch and by the harness transcription.

Mechanism (every hop cited)

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;
...
172      TAILQ_REMOVE(&link->hl_reqs, req, lr_next);
173      zfree(l2cap_req_pool, req);          /* <--- req is freed HERE */
174  }

l2cap_rtx β€” the RTX (Response Timeout eXpired) callout callback (sys/netbt/l2cap_misc.c:183-197):

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;                   /* safe: read BEFORE free */
190      l2cap_request_free(req);               /* frees req via line 173 */
191
192      DPRINTF("cid %d, ident %d\n",
193              (chan ? chan->lc_lcid : 0),
194              req->lr_id);                   /* <--- UAF READ (offset 17) */
195
196      if (chan && chan->lc_state != L2CAP_CLOSED)
197          l2cap_close(chan, ETIMEDOUT);
198  }

After line 190, req points at freed memory. Line 194 reads req->lr_id.

Why the stale read returns the original id in practice (not attacker-controlled in the single-threaded path):

struct l2cap_req layout (sys/netbt/l2cap.h:423-430, amd64): - offset 0 lr_link (8 bytes) - offset 8 lr_chan (8 bytes) - offset 16 lr_code (1 byte) - offset 17 lr_id (1 byte) ← the field read after free - offset 18 pad - offset 24 lr_rtx (struct callout) - ... lr_next (TAILQ_ENTRY)

zfree (sys/vm/vm_zone.c:211-244) writes only: - offset 0 = freelist link (((void **)item)[0] = zpcpu->zitems;, vm_zone.c:233) - offset 8 = ZENTRY_FREE magic, only under INVARIANTS (((void **)item)[1] = (void *)ZENTRY_FREE;, vm_zone.c:237)

It does not touch offset 16/17. So immediately after zfree, req->lr_id still holds the original id set in l2cap_request_alloc (l2cap_misc.c:129). The harness Test 1 confirms this (g_uaf_read_value == 0x42).

The latent risk β€” the reason this is a real bug worth fixing β€” is that the dereference is of freed memory: any intervening same-CPU zalloc from l2cap_req_pool hands the slot to a different consumer, who can shape offset 17 to an arbitrary byte. Test 2 of the harness models that and shows the read returns the attacker-shaped value (0xDD).

In the actual l2cap_rtx callout path there is no allocator call between the zfree and the DPRINTF, so the immediate read returns the benign original id; but the bug is still a genuine UAF β€” the compiler is free to reorder, the slab is shared, and any future code edit that inserts an allocation in between turns this into an attacker-shaped read. Plus, under BLUETOOTH_DEBUG the read is live today.

Why it is not exploitable to uid=0

This is a single-byte read of freed memory whose value: - in the immediate path is the original id (no information disclosed β€” the caller set it), - goes only to kprintf (dmesg), - has no write primitive, no control flow hijack, no influence over a victim object's lifetime or pointers.

There is no path from this read to privilege escalation. The "valid hard blocker β€” read-only primitive" applies: the bug gives the attacker no write capability at all, so no escalation chain exists to develop. (See AGENT.md Phase 6: "The primitive is genuinely read-only β€” no write, no corruption. Then there is no chain.") The honest classification is a low-impact latent hardening bug.

Reachability on this guest

Layer Status Evidence
Compiled into default kernel NO sys/conf/files:1614 netbt/l2cap_misc.c optional bluetooth; sys/config/X86_64_GENERIC has no device bluetooth (grep bluetooth β‡’ none)
Loadable module present yes (unloaded) /boot/kernel/netbt.ko exists; kldstat shows only ehci/xhci
BLUETOOTH_DEBUG enabled in module NO sys/netbt/Makefile has no -DBLUETOOTH_DEBUG β‡’ DPRINTF=((void)0) (bluetooth.h:145)
UAF read emitted in shipped binary NO disasm of l2cap_rtx in netbt.ko references req only at 0x8(%rdi) (the pre-free chan = req->lr_chan); no post-free req deref (see disasm_unfixed_l2cap_rtx.txt)
Live trigger reachable NO requires BT radio + L2CAP RTX timer firing; KVM guest has neither
Net runtime impact on default kernel NONE bug is dead source text on production builds

This is the same harness-precedent cluster as DF-0745 (sibling double-free on the same function) and the DF-0393/0594/0616/0732/0733 wifi/netgraph/bt-unreachable set.

PoC changes (what was authored β€” the folder started empty)

The PoC folder was empty at start (no README.md, no sources β€” only the DB row and the rendered www/findings/DF-0746.html summary). Authored from scratch:

  • harness.c β€” deterministic userspace transcription of l2cap_rtx + l2cap_request_free + a faithful vm_zone zalloc/zfree model. Two tests: (1) immediate stale read returns original id; (2) same-CPU slab reuse makes the freed read return attacker-shaped bytes. Prints UAF READ CONFIRMED.
  • harness_fixed.c β€” same body with lr_id captured into a local before l2cap_request_free. Proves no byte of *req is read after the free.
  • build.sh, run.sh β€” exact, runnable.
  • fix.diff β€” minimal one-hunk unified diff against sys/netbt/l2cap_misc.c (capture id = req->lr_id before the free, use id in the DPRINTF).

Fix validation (Phase 8)

  1. Applies cleanly to /usr/src (patch -p1 --forward < fix.diff β‡’ Hunk #1 succeeded at 185).
  2. Builds the actual kernel module with the fix: cd /usr/src/sys/netbt && make (warm obj, -Werror) β‡’ 0 errors, 0 warnings, produces netbt.ko (/usr/obj/usr/src/sys/netbt/netbt.ko, 105024 bytes). See fix_build.log.
  3. Disassembly proof: the fixed module's l2cap_rtx (disasm_fixed_l2cap_rtx.txt) is identical to the unfixed module's (disasm_unfixed_l2cap_rtx.txt) β€” both at 0x5710, 14 instructions, zero req references after the call to l2cap_request_free. This simultaneously proves (a) the fix doesn't perturb production codegen and (b) the production module never emitted the UAF read in the first place.
  4. Logic transcription: harness_fixed.c reproduces the fix at the source-logic level and prints FIXED: no read of *req after zfree; DPRINTF used the saved local (fix_run.log).

fix_status: fixed β€” the source-level UAF is eliminated; the production module was already free of the runtime read because DPRINTF is a no-op.

fix.diff (matches the finding's proposal): in l2cap_rtx (sys/netbt/l2cap_misc.c), introduce uint8_t id;, set id = req->lr_id; before l2cap_request_free(req), and use id in the DPRINTF. One-line logical change.

Fix verification

fixed
baseline reproduced→ patch + rebuild →patched clean

VALIDATED: fix.diff applies+compiles (netbt.ko rc=0 -Werror). Unfixed==fixed disassembly (DPRINTF no-op). harness FIXED: no read after free.

harness BUGGY: UAF CONFIRMED. harness FIXED: no read after free. netbt.ko disasm unfixed==fixed.
↓ fix.diffn/a -- module-level: netbt.ko built with fix.diff, rc=0 -Werror; disasm identical to unfixed (DPRINTF no-op in production)

Confirmed kernel references

Detail

Exploit chain

none -- read-only primitive (single byte). No write, no info disclosure (value is original id). DPRINTF-only. No escalation.

Evidence (decisive lines)

harness BUGGY: 'UAF READ CONFIRMED: req->lr_id dereferenced after zfree' (value 0x42 original; 0xDD after slab reuse). harness FIXED: 'FIXED: no read of *req after zfree'. Disassembly unfixed==fixed (DPRINTF no-op in production).

PoC changes

Authored from scratch: harness.c (BUGGY transcription), harness_fixed.c (FIXED with saved local), build.sh, run.sh, fix.diff (save lr_id before free), VERDICT.md, manifest.json.

Verified recommended fix

In l2cap_rtx:184-197, introduce uint8_t id; set id=req->lr_id before l2cap_request_free at :190; use id in DPRINTF at :192. Matches finding proposal. Full git-apply-able diff in findings/poc/DF-0746/fix.diff.

Verdict

REPRODUCED at source level via deterministic harness. l2cap_rtx calls l2cap_request_free(req) (zfree at :173), then :192 DPRINTF(...req->lr_id) reads from freed memory. Harness confirms UAF read. NOT reachable live: netbt is optional bluetooth (not in GENERIC), BLUETOOTH_DEBUG undefined so DPRINTF is no-op, no BT hardware. Production netbt.ko disassembly identical to fixed (no UAF read emitted). Impact none on default kernel.