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)
PoC verification
Evidence pack
findings/poc/DF-0746 Β· 18 files| File | Type | Description | Size | |
|---|---|---|---|---|
| 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 |
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.cisoptional bluetooth(sys/conf/files:1614) andX86_64_GENERIChas nodevice bluetoothβ the file is not in the running kernel, only in the loadable module/boot/kernel/netbt.ko.BLUETOOTH_DEBUGis not defined insys/netbt/Makefile, soDPRINTFexpands to((void)0)(sys/netbt/bluetooth.h:145) andreq->lr_idis never evaluated at runtime.- Decisive: the disassembly of
l2cap_rtxin the unfixednetbt.ko(disasm_unfixed_l2cap_rtx.txt) contains zero references toreqafter thecallq 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)
fix.diffapplies cleanly to/usr/src(patch -p1,Hunk #1 succeeded at 185).cd /usr/src/sys/netbt && makebuildsnetbt.kowith the fix β 0 errors, 0 warnings (-Werror), 105024 bytes.- Disassembly: unfixed and fixed
l2cap_rtxare 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). - Logic transcription:
harness_fixed.cprints 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.
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:
netbt/l2cap_misc.cisoptional bluetooth(sys/conf/files:1614) andX86_64_GENERIChas nodevice 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 (kldstatshows only ehci/xhci).- Even inside
netbt.ko,BLUETOOTH_DEBUGis not defined (sys/netbt/Makefilehas no-DBLUETOOTH_DEBUG), soDPRINTFexpands to((void)0)(sys/netbt/bluetooth.h:145) and the expressionreq->lr_idis never evaluated at runtime. The shipped module'sl2cap_rtxdisassembly (collected indisasm_unfixed_l2cap_rtx.txt) contains zero references toreqafter thecallq l2cap_request_freeβ only thechan->lc_statecheck at offset 8 of the (separately-captured)chanpointer, which is safe. - Decisively: the unfixed and fixed
netbt.komodules are byte-for-byte identical atl2cap_rtx(0x5710, same 14 instructions β seedisasm_unfixed_l2cap_rtx.txtvsdisasm_fixed_l2cap_rtx.txt). The bug exists only as source-level latent text that becomes live exclusively under-DBLUETOOTH_DEBUG. - 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 ofl2cap_rtx+l2cap_request_free+ a faithfulvm_zonezalloc/zfreemodel. Two tests: (1) immediate stale read returns original id; (2) same-CPU slab reuse makes the freed read return attacker-shaped bytes. PrintsUAF READ CONFIRMED.harness_fixed.cβ same body withlr_idcaptured into a local beforel2cap_request_free. Proves no byte of*reqis read after the free.build.sh,run.shβ exact, runnable.fix.diffβ minimal one-hunk unified diff againstsys/netbt/l2cap_misc.c(captureid = req->lr_idbefore the free, useidin theDPRINTF).
Fix validation (Phase 8)
- Applies cleanly to
/usr/src(patch -p1 --forward < fix.diffβHunk #1 succeeded at 185). - Builds the actual kernel module with the fix:
cd /usr/src/sys/netbt && make(warm obj,-Werror) β 0 errors, 0 warnings, producesnetbt.ko(/usr/obj/usr/src/sys/netbt/netbt.ko, 105024 bytes). Seefix_build.log. - 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 at0x5710, 14 instructions, zeroreqreferences after the call tol2cap_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. - Logic transcription:
harness_fixed.creproduces the fix at the source-logic level and printsFIXED: 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.
Recommended fix
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
fixedVALIDATED: 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.
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.
No comments yet.