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

Kernel heap OOB read in cdreaddvdstructure() default case via short-transfer underflow

Summary

In cdreaddvdstructure() default case dvdstruct->length computed as length-ccb->csio.resid-sizeof(header) in mixed int/u_int32_t/size_t arithmetic wraps to huge value when device returns short transfer (resid>length-4) with CAM_REQ_CMP status. Subsequent bcopy reads min(sizeof(dvdstruct->data)=2048 dvdstruct->length)=2048 bytes from databuf+4 even for small DVD structure formats whose databuf kmalloc as little as 8 bytes. Up to ~2044 bytes of adjacent kernel heap copied into dvdstruct->data which is user-supplied ioctl argument buffer copied back to userspace by IOWR semantics. Formats reaching default branch with length<2052 all vulnerable. Attacker: malicious SCSI/USB device plus SYSCAP_RESTRICTEDROOT opener. Device answers READ DVD STRUCTURE (opcode 0xad) for small format (MEDIUM_STAT) with GOOD status and 0-3 bytes transfer. cdrunccb returns success CAM_REQ_CMP post-read switch falls through to default underflowed dvdstruct->length drives 2048-byte bcopy from 8-byte databuf. DVDIOCREADSTRUCTURE is _IOWR ioctl handler aliases addr directly to dvdstruct kernel-resident alias 2048 leaked heap bytes land in caller struct dvd_struct returned to userspace. Impact: kernel heap info leak up to 2048 bytes per call useful for KASLR bypass and follow-on RCE attempt. [VERIFIED pass2: reproduced via CAM verbatim-interposition rig - malicious short transfer with CAM_REQ_CMP wraps the unsigned subtraction, 2048-byte bcopy from 8-byte MEDIUM_STAT databuf, up to 2044 bytes past end leaked to userspace ioctl buffer, byte-attributable canaries and live kernel pointers fffff800xx captured, heap-state dependent across runs; boundary matrix exact incl. resid 0xFFFFFFFF wrapping the other way defeating naive signed-cast fixes; fix-validated - checked transferred math returns EIO on every underflow row, sane transfers bit-identical; leak-only, SYSCAP_RESTRICTEDROOT plus malicious device reach]

Discussion (0)

No comments yet.

PoC verification

Evidence pack

findings/poc/DF-2269 Β· 34 files
FileTypeDescriptionSize
README.md β€” 3.7 KB ↓ raw
VERDICT.md β€” 9.0 KB ↓ raw
df_pre.c β€” 3.0 KB view raw
df_post.c β€” 18.8 KB view raw
gen.sh β€” 1.5 KB view raw
build.sh β€” 180 B view raw
quick.sh β€” 1.6 KB view raw
run.sh β€” 176 B view raw
run_fixed.sh β€” 1.5 KB view raw
build.log β€” 5.6 MB ↓ download
build_driver.log β€” 390 B view raw
quickkernel.log β€” 584.6 KB ↓ download
installkernel.log β€” 80.4 KB view raw
gen.log β€” 79 B view raw
rig_iteration1_bootpanic.txt β€” 409 B view raw
run_matrix.log β€” 28 B view raw
run_formats.log β€” 29 B view raw
run_leak.log β€” 26 B view raw
run_repeat.log β€” 28 B view raw
run_dmesg.log β€” 121.4 KB view raw
run_dmesg_full.txt β€” 173.7 KB view raw
run_fixed_matrix.log β€” 28 B view raw
run_fixed_formats.log β€” 29 B view raw
run_fixed_leak.log β€” 69 B view raw
run_fixed_repeat.log β€” 28 B view raw
run_fixed_dmesg.log β€” 171.3 KB view raw
run_fixed_currentboot.txt β€” 30.6 KB view raw
leak_sample_boot1.txt β€” 11.0 KB view raw
leak_sample_boot2.txt β€” 33.0 KB view raw
leak_boot2_summary.txt β€” 2.8 KB view raw
env.txt β€” 460 B view raw
fix.diff β€” 1.4 KB view raw
manifest.json β€” 2.2 KB view raw
verdict.json β€” 6.2 KB view raw

DF-2269 β€” Kernel heap OOB read in cdreaddvdstructure() default case

Verdict: REPRODUCED (impact: kernel heap info leak) β€” fix validated A/B.

