hammer2_pfsalloc kstrdup(ripdata->filename) with no NUL bound overreads the 64KB DIO buffer (mount-time panic / silent adjacent-heap overread)
| Field | Value |
|---|---|
| ID | DF-2624 |
| Status | new |
| Severity | Medium |
| CVSS 3.1 | CVSS:3.1/AV:L/AC:L/PR:H/UI:N/S:U/C:L/I:N/A:H |
| CWE | CWE-125 Out-of-bounds Read |
| File | sys/vfs/hammer2/hammer2_vfsops.c |
| Lines | 495 |
| 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
hammer2_pfsalloc does pmp->pfs_names[j] = kstrdup((const char
*)ripdata->filename, M_HAMMER2) (vfsops.c:495) on the on-disk filename
field, a fixed 256-byte array inside the 1024-byte inode data with no
NUL-termination guarantee anywhere in the mount path. A crafted PFS inode
whose bytes from filename[0] to the end of the DIO buffer are all non-zero
makes kstrdup read and copy past the end of the 1 KB buffer-cache
allocation into pfs_names[], from where the bytes are observable via
h2nod-* kernel thread names (lwkt_create '%s-%s', hammer2_admin.c:237-240)
and via the mount-by-label comparison oracle at vfsops.c:1108-1110.
Root cause
hammer2_disk.h:1010-1012 defines filename[HAMMER2_INODE_MAXNAME=256]
inside hammer2_inode_data_t (1024 bytes). update_pmps (vfsops.c:1553-1562)
and hammer2_vfs_mount:1391-1392 consume it directly; pfsalloc:495 kstrdup's
it without forcing termination; the tail of the inode (bytes 512..1023) is
fully attacker-controlled, so a craft with no zero byte in bytes 256..1023
pushes kstrdup past the end of the buffer backing chain->data.
Threat model & preconditions
- Attacker position: unprivileged mount (
vfs.usermount=1+ owned device) or root mounting a crafted image. - Privileges gained or impact: kernel heap over-read of a few hundred
bytes past a 1 KB buffer, persisted in
pmp->pfs_names[]and disclosed through thread names and a byte-by-byte label-match oracle. - Required config or capabilities: crafted image.
- Reachability: mount.
Proof of concept
Build & run
craft image: PFS inode with filename[0..255] and remaining inode bytes all 0xFF except valid CRC/check fields; mount the PFS; ps -a | grep h2nod
Expected output
h2nod-<garbage> thread names contain bytes never present in the image; mount @<guess> label matching confirms adjacent heap bytes.
Impact
Low-fidelity kernel heap info leak; no write.
Recommended fix
--- a/sys/vfs/hammer2/hammer2_vfsops.c
+++ b/sys/vfs/hammer2/hammer2_vfsops.c
@@ -492,7 +492,17 @@
if (force_local)
pmp->pfs_types[j] = HAMMER2_PFSTYPE_MASTER;
else
pmp->pfs_types[j] = ripdata->meta.pfs_type;
- pmp->pfs_names[j] = kstrdup((const char *)ripdata->filename, M_HAMMER2);
+ {
+ /* on-disk filename[] is not guaranteed NUL-terminated */
+ char *name = kmalloc(HAMMER2_INODE_MAXNAME + 1,
+ M_HAMMER2, M_WAITOK);
+ bcopy(ripdata->filename, name, HAMMER2_INODE_MAXNAME);
+ name[HAMMER2_INODE_MAXNAME] = 0;
+ pmp->pfs_names[j] = name;
+ }
pmp->pfs_hmps[j] = chain->hmp;
Timeline
- 2026-08-28 Discovered during automated audit (pass 2, GLM 5.3).
Verification (2026-08-29, pass 2)
REPRODUCED on the stock kernel β upgraded Low β Medium. A second PFS inode
relocated to a 64KB-window tail with the filename array and inode tail
filled non-NUL: kstrdup walked past the DIO buffer (instrumented
len=772, 516 bytes past the 256-byte array, copying 4 adjacent-kernel-heap
bytes into pfs_names), and on unmapped layouts the stock kernel panics
(fatal trap 12, strlen <- hammer2_pfsalloc+0x157, observed 1-in-2 boots).
Fix (strnlen bound + warn) validated on kernel #2: 3/3 clean mounts.
Evidence: findings/poc/DF-2624/ (forge_2624.py, instrumented + panic logs,
fix.diff, fix logs).
Discussion (0)
PoC verification
Evidence pack
findings/poc/DF-2624 Β· 18 files| File | Type | Description | Size | |
|---|---|---|---|---|
| forge_2624.py | β | 3.1 KB | view raw | |
| h2common.py | β | 3.8 KB | view raw | |
| run_2624.sh | β | 529 B | view raw | |
| run_stock.log | β | 1.7 KB | view raw | |
| console_excerpts.txt | β | 2.8 KB | view raw | |
| fix.diff | β | 932 B | view raw | |
| instr_build.log | β | 1.2 MB | β download | |
| instr_build2.log | β | 5.6 MB | β download | |
| instr_install.log | β | 80.4 KB | view raw | |
| fix_build.log | β | 5.6 MB | β download | |
| fix_install.log | β | 80.4 KB | view raw | |
| env.txt | β | 505 B | view raw | |
| README.md | β | 2.7 KB | β raw | |
| VERDICT.md | β | 4.3 KB | β raw | |
| build.sh | β | 851 B | view raw | |
| run.sh | β | 211 B | view raw | |
| manifest.json | β | 1.3 KB | view raw | |
| verdict.json | β | 6.3 KB | view raw |
DF-2624 β kstrdup(ripdata->filename) with no NUL-termination guarantee overreads the 64KB DIO buffer
- Cited:
sys/vfs/hammer2/hammer2_vfsops.c:495(hammer2_pfsalloc)
What this pack contains
| file | what |
|---|---|
forge_2624.py + h2common.py |
image forger: poison PFS inode with non-NUL filename, relocated to a 64KB-window tail |
run_2624.sh |
guest trigger (mount β hammer2_update_pmps β pfsalloc) |
run_stock.log |
stock kernel #0 run (silent overread; mount succeeds) |
console_excerpts.txt |
instrumented len=772 print, the strlen β hammer2_pfsalloc+0x157 panic trace, fix-kernel warnings |
fix.diff |
bounded copy (strnlen + kmalloc + bcopy + warning) |
Build
- Guest (root): base image β
newfs_hammer2 -L testvol, mount, one file,hammer2 pfs-create /mnt/h2x/poison(creates the second PFS the forger poisons), sync, umount; pull to host. - Host:
python3 forge_2624.py base2624.img craft2624.img* relocates the poison PFS inode block toWIN|0xFC00|10(1024-byte block ending exactly at the 64KB buffer end), * fills[0x100,0x400)(the wholefilename[256]array + the inode blockset area) with 0x50 β no NUL anywhere before the buffer end, * poison bref: mirror_tid=0 (no recovery recursion), CHECK_NONE; sroot bref CHECK_NONE; volhdr CRCs recomputed. - Push
craft2624.imgto guest.
Run (root)
vnconfig -c vn0 craft2624.img
mount -o ro -t hammer2 /dev/vn0@testvol /mnt/h2x
hammer2 pfs-list /mnt/h2x # shows the poison PFS
Expected
hammer2_update_pmps() (vfsops.c:1552-1571) pfsallocs every PFS under
the sroot on any mount, so pfsalloc() runs
kstrdup(ripdata->filename) with no NUL in the 256-byte array, walking
through the blockset filler and off the end of the 64KB DIO buffer:
- stock, adjacent page mapped (observed): silent β kstrdup result was
772 bytes = 768 in-block + 4 bytes past the DIO buffer (instrumented
print), copied into
pmp->pfs_names[0]; - adjacent page unmapped (observed on the instrumented kernel; the
faulting strlen is the stock kstrdup β the instrumentation only adds a
post-return report): kernel panic
strlen() at strlen+0x14 β hammer2_pfsalloc() at +0x157, fault VA page-granular at the DIO buffer end; - fixed kernel: 3/3 mounts succeed, no panic, console warns
hammer2_pfsalloc: PFS filename not NUL-terminated (truncated to 256).
Userspace observability of the overread bytes on stock: none found β
thread names are truncated to MAXCOMLEN (16) and hammer2 pfs-list
prints the on-disk name via meta.name_len; the copied heap bytes stay
in kernel memory (pmp->pfs_names[]). The demonstrated impact is the
mount-time panic (memory-layout dependent) plus the bounded heap overread.
DF-2624 VERDICT
Status: reproduced (unbounded kstrdup overread past the 64KB DIO buffer, proven two ways: instrumented length 772 = 4 bytes past the buffer, and a kernel panic inside the stock kstrdup's strlen). Impact: panic (mount-time, layout-dependent) + silent adjacent-heap overread; no userspace disclosure found. Confidence: certain.
Root cause (line-precise)
sys/vfs/hammer2/hammer2_vfsops.c:495 (hammer2_pfsalloc)
pmp->pfs_names[j] = kstrdup((const char *)ripdata->filename, M_HAMMER2);
ripdata->filename is unsigned char filename[HAMMER2_INODE_MAXNAME]
(=256, disk.h:912/1012) inside the 1024-byte inode data block. Nothing
on the ingestion path guarantees a NUL inside the array:
- the on-disk name length is
meta.name_len, butkstrdupre-derives the length withstrlenβ a missing NUL makes the walk leave the array, continue through the rest of the inode block, and (when the block sits at a 64KB window tail) past the end of the DIO buffer into adjacent kernel memory until a zero byte happens to appear; - there is no
strnlenbound, andHAMMER2_INODE_MAXNAMEis not consulted anywhere before :495 (grep over the whole call chain: update_pmps :1561 β pfsalloc :487-495).
Reachability: hammer2_update_pmps() (vfsops.c:1552-1571) pfsallocs
every INODE child under the sroot on any mount of the device β the
poison PFS does not need to be the mounted label (a non-NUL name can in
fact never match a label: the label scan at :1394 uses strcmp, whose
terminator requirement makes a non-NUL filename unmatchable).
PoC
forge_2624.py: base image has a second PFS (created in-guest with
hammer2 pfs-create); the forger relocates its inode block to
WIN|0xFC00|10 β a 1024-byte block ending exactly at the 64KB buffer
end (passes the window-fit KKASSERT at io.c:127), fills
[0x100,0x400) with 0x50 (no NUL), zeroes the bref's mirror_tid so
recovery does not recurse, and neutralizes ancestor checks (DF-2616
technique). Mount testvol (healthy) β update_pmps β pfsalloc(poison)
β kstrdup walks.
Observed
- Stock kernel #0 (
run_stock.log,console_excerpts.txt[A]): mount succeeds; the overread is silent (adjacent page mapped, NUL found shortly past the buffer).hammer2 pfs-listshows the poison PFS (PPPPPβ the on-disk view viameta.name_len=5). - Instrumented kernel #1, mapped case (
console_excerpts.txt[C]):DF2624: ... kstrdup result len=772 (516 bytes past array)β 772 = 256 (filename array) + 512 (blockset filler) + 4 bytes read past the end of the 64KB DIO buffer, all copied intopmp->pfs_names[0]of the poison pmp. - Instrumented kernel #1, unmapped case (
console_excerpts.txt[B]):Fatal trap 12 β¦ panic: page faultwith backtracestrlen() at strlen+0x14 β hammer2_pfsalloc() at hammer2_pfsalloc+0x157, fault VA page-granular = the page after the DIO buffer. The instrumentation only reports after kstrdup returns; the faulting strlen is the stockkstrdupat :495 β the panic is stock behavior under that heap layout.
Honest ceiling
The overread reads real adjacent kernel heap (bytes 768..771 of the walk
on the mapped run) into a kernel string, and can panic the kernel at
mount. No path was found that returns those bytes to userspace:
pfs_names feeds thread names (truncated to MAXCOMLEN=16 β never
reaches the tail), hammer2 pfs-list prints the on-disk name, and the
vol-list ioctl reads pmp->pfs_names[0] of the mounted pmp only.
So: local DoS (mount-time panic, layout dependent) + in-kernel
over-read; Low/Medium boundary β filed Low, consistent.
Fix
- pmp->pfs_names[j] = kstrdup((const char *)ripdata->filename, M_HAMMER2);
+ {
+ size_t nlen = strnlen((const char *)ripdata->filename,
+ HAMMER2_INODE_MAXNAME);
+ pmp->pfs_names[j] = kmalloc(nlen + 1, M_HAMMER2,
+ M_WAITOK | M_ZERO);
+ bcopy(ripdata->filename, pmp->pfs_names[j], nlen);
+ if (nlen == HAMMER2_INODE_MAXNAME)
+ kprintf("hammer2_pfsalloc: PFS filename not "
+ "NUL-terminated (truncated)\n");
+ }
Fix validation (kernel #2)
Same crafted image mounted three times: 3/3 succeed, no panic, the
truncation warning fires each time, pfs-list unchanged, testvol mount
unaffected (console_excerpts.txt [D]).
Fix verification
fixedfix.diff (strnlen-bounded copy + truncation warning) included in kernel #2 (fix_build.log). Exact PoC image mounted three times: 3/3 succeed with NO panic (baseline: silent on stock, panic on the instrumented boot - both faces eliminated by construction since strlen can no longer leave the array), the warning 'hammer2_pfsalloc: PFS filename not NUL-terminated (truncated to 256)' fires on every mount, pfs-list output unchanged (PPPPP), and the healthy testvol PFS mounts normally.
['fix.diff', 'console_excerpts.txt [D]', 'fix_build.log / fix_install.log']
Confirmed kernel references
Detail
Exploit chain
root (or vfs.usermount=1 + owned vn) mounts crafted image -> hammer2_update_pmps scans sroot children -> hammer2_pfsalloc(poison PFS) -> kstrdup(ripdata->filename) with no NUL in the 256-byte array -> strlen walks the inode block (blockset filler) and off the end of the 64KB DIO buffer into adjacent kernel heap -> adjacent bytes copied into pmp->pfs_names[0] (silent overread; no userspace consumer found) OR the first past-buffer byte lies in an unmapped page -> fatal trap 12 in strlen (mount-time DoS). No write primitive, no userland disclosure demonstrated.
Evidence (decisive lines)
["console_excerpts.txt [C] - instrumented: 'DF2624: pfsalloc filename has NO NUL in 256-byte array; kstrdup result len=772 (516 bytes past array)' (772 = 256 array + 512 blockset + 4 past the 64KB DIO buffer)", "console_excerpts.txt [B] - 'Fatal trap 12 ... strlen() at strlen+0x14 / hammer2_pfsalloc() at hammer2_pfsalloc+0x157', fault VA 0xfffff80055af6000 page-granular (end of the DIO buffer)", 'run_stock.log - stock kernel: mount succeeds, hammer2 pfs-list shows MASTER ... PPPPP (poison PFS present, silent overread)', "console_excerpts.txt [D] - fix kernel #2: 3/3 mounts, no panic, 'hammer2_pfsalloc: PFS filename not NUL-terminated (truncated to 256)' x3", 'forge_2624.py output - poison inode 0x1400400 -> 0x220fc00 (window tail), [0x100,0x400)=0x50*0x300, mirror_tid=0']
PoC changes
No seed code. The seed sketch assumed the poison PFS could be mounted by label - impossible (label scan strcmps against the NUL-less name and can never match); the working trigger mounts the HEALTHY testvol PFS while hammer2_update_pmps pfsallocs the poison one during the same mount. pfs-create in-guest produced a PFS named 'LOCAL' (its cluster-role default label), used as-is. The 4 bytes actually read past the buffer (result[768..771]) were beyond the hook's 96-byte hexdump window; the length evidence (772) proves the past-buffer read.
Verified recommended fix
Bound the ingestion: nlen = strnlen(ripdata->filename, HAMMER2_INODE_MAXNAME); kmalloc(nlen+1) + bcopy + warn when nlen == HAMMER2_INODE_MAXNAME (never kstrdup an unbounded on-disk string).
Verdict
CONFIRMED two ways. hammer2_pfsalloc (vfsops.c:495) kstrdup's ripdata->filename with no NUL-termination bound: the on-disk filename is a 256-byte array (disk.h:1012) inside the 1KB inode block and nothing on the ingestion path checks it (the length field meta.name_len is not consulted; hammer2_update_pmps at :1552-1571 pfsallocs EVERY PFS under the sroot on any mount, so a poison PFS need not even be the mounted label - and a NUL-less name can never match a label anyway because the :1394 scan uses strcmp). Forge: poison PFS inode relocated to the tail of a fresh 64KB window (data_off=WIN|0xFC00|10, block ending exactly at the buffer end), [0x100,0x400) filled with 0x50, mirror_tid=0, CHECK_NONE ancestors, volhdr CRCs recomputed. Observed: (a) instrumented kernel, mapped case: 'DF2624: ... kstrdup result len=772 (516 bytes past array)' - the walk read 768 in-block bytes plus 4 bytes PAST THE 64KB DIO BUFFER END and copied them into pmp->pfs_names[0]; (b) instrumented kernel, unmapped case: 'Fatal trap 12 ... panic: page fault' with backtrace 'strlen() at strlen+0x14 <- hammer2_pfsalloc() at +0x157', fault VA page-granular at the DIO buffer end - the faulting strlen is the STOCK kstrdup (the instrumentation only reports after kstrdup returns), so the panic is stock behavior under that heap layout; (c) stock kernel: mount succeeds silently (mapped case), pfs-list shows the poison PFS. Userspace observability of the overread bytes: none found - thread names truncate to MAXCOMLEN=16, pfs-list prints the on-disk name, the vol-list ioctl reads only the mounted pmp's pfs_names[0]. Impact therefore: mount-time kernel panic (layout-dependent, ~1 in 2 on this guest) + silent bounded adjacent-heap overread into a kernel string. fix.diff bounds the copy with strnlen+kmalloc+bcopy and warns; kernel #2: 3/3 mounts succeed, no panic, warning fires each time, no regression.
No comments yet.