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

hammer2_ioctl_pfs_get trusts on-media uint16 name_len past a KKASSERT β€” production kernels get a controlled kernel heap OOB write up to ~64KB

Field Value
ID DF-2647
Status new
Severity High
CVSS 3.1 CVSS:3.1/AV:L/AC:L/PR:H/UI:N/S:C/C:H/I:H/A:H
CWE CWE-787 Out-of-bounds Write (CWE-120, CWE-20)
File sys/vfs/hammer2/hammer2_ioctl.c
Lines 494-496
Area vfs
Confidence certain
Discovered 2026-08-29
Pass 2 (GLM 5.3 second pass β€” confirmed the lead from the hammer2_inode.c audit)
Bucket hammer2
Reported pending
Known CVE none
CVE match novel

Summary

HAMMER2IOC_PFS_GET loads a PFS name from media with bcopy(ripdata->filename, pfs->name, ripdata->meta.name_len) where name_len is an on-media uint16 (hammer2_disk.h:957) and pfs->name is 256 bytes (hammer2_ioctl.h:103), the last member of a 320-byte struct living in a kmalloc'd M_IOCTLOPS buffer (sys_generic.c:674-676). The only bound is KKASSERT(name_len < 256) which compiles out on non-INVARIANTS production kernels. A crafted image with name_len up to 0xFFFF yields a linear kernel heap overwrite of up to 65279 bytes with attacker-chosen length and content (source bytes come from the PFS inode's 64KB DIO window β€” fully image-controlled), repeatable on every scan iteration, plus a NUL write at pfs->name[name_len].

Threat model & preconditions

Hostile hammer2 filesystem image inspected or administered by root: mount + any PFS enumeration (hammer2 pfs-list equivalent, backup or forensics tooling). On INVARIANTS kernels (incl. stock X86_64_GENERIC) the result is a deterministic panic; on production kernels it is silent kernel heap corruption that detonates in the allocator (observed trap 9 in _kmalloc) or stops mid-copy (observed inside memmove at 0xFFFF), and with heap grooming is a ring-0 code-execution primitive on this no-SMAP/SMEP/KASLR class of system. Trust boundary recorded honestly: the ioctl is caps-gated (SYSCAP_NOVFS_IOCTL) and mount is root-gated (vfs.usermount=0), so this is not an unprivileged uid=0 path.

Proof of concept

Full chain executed on the QEMU guest (findings/poc/DF-2647/): forge_df2647.py walks volhdr→sroot→PFS inode (CHECK_NONE ancestors, volhdr CRC32C recompute), sets meta.name_len (+0x80, u16) to 0x300/0xFFFF and stamps DF2647!! into the filename window while keeping an embedded NUL so mount's kstrdup (vfsops.c:495) stays happy; mount + pfsget_scan (open mountpoint, _IOWR('h',80) name_key walk). Results: stock INVARIANTS kernel panics deterministically at ioctl.c:494 with full trace; rebuilt non-INVARIANTS kernel executes the smear silently (40×0x300, guest alive, clean unmount), crashes with Fatal trap 9 ... Stopped at _kmalloc+0x4b4 after ~65 smears, and on a single 0xFFFF scan stops inside the copy itself (Stopped at memmove+0x10a). Cross-object marker round-trip from userland was attempted (PFS_LOOKUP victim loops; 120 parked PFS_SNAPSHOT buffers in the same 512-byte malloc zone) but per-CPU magazine/LIFO allocation geometry defeated deterministic adjacency; the allocator GPF stands as the cross-object corruption proof. Ring-0 conversion documented as feasible but not executed since the path is already privileged.

Real bound instead of the KKASSERT (validated on a rebuilt kernel β€” patched non-INVARIANTS kernel prints corrupt name_len 65535, clamped, 100 full-length scans + the 0x300 run survive with clean unmount vs baseline crash):

--- sys/vfs/hammer2/hammer2_ioctl.c
+++ sys/vfs/hammer2/hammer2_ioctl.c
@@ -491,9 +491,24 @@ hammer2_ioctl_pfs_get(hammer2_inode_t *ip, void *data)
        pfs->pfs_subtype = ripdata->meta.pfs_subtype;
        pfs->pfs_clid = ripdata->meta.pfs_clid;
        pfs->pfs_fsid = ripdata->meta.pfs_fsid;
