Missing SUSP entry-length bounds check in cd9660_rrip_loop enables OOB heap read and kernel memory info leak
Summary
cd9660_rrip.c:509 while(pend>=phead+1) ensures only 4B header. :515 ptable->func(phead,ana) dispatched BEFORE checking phead+length<=pend. :530 plausibility check only rejects length<sizeof(header) never upper bound. Handlers trust h.length: SL :116 pcompe=p+h.length :181-193 bcopy(inbuf,outbuf,clen) up to 255B OOB into symlink returned by readlink. NM :258 wlen=h.length-5 :276 bcopy into d_name returned by getdents. TF :334 reads 68B from 5B entry. Crafted ISO near block end reads past buffer into heap. Post-mount unprivileged user: ls/readlink/stat triggers.
Discussion (0)
PoC verification
Evidence pack
findings/poc/DF-0878 Β· 19 files| File | Type | Description | Size | |
|---|---|---|---|---|
| harness.c | trigger-source | deterministic transcription of cd9660_rrip_loop + NM handler with poisoned allocator (proves 239B OOB past pend) | 6.8 KB | view raw |
| craft_iso.py | trigger-source | hand-built ISO9660 generator with crafted SP+ER+NM SUSP entries; --early (leak) and --boundary (panic) variants | 10.1 KB | view raw |
| dumpents.c | trigger-source | direct getdents dumper, hex-dumps each d_name to reveal leaked/OOB bytes | 2.4 KB | view raw |
| df0878_early.iso | trigger-image | crafted ISO, NM length=255 positioned mid-block -> in-buffer OOB read leaks 246 sentinel bytes into filename | 42.0 KB | β download |
| df0878_bnd.iso | trigger-image | crafted ISO, NM length=255 at block end -> 238B past the 2048-byte buffer -> page-fault panic | 42.0 KB | β download |
| build.sh | build-script | cc -O2 -o harness/dumpents | 268 B | view raw |
| run.sh | run-script | runs the deterministic harness | 578 B | view raw |
| build.log | build-log | harness + dumpents build output | 148 B | view raw |
| run.log | run-log | harness decisive run (239B OOB) | 967 B | view raw |
| leak_live.log | run-log | live in-kernel leak: entry namelen=250, d_name=5a5a5a00cccc... | 1018 B | view raw |
| panic.txt | panic-signature | Fatal trap 12 page fault in memmove+0x2e (bcopy in cd9660_rrip_altname:276) on boundary variant | 339 B | view raw |
| env.txt | environment | uname, kern.version, cc, cd9660 in base kernel, INVARIANTS=1, vfs.usermount=0 | 610 B | view raw |
| fix.diff | suggested-fix | adds upper-bound check 'phead+h.length>pend -> break' before handler dispatch in cd9660_rrip_loop | 909 B | view raw |
| fix_build.log | build-log | single-fix nativekernel build (rc=0), full output | 5.6 MB | β download |
| fix_run.log | run-log | before/after contrast: baseline leaks/panics, fixed #1 clean | 947 B | view raw |
| VERDICT.md | verdict | full narrative: mechanism, reproduction, impact ceiling, fix validation | 7.7 KB | β raw |
| README.md | readme | reproduce instructions | 3.1 KB | β 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-0878 β PoC
Missing SUSP entry-length upper-bound check in cd9660_rrip_loop
(sys/vfs/isofs/cd9660/cd9660_rrip.c) β OOB heap read / kernel info leak +
local DoS (panic). See VERDICT.md for the full analysis.
Files
| file | purpose |
|---|---|
harness.c |
deterministic transcription of cd9660_rrip_loop + NM handler with a poisoned allocator (proves OOB read extent without touching the kernel) |
craft_iso.py |
hand-built ISO9660 image generator with crafted SUSP SP+ER+NM entries (--early in-buffer leak variant, --boundary past-buffer panic variant) |
dumpents.c |
direct getdents dumper that hex-dumps each d_name so leaked/OOB bytes are visible |
build.sh / run.sh |
reproducible build/run of the harness |
fix.diff |
git apply-able one-line upper-bound check fix |
build.log / run.log / leak_live.log / panic.txt / fix_build.log / fix_run.log / env.txt |
full untrimmed evidence |
Reproduce (on the DragonFly guest)
Deterministic harness (no kernel interaction)
./build.sh && ./run.sh # expect: "239 bytes read PAST the directory-record boundary (pend)"
Live in-kernel reproduction (needs root to mount, unprivileged to trigger)
# 1. host: build the two ISO variants python3 craft_iso.py --early df0878_early.iso python3 craft_iso.py --boundary df0878_bnd.iso # 2. guest (root): mount the crafted image scp df0878_early.iso dfbsd:/root/ ssh dfbsd vnconfig vn0 /root/df0878_early.iso mkdir -p /mnt/iso mount_cd9660 /dev/vn0 /mnt/iso cc -O2 -o dumpents dumpents.c # 3. unprivileged trigger: any user can ls/stat the mountpoint su -m maxx -c './dumpents /mnt/iso' # or just: ls /mnt/iso # -> entry 'Z' shows namelen=250 with 246 bytes of leaked 0xCC sentinel # 4. boundary variant -> kernel panic (page fault in memmove/bcopy) vnconfig -u vn0 ; vnconfig vn0 /root/df0878_bnd.iso ; mount_cd9660 /dev/vn0 /mnt/iso ./dumpents /mnt/iso # -> Fatal trap 12: page fault while in kernel mode (supervisor read, page not present) # Stopped at memmove+0x2e
Expected result
--early: theZentry'sd_nameis 250 bytes, of which 246 are the0xCCbytes that were placed past the record boundary β proving the NM handler read pastpend.--boundary: the same read runs ~238 bytes past the 2048-byte directory buffer into unmapped kernel memory βFatal trap 12panic.
On the fixed kernel (fix.diff applied)
--early: theZentry is a normal 1-byte name (Z) β no leak.--boundary: all directory entries are returned cleanly β no panic.
Notes
vfs.usermount=0on the audit guest, so root mounts the crafted image; the disclosure/DoS is then triggered by any unprivilegedls/stat/readlink(realistic admin-mounted-ISO threat model).- RRIP is only enabled when the
.record carries anER IEEE_P1282entry (cd9660_rrip.c:719insists on the ER field), not justSP. - The bug is a read-only OOB primitive; there is no write capability, so no privilege-escalation chain is derivable from this finding alone.
DF-0878 β VERDICT
Verdict: REPRODUCED (info leak + local DoS/panic). Fix VALIDATED on a single-fix kernel.
The bug (confirmed by source trace)
cd9660_rrip_loop() (sys/vfs/isofs/cd9660/cd9660_rrip.c) walks the SUSP
(System Use) entries of an ISO9660 directory record. The loop's only length
guard is a lower-bound check; it never verifies that a SUSP entry's
declared h.length fits within the record before handing the entry to its
handler:
cd9660_rrip.c:509βwhile (pend >= phead + 1)only ensures room for the 4-byteISO_SUSP_HEADER(type[2] + length + version).cd9660_rrip.c:515βresult |= ptable->func(phead, ana)dispatches the handler with no check thatphead + h.length <= pend.cd9660_rrip.c:530βif (isonum_711(phead->length) < sizeof(*phead)) break;is a lower-bound plausibility check only (rejectslength < 4); it never boundslengthfrom above, and it runs after the dispatch anyway.
The handlers trust the attacker-controlled h.length:
- NM (
cd9660_rrip_altname,cd9660_rrip.c:258) βwlen = h.length - 5;cd9660_rrip.c:276βbcopy((char*)p + 5, outbuf, wlen)copiesh.length-5bytes fromp+5into the alternate-name buffer (returned to userspace as the filename bygetdents, or bystat). Whenp+5+wlenexceedspend, this reads past the directory record's declared end. - SL (
cd9660_rrip_slink,cd9660_rrip.c:116) βpcompe = (char*)p + isonum_711(p->h.length); the component loop (:125-193) and itsbcopy(inbuf,outbuf,wlen)at:193read OOB component bytes into the symlink target (returned byreadlink). - TF (
cd9660_rrip_tstamp,cd9660_rrip.c:334) β reads 7/17-byte timestamps from an entry that may be only 5 bytes long.
ISO_SUSP_HEADER.length is a single 7.11 byte (cd9660_rrip.h:43), so the
OOB read extent is up to 255 bytes past the SUSP entry header, i.e. up to
~250 bytes past the record boundary. When the malicious record is placed at
the end of a directory block, the read runs past the 2048-byte directory
buffer into kernel heap.
Reproduction
1. Deterministic harness (harness.c)
Transcribes cd9660_rrip_loop + the NM handler verbatim with a poisoned
allocator (a 16-byte "record" followed by a sentinel-filled heap tail). With
NM.length=255, the handler copies 250 bytes from p+5; 239 bytes land
past pend, all carrying the heap sentinel. This proves the OOB read extent
unconditionally, independent of any kernel memory layout.
[loop] dispatched NM handler: copied 250 bytes; 239 bytes read PAST pend (OOB)
RESULT: 239 bytes read PAST the directory-record boundary (pend)
239 of those leaked bytes carry the heap sentinel 0x5a
2. Live in-kernel leak β "early" ISO variant
craft_iso.py --early builds a minimal ISO9660 image whose root directory has
a . record carrying the required SP + ER IEEE_P1282 entries (RRIP is
only enabled when cd9660_rrip_offset finds an ER, cd9660_rrip.c:719) and
a file Z whose System Use area holds a single NM entry with the length
byte forged to 255 but only 8 bytes physically present. The rest of the
block is a 0xCC sentinel after a record-list terminator.
Root mounts (vfs.usermount=0, so root mounts; the trigger is unprivileged),
then an unprivileged getdents (ls/stat) on the mountpoint fires the NM
handler. The returned filename is 250 bytes: 'ZZZ' + 0x00 + 246 Γ 0xCC
β the 0xCC bytes were placed past the record boundary and were read back
into the filename, proving the in-kernel OOB read past pend:
entry #2: ino=39005 dirsiz=272 type=0 namelen=250
d_name (250 bytes):
hex: 5a5a5a00cccccccc...cccc (246 bytes of leaked sentinel)
3. Live in-kernel panic β "boundary" ISO variant
craft_iso.py --boundary positions the same forged-NM record as the last
record of the 2048-byte root directory block (ending exactly at byte 2048).
The NM handler's 250-byte read then starts at block offset 2036 and runs to
2286 β 238 bytes past the 2048-byte directory buffer. On the default
X86_64_GENERIC kernel this faults into unmapped kernel address space:
Fatal trap 12: page fault while in kernel mode fault virtual address = 0xfffff80050ca7000 fault code = supervisor read data, page not present Stopped at memmove+0x2e: movq 0x8(%rsi),%rdx
memmove is the bcopy backend; the fault is the read inside
cd9660_rrip_altname:276. This is a reliable local DoS (panic) via an
unprivileged getdents after a root mount of a crafted image.
Impact ceiling
This is a read-only OOB primitive: every affected handler bcopys from
the OOB region into a legitimate kernel buffer (the filename / symlink
target / inode timestamps). There is no attacker-controlled write to an
attacker-chosen kernel address, so there is no privilege-escalation chain
from this bug alone.
Realistic impact:
- Kernel info leak β up to ~250 bytes of kernel heap / adjacent buffer-cache
data per call, disclosed to an unprivileged user through the filename (NM),
symlink target (SL), or inode timestamps (TF). Useful for defeating KASLR
(moot on this guest where KASLR is off) and for heap-layout reconnaissance.
- Local DoS β the boundary-variant page fault is a reliable unprivileged
kernel panic (confirmed on the default GENERIC kernel).
- Threat model β root must mount / make mountable a crafted ISO
(vfs.usermount=0); the disclosure/DoS trigger is then any unprivileged
ls/stat/readlink on the mountpoint.
The fix
Add the missing upper-bound check in cd9660_rrip_loop, before the handler
dispatch, so a forged h.length cannot drive a read past pend:
while (pend >= phead + 1) {
if ((char *)phead + isonum_711(phead->length) > (char *)pend)
break; /* DF-0878: whole entry must fit */
if (isonum_711(phead->version) == 1) {
...
This is minimal, targeted at the root cause, and closes all three handlers
(NM/SL/TF) at once because they are all dispatched through this loop. Full
git apply-able diff: fix.diff.
Fix validation (Phase 8)
Built a single-fix kernel from /usr/src with only this diff applied
(make -j6 nativekernel KERNCONF=X86_64_GENERIC, rc=0), installed
kernel.strippedβ/boot/kernel/kernel, rebooted to
6.5-DEVELOPMENT #1 (sha256 8d4915fbβ¦).
| variant | baseline #0 (unpatched) |
fixed #1 |
|---|---|---|
| early | namelen=250, d_name=5a5a5a00cccc⦠(246 B leak) |
namelen=1, d_name=5a (no leak) |
| boundary | Fatal trap 12 page-fault panic in memmove |
all 59 entries returned, no panic |
The fix eliminates both the info leak and the panic. Baseline reproduces both
(clean before/after). See fix_run.log.
PoC changes from the seeded scaffold
The PoC was built from scratch (no seeded scaffold existed for this finding):
- harness.c β deterministic transcription of the loop + NM handler with a
poisoned allocator.
- craft_iso.py β hand-built ISO9660 image generator (PVD, path table, root
directory block) with crafted SUSP SP+ER+NM entries; --early
(in-buffer OOB leak) and --boundary (past-buffer panic) variants. Two
non-obvious requirements discovered during verification: (1) RRIP requires an
ER IEEE_P1282 entry in the . record (cd9660_rrip.c:719 insists on it),
not just SP; (2) the boundary record must end exactly at byte 2048 with no
zero-gap or readdir skips it.
- dumpents.c β direct getdents dumper that hex-dumps each d_name so the
leaked/OOB bytes are visible (DragonFly struct dirent uses _DIRENT_DIRSIZ,
no d_reclen).
Fix verification
fixedVALIDATED the fix. Built a single-fix kernel (only this diff applied, make -j6 nativekernel rc=0, cd9660_rrip.c compiled clean with -Werror), installed kernel.stripped->/boot/kernel/kernel, rebooted to #1. Before (baseline #0): early ISO -> entry 'Z' namelen=250 d_name=5a5a5a00cccc... (246 leaked bytes); boundary ISO -> Fatal trap 12 page fault panic in memmove+0x2e. After (fixed #1): early ISO -> entry 'Z' namelen=1 d_name=5a (no leak); boundary ISO -> all 59 directory entries returned cleanly, guest stays up (no panic). The fix eliminates both the info leak and the panic. Clean before/after on the default GENERIC kernel (INVARIANTS ON).
baseline #0 early: 'entry #2: namelen=250 d_name=5a5a5a00cccc...' (246B leak). baseline #0 boundary: 'Fatal trap 12 page fault ... Stopped at memmove+0x2e'. fixed #1 early: 'entry #2: namelen=1 d_name=5a (Z)' (no leak). fixed #1 boundary: 'entry #58: namelen=1 d_name=5a / [getdents EOF after 59 entries] / guest_up_after_boundary_test=YES' (no panic).
Confirmed kernel references
- sys/vfs/isofs/cd9660/cd9660_rrip.c:509
- sys/vfs/isofs/cd9660/cd9660_rrip.c:515
- sys/vfs/isofs/cd9660/cd9660_rrip.c:530
- sys/vfs/isofs/cd9660/cd9660_rrip.c:258
- sys/vfs/isofs/cd9660/cd9660_rrip.c:276
- sys/vfs/isofs/cd9660/cd9660_rrip.c:116
- sys/vfs/isofs/cd9660/cd9660_rrip.c:193
- sys/vfs/isofs/cd9660/cd9660_rrip.c:334
- sys/vfs/isofs/cd9660/cd9660_rrip.h:43
- sys/vfs/isofs/cd9660/cd9660_vnops.c:542
Detail
Exploit chain
none -- this is a genuinely read-only OOB primitive. Every affected handler (NM/SL/TF) bcopy()s FROM the OOB region INTO a legitimate kernel buffer (the filename returned by getdents, the symlink target returned by readlink, or inode timestamp fields). There is no attacker-controlled write to an attacker-chosen kernel address, so no privilege-escalation chain is derivable from this bug. The attacker-controllable impacts are: (a) kernel info leak of up to ~250 bytes/call of heap/adjacent-buffer-cache data disclosed through the filename/symlink target, and (b) local DoS via the page-fault panic when the OOB read crosses into unmapped kernel memory. Per Phase 6 a read-only primitive has no escalation chain; impact ceiling documented in VERDICT.md.
Evidence (decisive lines)
harness: '[loop] dispatched NM handler: copied 250 bytes; 239 bytes read PAST pend (OOB) ... 239 of those leaked bytes carry the heap sentinel 0x5a'. Live leak (early ISO, unprivileged getdents): 'entry #2: namelen=250, d_name hex: 5a5a5a00cccccccc...cccc (246 leaked sentinel bytes past record boundary)'. Live panic (boundary ISO): 'Fatal trap 12: page fault while in kernel mode / fault virtual address = 0xfffff80050ca7000 / fault code = supervisor read data, page not present / Stopped at memmove+0x2e: movq 0x8(%rsi),%rdx' (the bcopy in cd9660_rrip_altname:276).
PoC changes
Built the entire PoC from scratch (no seeded scaffold). harness.c transcribes cd9660_rrip_loop + cd9660_rrip_altname verbatim with a poisoned allocator. craft_iso.py hand-builds a minimal ISO9660 image (PVD, path table, root dir block) with crafted SP+ER+NM SUSP entries in --early (in-buffer OOB leak) and --boundary (past-buffer panic) variants. dumpents.c is a getdents dumper that hex-dumps d_name (DragonFly struct dirent uses _DIRENT_DIRSIZ, no d_reclen). Two non-obvious facts discovered during verification: (1) RRIP is only enabled when the '.' record carries an ER IEEE_P1282 entry (cd9660_rrip.c:719 insists on ER), not just SP; (2) the boundary record must end exactly at byte 2048 with no zero-gap or readdir skips it.
Verified recommended fix
In cd9660_rrip_loop, immediately inside while (pend >= phead + 1), add if ((char *)phead + isonum_711(phead->length) > (char *)pend) break; before the version check/handler dispatch at cd9660_rrip.c:510-515. This is the missing upper-bound check; it closes all three handlers (NM/SL/TF) at once since they are all dispatched through this loop. The full git-apply-able diff is in findings/poc/DF-0878/fix.diff. This supersedes/matches the finding markdown's proposal (the finding proposed the same check at the same location).
Verdict
REPRODUCED. cd9660_rrip_loop (cd9660_rrip.c:509) only checks the 4-byte SUSP header fits (while(pend>=phead+1)), then dispatches ptable->func at :515 with NO check that phead+h.length<=pend; the plausibility check at :530 is lower-bound only and runs after dispatch. The NM handler (cd9660_rrip.c:258 wlen=h.length-5, :276 bcopy((char*)p+5,outbuf,wlen)) and SL (:116 pcompe=p+h.length, :193 bcopy) and TF (:334) handlers all trust the attacker-controlled h.length (a single 7.11 byte, max 255). A crafted ISO with an NM entry whose length byte is forged to 255 drives a read of up to ~250 bytes past the record boundary. Confirmed three ways: (1) deterministic harness transcribing the loop+NM handler with a poisoned allocator reads 239 bytes past pend; (2) live in-kernel getdents on a mounted crafted ISO (root mount, unprivileged ls trigger) returns a 250-byte filename 5a5a5a00cccc... containing 246 bytes of sentinel read past the record boundary (info leak); (3) with the malicious record at the block end, the same read runs ~238 bytes past the 2048-byte directory buffer and faults at kernel address 0xfffff80050ca7000 -> Fatal trap 12: page fault, supervisor read data page not present, Stopped at memmove+0x2e (the bcopy inside cd9660_rrip_altname:276) = reliable local DoS.
No comments yet.