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.
Recommended fix
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)
PoC verification
Evidence pack
findings/poc/DF-2627 Β· 22 files| File | Type | Description | Size | |
|---|---|---|---|---|
| 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_17axxxxxKVM,0x00000001_00000040direct map). The kernel itself prints KVM pointers of the same shape indmesg(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 batteriesfix_validate.shβ post-fix re-run + controlrun.log,run2.logβ full untrimmed baseline output (stock kernel)fix_run.log,fix_run2.logβ full untrimmed fixed-kernel outputfix_build.log,fix_install.logβ kernel rebuild/install logsleak_sample.txtβ ALL raw cookie values, 1355 samplesenv.txtβ guest environmentfix.diffβ the verified fixmanifest.json,verdict.json
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 firsthammer2_xop_collect()in the loop; an error breaks out immediately withbrefnever 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 theelseat :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 BEFOREif (error)at :4647 β the poisoned offset is committed to the file even on the error return.- Userspace readback:
lseek(fd, 0, SEEK_CUR)returnsfp->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 cookie0x7fffffffffffffffβ 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
fixedfix.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']
Confirmed kernel references
- 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
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.
No comments yet.