sys/bus/cam/scsi/scsi_cd.c:4288-4297: in the default: case of cdreaddvdstructure(), dvdstruct->length is computed as length - ccb->csio.resid - sizeof(header) in mixed int / u_int32_t arithmetic. A device that completes READ DVD STRUCTURE (opcode 0xad) with CAM_REQ_CMP and a transfer short of the 4-byte header (or any resid > length - 4) wraps the unsigned subtraction, min(2048, ~4G) selects 2048, and the bcopy reads up to 2044 bytes past an 8-byte kmalloc(M_DEVBUF) databuf into the caller's dvd_struct.data β€” which the ioctl layer then copies out to userspace (sys/kern/sys_generic.c:725).

Rig

Same CAM verbatim-interposition pattern as the DF-2263/2264/2265 scsi_sa rig: a byte-for-byte scsi_cd.c (sha256-gated to the audit tree: 34ae0dc0ed9a601b91f1849f70fc876bfc96232fc9e74306d8bda47bf5a5d0b8) is concatenated between df_pre.c (interposes only xpt_path_periph, cam_periph_getccb, cam_periph_runccb, xpt_release_ccb) and df_post.c (fake malicious transport + heap-canary groom + sysctl drivers) and compiled into the kernel as bus/cam/scsi/df2269_harness.c ("device cd" is commented out of the guest config so the harness TU is the only copy). The shims PASS THROUGH to the real CAM functions except during the synchronous window around the tested cdioctl() call β€” this guest really has an ATAPI DVD-ROM (acd0) that must keep working.

Everything between entry and the buggy line is REAL kernel code: cdioctl β†’ cam_periph_hold β†’ cdcheckmedia-skip (VALID_MEDIA) β†’ cdreaddvdstructure β†’ cdgetccb β†’ scsi_read_dvd_structure β†’ cam_fill_csio β†’ cdrunccb β†’ (fake transport sets resid + CAM_REQ_CMP) β†’ the default-case arithmetic and bcopy.

Build / run (audit host, from this directory)

./build.sh            # push + full nativekernel + install + reboot (~12 min)
./run.sh              # battery: matrix, formats, leak dump, repeat

For the fixed (A/B) run:

./run_fixed.sh        # applies fix.diff in the guest, quickkernel, reboot,
                      # re-runs the identical battery, un-patches

Key sysctl triggers (run as root on the guest):

sysctl debug.df2269.matrix=1    # resid boundary matrix (MEDIUM_STAT, 8B buf)
sysctl debug.df2269.formats=1   # 2-byte short transfer on all 11 formats
sysctl debug.df2269.leak=1      # full 2048-byte dump of dvd_struct.data
sysctl debug.df2269.repeat=N    # N leak runs (heap-state variance)

Expected (unpatched)

  • transferred=8/5/4 (resid=0/3/4): clean β€” length 4/1/0, no OOB.
  • transferred<=3 (resid>=5), resid>length, resid=0xFFFFFFFF: length=0xffffffxx, *** OOB READ: kernel copied 2048 bytes from a 8-byte heap buffer (2044 bytes past its end), leaked data[] contains pre-placed canary chunks AND kernel heap pointers (M_DEVBUF zone free-list linkage, e.g. fffff8008d3622xx every 16 bytes).
  • BCA (192B buf): 1860 bytes past end, 190 ptr-like values.
  • Formats with len>=2052 (DISCKEY/DDS/MANUFACT/DISCKEYBLOCK/LIST): wrapped length reported to userspace but copy stays in bounds.

Expected (patched, fix.diff)

  • Sane transfers bit-identical (same return, same length, same fnv1a).
  • Every underflow/impossible-resid row: ret=5 (EIO), length=0, copy=0, zero leaked bytes, zero markers, zero kernel pointers.

Files

See manifest.json. Highlights: run_dmesg.log (unfixed battery), run_fixed_currentboot.txt (fixed battery), leak_sample_boot1.txt / leak_sample_boot2.txt / leak_boot2_summary.txt (leak dumps and variance across boots), VERDICT.md (full narrative), fix.diff (git-apply-able against sys/).

VERDICT.md
↓ download raw

DF-2269 β€” VERDICT

Finding. sys/bus/cam/scsi/scsi_cd.c:4283-4298, default case of cdreaddvdstructure():

dvdstruct->length = length - ccb->csio.resid -
    sizeof(struct scsi_read_dvd_struct_data_header);
bcopy(databuf + sizeof(struct scsi_read_dvd_struct_data_header),
      dvdstruct->data,
      min(sizeof(dvdstruct->data), dvdstruct->length));

