DragonFlyBSD Kernel Audit
DF-2627 / verdict.json
← back to finding ↓ download raw
{
  "finding_id": "DF-2627",
  "status": "reproduced",
  "reproduced": 1,
  "impact": "leak",
  "confidence": "certain",
  "verdict": "Confirmed and reproduced end-to-end: hammer2_vop_readdir's stack-uninitialized hammer2_blockref_t bref (vnops.c:601) is read at vnops.c:750 when the FIRST xop collect fails (break at :688-692 before the only assignment at :700), stored into uio_offset at :758, and committed to fp->f_offset by kern_getdirentries even on the error return (vfs_syscalls.c:4646 executes before the error check at :4647). Trigger: hammer2 directory with empty direct slots whose indirect blocks fail XXHASH64 (one flipped byte per indirect data block; forge technique from DF-2616/17/18/20, no ancestor/volhdr edits so mount succeeds). getdents(2) returns -1 EDOM (CHECK->EDOM per hammer2.h:1322) and lseek(fd,0,SEEK_CUR) hands 63 bits of stale kernel stack to ANY user: 1355 samples (root and nobody, 7 stack pre-history modes) yielded 9 distinct values, every one a bit-63-masked kernel pointer (0xfffff800_8d2xxxxx / 0xfffff801_16-17axxxxx KVM - same region dmesg prints, e.g. hmp=0xfffff801192a0000 - plus 0x1_00000040 direct-map). Value varies deterministically with syscall pre-history = stale stack, not on-disk data; control image shows normal cookies (end-of-dir 0x7fffffffffffffff, no EDOM). Honest ceiling: info-leak/KASLR-aid and stale-stack-word oracle, unprivileged, silent, repeatable; NOT memory corruption and no direct escalation (DragonFly ships without KASLR today) - Medium severity as filed. fix.diff (else-if error==0 guard + bzero bref) rebuilt as kernel #1: 1605/1605 samples then read cookie=2 (last good offset, zero variance, zero kernel pointers) with EDOM preserved, and healthy-image listing unchanged - leak eliminated with no regression.",
  "exploit_chain": "unpriv user -> getdents(2) on hammer2 dir whose indirect block fails CRC (media corruption or crafted image) -> first xop collect errors (EDOM) -> vnops.c:750 reads uninit bref.key -> :758 uio_offset -> vfs_syscalls.c:4646 fp->f_offset even on error -> lseek(fd,0,SEEK_CUR) returns 63-bit stale kernel-stack word; attacker primes the thread's kernel stack with chosen syscalls (open/fstat, socket, writev, prior readdir) to shape/repeat the leaked word; observed leaked words are kernel KVM/direct-map pointers (KASLR/heap-layout oracle). No write primitive; escalation not applicable to this class.",
  "evidence": [
    "run.log / run2.log - full untrimmed leak batteries on stock INVARIANTS kernel #0 (505 + 850 samples, root+nobody, all modes rc=-1 errno=33 with kernel-pointer cookies)",
    "leak_sample.txt - ALL 1355 raw cookie values, 9 distinct: 0x7ffff80116ad8c40/0x7ffff80116ad65c0/0x7ffff8008d23f1c0/0x7ffff8008d225bc0/0x7ffff8008d23e740/0x0000000100000040/0x7ffff80117a831c0/0x7ffff80117a82ac0/0x7ffff801172e69c0",
    "forge_df2627.py output - PFS blockset slots verified (slot0 EMPTY, 3 INDIRECTs), byte 0x678 of each indirect data block XOR 0xA5, no ancestor edits",
    "control run (end of run.log): base2627.img rc=1008 errno=0 cookie=0x7fffffffffffffff",
    "fix_run.log / fix_run2.log - fixed kernel #1: 1605/1605 cookie=0x2, errno=33 preserved; control image 40/40 files listed",
    "fix_build.log / fix_install.log - kernel #1 build+install from fix.diff",
    "VERDICT.md - line-precise trace table and per-mode leak distribution"
  ],
  "kernel_refs": [
    "sys/vfs/hammer2/hammer2_vnops.c:601",
    "sys/vfs/hammer2/hammer2_vnops.c:688-692",
    "sys/vfs/hammer2/hammer2_vnops.c:700",
    "sys/vfs/hammer2/hammer2_vnops.c:745-751",
    "sys/vfs/hammer2/hammer2_vnops.c:758",
    "sys/kern/vfs_syscalls.c:4644-4646",
    "sys/vfs/hammer2/hammer2_chain.c:1070-1072",
    "sys/vfs/hammer2/hammer2_chain.c:2473-2476",
    "sys/vfs/hammer2/hammer2_xops.c:213-242",
    "sys/vfs/hammer2/hammer2_cluster.c:471-477",
    "sys/vfs/hammer2/hammer2_cluster.c:533-536",
    "sys/vfs/hammer2/hammer2.h:1322-1323",
    "sys/vfs/hammer2/hammer2_disk.h:466-467"
  ],
  "poc_changes": "Seed had no code: materialized everything. Trigger redesign vs claim: a CRC-broken PFS *inode* is rejected at mount (vfsops.c:1428-1433), so corruption moved into the INDIRECT data blocks; direct slots emptied (12 sacrificial files rm'd before umount) so the FIRST collect fails even when reading from offset 0. Forger needs no CHECK_NONE ancestor tricks and no volhdr CRC recompute (only data-block bytes flipped). Sampler written in C (getdents + lseek readback, modes n/f/o/r/s/w); fixed a fork-mode stdio buffering bug (children _exit'd without flush) and the DragonFly getdents(int,char*,int) prototype on the second attempt.",
  "attempts": 2,
  "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": 3300,
  "guest_dirty": 0,
  "build_cmd": "sh build.sh  (mkbase2627.sh in guest as root; forge_df2627.py on host; cc -O -o leak_df2627 leak_df2627.c in guest)",
  "run_cmd": "vnconfig -c vn0 craft2627.img && mount -t hammer2 /dev/vn0@testvol /mnt/h2x && sh run_guest.sh && sh run_guest2.sh  (leak readback also verified as user nobody)",
  "code_hash": "38f8d325a32c260d67cfff3e6619c8a62da55661a0714780298545bd87b32d6a",
  "notes": "Leak is per-call deterministic given the same syscall pre-history and varies across pre-histories (stale-stack signature); leaked words were pointer-rich KVM addresses on this INVARIANTS kernel, no panic/KASSERT fired (uninit stack reads are not trapped). Mount of the crafted image requires root unless vfs.usermount=1 with an owned vn device (per finding threat model); the disclosure itself (getdents+lseek on a world-readable corrupted dir) is fully unprivileged - verified as nobody. Real-world trigger does not need a crafted image: any on-media corruption of a hammer2 directory indirect block (CRC error, bad sector) poisons every getdents on that directory. Entropy caveat recorded honestly: values are not random per call - each distinct pre-history yields one stable value; the primitive leaks whatever 8-byte word last occupied that stack slot, here kernel pointers.",
  "recommended_fix": "In hammer2_vop_readdir only advance saveoff from bref.key when the last collect succeeded (} else if (error == 0) { ... }) and bzero bref at the top; kern_getdirentries should also avoid committing a poisoned uio_offset to f_offset on error ( vfs_syscalls.c:4646).",
  "fix_status": "fixed",
  "fix_kernel_uname": "DragonFly dfbsd 6.5-DEVELOPMENT DragonFly 6.5-DEVELOPMENT #1: Fri Aug 28 21:04:58 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 applied to guest /usr/src (patch -p1, git-apply-able against the repo), kernel rebuilt (make -j6 nativekernel, fix_build.log) and installed (fix_install.log), rebooted into kernel #1. Exact-same PoC battery re-run: 1605/1605 samples on the corrupt image return rc=-1 errno=33 with cookie=0x0000000000000002 (last good offset) - zero kernel pointers, zero variance, so baseline leak is gone while the genuine EDOM error is preserved; control image still lists 40/40 files with normal end-of-dir cookie 0x7fffffffffffffff - no regression.",
  "fix_evidence": [
    "fix.diff",
    "fix_build.log (35693 lines, untrimmed)",
    "fix_install.log",
    "fix_run.log / fix_run2.log (1605 samples, all cookie=0x2)",
    "fix_validate.sh output captured in fix_run.log tail: control image 40 files, rc=1008"
  ]
}