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

hammer2_vop_readdir reads uninitialized stack (bref) on first-xop-error and discloses it to userspace via the directory cookie/offset

Field Value
ID DF-2627
Status new
Severity Medium
CVSS 3.1 CVSS:3.1/AV:L/AC:H/PR:L/UI:N/S:U/C:H/I:N/A:N
CWE CWE-457 Use of Uninitialized Variable
File sys/vfs/hammer2/hammer2_vnops.c
Lines 745-758
Area vfs
Confidence likely
Discovered 2026-08-28
Pass 2 (GLM 5.3 second pass)
Bucket hammer2
Reported pending
Known CVE none
CVE match novel

Summary

In hammer2_vop_readdir, hammer2_blockref_t bref (:601) is only written by hammer2_cluster_bref() inside the collection loop (:700). If the FIRST hammer2_xop_collect() returns any error other than ENOENT β€” EIO/EDOM (cluster_check I/O or CRC error), EINTR, EAGAIN-mapped codes (hammer2_error_to_errno, hammer2.h:1317-1356) β€” the loop breaks at :688-692 without ever writing bref, and :750 evaluates bref.key & HAMMER2_DIRHASH_USERMSK from uninitialized stack, storing it into uio->uio_offset at :758. kern_getdirentries copies auio.uio_offset into fp->f_offset UNCONDITIONALLY, even when VOP_READDIR returned an error (sys/kern/vfs_syscalls.c:4645-4646), so the user reads the stale stack contents with lseek(fd, 0, SEEK_CUR) β€” a ~63-bit kernel-stack disclosure per call.

Root cause

vnops.c:683-751: for (;;) { error = hammer2_xop_collect(...); error = hammer2_error_to_errno(error); if (error) break; ... hammer2_cluster_bref(&xop->head.cluster, &bref); ... } β€” after the loop, if (error == ENOENT) {...} else { saveoff = bref.key & HAMMER2_DIRHASH_USERMSK; } (:745-751). The backend readdir worker propagates per-cluster errors from chain_lookup/chain_next I/O and CRC failures via hammer2_xop_feed (hammer2_xops.c:213-242), and hammer2_xop_collect can also return EIO from hammer2_cluster_check (hammer2_admin.c:988). None of those write bref on a first-iteration failure. Stale value flow: bref.key (:750) β†’ saveoff β†’ uio->uio_offset (:758) β†’ fp->f_offset (vfs_syscalls.c:4646, executed before the if (error) goto done) β†’ lseek(fd,0,SEEK_CUR). The leaked slot is 63 bits of prior kernel-stack contents from the same thread β€” frequently kernel pointers, aiding KASLR defeat.

Threat model & preconditions

  • Attacker position: unprivileged local user with read (search) permission on a hammer2 directory.
  • Privileges gained or impact: 63 bits of kernel stack per call; repeated during ongoing I/O errors harvests pointers for ASLR/KASLR bypass chaining.
  • Required config or capabilities: an EIO-class error on the first collect of a getdents β€” degraded/failing storage, an on-media CRC corruption of a directory block, or a multi-slave cluster losing quorum. All realistic production states; on a healthy single disk the trigger is environmental, hence AC:H.
  • Reachability: getdents on a failing directory read.

Proof of concept

Build & run

int fd=open("/h2/dir",O_RDONLY); char buf[8192];
syscall(SYS_getdents, fd, buf, sizeof buf);  /* returns -1 EIO */
long off = lseek(fd, 0, SEEK_CUR); printf("%#lx\n", off);
/* failure injected by corrupting one directory data block's CRC offline,
   or a wedged slave in a 2-node pfs */

Expected output

getdents returns EIO yet lseek returns a large pseudo-random value that is
NOT the pre-call offset β€” that value is uninitialized stack; repeat to
collect samples containing stable kernel pointer fragments.

Impact

Kernel-stack info leak (63 bits/call, pointer-rich) on I/O-error paths of hammer2 directories.

Do not fabricate saveoff from bref unless at least one entry was collected; zero bref defensively:

--- a/sys/vfs/hammer2/hammer2_vnops.c
+++ b/sys/vfs/hammer2/hammer2_vnops.c
@@ -601,6 +601,7 @@ hammer2_vop_readdir(struct vop_readdir_args *ap)
    hammer2_xop_readdir_t *xop;
    hammer2_blockref_t bref;
+   int dident = 0;
@@ -698,6 +699,7 @@ hammer2_vop_readdir(struct vop_readdir_args *ap)
        hammer2_cluster_bref(&xop->head.cluster, &bref);