length is int (scsi_cd.c:4113), ccb->csio.resid is u_int32_t (cam_ccb.h:607), sizeof(...) is size_t. The subtraction is evaluated unsigned: any device response with (length - resid) < 4 wraps to ~0xFFFFFFFF, min(2048, ~4G) = 2048, and the bcopy source is databuf + 4 of a kmalloc(length) buffer (scsi_cd.c:4208). For every default-case format with length < 2052 the copy runs off the end of the allocation. The 2048 copied bytes land in dvd_struct.data (dvdio.h:58), which the generic ioctl layer copies out to userspace after the handler returns 0 (sys_generic.c:668 size=IOCPARM_LEN=2064; sys_generic.c:725-726 copyout; devfs_vnops.c:1569 dev_dioctl dispatch).

Reachability (honest, per team directive). - Entry: ioctl(fd, DVDIOCREADSTRUCTURE, &ds) on an open CD device; cdopen gates the open with caps_priv_check_self(SYSCAP_RESTRICTEDROOT) (scsi_cd.c:1006) β€” the caller must be root-equivalent (or hold the RESTRICTEDROOT privilege). Not reachable by an unprivileged local user on a stock box. - Trigger: the device (SCSI or USB mass-storage presented as a CD) answers READ DVD STRUCTURE with GOOD status and a short transfer that delivers fewer than 4 bytes β€” trivially programmable into a malicious gadget or a bugged drive firmware. cdrunccb returns success for CAM_REQ_CMP, so nothing upstream sanity-checks resid before the arithmetic. - Combination required: privileged opener + attacker-influenced device. Realistic scenarios: malicious USB stick imitating a DVD drive attacked at a machine where an admin runs cd/dvd tooling (libdvdcss-style structure enumeration issues exactly these ioctls); or a compromised drive firmware. On its own this is a robustness + info-leak defect, not a plain local privesc.

