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

Heap OOB read/write via zero-length segment array from integer overflow in n

Summary

fwdma_malloc_multiseg accepts untrusted int n without validating n>0 or checking caller nchunk*npacket product did not overflow. When product overflows to 0 (e.g. nchunk=65536 npacket=65536 -> 0 mod 2^32) nseg computes to 0 and function allocates struct fwdma_alloc_multi header with zero seg[] entries. Caller stores original non-overflowed nchunk/npacket in fw_xferq and inline accessors fwdma_v_addr/fwdma_bus_addr index am->seg[0] without bounds-checking against am->nseg reading 24 bytes past 32-byte allocation from adjacent slab object and dereferencing result. fw_write path writes user data through OOB-derived pointer (arbitrary kernel write). fw_read path reads kernel memory (info leak). Exploitation requires heap grooming to control adjacent chunk contents.

Discussion (0)

No comments yet.

PoC verification

Evidence pack

findings/poc/DF-2298 Β· 16 files
FileTypeDescriptionSize
df229x.c β€” 19.9 KB view raw
Makefile β€” 1.8 KB ↓ download
shim/ β€” 4.0 KB ↓ download
build.sh β€” 208 B view raw
run_bootA.sh β€” 803 B view raw
run_fix.sh β€” 1.1 KB view raw
run.log β€” 2.0 KB view raw
fix_run.log β€” 2.6 KB view raw
baseline_build.log β€” 7.0 KB view raw
fix_build.log β€” 7.0 KB view raw
env.txt β€” 2.0 KB view raw
fix.diff β€” 1.4 KB view raw
README.md β€” 3.2 KB ↓ raw
VERDICT.md β€” 5.5 KB ↓ raw
manifest.json β€” 2.1 KB view raw
verdict.json β€” 7.0 KB view raw

DF-2298 β€” Heap OOB read/write via zero-length segment array from integer overflow in n

File: sys/bus/firewire/fwdma.c:145-166 + sys/bus/firewire/fwdma.h:59-75 Verdict: REPRODUCED β€” OOB read of a groom-controlled pointer + production fw_write landing 2048 attacker-chosen bytes at the chosen kernel address.

Claim

FW_SSTBUF (no validation, fwdev.c:504-506) β†’ fwdev_allocbuf (fwdev.c:109) computes n = b->nchunk * b->npacket as an int multiply. For nchunk = npacket = 0x10000 the product wraps to 0 mod 2^32. fwdma_malloc_multiseg(fc, 4, 512, 0, ...) then computes nseg = howmany(0, 4096/512) = 0, kmallocs only the 32-byte struct fwdma_alloc_multi header (fwdma.c:162-163) and returns it as success with a zero-length seg[] array. fwdev_allocbuf stores the original nchunk/npacket in the queue and marks FWXFERQ_EXTBUF, so production fw_write/fw_read reach fwdma_v_addr() (fwdma.h:59-66), which reads am->seg[0].v_addr β€” 8..24 bytes past the 32-byte allocation β€” and uiomove()s attacker iso data to that stale/groomed pointer.

How to build / run (no FireWire hardware needed β€” KLD harness)

The harness compiles the unmodified production firewire.c + fwdma.c + fwdev.c into df229x.ko (DF-5613 pattern) and drives the real fw_ioctl / fw_write paths. Test 2:

  1. sprays the 32-byte kmalloc zone with chunks whose every qword is the victim pointer, then punches holes (every 2nd chunk freed);
  2. FW_SSTBUF{tx:{0x10000, 0x10000, 512}} + FW_STSTREAM β†’ the zero-seg am lands in a hole β†’ the OOB am->seg[0].v_addr (at am+40) reads back the pointer we chose;
  3. one production fw_write() (fwdev.c:388-392) uiomoves the 4-byte iso header + len (2044) payload bytes of attacker data to that address.
# guest, root, fresh `vm.sh reset with-src`
cd /root/df229x && sh build.sh && kldload ./df229x.ko
sysctl kern.df229x_test=2 && sysctl kern.df229x_status

Expected (baseline, reproduced)

df2298/a1: am (32-byte kmalloc, nseg=0) -> OOB am->seg[0].v_addr = 0xfffff801187ea000
           (read at am+40); groom target = 0xfffff801187ea000