+           dident = 1;
@@ -747,7 +749,9 @@ hammer2_vop_readdir(struct vop_readdir_args *ap)
        saveoff = (hammer2_key_t)-1;
    } else {
-       saveoff = bref.key & HAMMER2_DIRHASH_USERMSK;
+       if (dident)
+           saveoff = bref.key & HAMMER2_DIRHASH_USERMSK;
+       /* else: keep the caller-supplied saveoff on error */
    }

(Companion hardening in sys/kern/vfs_syscalls.c: propagate auio.uio_offset into fp->f_offset only when error == 0 β€” but the filesystem-side fix is sufficient and targeted.)

References

  • vfs_syscalls.c:4645-4646 (unconditional f_offset update)
  • hammer2_admin.c:982-988 (EIO from cluster_check)

Timeline

  • 2026-08-28 Discovered during automated audit (pass 2, GLM 5.3).

Discussion (0)

No comments yet.

PoC verification

Evidence pack

findings/poc/DF-2627 Β· 22 files
FileTypeDescriptionSize
README.md β€” 4.8 KB ↓ raw
VERDICT.md β€” 6.2 KB ↓ raw
manifest.json β€” 2.3 KB view raw
verdict.json β€” 7.4 KB view raw
forge_df2627.py β€” 7.5 KB view raw
mkbase2627.sh β€” 1.2 KB view raw
leak_df2627.c β€” 3.6 KB view raw
build.sh β€” 1002 B view raw
run_guest.sh β€” 1.1 KB view raw
run_guest2.sh β€” 882 B view raw
fix_validate.sh β€” 1.0 KB view raw
base2627.img β€” 64.0 MB ↓ download
craft2627.img β€” 64.0 MB ↓ download
run.log β€” 25.9 KB view raw
run2.log β€” 42.8 KB view raw
leak_sample.txt β€” 68.1 KB view raw
env.txt β€” 386 B view raw
fix_run.log β€” 38.4 KB view raw
fix_run2.log β€” 42.8 KB view raw
fix_build.log β€” 5.6 MB ↓ download
fix_install.log β€” 80.4 KB view raw
fix.diff β€” 2.0 KB view raw

DF-2627 β€” hammer2_vop_readdir uninitialized-stack disclosure via directory cookie

What this is

hammer2_vop_readdir() (sys/vfs/hammer2/hammer2_vnops.c:601) declares hammer2_blockref_t bref; uninitialized. bref is only assigned inside the collect loop after a successful hammer2_xop_collect() (vnops.c:700). When the first collect returns an error (break at vnops.c:688-692), vnops.c:750 reads bref.key from stale kernel stack into saveoff and vnops.c:758 stores it into uio->uio_offset. kern_getdirentries() (sys/kern/vfs_syscalls.c:4645-4646) then copies auio.uio_offset into fp->f_offset even on the error return (the store at :4646 precedes the if (error) at :4647), so an unprivileged user reads 63 bits of stale kernel stack back with lseek(fd, 0, SEEK_CUR) after a failing getdents(2).

Trigger

A hammer2 directory whose entry scan fails on the first xop collect: a PFS-root directory blockset with EMPTY direct slots whose INDIRECT blocks fail their XXHASH64 check (one flipped byte in each indirect data block). Chain resolution sets chain->error = HAMMER2_ERROR_CHECK (hammer2_chain.c:1070-1072); the lookup's parent->error bail (hammer2_chain.c:2473-2476) fails the first lookup; the xop feeds NULL+CHECK; hammer2_cluster_check (hammer2_cluster.c:471-477,533-536) returns it; hammer2_error_to_errno maps CHECK→EDOM; the first collect errors out with bref never assigned.

Such an image is produced by forge_df2627.py from base2627.img (built by mkbase2627.sh: 12 sacrificial files fill the 4 direct slots, 40 payload files spill into 3 indirect blocks, sacrificial files are removed so the direct slots are EMPTY on disk). Only bytes inside the indirect data blocks are flipped β€” volhdr/sroot/PFS-inode blocks and all stored checks are untouched, so mount succeeds normally (vfsops.c:1428 only rejects a CRC-broken PFS inode, not broken children).

Real-world reachability: any hammer2 media corruption (bad block, CRC error, torn write) hitting a directory's indirect block turns every getdents() on that directory into a disclosure; no crafted image needed.

Build

sh build.sh        # base image in guest (root), forge variant on host,
                   # compile leak_df2627.c in guest, push images back

Run (guest, root for the mount; the LEAK itself is unprivileged)

vnconfig -c vn0 /root/poc/df2627/craft2627.img
mount -t hammer2 /dev/vn0@testvol /mnt/h2x
chmod 755 /mnt/h2x
sh /root/poc/df2627/run_guest.sh     # battery 1: root + nobody, 505 samples
sh /root/poc/df2627/run_guest2.sh    # battery 2: priming modes, 850 samples

