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

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.

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

No comments yet.

PoC verification

Evidence pack

findings/poc/DF-2624 Β· 18 files
FileTypeDescriptionSize
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

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

  1. 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.
  2. Host: python3 forge_2624.py base2624.img craft2624.img * relocates the poison PFS inode block to WIN|0xFC00|10 (1024-byte block ending exactly at the 64KB buffer end), * fills [0x100,0x400) (the whole filename[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.
  3. Push craft2624.img to 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.

VERDICT.md
↓ download raw

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, but kstrdup re-derives the length with strlen β€” 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 strnlen bound, and HAMMER2_INODE_MAXNAME is 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

  1. 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-list shows the poison PFS (PPPPP β€” the on-disk view via meta.name_len=5).
  2. 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 into pmp->pfs_names[0] of the poison pmp.
  3. Instrumented kernel #1, unmapped case (console_excerpts.txt [B]): Fatal trap 12 … panic: page fault with backtrace strlen() 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 stock kstrdup at :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

fixed
baseline reproduced→ patch + rebuild →patched clean

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

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.