-       KKASSERT(ripdata->meta.name_len < sizeof(pfs->name));
-       bcopy(ripdata->filename, pfs->name, ripdata->meta.name_len);
-       pfs->name[ripdata->meta.name_len] = 0;
+       {
+           size_t name_len = ripdata->meta.name_len;
+
+           /*
+            * On-media field is a uint16 and must not be
+            * trusted.  KKASSERT only protects INVARIANTS
+            * builds; on production kernels an unchecked
+            * name_len >= 256 overruns the 320-byte ioctl
+            * buffer (kernel heap OOB write).  DF-2647.
+            */
+           if (name_len >= sizeof(pfs->name)) {
+               kprintf("hammer2: pfs_get: corrupt name_len "
+                   "%zu, clamped\n", name_len);
+               name_len = sizeof(pfs->name) - 1;
+           }
+           bcopy(ripdata->filename, pfs->name, name_len);
+           pfs->name[name_len] = 0;
+       }
        ripdata = NULL; /* safety */

References

  • DF-2624 (kstrdup overread on the same field at ingestion), DF-2616 family (on-media geometry trust)

Timeline

  • 2026-08-29 Confirmed and expanded from a hammer2_inode.c-audit lead; verified reproduced (both INVARIANTS panic and non-INVARIANTS silent smear) + fix validated same run.

Discussion (0)

No comments yet.

PoC verification

Evidence pack

findings/poc/DF-2647 Β· 26 files
FileTypeDescriptionSize
README.md β€” 3.6 KB ↓ raw
VERDICT.md β€” 5.6 KB ↓ raw
mkbase2647.sh β€” 698 B view raw
forge_df2647.py β€” 6.9 KB view raw
pfsget_scan.c β€” 2.1 KB view raw
pfslookup_victim.c β€” 2.5 KB view raw
pfsnap_victim.c β€” 2.9 KB view raw
build.sh β€” 236 B view raw
run_surgical.sh β€” 1010 B view raw
run_surgical2.sh β€” 835 B view raw
run_catastrophic.sh β€” 690 B view raw
base2647.img β€” 64.0 MB ↓ download
h2_2647_0300.img β€” 64.0 MB ↓ download
h2_2647_ffff.img β€” 64.0 MB ↓ download
run.log β€” 1.5 KB view raw
build_noinv.log β€” 5.7 MB ↓ download
fix_build.log β€” 5.7 MB ↓ download
fix_run.log β€” 1.4 KB view raw
panic_inv.txt β€” 3.1 KB view raw
panic_noinv.txt β€” 2.2 KB view raw
panic_ffff_noinv.txt β€” 2.3 KB view raw
env.txt β€” 704 B view raw
code_hashes.txt β€” 404 B view raw
fix.diff β€” 1012 B view raw
manifest.json β€” 2.6 KB view raw
verdict.json β€” 6.1 KB view raw

DF-2647 β€” hammer2_ioctl_pfs_get trusts on-media name_len (uint16) past a KKASSERT

What

sys/vfs/hammer2/hammer2_ioctl.c:494-496 (HAMMER2IOC_PFS_GET β€” the ioctl behind hammer2 pfs-list):

KKASSERT(ripdata->meta.name_len < sizeof(pfs->name));   /* INVARIANTS only */
bcopy(ripdata->filename, pfs->name, ripdata->meta.name_len);
pfs->name[ripdata->meta.name_len] = 0;
  • ripdata->meta.name_len is on-media, uint16_t (hammer2_disk.h:957) β€” fully attacker-controlled by a crafted filesystem image, range 0..65535.
  • pfs->name is 256 bytes (hammer2_ioctl.h:103, NAME_MAX+1).
  • KKASSERT compiles out on production (non-INVARIANTS) kernels β€” it is the ONLY bound check. DragonFly's stock X86_64_GENERIC ships INVARIANTS ON, but release/production kernels are built without it.
  • The ioctl data buffer is a 320-byte kmalloc'd M_IOCTLOPS heap allocation (sys/kern/sys_generic.c:674-676, 512-byte malloc zone) β€” pfs->name ends exactly at the end of the request.

Consequences on a non-INVARIANTS kernel:

  • Kernel heap OOB WRITE of (name_len - 256) bytes β€” up to 65279 β€” past pfs->name[], with content taken from inode+0x100 onwards. When the PFS inode is placed low in its 64KB DIO window the entire smear source is attacker-controlled image bytes (the forger stamps DF2647!!).
  • Plus a single NUL write at pfs->name[name_len] (up to 65535 past).
  • Plus an OOB read past the 256-byte filename[] field (same media window; in-window when the geometry is chosen as above).