What was reproduced (unfixed kernel #3, real cdioctl path). The harness is the established CAM verbatim-interposition rig: sha256-gated verbatim scsi_cd.c compiled into the kernel with only four external symbols interposed, every shim passing through to the real CAM function outside the tested call (the guest has a real ATAPI acd0). The fake transport answers opcode 0xad with attacker-chosen transfer counts; the heap around the 8-byte databuf is groomed with same-size-class M_DEVBUF canary chunks (0xC0-0xFF filled, every 2nd freed) plus a 0x5A runway.

Boundary matrix (MEDIUM_STAT, length = 8, databuf = 8 bytes):

transferred resid dvdstruct->length bcopy observed
8 0 4 4 clean (4 device bytes)
5 3 1 1 clean
4 4 0 0 clean boundary
3 5 0xFFFFFFFF 2048 2004 non-zero bytes, 160 canary-marker hits, 36 kernel-pointer values
2 6 0xFFFFFFFE 2048 2044 non-zero, markers + pointers
1 7 0xFFFFFFFD 2048 2004 non-zero, 160 markers, 36 pointers
0 8 0xFFFFFFFC 2048 2044 non-zero
0 9 (len+1) 0xFFFFFFFB 2048 2004 non-zero
0 12 (len+4) 0xFFFFFFF8 2048 2044 non-zero
0 100 0xFFFFFFA0 2048 2004 non-zero
0 0xFFFFFFFF 5 5 1-byte overread β€” the u32 wrap wraps the other way; proves a signed-cast-only fix is insufficient

Formats sweep (transferred=2):

format length OOB bytes past end markers ptr-like
MEDIUM_STAT 0x09 8 2044 5-160 5-37
SPARE_AREA 0x0A 16 2036 13 14
PROTDISCID 0x06 20 2032 17 18
UNIQUEID 0x0F 22 2032 930 30
PRERECORDED 0x0E 68 1984 65 66
BCA 0x03 192 1860 189 190
DISCKEY/DDS/MANUFACT 0x02/0x08/0x04 2052 0 (in bounds) 0 0
DISCKEYBLOCK 0x07 / LIST 0xFF 28672/65535 0 (in bounds) 0 0

Attribution is byte-exact: data[0..3] are the in-bounds tail of the databuf (zeros), data[4..7] = df df df df β€” the first canary chunk starts exactly at databuf+8, i.e. the first byte past the allocation. The pointer scan finds kernel virtual addresses every 16 bytes (fffff8008d362250/260/270/... β€” the M_DEVBUF zone free-list linkage in the freed canary holes), so the disclosure includes live kernel heap pointers (KASLR-relevant on kernels that randomize; this guest does not). dmesg excerpt (headline leak, transferred=2):

DUMP +0000: 00 00 00 00 df df df df df df df df ff ff ff ff
DUMP +0010: ff ff ff ff 5a 5a 5a 5a 5a 5a 5a 5a 5a 5a 5a 5a
ptr-like fffff8008d362250 at data[12] => databuf+16 (8 past end)
ptr-like fffff8008d362260 at data[28] => databuf+32 (24 past end)
...
SUMMARY ... transferred=2 resid=6 -> ret=0 length=4294967294 copy=2048
          nonzero=2044 markers=5 ptrlikes=5

Heap-state dependence (KASLR-bypass relevance): repeated identical calls yield different leak contents β€” nonzero=2044/markers=10/ptrlikes=7 vs nonzero=2006/markers=160/ptrlikes=37 alternating with the hole the allocator picks; a second boot returned an all-zero sweep once and pointer-rich sweeps other times (leak_sample_boot1.txt, leak_sample_boot2.txt, leak_boot2_summary.txt).

Secondary userland effect: for the in-bounds formats (>=2052) and for DVD_STRUCT_LIST the userspace-visible dvd_struct.length is still the wrapped ~0xFFFFFFFE; any caller that trusts it to bound a copy out of data[] overreads its own buffer. The kernel OOB read is confined to formats with length < 2052.

Privesc assessment (closed). The primitive is a kernel-heap READ of up to 2048 bytes into the privileged caller's own struct; there is no write, no length control beyond min(2048, wrap), and the copy target is a correctly sized 2048-byte field (no write overflow anywhere). No uid=0 route exists from this bug alone; escalation would require a separate memory-corruption primitive for which this leak provides heap layout and pointer disclosure (the classic KASLR/heap-grooming enabler). Impact class: kernel info leak, privileged-opener + malicious-device reach β€” consistent with the Medium/kernleak bucket.

Fix validation (A/B, kernel #4 vs #3, identical battery). fix.diff rejects resid > length and transferred < sizeof(header) with EIO before any length is derived:

  • Unfixed: transferred=3..0 and resid=9/12/100/0xFFFFFFFF rows return 0 with length=0xffffffxx and 2048-byte OOB copies (see run_dmesg.log).
  • Fixed: the same rows return ret=5 (EIO), length=0, copy=0, nonzero=0, markers=0, ptrlikes=0, and zero OOB READ lines (run_fixed_currentboot.txt).
  • Sane transfers are bit-identical: transferred=8 β†’ length=4, fnv1a=7ac988210d38cfe1; transferred=5 β†’ length=1, fnv1a=5137c7c66c9c4391; transferred=4 β†’ length=0 β€” the same hashes as the unfixed kernel, proving no behavioral change for well-behaved devices. The resid=0xFFFFFFFF row (which would defeat a naive signed-cast fix, unfixed result length=5/copy=5) now returns EIO.
  • The fix was applied only to the guest build tree; the audit-side sys/ tree is untouched. Guest source reverted to the verbatim sha256 afterwards, and the guest was reset to the with-src snapshot.

Rig iterations (what it took). 1. Harness v1 panicked at boot in cderror (NULL periph): the guest has a real ATAPI DVD-ROM, so boot-time CAM traffic hit the always-on shims. Fixed by making every interposed shim a pass-through except during the armed window around the tested cdioctl call, and moving the fabricated periph to unit 5. (Recovered the wedged guest without losing the build by switching the QEMU serial chardev to a socket via the monitor and selecting "Boot DragonFly using kernel.old" on the loader menu.) 2. Harness v2 executed but the transport sanity check rejected every CDB: READ DVD STRUCTURE puts alloc_len at CDB bytes 8-9 (not 9-10). Fixed the parse; kernel #3 reproduced the finding. 3. Hand-written fix.diff was malformed; regenerated mechanically with diff -u and dry-run verified before the A/B build.

Kernel references. sys/bus/cam/scsi/scsi_cd.c:4288-4289 (wrapped length), :4295-4297 (OOB bcopy), :4207-4211 (kmalloc(length)), :4122-4205 (format lengths), :4226-4227 (cdrunccb trusts CAM_REQ_CMP), :1006 (open gated by SYSCAP_RESTRICTEDROOT), :2751-2758 (DVDIOCREADSTRUCTURE dispatch); sys/bus/cam/cam_ccb.h:607 (resid is u_int32_t); sys/sys/dvdio.h:58-66 (dvd_struct, data[2048]); sys/kern/sys_generic.c:668,725-726 (whole-struct copyout on IOC_OUT); sys/vfs/devfs/devfs_vnops.c:1518-1569 and sys/kern/kern_device.c:237-260 (dispatch to cdioctl).

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
A/B on identical battery: unfixed kernel #3 returns 0 with length 0xffffffxx and 2048-byte OOB copies on every underflow row; fix.diff kernel #4 returns EIO with length 0 copy 0 nonzero 0 markers 0 ptrlikes 0 on the same rows and on all 11 formats with a 2-byte short transfer, while transferred 8 5 4 rows are bit-identical (fnv1a 7ac988210d38cfe1 5137c7c66c9c4391 28c31cf8df2ec325 match the unfixed hashes); the resid 0xFFFFFFFF row that defeats a naive signed-cast fix (unfixed length 5 copy 5) is also rejected
↓ fix.diffper-fix-DF-2269

Confirmed kernel references

Detail

Evidence (decisive lines)

run_dmesg.log: unfixed battery, the *** OOB READ lines, marker attribution (marker XX: data[N..M] => N bytes PAST the 8-byte databuf) and ptr-like fffff8008d3622xx listings; run_fixed_currentboot.txt: identical battery on the fix.diff kernel, all underflow rows ret=5 EIO length=0 copy=0 nonzero=0 markers=0 while transferred=8 5 4 rows keep the exact unfixed fnv1a hashes 7ac988210d38cfe1 5137c7c66c9c4391 28c31cf8df2ec325; leak_sample_boot1.txt / leak_sample_boot2.txt / leak_boot2_summary.txt: full 2048-byte dumps across boots showing heap-state variance; VERDICT.md: boundary matrix table, formats sweep table, fix A/B and rig iteration notes

PoC changes

No evidence pack existed; built the DF-2263-style CAM verbatim-interposition rig from scratch for scsi_cd.c: df_pre.c interposes xpt_path_periph cam_periph_getccb cam_periph_runccb xpt_release_ccb, df_post.c implements the malicious 0xad transport (GOOD status plus chosen transferred/resid), a same-size-class M_DEVBUF canary groom (0xC0-0xFF markers every 2nd freed plus 0x5A runway) around the kmalloc(length) databuf, marker and kernel-pointer scans with byte offsets past the allocation end, a full 2048-byte hexdump, and sysctls debug.df2269.matrix formats leak repeat. Two iterations were needed: shims must pass through to real CAM outside the armed window because the guest has a real ATAPI acd0 whose boot-time CCBs otherwise hit a NULL periph panic, and READ DVD STRUCTURE alloc_len lives at CDB bytes 8-9, not 9-10

Verified recommended fix

In the default case compute transferred with checked unsigned math and fail with EIO when resid exceeds the allocation or the transfer never covered the 4-byte header (see fix.diff)

Verdict

Reproduced on the stock INVARIANTS guest through the real cdioctl to cdreaddvdstructure path with a verbatim sha256-gated scsi_cd.c compiled into the kernel and only four CAM externals interposed (pass-through outside the tested call so the real ATAPI acd0 keeps working). A malicious transport answering READ DVD STRUCTURE with CAM_REQ_CMP and a short transfer drives the mixed int u_int32_t subtraction at scsi_cd.c:4288-4289 to wrap, so min(2048, ~4G) copies a full 2048 bytes out of the small format databuf: for MEDIUM_STAT (8-byte kmalloc) 2044 bytes are read past the end, for BCA 1860, SPARE_AREA 2036, PROTDISCID 2032, UNIQUEID 2032, PRERECORDED 1984; formats with length >= 2052 stay in bounds but still report the wrapped ~0xFFFFFFFE length to userspace. The boundary matrix pins the wrap exactly: transferred 8 5 4 are clean (length 4 1 0), transferred 3..0 and resid 9 12 100 wrap to 0xffffffxx with 2048-byte copies, and resid 0xFFFFFFFF wraps the other way to length 5 (a 1-byte overread, proving a signed-cast-only fix is insufficient). The leaked dvd_struct.data contains byte-exact heap-neighbor content: the first canary chunk begins at databuf+8 (data[4..7] of the dump) and kernel heap pointers (M_DEVBUF zone free-list linkage, fffff8008d3622xx every 16 bytes) appear throughout; content varies run to run and boot to boot (nonzero 2044 vs 2006 vs 0; markers 5-160; ptrlikes 5-37), which is the heap-state-dependent disclosure a KASLR or heap-grooming attacker wants. The ioctl layer copies the whole 2064-byte struct back to userspace after the handler returns 0 (sys_generic.c:668,725). Honest ceiling per team directive: leak-only, no write primitive (the destination field is exactly 2048 bytes), and reach needs a caller that passed the SYSCAP_RESTRICTEDROOT open gate (scsi_cd.c:1006) plus an attacker-controlled device; privesc closed, no uid=0 route from this bug alone.