chgetelemstatus() allocates READ ELEMENT STATUS buffers without M_ZERO and ignores resid: short-but-GOOD device transfers disclose stale kernel heap through CHIOGSTATUS copyout (DF-5272 class)
Summary
Both RES buffers kmalloc'd without M_ZERO and no resid check: a changer completing GOOD with only the 8-byte header leaves page header + descriptors as stale heap - copy_element_status copies eaddr/flags/sense/scsi fields and (stale PVOLTAG/AVOLTAG flags) 32+2 voltag bytes per element to the CHIOGSTATUS caller, up to 65535 elements/call. Distinct from DF-1028/2271 (those walk PAST the allocation; this discloses INSIDE it). Guest-verified with the evilcd3 T_CHANGER rig: pre-freed stamped 80-byte chunks -> every returned element carries the 0x26 stamp (84 non-zero bytes, 3 runs); natural stale heap leaked without marking. /dev/chN 0600 root:operator. M_ZERO-both-allocs fix.
Discussion (0)
PoC verification
Evidence pack
findings/poc/DF-5332 Β· 15 files| File | Type | Description | Size | |
|---|---|---|---|---|
| README.md | β | 2.6 KB | β raw | |
| VERDICT.md | β | 5.4 KB | β raw | |
| evilcd3.c | β | 16.1 KB | view raw | |
| poc_chres.c | β | 4.6 KB | view raw | |
| build.sh | β | 85 B | view raw | |
| run.sh | β | 154 B | view raw | |
| build.log | β | 1.2 KB | view raw | |
| run.log | β | 6.9 KB | view raw | |
| run.2.log | β | 7.2 KB | view raw | |
| fixedA.log | β | 11.1 KB | view raw | |
| leak_sample.txt | β | 526 B | view raw | |
| env.txt | β | 188 B | view raw | |
| fix.diff | β | 791 B | view raw | |
| manifest.json | β | 1.2 KB | view raw | |
| verdict.json | β | 3.8 KB | view raw |
DF-5332 evidence pack β chgetelemstatus() READ ELEMENT STATUS buffers are kmalloc'd without M_ZERO; a short-but-GOOD transfer leaves stale kernel heap that is walked and copyout()ed via CHIOGSTATUS
Finding (one line)
chgetelemstatus() (sys/bus/cam/scsi/scsi_ch.c:1089 and :1129) allocates
both READ ELEMENT STATUS transfer buffers with kmalloc(..., M_INTWAIT)
without M_ZERO and never checks csio.resid; a medium changer that
completes the command GOOD but transfers fewer bytes than the allocation
length (perfectly legal SCSI β the allocation length is a maximum)
leaves the un-written tail of the buffer as stale kernel heap, which
copy_element_status() (scsi_ch.c:984-1051) then reads (page-header
flags, descriptor fields, and β if the stale page-header flags say so β
32+6-byte volume tags) and copyout()s to the caller (scsi_ch.c:1186).
Same class as DF-5272 (scsi_cd CDIOCREADSUBCHANNEL), which the pass-1
findings for this driver (DF-1028 desclen/avail/flags OOB, DF-2271
avail-walk-past-buffer) do not cover: those read past the allocation;
this discloses uninitialized memory inside the allocation.
Reproduce (guest, root; EVIL kernel = GENERIC + device ch)
cd /root/df5332 ./build.sh && ./run.sh
evilcd3.ko(derived from the DF-5327 evilcd2 rig) emulates a T_CHANGER at target 1 (/dev/ch0), mode pages 0x1d (1 picker, 8 slots at address 256) / 0x1f.debug.evilcd.resmode=1: the sizing RES (count==1) answers normally with edl=16; every real RES (count>1) transfers only the 8-byte status header (withavail = countinside it) and completes CAM_REQ_CMP with resid>0 β byte-for-byte what an evil real changer does on the wire.debug.evilcd.markres=Nstamps 96 chunks of exactly8+8+16*Nbytes (the size the driver will allocate) and frees them, so the driver's kmalloc re-uses marked heap.poc_chres 3 1 4: 3 runs, marked, CHIOGSTATUS(chet=CHET_ST, base=0, count=4); then 2 unmarked runs.
Expected (vulnerable kernel)
[EVILCH] RES evil short: ... SENT=8B only on the console, and the
returned struct changer_element_status array full of non-zero bytes
(marker pattern A/B/... in marked runs; natural stale heap β old
allocation contents β in unmarked runs) in ces_int_addr, ces_flags,
ces_sensecode/qual, ces_source_addr/type, ces_scsi_id/lun and the
volume tags, even though the device sent nothing but the 8-byte header.
With a patched kernel (fix.diff: M_ZERO on both allocations) the same
run returns all-zero structs.
Rig knobs
debug.evilcd.resmode0/1debug.evilcd.markresNdebug.evilcd.rescan1debug.evilcd.resshorts/ressize/ressent(readback)
DF-5332 VERDICT
Status: REPRODUCED (deterministic, 6/6 runs: 3 marked + 3 unmarked) β fix validated
Root cause (source-accurate)
chgetelemstatus() (sys/bus/cam/scsi/scsi_ch.c):
- :1089
data = (caddr_t)kmalloc(1024, M_DEVBUF, M_INTWAIT);β first READ ELEMENT STATUS buffer, no M_ZERO. - :1129
data = (caddr_t)kmalloc(size, M_DEVBUF, M_INTWAIT);β second (descriptor) buffer, no M_ZERO. - Neither the driver nor
cam_periph_runccb()(cam_periph.c:960-969, status check only) looks atcsio->resid. A SCSI device may complete READ ELEMENT STATUS GOOD while transferring fewer bytes than the CDB's allocation length β a short-but-good transfer is legal and leavesresid > 0with status CAM_REQ_CMP. - The device therefore fully controls how much of each buffer is actually
written. Everything beyond that is stale kernel heap (M_DEVBUF zone
of size 1024 /
8+8+desclen*count). - :1118
desclen = scsi_2btoul(pg_hdr->edl)β from buffer #1 (if the device short-transfers below byte 12 this is stale heap, feeding thesizecomputation and the descriptor stride); - :1156-1183
avail = scsi_2btoul(st_hdr->count)(device), thencopy_element_status(softc, pg_hdr->flags, desc, ces)walksavaildescriptors atdesclenstride.pg_hdr->flags(data[9]) comes from the stale tail of buffer #2 in the short case; if it has READ_ELEMENT_STATUS_PVOLTAG/AVOLTAG (0x80/0x40) set,copy_voltag()additionally copies 32+2 stale bytes per element. - :1186
copyout(user_data, cesr->cesr_element_status, avail * sizeof(...))hands all of it to userland.
Distinct from the known findings: DF-1028/DF-2271 are out-of-bounds
reads (desclen/avail walking past the allocation); this finding is the
in-bounds uninitialized-memory disclosure (missing M_ZERO + missing
resid validation) β the DF-5272 (CDIOCREADSUBCHANNEL) class in the ch
driver.
How it was reproduced (guest, EVIL = GENERIC + device ch)
Rig: evilcd3.ko (fake T_CHANGER at target 1, /dev/ch0; mode page 0x1d
reports 1 picker / 8 slots @ addr 256). debug.evilcd.resmode=1 makes the
sizing RES (count==1) answer normally (edl=16) and every real RES
(count>1) transfer only the 8-byte status header with
avail = cesr_element_count inside it, completing CAM_REQ_CMP with
resid = dxfer_len - 8 β byte-for-byte what a hostile real changer does on
the wire.
PoC poc_chres 3 1 4: CHIOGSTATUS(chet=CHET_ST, base=0, count=4);
debug.evilcd.markres=4 first stamps 96 freed M_DEVBUF chunks of exactly
8+8+16*4 = 80 bytes with marker bytes 0x21..0x2F (chosen WITHOUT the
0x40/0x80 bits so the stale page-header flags cannot set PVOLTAG/AVOLTAG
and drag in the separately-filed OOB-walk class).
Result (marked runs, run.log)
Every returned struct changer_element_status contained the 0x26 marker:
-- element 0 (92 bytes, 7 non-zero): int_addr=0x2626 flags=0x126 sense=0x26/0x26 scsi_id=38 ... [***] LEAK CONFIRMED: 84 non-zero bytes across 3 runs
28 non-zero bytes per run, identical across runs β the driver's 80-byte kmalloc deterministically reused the stamped chunk.
Result (unmarked runs, run.2.log β natural stale heap)
Without any marking, real stale kernel heap was disclosed:
int_addr=0xa0cf flags=0x3f9 sense=0x01/0xf8 scsi_id=255 lun=7 int_addr=0x805a flags=0x3fb ... [***] LEAK CONFIRMED: 51 non-zero bytes across 3 runs
(0xa0cf, 0x805a, f9 03 01 f8 ff β residue of prior kernel
allocations in the 80-byte M_DEVBUF zone; high bytes consistent with
freed-slab metadata/pointer fragments.)
Console corroboration (dmesg):
[EVILCH] RES probe: sea=256 count=1 full 32B (edl=16) [EVILCH] RES evil short: sea=256 count=4 dxfer=80 SENT=8B only
Impact
Kernel heap info leak to the CHIOGSTATUS caller. /dev/chN is 0600
root:operator (make_dev at scsi_ch.c:364-366), so the direct reader is
root/operator β but the content is whatever previously lived in those
M_DEVBUF chunks (any other subsystem's data: file path fragments, socket
buffers, crypto material...). Combined with an evil/compromised changer
(USB-attached SCSI changers exist), this is a kernel-memory disclosure
primitive against whoever queries element status. Class/severity aligned
with DF-5272 (Medium, kernleak). No write primitive, no userβroot path β
leak only.
Fix validation (done)
fix.diff adds M_ZERO to both allocations. Guest /usr/src patched,
kernel rebuilt (EVIL), reinstalled, rebooted; exact same PoC rerun:
all returned structs zero β run prints all returned bytes zero /
[---] (exit 3), zero non-zero bytes across marked and unmarked runs
(fixedA.log), while the rig still reports
RES evil short: ... SENT=8B only β proving the device behaviour was
unchanged and only the kernel-side disclosure was removed.
Baseline (vulnerable): 84 (marked) + 51 (unmarked) non-zero bytes. Patched: 0 non-zero bytes in both modes.
Guest
DragonFly dfbsd 6.5-DEVELOPMENT #0: Fri Oct 2 22:55:55 UTC 2026
root@dfbsd:/usr/obj/usr/src/sys/EVIL x86_64 (GENERIC + device ch);
gcc (DragonFly) 8.3; evilcd3.ko rig.
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
fixedApplied fix.diff (M_ZERO x2) to the guest /usr/src, rebuilt EVIL kernel (#1 Fri Oct 2 23:30:20 UTC 2026), rebooted, re-ran the exact same PoC: marked and unmarked runs both return 0 non-zero bytes (PoC exits 3 'all returned bytes zero') while the rig still performs the identical 8-byte short transfers (dmesg SENT=8B count 13) -- disclosure eliminated, device behaviour unchanged. Baseline on the vulnerable kernel: 84 (marked) + 51 (unmarked) non-zero bytes leaked.
findings/poc/DF-5332/fixedA.log (patched-kernel run: all-zero hexdumps, MARKED_RC=0/UNMARKED_RC=3); findings/poc/DF-5332/run.log + run.2.log (vulnerable baseline)
Confirmed kernel references
Detail
Evidence (decisive lines)
run.log (marked: 'LEAK CONFIRMED: 84 non-zero bytes across 3 runs', full per-element hexdumps showing 0x26 markers); run.2.log (unmarked: 'LEAK CONFIRMED: 51 non-zero bytes', natural heap 0xa0cf/0x805a/0xff); leak_sample.txt; dmesg lines in run.log '[EVILCH] RES evil short: sea=256 count=4 dxfer=80 SENT=8B only'; fixedA.log (patched kernel: all-zero structs, PoC exits 3 while rig still short-transfers, dmesg count SENT=8B = 13)
PoC changes
Derived rig from DF-5327's evilcd2.c (kept fake T_CHANGER + SIM skeleton, added READ ELEMENT STATUS emulation, markres heap-stamping knob, invalidate knob); PoC written fresh (CHIOGSTATUS struct from guest
Verified recommended fix
Add M_ZERO to both chgetelemstatus() kmallocs (scsi_ch.c:1089, :1129) so short-but-GOOD transfers parse as zeros
Verdict
chgetelemstatus() allocates both READ ELEMENT STATUS transfer buffers without M_ZERO (scsi_ch.c:1089, :1129) and never checks csio->resid; an evil medium changer that completes the command GOOD but transfers only the 8-byte status header (legal SCSI; resid>0, CAM_REQ_CMP) leaves the page header and all descriptors as stale kernel heap, which copy_element_status() parses and copyout()s to the CHIOGSTATUS caller. Reproduced deterministically on the QEMU guest with the evilcd3 rig: marked runs returned the exact 0x26 stamp bytes in ces_int_addr/ces_flags/ces_sense*/ces_scsi_id (28 non-zero bytes per run, 3/3 runs), unmarked runs returned natural stale-heap bytes (0xa0cf, 0x805a, 0xff..., 17 non-zero bytes per run, 3/3 runs). Distinct from DF-1028/DF-2271 (those walk PAST the allocation; this discloses uninitialized memory INSIDE it) -- the DF-5272 class in the ch driver. Leak only; no write primitive.
No comments yet.