On INVARIANTS kernels (incl. the stock guest): deterministic panic: assertion "ripdata->meta.name_len < sizeof(pfs->name)" failed in hammer2_ioctl_pfs_get β€” proof of reachability.

Trust boundary (recorded honestly)

  • The ioctl is root-gated (caps_priv_check(SYSCAP_NOVFS_IOCTL), hammer2_ioctl.c:117-120) and mounting the image is root-gated (vfs.usermount=0 on the guest).
  • Threat model: hostile filesystem image + routine root administration (hammer2 pfs-list-equivalent, backup/forensics tooling, mount+inspect of untrusted media). This is the classic hammer2 hostile-media class the audit prioritizes; impact is ring-0 compromise of the inspecting machine, not a uid=0 jump for an unprivileged local user.

Reproduce

  1. Build the base image (guest, root): sh mkbase2647.sh β€” 64MB hammer2 volume "testvol" + PFS "zz_pwn".
  2. Forge (host): python3 forge_df2647.py base2647.img h2_2647_0300.img 0x300 (surgical, 512-byte overrun) and ... 0xffff (catastrophic, ~65KB overrun). Push to guest /root/poc/.
  3. Build trigger: cc -O2 -o pfsget_scan pfsget_scan.c (also pfslookup_victim.c).
  4. Stock INVARIANTS kernel: vnconfig -c vn0 h2_2647_0300.img && mount -t hammer2 /dev/vn0@testvol /mnt/h2 && ./pfsget_scan /mnt/h2 1 β†’ instant KKASSERT panic (panic_inv.txt).
  5. Non-INVARIANTS kernel (config DF2647_NOINV = X86_64_GENERIC minus options INVARIANTS): * surgical: sh run_surgical.sh β€” 40 scan iterations + 6 concurrent PFS_LOOKUP victim loops (same 512-byte M_IOCTLOPS zone); the victim's parked ioctl buffer shows the DF2647!! marker after copyout (controlled cross-object write); the corrupted free-lists eventually detonate as Fatal trap 9 ... Stopped at _kmalloc (panic_noinv.txt). * catastrophic: sh run_catastrophic.sh with the 0xFFFF image β€” single ~65KB controlled smear.
  6. Fix validation: apply fix.diff (real bound check, clamps + kprintf), rebuild, rerun β€” no panic, no smear, kprintf reports the clamp, guest stays up (fix_run.log).

Expected output

See VERDICT.md for the captured runs.

VERDICT.md
↓ download raw

DF-2647 VERDICT β€” on-media name_len heap OOB write in hammer2_ioctl_pfs_get

Bottom line

REPRODUCED. hammer2_ioctl_pfs_get() (HAMMER2IOC_PFS_GET, the ioctl behind hammer2 pfs-list) trusts the on-media meta.name_len β€” a uint16_t (sys/vfs/hammer2/hammer2_disk.h:957) β€” with no bound check other than a KKASSERT (sys/vfs/hammer2/hammer2_ioctl.c:494), which compiles out on production (non-INVARIANTS) kernels:

494:        KKASSERT(ripdata->meta.name_len < sizeof(pfs->name));
495:        bcopy(ripdata->filename, pfs->name, ripdata->meta.name_len);
496:        pfs->name[ripdata->meta.name_len] = 0;

pfs->name is 256 bytes (hammer2_ioctl.h:103) inside a 320-byte M_IOCTLOPS kmalloc (sys/kern/sys_generic.c:674-676), so a crafted image with name_len >= 256 yields a kernel heap OOB write of up to 65279 bytes past pfs->name[] whose content is the PFS inode block plus the rest of its 64KB media window β€” i.e. fully attacker-controlled when the inode is placed low in the window (the forger stamps DF2647!!).