df2298/a1: fw_write err=0; victim page now: "" / "DF2298-OOB-WRITE"
df2298/a1: CONTROLLED WRITE CONFIRMED: attacker iso payload ... landed at the
           groom-chosen kernel address ... via the OOB-read seg[0].v_addr
kern.df229x_status: DF-2298 REPRODUCED: ... OOB-read-to-arbitrary-write

(Groom hit 4/4 attempts across two boots. Without the groom the same OOB read hands fw_write an uncontrolled kernel pointer β€” a wild write/panic.)

Expected (fix.diff applied, module rebuilt β€” fix_run.log)

df2298/a*: STSTREAM err=22 -- bufspec rejected, no zero-seg allocation
kern.df229x_status: DF-2298 nchunk=npacket=0x10000: STSTREAM err=22 (rejected, no write)
kern.df229x_status: DF-2298b npacket=0: STSTREAM err=22      (trivial n=0 variant)
kern.df229x_status: regression OK: legit bufspec still works

Files

df229x.c (harness; test 2 = this finding, test 4 = trivial npacket=0 variant), Makefile + shim/, build.sh, run_bootA.sh / run_fix.sh, run.log (decisive baseline), fix_run.log, baseline_build.log / fix_build.log, env.txt, fix.diff, VERDICT.md, manifest.json, verdict.json

VERDICT.md
↓ download raw

DF-2298 β€” VERDICT

Status: REPRODUCED (impact: memcorrupt β€” OOB read of a heap-adjacent, groom-controlled pointer feeding a production uiomove write of fully attacker-chosen bytes: a demonstrated write-what-where class primitive). Confidence: certain.

Reachability trace (production code, this tree, today)

  1. fwdev.c:504-506 β€” FW_SSTBUF copies struct fw_isobufreq (userspace unsigned int triples, firewire.h:50-56) unvalidated.
  2. fwdev.c:103-109 β€” fwdev_allocbuf: kmalloc(sizeof(fw_bulkxfer) * b->nchunk) (48 * 0x10000 = 3 MB, fine), then fwdma_malloc_multiseg(fc, 4, roundup2(512,4), b->nchunk * b->npacket, ...). The element count is an int multiply: 0x10000 * 0x10000 == 0 (mod 2^32).
  3. fwdma.c:157-166 β€” esize=512 ≀ PAGE_SIZE β†’ ssize = rounddown(4096,512) = 4096, nseg = howmany(0, 8) = 0; am = kmalloc(32 + 24*0) β€” only the 32-byte header β€” am->nseg = 0, the per-segment loop (fwdma.c:193-203) is skipped, and am is returned as success.
  4. fwdev.c:116-134 β€” the original bnchunk=0x10000, bnpacket=0x10000 are stored, 65536 fw_bulkxfer entries initialized, queue flagged FWXFERQ_EXTBUF. From userspace the ioctl returned 0: nothing looks wrong.
  5. fwdev.c:388-392 β€” fw_write: fp = fwdma_v_addr(it->buf, poffset+queued); fwdma.h:59-66 computes am->seg[0/4096].v_addr β€” seg[0] begins at am+32, i.e. entirely past the 32-byte allocation; v_addr is the qword at am+40, read from the adjacent 32-byte slab chunk. Then uiomove(fp, 4) + uiomove(fp->payload, fp->len) write attacker bytes to that pointer. (fw_read, fwdev.c:321-330, is the arbitrary-read mirror.)

What was run (decisive evidence, run.log)

