DragonFlyBSD Kernel Audit
DF-2619 / verdict.json
← back to finding ↓ download raw
{
  "finding_id": "DF-2619",
  "status": "reproduced",
  "reproduced": 1,
  "impact": "leak",
  "confidence": "certain",
  "verdict": "CONFIRMED and reproduced two ways on the stock INVARIANTS kernel, with the impact ceiling well above the Low/OOB-read filing. hammer2_chain_dirent_test() (chain.c:5781-5790) bcmp's name_len bytes from chain->data->buf using the on-disk namlen with no bound against chain->bytes, and hammer2_vop_readdir() (vnops.c:723-737) copies the same unbounded namlen from chain->data->buf straight into the getdents(2) result. A forged radix-7 (128B) dirent block at the tail of a 64KB window (0x210FF87; passes the only geometry check, the window-fit KKASSERT at io.c:127) with namlen=255 makes both consumers read 127 bytes past the end of the 64KB DIO buffer. Observed: (a) mapped-adjacent case - getdents returned the 255-byte name whose last 127 bytes are adjacent kernel memory (pointer-like 0x00065a2378f3546f values, 0x1c0/2 fields) - a userspace-visible kernel heap disclosure for any user able to read the directory after the (root/vfs.usermount) mount; (b) unmapped case - fatal trap 12 at memcpy+0x19 (stock, RIP verified = memcpy) and, with debug.debugger_on_panic=0, full backtrace memcpy+0x19 <- hammer2_vop_readdir+0x5c8, plus a separate fault INSIDE the dirent_test compare itself (hammer2_chain_dirent_test+0x89: cmpb %sil,(%rcx,%r8,1)) via stat(2) of the long name. Controls: base image lists/stats normally. Fix.diff clamps the dirent_test compare to chain->bytes and skips readdir entries whose namlen exceeds their radix-derived block size; kernel #2 re-run: malformed entry skipped (no leak bytes, no panic, stat ENOENT) with zero regression on legitimate 255-char names (base control still lists and stats).",
  "exploit_chain": "root (or vfs.usermount=1 + owned vn) mounts crafted image -> forged DIRENT (key=dirhash(name), namlen=255) with data_off=WIN|0xFF80|7 -> chain load succeeds (radix 7 block fits the 64KB window per io.c:127 KKASSERT; CHECK_NONE ancestors per DF-2616 technique; volhdr CRCs recomputed) -> any getdents(2) on the directory returns 127 bytes of kernel memory past the DIO buffer in the dirent name (unprivileged read of a world-readable dir suffices); stat(2) of the long name drives the same unbounded compare in dirent_test. Read-only primitive: KASLR/heap-layout aid (adjacent buffer-header/pointer bytes); no write primitive. Memory-layout dependent panic face: fatal trap 12 when the page after the buffer is unmapped.",
  "evidence": [
    "run_stock.log - full untrimmed stock-kernel getdents dump: forged image name hexdump ends 4141...(128) then 01 00 ... 6f54f378235a0600 ... (kernel bytes past the 64KB DIO buffer); base-image control clean",
    "console_excerpts.txt [A] - stock panic: Fatal trap 12, memcpy+0x19, fault VA 0xfffff8005f926000 (page-granular, = page after the DIO buffer)",
    "console_excerpts.txt [C] - instrumented kernel: Stopped at hammer2_chain_dirent_test+0x89: cmpb %sil,(%rcx,%r8,1) (the cited compare reading past the buffer)",
    "console_excerpts.txt [D] - full panic backtrace memcpy+0x19 <- hammer2_vop_readdir+0x5c8",
    "forge_2619.py output - DIRENT bref 0x1c0000a -> 0x210ff87 (radix 7, 128B block at window tail)",
    "fix_run.log - kernel #2: forged image lists cleanly with the malformed entry absent, stat -> ENOENT, marker entry intact; base control 255-char name still lists+stats"
  ],
  "kernel_refs": [
    "sys/vfs/hammer2/hammer2_chain.c:5781-5790",
    "sys/vfs/hammer2/hammer2_vnops.c:723-737",
    "sys/vfs/hammer2/hammer2_chain.c:1100",
    "sys/vfs/hammer2/hammer2_io.c:120-127",
    "sys/vfs/hammer2/hammer2_io.c:555-563",
    "sys/vfs/hammer2/hammer2_xops.c:275-290"
  ],
  "poc_changes": "Seed sketch's geometry was wrong twice over: (1) it suggested radix 8 (256B) which is >= namlen 255 (no OOB of chain->bytes) and (2) it ignored that a window-CROSSING block trips the io.c:127 KKASSERT. Working geometry: radix 7 (chain->bytes=128 < 255) at in-window offset 0xFF80 so the block ends exactly at the 64KB buffer end and the 255-byte compare runs 127 bytes past the buffer. Also found and used the far better-exposed consumer hammer2_vop_readdir (vnops.c:723-737) which returns the OOB bytes to userspace, where the filed path (dirent_test bcmp) alone is silent unless it faults. DragonFly dirent layout fixed on the fly (no d_reclen; getdents(int,char*,size_t) declared manually; stat -t found to always exit 0 so a C statprobe was written).",
  "attempts": 4,
  "guest_uname": "DragonFly dfbsd 6.5-DEVELOPMENT DragonFly 6.5-DEVELOPMENT #0: Thu Jul  2 06:02:54 UTC 2026     root@dfbsd:/usr/obj/usr/src/sys/X86_64_GENERIC  x86_64",
  "runtime_sec": 6600,
  "guest_dirty": 0,
  "build_cmd": "sh build.sh  (base image + files in guest; forge_2619.py on host; cc -O getdents_dump.c / statprobe.c in guest)",
  "run_cmd": "vnconfig -c vn0 craft2619.img && mount -o ro -t hammer2 /dev/vn0@testvol /mnt/h2x && ./getdents_dump /mnt/h2x && ./statprobe /mnt/h2x/AAAA...A",
  "code_hash": "forge_2619.py sha256 4060e3be4c489b2e4ab9661d8125d5e37144b63d833d2cb7ca5db4c0c8da93cf; getdents_dump.c sha256 aa0a000213ea2681a21e7906a5241573e804e3f554f65dcaca61d4eb99e688a6; statprobe.c sha256 1595f8908a3419a22ae6b4acbd2e675697814b9d8372c87ed83f7a6adf58fe6a",
  "notes": "Both faces are memory-layout dependent: same forged image silently leaked (run_stock.log) on one boot and panicked on another. A load-time rejection fix (chain->error at load_data) was considered and rejected: the readdir xop feeds chains without checking chain->error and xop_gdata would then deref NULL chain->data - the shipped fix clamps both consumers instead. Real-world trigger without a crafted image: on-media corruption of a hammer2 dirent block (reader accepts namlen > block size).",
  "recommended_fix": "Bound both consumers of on-disk namlen: in hammer2_chain_dirent_test require name_len <= chain->bytes for the data-block bcmp, and in hammer2_vop_readdir skip entries whose namlen exceeds 1 << (bref.data_off & HAMMER2_OFF_MASK_RADIX).",
  "fix_status": "fixed",
  "fix_kernel_uname": "DragonFly dfbsd 6.5-DEVELOPMENT DragonFly 6.5-DEVELOPMENT #2: Sat Aug 29 00:17:00 UTC 2026     root@dfbsd:/usr/obj/usr/src/sys/X86_64_GENERIC  x86_64",
  "fix_baseline_reproduced": 1,
  "fix_patched_reproduced": 0,
  "fix_verdict": "fix.diff (dirent_test clamp + readdir skip) applied to guest /usr/src, kernel #2 built (fix_build.log, untrimmed) and installed. Exact PoC re-run: forged image getdents returns rc=0 with the malformed 255-A entry ABSENT (no garbage tail, no kernel bytes), statprobe on the 255-char name returns ENOENT, healthy entry longname_marker_check still lists (ino 1024) and stats; base-image control unchanged (255-char entry lists, stat succeeds ino 1025, proving no regression on legitimate long-name dirents). No panics during validation.",
  "fix_evidence": [
    "fix.diff",
    "fix_build.log / fix_install.log (untrimmed)",
    "fix_run.log",
    "console_excerpts.txt [A]/[D] vs fix_run.log comparison"
  ]
}