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)
PoC verification
Evidence pack
findings/poc/DF-2269 Β· 34 filesDF-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/).
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 zeroOOB READlines (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=0xFFFFFFFFrow (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
fixedA/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
Confirmed kernel references
- sys/bus/cam/scsi/scsi_cd.c:4288
- sys/bus/cam/scsi/scsi_cd.c:4289
- sys/bus/cam/scsi/scsi_cd.c:4295
- sys/bus/cam/scsi/scsi_cd.c:4297
- sys/bus/cam/scsi/scsi_cd.c:4207
- sys/bus/cam/scsi/scsi_cd.c:4208
- sys/bus/cam/scsi/scsi_cd.c:4122
- sys/bus/cam/scsi/scsi_cd.c:4226
- sys/bus/cam/scsi/scsi_cd.c:1006
- sys/bus/cam/scsi/scsi_cd.c:2751
- sys/bus/cam/cam_ccb.h:607
- sys/sys/dvdio.h:58
- sys/kern/sys_generic.c:668
- sys/kern/sys_generic.c:725
- sys/vfs/devfs/devfs_vnops.c:1569
- sys/kern/kern_device.c:237
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.
No comments yet.