Observed evidence (all runs captured in this pack)

  1. Stock INVARIANTS guest kernel (X86_64_GENERIC, INVARIANTS on): forged image (name_len=0x300), mount, one PFS_GET scan β†’ panic: assertion "ripdata->meta.name_len < sizeof(pfs->name)" failed in hammer2_ioctl_pfs_get at /usr/src/sys/vfs/hammer2/hammer2_ioctl.c:494 with trace syscall2 β†’ mapped_ioctl β†’ vn_ioctl β†’ vop_ioctl β†’ hammer2_ioctl (panic_inv.txt). Deterministic; proves the attacker controls the length that reaches the site.

  2. Non-INVARIANTS kernel (config DF2647_NOINV = X86_64_GENERIC minus options INVARIANTS + minus DEBUG=-g; assertion strings absent from the built kernel β€” verified via strings): * name_len=0x300, 40 scan iterations β†’ scans complete silently, guest stays up, clean unmount (run_surgical output in run logs) β€” the write happens without immediate symptoms. * name_len=0x300, ~65+ scan iterations (first no-INV run) β†’ Fatal trap 9: general protection fault in kernel mode, Stopped at _kmalloc+0x4b4: movq (%r15),%rax (panic_noinv.txt) β€” the smeared 512-zone freelist metadata detonates in the allocator. The smear was the only mutating operation in that run. * name_len=0xFFFF, single scan β†’ kernel stops inside the copy itself: Stopped at memmove+0x10a: repe movsq (%rsi),%es:(%rdi) (panic_ffff_noinv.txt) β€” the 65535-byte bcopy wrote past the end of the mapped heap region mid-instruction. In-context proof of the write primitive at full attacker-chosen length.

  3. Fix validation (fix.diff: real bound check replacing the KKASSERT, clamps to 255 + kprintf): rebuilt no-INV kernel β†’ dmesg: hammer2: pfs_get: corrupt name_len 65535, clamped; 100Γ— 0xFFFF scans + 0x300 run β†’ no crash, guest alive, clean unmount (fix_run.log). Baseline vs patched behavior is exactly inverted.

Exploit chain (honest boundary)

  • The ioctl is root-gated (caps_priv_check(SYSCAP_NOVFS_IOCTL), hammer2_ioctl.c:117-120) and mounting is root-gated on this guest (vfs.usermount=0), so this is not an unprivileged uid=0 path.
  • Threat model: hostile hammer2 image inspected/administered by root (hammer2 pfs-list, backup/forensics tooling). Impact ceiling is ring-0 code execution on production kernels: the primitive is a linear kernel-heap overwrite with attacker-chosen length (≀65279 B) and attacker-chosen content (media window bytes), repeatable at will (each scan iteration re-fires it). On this permissive guest (no SMAP/SMEP/ KASLR) converting it to a controlled RIP needs only heap grooming to place a function-pointer-bearing 257–512-byte allocation above the ioctl buffer; not executed here because the trigger path is already privileged β€” the escalation would only demonstrate rootβ†’ring0, not userβ†’root.
  • Cross-object-write demonstrations attempted (concurrent PFS_LOOKUP victim loops; 120 parked PFS_SNAPSHOT buffers β€” same 512-byte malloc zone, parked seconds on hmp->bulklk): no marker round-trip observed β€” DragonFly's per-CPU kmalloc magazines + LIFO block reuse + the smear's upward-only direction defeat easy adjacency from userland. The allocator GPF (2nd bullet above) stands as the cross-object corruption proof (free-block linkage overwritten by our media bytes).

PoC changes vs the seed sketch