Expected output (stock INVARIANTS kernel #0 β€” VERIFIED)

sample N rc=-1 errno=33 cookie=0x7ffff80116ad8c40     <- kernel KVM pointer
sample N rc=-1 errno=33 cookie=0x7ffff8008d225bc0     <- kernel KVM pointer
sample N rc=-1 errno=33 cookie=0x0000000100000040     <- direct-map pointer
  • 1355 samples across 7 modes (in-process, fork-per-sample, open/fstat prime, healthy-readdir prime, socket prime, writev prime, and the same as unprivileged user nobody).
  • 9 distinct leaked values, every one a bit-63-masked kernel pointer (0xfffff800_8d2xxxxx / 0xfffff801_16axxxxx / 0xfffff801_17axxxxx KVM, 0x00000001_00000040 direct map). The kernel itself prints KVM pointers of the same shape in dmesg (hammer2_mount: hmp=0xfffff801192a0000).
  • The value is deterministic per syscall pre-history and CHANGES with the prehistory (7 modes β†’ 9 values) β€” the signature of stale stack, not of any on-disk or computed quantity.
  • Control (uncorrupted image): rc=1008 errno=0 cookie=0x7fffffffffffffff (the documented end-of-dir marker) β€” the cookie channel behaves normally.
  • No panic, no wedge; the mount unmounts cleanly afterwards.

Fix validation (kernel #1, this fix.diff)

patch -p1 < fix.diff && make -j6 nativekernel && make installkernel, reboot:

  • Corrupt image: 1605/1605 samples β†’ rc=-1 errno=33 cookie=0x0000000000000002 β€” EDOM still reported, cookie pinned at the last good offset, zero kernel pointers, zero variance.
  • Healthy image: still lists all 40 files (rc=1008, end-of-dir cookie unchanged) β€” no regression.

Files

  • forge_df2627.py β€” image forger (host python3)
  • mkbase2627.sh β€” base image builder (guest, root)
  • leak_df2627.c β€” leak sampler (getdents + lseek cookie readback)
  • run_guest.sh, run_guest2.sh β€” leak batteries
  • fix_validate.sh β€” post-fix re-run + control
  • run.log, run2.log β€” full untrimmed baseline output (stock kernel)
  • fix_run.log, fix_run2.log β€” full untrimmed fixed-kernel output
  • fix_build.log, fix_install.log β€” kernel rebuild/install logs
  • leak_sample.txt β€” ALL raw cookie values, 1355 samples
  • env.txt β€” guest environment
  • fix.diff β€” the verified fix
  • manifest.json, verdict.json
VERDICT.md
↓ download raw

DF-2627 VERDICT β€” hammer2_vop_readdir uninitialized-stack disclosure

status: reproduced β€” 63-bit kernel-stack disclosure to an unprivileged local user, via the hammer2 directory cookie, on a failing getdents(2). Fix authored, built, and validated in-guest (leak eliminated, no regression).

1. Source-level confirmation (all lines verified in the read-only tree)

  • sys/vfs/hammer2/hammer2_vnops.c:601 β€” hammer2_blockref_t bref; declared uninitialized.
  • sys/vfs/hammer2/hammer2_vnops.c:688-692 β€” the first hammer2_xop_collect() in the loop; an error breaks out immediately with bref never assigned (assignment is only at :700, hammer2_cluster_bref(), after a successful collect).
  • sys/vfs/hammer2/hammer2_vnops.c:745-751 β€” error != ENOENT (here EDOM) takes the else at :749-750: saveoff = bref.key & HAMMER2_DIRHASH_USERMSK; β†’ read of uninitialized stack.
  • sys/vfs/hammer2/hammer2_vnops.c:758 β€” uio->uio_offset = saveoff & ~HAMMER2_DIRHASH_VISIBLE; β†’ stale stack bytes stored into the directory offset. USERMSK (hammer2_disk.h:467) strips bit 63 β†’ 63 bits disclosed.
  • sys/kern/vfs_syscalls.c:4644-4646 β€” loff = auio.uio_offset = fp->f_offset; … error = VOP_READDIR_FP(...) … fp->f_offset = auio.uio_offset; executes BEFORE if (error) at :4647 β†’ the poisoned offset is committed to the file even on the error return.
  • Userspace readback: lseek(fd, 0, SEEK_CUR) returns fp->f_offset.

Error propagation for the chosen trigger (all verified): hammer2_chain_load_data CRC failure sets chain->error = HAMMER2_ERROR_CHECK (hammer2_chain.c:1070-1072); hammer2_chain_lookup bails via the parent->error check (hammer2_chain.c:2473-2476) when the CRC-broken indirect becomes the parent; hammer2_xop_readdir feeds NULL+CHECK (hammer2_xops.c:213-223,242); hammer2_cluster_check returns the fed error for the single-master cluster (hammer2_cluster.c:471-477 keynull path, :533-536); hammer2_error_to_errno maps CHECK→EDOM (hammer2.h:1322-1323).

2. Trigger construction

mkbase2627.sh (guest): PFS "testvol"; 12 sacrificial files fill the 4 direct blockref slots (HAMMER2_SET_COUNT=4, hammer2_disk.h:133), 40 payload files spill into 3 INDIRECT blocks; sacrificial files removed β†’ direct slots EMPTY on media (verified by the forger: slot0 type=0).

forge_df2627.py (host): volhdr walk (technique proven in DF-2616/17/18/20) β†’ sroot inode β†’ PFS inode β†’ blockset; XOR 0xA5 into one 0x00 byte at offset 0x678 of each indirect data block. No ancestor block is edited and no stored check is altered, so every parent CRC stays valid and mount succeeds; the indirects simply fail their XXHASH64 verification on resolution. A read starting at offset 0 emits "." and ".." and then hits the broken indirect on the FIRST collect β€” exactly the uninitialized path.

ls /mnt/h2x on the crafted image returns silently (getdents error), no panic, no wedge (unmount clean) β€” the disclosure is silent.

3. Observed leak (stock INVARIANTS kernel #0, full logs in run.log/run2.log)

1355 samples (root + nobody; in-process, fork-per-sample, and four kernel- stack priming modes: open/fstat, healthy-dir readdir, socket, pipe+writev):

mode who leaked cookie (Γ—count)
n (no prime) root 0x7ffff80116ad8c40 (155)
o (open+fstat prime) root 0x7ffff80116ad65c0 (150)
r (readdir prime) root 0x7ffff8008d23f1c0 (100)
s (socket prime) root 0x7ffff8008d225bc0 (150)
w (writev prime) root 0x7ffff8008d23e740 (150)
f (fork prime) root+nobody 0x0000000100000040 (250)
s (socket prime) nobody 0x7ffff80117a831c0 (100)
w (writev prime) nobody 0x7ffff80117a82ac0 (100)

every call: rc=-1 errno=33 (EDOM). 9 distinct values total; each is a bit-63-stripped kernel pointer: 0xfffff800_8d2xxxxx/0xfffff801_16/17axxxxx (KVM β€” the same region the kernel prints in dmesg, e.g. hammer2_mount: hmp=0xfffff801192a0000) and 0x00000001_00000040 (direct map). Values are deterministic per pre-history and change with the pre-history β€” stale stack, not on-disk data (the on-disk cookies for this image are name hashes of p0..p39, none of which appear; and a correct implementation would leave the cookie at 2, which is what the fixed kernel returns).

Control image (uncorrupted): rc=1008 errno=0 cookie=0x7fffffffffffffff β€” the documented end-of-dir marker (vnops.c:745-748), deterministic, no error. The contrast proves the cookie channel is only poisoned on the error path.

4. Honest impact assessment

  • 63 bits of kernel stack per failing getdents, repeatable at will, unprivileged (nobody verified), silent (no console message).
  • Leaked content here was pointer-rich (kernel KVM pointers) β€” a genuine KASLR-aid / heap-layout oracle where KASLR or heap randomization exists. DragonFly currently ships without KASLR, so on today's default kernel this is primarily an info-leak primitive (kernel pointer disclosure and stale stack-word oracle), not directly an escalation. Per the finding's Medium severity β€” correct.
  • Not memory corruption: no write primitive, no panic observed (INVARIANTS kernel silent β€” nothing asserts an uninitialized stack read).
  • DoS note: the underlying EDOM is genuine media corruption signaling; the bug is only the cookie content.

5. Fix validation (kernel #1, built from fix.diff, full logs in fix_*.log)

patch -p1 < fix.diff (git-apply-able against the repo tree), make -j6 nativekernel && make installkernel, reboot into kernel #1:

  • Corrupt image: 1605/1605 samples rc=-1 errno=33 cookie=0x0000000000000002 β€” EDOM preserved, cookie pinned to the last good offset (after "." and ".."), zero kernel pointers, zero variance. Leak eliminated.
  • Healthy image: 40/40 files listed, rc=1008, end-of-dir cookie 0x7fffffffffffffff β€” no behavioral regression.

fix_status: fixed.

6. Conclusion

The claimed path is real, line-accurate, and reproducible end-to-end: uninitialized bref.key β†’ uio_offset β†’ f_offset (stored even on error) β†’ lseek() readback. Verified with 1355 baseline samples across privileged and unprivileged callers and multiple stack pre-histories, and eliminated by the two-line fix validated on a rebuilt kernel.

Fix verification

fixed
baseline reproduced→ patch + rebuild →patched clean

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.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']
↓ fix.diffDragonFly 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

Confirmed kernel references

Detail

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 (decisive lines)

['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']

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.

Verified 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).

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.