Production path driven end-to-end with the OOB pointer proven controlled before any write fires:

  • 512Γ—kmalloc(32, M_FW) groom chunks each pre-filled with the victim pointer, every 2nd freed β†’ am's kmalloc(32, M_FW) (fwdma.c:162) lands in a hole;
  • df2298/a1: STSTREAM OK (err=0) ... nseg=0 ssize=4096 esize=512 bnchunk=65536 bnpacket=65536 β€” the overflow-to-zero state itself;
  • OOB am->seg[0].v_addr = 0xfffff801187ea000 (read at am+40); groom target = 0xfffff801187ea000 β€” the OOB read returns exactly the pointer we planted (groom hit 4/4 across two boots: 3/3 on the first run's attempts, 1/1 decisive);
  • production fw_write β†’ err=0; victim page now: "" / "DF2298-OOB-WRITE" β€” header at victim[0..4) (NUL bytes print empty), payload DF2298-OOB-WRITE!… at victim+4;
  • CONTROLLED WRITE CONFIRMED: attacker iso payload (header + 2044 bytes) landed at the groom-chosen kernel address.

Primitive characterization (write-capable bug β†’ what it is)

  • What: 4 semi-constrained bytes (iso stream header: len:16 bounds the following write) then up to len (u16, ≀65535) fully attacker-chosen bytes per packet, repeatable per chunk/packet.
  • Where: any address the attacker can plant in the adjacent 32-byte kmalloc chunk β€” i.e. arbitrary kernel address (demonstrated: the write landed precisely on the pre-registered victim page; the harness never fires the write unless the OOB read provably returned the planted pointer).
  • Read mirror: fw_read (fwdev.c:321-330) uiomoves out of the same OOB-derived pointer β†’ arbitrary-read shape.
  • uid=0 route: on the no-KASLR/no-SMAP guest, spray the 32-byte zone with the address of any known writable function pointer / ops table (fixed kernel addresses), overwrite it via the iso payload, trigger the call β€” or use the read mirror to walk allproc β†’ own p_ucred and zero the uids with the write. Full uid=0 chain not constructed this run: entry requires a FireWire controller's /dev/fw* node (0660 root:operator), which the QEMU guest does not have β€” same evidence-class standard as DF-5413/DF-5548/DF-5613 (hardware-gated entry, primitive proven on production code).
  • Without grooming, the same OOB read hands uiomove an uncontrolled kernel qword β†’ wild write β†’ panic (trivially reachable DoS).

Reconciliation with the pass-2 negative note

Pass 2 killed only the other sub-variant ("size_t wrap of sizeof(fwdma_seg)*nseg unreachable on 64-bit … 32-bit-only"). The finding's primary claim β€” int product nchunk*npacket wrapping to 0 β†’ zero-length seg[] β†’ OOB access β€” is 64-bit reachable and reproduced here on x86-64. Also note npacket=0 reaches the identical n=0 state without any overflow (tested: rejected by the fix, DF-2298b err=22).

Fix validation (fix.diff, applied to guest /usr/src, module rebuilt)

  • Overflow variant 0x10000Γ—0x10000 β†’ STSTREAM err=22, no zero-seg allocation, no write (it=0 buf=0 bulkxfer=0) β€” the fwdev_allocbuf hunk rejects npacket > 0x7fffffff / nchunk before the multiply feeds fwdma_malloc_multiseg.
  • Trivial variant npacket=0 β†’ err=22.
  • Hard boundary: direct fwdma_malloc_multiseg(esize=512, n=0) β†’ NULL, no trap (the fwdma.c hunk), so no caller can reach the zero-length seg[] state even if a future caller forgets validation.
  • Regression: legit {4,8,2048} still allocates and streams. Guest alive (fix_run.log).

Fix status (batch validation)

Applied with the 36 sibling fix.diffs of this batch in findings/poc/_batch41/all_fixes.patch; single in-guest make -j6 nativekernel KERNCONF=X86_64_GENERIC completed RC=0 with zero errors under -Werror (full log: findings/poc/_batch41/fix_build8.log). Behavior A/B intentionally skipped for this Info-tier item.

Fix verification

fixed
baseline reproduced→ patch + rebuild →patched clean

fix.diff (fwdev.c fwdev_allocbuf validation incl. the nchunknpacket overflow check + fwdma.c esize/n hard guard) applied to the guest /usr/src; harness module rebuilt from the patched production sources. Baseline: STSTREAM returns 0 with a 0-seg 32-byte am and the groomed production fw_write lands 2048 attacker bytes on the chosen address. Patched: the 0x100000x10000 vector is rejected (err=22, it=0 buf=0 bulkxfer=0, no write), the trivial npacket=0 variant is rejected (err=22), the direct fwdma_malloc_multiseg(n=0) probe returns NULL, and the legit {4,8,2048} regression still allocates and streams; guest alive after the full suite.

fix_run.log (patched suite incl. 'df2298/a*: STSTREAM err=22 -- bufspec rejected, no zero-seg allocation' and 'regression OK'), fix_build.log (patched build), fix.diff (git apply --check verified against the repo sys/ tree; patch(1) applied cleanly to guest /usr/src)
↓ fix.diffper-fix-DF-2298

Confirmed kernel references

Detail

Exploit chain

open /dev/fw0.0 (operator group) -> ioctl(FW_SSTBUF, {0x10000,0x10000,512}) -> ioctl(FW_STSTREAM) [n wraps to 0; queue left with 65536 chunks and a zero-seg 32-byte am] -> spray/groom the 32-byte kmalloc zone so the chunk following am contains the desired target address -> write()/fw_write iso packets: 4 semi-constrained header bytes + up to 65535 fully attacker-chosen bytes land at the target (repeatable per packet/chunk) -> on the no-KASLR/no-SMAP guest, target a known writable function pointer/ops table and trigger it (or use the fw_read OOB mirror to walk allproc to p_ucred and zero the uids) -> uid=0. Full uid=0 chain not constructed this run (device-gated entry; no /dev/fw* in QEMU), matching the DF-5413/DF-5548/DF-5613 evidence standard.

Evidence (decisive lines)

run.log (decisive baseline: 'STSTREAM OK ... nseg=0 ssize=4096 esize=512 bnchunk=65536 bnpacket=65536'; 'am (32-byte kmalloc, nseg=0) -> OOB am->seg[0].v_addr = 0xfffff801187ea000 ... groom target = 0xfffff801187ea000'; 'fw_write err=0; victim page now: "" / "DF2298-OOB-WRITE"'; 'CONTROLLED WRITE CONFIRMED: attacker iso payload (header + 2044 bytes ...) landed at the groom-chosen kernel address'), fix_run.log (patched: 'STSTREAM err=22 -- bufspec rejected, no zero-seg allocation (it=0 buf=0 bulkxfer=0)'; 'DF-2298b npacket=0: STSTREAM err=22'; 'regression OK'), baseline_build.log / fix_build.log, env.txt

PoC changes

No seed existed (poc_path was empty). Harness df229x.c written fresh on the DF-5613/DF-5413 KLD pattern; test 2 grooms the 32-byte kmalloc zone (planted victim pointer, every-2nd-chunk freed), drives the production SSTBUF+STSTREAM pair with the finding's exact 0x10000*0x10000 vector, and only then fires one production fw_write -- and only after proving the OOB-read pointer equals the planted one. Two harness bugs were fixed during iteration: (1) the payload fill started the 17-byte pattern at i%17 instead of (i-4)%17, rotating it by 4; (2) the scan signature 'DF2298-W' never occurs in 'DF2298-OOB-WRITE!' -- the first run's write HAD landed (victim+4 showed the rotated pattern in dmesg) but the checker missed it; both fixed and re-run clean.

Verified recommended fix

Validate in fwdev_allocbuf (EINVAL for psize==0/psize>0x7fffffff/nchunk==0/npacket==0/npacket > 0x7fffffff/nchunk) and hard-guard fwdma_malloc_multiseg (reject esize<=0 || n<=0 -> NULL so no zero-length seg[] can ever be returned) -- fix.diff in this pack.

Verdict

REPRODUCED on the QEMU guest with the unmodified production firewire.c+fwdma.c+fwdev.c compiled into KLD df229x.ko. FW_SSTBUF{tx:{nchunk=0x10000, npacket=0x10000, psize=512}} (no validation, fwdev.c:504-506) -> fwdev_allocbuf's int multiply nchunk*npacket wraps to 0 (fwdev.c:109) -> fwdma_malloc_multiseg computes nseg=howmany(0,8)=0, kmallocs ONLY the 32-byte fwdma_alloc_multi header (fwdma.c:162-163) and returns it as SUCCESS with a zero-length seg[] array; fwdev_allocbuf stores the ORIGINAL nchunk/npacket and flags FWXFERQ_EXTBUF, so the ioctl pair returns 0. After a 512-chunk groom of the 32-byte kmalloc zone (planted pointer + punched holes), the zero-seg am landed in a hole 4/4 times across two boots; fwdma_v_addr (fwdma.h:59-66) OOB-read am->seg[0].v_addr at am+40 returned EXACTLY the planted pointer, and production fw_write (fwdev.c:388-392) then uiomove()'d the 4-byte iso header + 2044 attacker bytes onto the chosen kernel address ('CONTROLLED WRITE CONFIRMED ... DF2298-OOB-WRITE'). The harness only fires fw_write when the OOB read provably returned the planted pointer, so the demonstration is a controlled write, not a lucky crash.