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

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)

No comments yet.

PoC verification

Evidence pack

findings/poc/DF-0878 Β· 19 files
FileTypeDescriptionSize
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
README.md readme reproduce instructions
↓ download 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 : the Z entry's d_name is 250 bytes, of which 246 are the 0xCC bytes that were placed past the record boundary β€” proving the NM handler read past pend.
  • --boundary: the same read runs ~238 bytes past the 2048-byte directory buffer into unmapped kernel memory β†’ Fatal trap 12 panic.

On the fixed kernel (fix.diff applied)

  • --early : the Z entry is a normal 1-byte name (Z) β€” no leak.
  • --boundary: all directory entries are returned cleanly β€” no panic.

Notes

  • vfs.usermount=0 on the audit guest, so root mounts the crafted image; the disclosure/DoS is then triggered by any unprivileged ls/stat/readlink (realistic admin-mounted-ISO threat model).
  • RRIP is only enabled when the . record carries an ER IEEE_P1282 entry (cd9660_rrip.c:719 insists on the ER field), not just SP.
  • The bug is a read-only OOB primitive; there is no write capability, so no privilege-escalation chain is derivable from this finding alone.
VERDICT.md verdict full narrative: mechanism, reproduction, impact ceiling, fix validation
↓ download raw

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-byte ISO_SUSP_HEADER (type[2] + length + version).
  • cd9660_rrip.c:515 β€” result |= ptable->func(phead, ana) dispatches the handler with no check that phead + h.length <= pend.
  • cd9660_rrip.c:530 β€” if (isonum_711(phead->length) < sizeof(*phead)) break; is a lower-bound plausibility check only (rejects length < 4); it never bounds length from 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) copies h.length-5 bytes from p+5 into the alternate-name buffer (returned to userspace as the filename by getdents, or by stat). When p+5+wlen exceeds pend, 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 its bcopy(inbuf,outbuf,wlen) at :193 read OOB component bytes into the symlink target (returned by readlink).
  • 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

fixed
baseline reproduced→ patch + rebuild →patched clean

VALIDATED 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).
↓ fix.diffDragonFly 6.5-DEVELOPMENT #1: Mon Jul 6 09:22:26 UTC 2026 root@dfbsd:/usr/obj/usr/src/sys/X86_64_GENERIC x86_64 (sha256 8d4915fb0dc126643be8ffa6231d01109d44aa43238323cdf465a834afe831dd)

Confirmed kernel references

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.