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.
Recommended fix
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)
PoC verification
Evidence pack
findings/poc/DF-2647 Β· 26 files| File | Type | Description | Size | |
|---|---|---|---|---|
| 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_lenis on-media, uint16_t (hammer2_disk.h:957) β fully attacker-controlled by a crafted filesystem image, range 0..65535.pfs->nameis 256 bytes (hammer2_ioctl.h:103, NAME_MAX+1).KKASSERTcompiles 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->nameends 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 β pastpfs->name[], with content taken frominode+0x100onwards. When the PFS inode is placed low in its 64KB DIO window the entire smear source is attacker-controlled image bytes (the forger stampsDF2647!!). - 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=0on 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
- Build the base image (guest, root):
sh mkbase2647.shβ 64MB hammer2 volume "testvol" + PFS "zz_pwn". - 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/. - Build trigger:
cc -O2 -o pfsget_scan pfsget_scan.c(alsopfslookup_victim.c). - 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). - 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 theDF2647!!marker after copyout (controlled cross-object write); the corrupted free-lists eventually detonate asFatal trap 9 ... Stopped at _kmalloc(panic_noinv.txt). * catastrophic:sh run_catastrophic.shwith the 0xFFFF image β single ~65KB controlled smear. - 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.
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)
-
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:494with tracesyscall2 β mapped_ioctl β vn_ioctl β vop_ioctl β hammer2_ioctl(panic_inv.txt). Deterministic; proves the attacker controls the length that reaches the site. -
Non-INVARIANTS kernel (config
DF2647_NOINV= X86_64_GENERIC minusoptions INVARIANTS+ minusDEBUG=-g; assertion strings absent from the built kernel β verified viastrings): *name_len=0x300, 40 scan iterations β scans complete silently, guest stays up, clean unmount (run_surgicaloutput 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. -
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
sys/vfs/hammer2/hammer2_ioctl.c:487-497β the vulnerable copysys/vfs/hammer2/hammer2_ioctl.h:92-106β hammer2_ioc_pfs layout (name[NAME_MAX+1] = 256, last member, struct = 320 B)sys/vfs/hammer2/hammer2_disk.h:957β name_len is uint16_t (media)sys/vfs/hammer2/hammer2_disk.h:1012β filename[256] @ inode+0x100sys/kern/sys_generic.c:668-697β kmalloc'd M_IOCTLOPS ioctl buffersys/vfs/hammer2/hammer2_vfsops.c:495β mount tolerates hostile name_len (kstrdup only needs the embedded NUL we preserve)
Reproduce
See README.md. Guest left in clean state (vm.sh reset with-src).
Fix verification
fixedfix.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
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.
No comments yet.