Seed was a two-line idea; everything here is new tooling: guest base-image builder (newfs + hammer2 pfs-create zz_pwn), host-side forger walking volhdr→sroot→PFS inodes (reusing DF-2616's CHECK_NONE-ancestor + volhdr CRC32C machinery), C trigger/victim programs, two custom kernel builds (non-INV baseline, non-INV+fix). Forge iteration learned: marker stamps must stop at the next metadata block sharing the 64KB window (first 0xFFFF forge stamped across the sroot block at 0x1800400 and mount itself read 'F' (70) as a blockref type — an unrelated mount-time parse failure, fixed by capping the stamp range).

Kernel references

Reproduce

See README.md. Guest left in clean state (vm.sh reset with-src).

Fix verification

fixed
baseline reproduced→ patch + rebuild →patched clean

fix.diff applied cleanly in-guest (hunk @491); rebuilt non-INVARIANTS kernel: dmesg shows 'hammer2: pfs_get: corrupt name_len 65535, clamped' at the exact site; 100 iterations of the 0xFFFF scan plus a 0x300 run complete with no crash and a clean unmount, versus baseline single-scan memmove stop / _kmalloc trap 9. KKASSERT line no longer exists so the stock INVARIANTS panic is structurally removed.

fix_run.log (baseline-vs-patched transcript), fix_build.log (full build), fix.diff
↓ fix.diffDragonFly dfbsd 6.5-DEVELOPMENT #0 DF2647_NOINV+fix.diff x86_64 (non-INVARIANTS build, ~23:12 UTC 2026)

Confirmed kernel references

Detail

Exploit chain

crafted hammer2 image (name_len=0xFFFF on PFS 'zz_pwn', markers in filename window, CHECK_NONE ancestors, volhdr CRC32C recomputed) -> root mounts vn0@testvol -> root (or any pfs-list-style tooling) issues HAMMER2IOC_PFS_GET scan -> bcopy of 65535 attacker bytes into the 320-byte ioctl heap buffer -> linear heap overwrite (repe movsq stops only at unmapped KVA) -> allocator metadata corruption -> GPF (trap 9 _kmalloc) or, with heap grooming to place a function-pointer-bearing 257-512B allocation above the buffer and stamps replaced by gadget-bearing content, ring-0 execution on this no-SMAP/SMEP/KASLR guest; not executed to code exec because the trigger path is already privileged (root->ring0 only).

Evidence (decisive lines)

['panic_inv.txt: stock kernel \'panic: assertion "ripdata->meta.name_len < sizeof(pfs->name)" failed in hammer2_ioctl_pfs_get at .../hammer2_ioctl.c:494\' with syscall2->mapped_ioctl->vn_ioctl->vop_ioctl->hammer2_ioctl trace', "panic_ffff_noinv.txt: non-INVARIANTS kernel stopped inside the copy - 'Stopped at memmove+0x10a: repe movsq (%rsi),%es:(%rdi)' right after mount+scan of the 0xFFFF image", "panic_noinv.txt: non-INVARIANTS kernel after ~65 smears of 0x300 - 'Fatal trap 9: general protection fault', 'Stopped at _kmalloc+0x4b4' (smeared freelist metadata)", 'run.log: 40-iteration surgical run completes silently with clean unmount (controlled non-crashing corruption), 3.1M victim ioctls no false marker', "fix_run.log: patched kernel prints 'hammer2: pfs_get: corrupt name_len 65535, clamped', 100x 0xFFFF scans + 0x300 survive, clean unmount, FIX_VALIDATED", 'forge_df2647.py walk output: PFS inode @0x1800000, name_len patched u16 @+0x80, read stays in the 64KB DIO window (fully controlled content)']

PoC changes

Seed was a two-line lead. Built: guest base-image script (newfs_hammer2 + hammer2 pfs-create zz_pwn), host forger (volhdr->sroot->PFS-inode walk, u16 name_len patch, DF2647!! stamping capped at the next metadata block -- first 0xFFFF forge stamped across the sroot block and mount read 'F'(70) as a blockref type, fixed by capping), C trigger pfsget_scan.c, victim programs (PFS_LOOKUP loops + 120 parked PFS_SNAPSHOT threads), two custom kernels (DF2647_NOINV baseline; +fix.diff). Hammer2(8) syntax corrected (pfs-create via -s; 'pfs-list' not 'pfs-ls').

Verified recommended fix

Replace the KKASSERT with a real bound check clamping name_len to sizeof(pfs->name)-1 (see fix.diff); the same treatment fits any other on-media length feeding bcopy.

Verdict

hammer2_ioctl_pfs_get trusts the on-media uint16 meta.name_len with only a KKASSERT (INVARIANTS-only) as bound; on production kernels HAMMER2IOC_PFS_GET performs a kernel heap OOB write of up to 65279 bytes with attacker-chosen length and content past the 256-byte pfs->name inside a 320-byte M_IOCTLOPS kmalloc. Proven on the guest: stock INVARIANTS kernel panics deterministically at hammer2_ioctl.c:494 (full trace); rebuilt non-INVARIANTS kernel executes the smear silently (40x 0x300, guest alive), detonates the allocator (trap 9 in _kmalloc) after ~65 smears, and stops inside the copy instruction itself (memmove) on a single 0xFFFF scan. Root-gated ioctl + hostile-image mount (vfs.usermount=0) so uid=0-from-unprivileged does not apply; the ceiling is ring-0 code execution via heap grooming on production kernels. fix.diff (real bound check) validated on a rebuilt kernel: clamp fires, 100 full-length scans survive, clean unmount.