NULL deref in direct I/O paths when hammer_get_volume fails β hammer_rel_volume(NULL) unconditional
Summary
hammer_io.c:1487,1561,1726 hammer_get_volume(hmp,vol_no,&error) may return NULL (ENOENT RB_LOOKUP hammer_ondesk.c:430 or hammer_load_volume I/O error :441). :1501,1600,1760 hammer_rel_volume(volume,0) UNCONDITIONAL β no NULL check. hammer_rel_volume (hammer_ondisk.c:533) immediately derefs volume->io.lock = NULL deref panic. if(error==0) guards correctly short-circuit volume->maxbuf_off/ondisk/devvp access but trailing release is unconditional. hammer_blockmap_lookup (hammer.h:1507) skips validation when hammer_verify_zone==0 (default) passes invalid data_offset vol_no through verbatim. Correct NULL-check pattern at hammer_recover.c:1104-1108. Trigger: crafted HAMMER image B-Tree leaf data_offset vol_no >= mounted volume count mount then cat file. DoS only not memory corruption. Fix: if(volume) hammer_rel_volume(volume,0) at 3 call sites + NULL guard in hammer_rel_volume itself.
Discussion (0)
PoC verification
Evidence pack
findings/poc/DF-0792 Β· 12 files| File | Type | Description | Size | |
|---|---|---|---|---|
| corrupt_hammer.c | trigger-source | offline image corruptor: flips vol_no byte of zone-10 leaf entries, recomputes btree node CRC | 4.8 KB | view raw |
| build.sh | build-script | cc -O2 -o corrupt_hammer corrupt_hammer.c | 206 B | view raw |
| run.sh | run-script | end-to-end: newfs image, write file, corrupt, remount, cat -> panic (unpatched) or ENOENT (patched) | 2.5 KB | view raw |
| fix.diff | suggested-fix | NULL guards at 3 io.c call sites + defense-in-depth NULL check in hammer_rel_volume | 1.2 KB | view raw |
| panic.txt | panic-signature | Fatal trap 12 in hammer_rel_interlock+0x20 (movl (%r12),%ebx), fault va=0x0, from cat reading corrupted testfile | 2.2 KB | view raw |
| fix_build.log | build-log | full make nativekernel output for the patched kernel; NK_DONE rc=0 | 5.6 MB | β download |
| fix_run.log | run-log | full run.sh output on the patched #1 kernel; cat returns ENOENT, no panic | 2.1 KB | view raw |
| env.txt | environment | uname, kern.version (#1), kernel sha256, vfs.usermount=0, verify_zone=0 | 511 B | view raw |
| README.md | readme | human-facing reproduction and expected-behaviour table | 3.9 KB | β raw |
| VERDICT.md | verdict | root-cause narrative with path:line citations | 6.1 KB | β raw |
| ../fix_build_combined.log | build-log | Combined 41-finding kernel build (rc=0, -Werror clean) | 5.6 MB | β download |
| ../fix_build_summary.txt | build-summary | Summary of the combined 41-finding kernel build | 826 B | view raw |
DF-0792 β NULL deref in HAMMER direct I/O when hammer_get_volume fails
Verdict
REPRODUCED β Low-severity local DoS (kernel panic via NULL-pointer fetch).
The bug is a real, reachable NULL-pointer dereference in three HAMMER
direct-I/O paths. Triggering it requires an admin to mount a maliciously
crafted HAMMER v1 filesystem image and then read any file whose B-Tree
leaf entries reference a volume number that is not present in the mount.
No memory corruption, no info leak, no privilege escalation β pure DoS
(kernel panic). Validated end-to-end: panic on the unpatched #0 kernel,
clean EIO/ENOENT error return + guest stays up on the single-fix #1
kernel.
How to reproduce
# As root on the DragonFlyBSD guest (vfs.usermount=0 by default).
scp corrupt_hammer.c run.sh build.sh dfbsd:/root/htest/
ssh dfbsd
sh /root/htest/run.sh
run.sh does everything end-to-end:
1. Creates a 2 GiB sparse image and newfs_hammer -V 6 on it (vol_version
6 so the B-Tree node CRC is plain crc32, which the corruptor can
recompute locally).
2. Mounts it, writes a 256 KiB testfile (forces the zone-10 large-data
direct-I/O path), syncs, unmounts.
3. Runs corrupt_hammer, which scans the offline image for B-Tree leaf
nodes (validated by recomputing the node CRC), finds every zone-10
leaf entry whose data_len >= 16 KiB, and flips the vol_no byte of
data_offset from 0 to 7 (any value not in the mount). The B-Tree
node CRC is then recomputed so the kernel's
hammer_crc_test_btree() check (hammer_ondisk.c:1315) does not
reject the node before reaching the buggy call site.
4. Re-mounts the now-malicious image and cats testfile.
Expected
| Kernel | Behaviour |
|---|---|
Unpatched #0 (audit base) |
Kernel panic; guest dies in DDB. |
Stopped at hammer_rel_interlock+0x20: movl (%r12),%ebx |
|
Patched #1 (this fix.diff) |
cat: ...: No such file or directory (ENOENT from the |
missing volume). Guest stays up. dmesg shows repeated |
|
hammer_io_direct_read: failed @ 2070000022000000. |
Trigger realism
vfs.usermount=0 on this guest, so mounting a HAMMER image requires
root. The realistic threat is "admin mounts a HAMMER image obtained from
an untrusted source" (USB stick, downloaded snapshot, restore-from-backup
of unknown provenance). HAMMER is no longer the default filesystem
(HAMMER2 is), but HAMMER v1 is still supported, so an admin can be
presented with one. The default vfs.hammer.verify_zone=0 means the
blockmap lookup does not validate the zone-2 vol_no before it reaches
hammer_get_volume, so the malicious value is delivered verbatim.
Files in this folder
| File | What |
|---|---|
corrupt_hammer.c |
offline image corruptor: flips vol_no byte + rebuilds btree node CRC |
build.sh |
cc -O2 -o corrupt_hammer corrupt_hammer.c |
run.sh |
end-to-end trigger (newfs, mount, write, corrupt, remount, cat) |
fix.diff |
git-apply-able unified diff: NULL guards at 3 io.c call sites + defense-in-depth NULL check in hammer_rel_volume itself |
panic.txt |
serial-console panic signature from the unpatched run |
fix_build.log |
full make nativekernel output for the patched kernel |
fix_run.log |
full run.sh output on the patched kernel (no panic) |
env.txt |
guest uname, kern.version, kernel sha256, sysctl state |
VERDICT.md |
human-readable root-cause narrative |
manifest.json |
machine-readable artifact catalog |
DF-0792 β VERDICT
Status: REPRODUCED (Low-severity local DoS / kernel panic via NULL-pointer fetch)
Fix: VALIDATED (panic on #0, clean error return + guest stays up on #1)
Class: NULL-pointer dereference (CWE-476). No memory corruption, no info leak, no privesc.
Mechanism (trigger β primitive β effect)
Three direct-I/O paths in sys/vfs/hammer/hammer_io.c follow the same
shape: look up the volume by vol_no, do the I/O if no error, and
unconditionally release the volume reference afterwards β even when
the lookup just returned NULL with an error set.
Call site 1 β hammer_io_direct_read (sys/vfs/hammer/hammer_io.c:1487-1501)
vol_no = HAMMER_VOL_DECODE(zone2_offset);
volume = hammer_get_volume(hmp, vol_no, &error); /* may return NULL */
if (error == 0 && zone2_offset >= volume->maxbuf_off) /* guarded */
error = EIO;
if (error == 0) {
/* ...uses volume... */ /* guarded */
}
hammer_rel_volume(volume, 0); /* BUG: unconditional */
Call site 2 β hammer_io_indirect_read (sys/vfs/hammer/hammer_io.c:1561-1600)
Same shape; same trailing hammer_rel_volume(volume, 0) at line 1600.
Call site 3 β hammer_io_direct_write (sys/vfs/hammer/hammer_io.c:1726-1760)
Same shape; trailing release at line 1760.
Why NULL is reachable
hammer_get_volume (sys/vfs/hammer/hammer_ondisk.c:421-448) returns
NULL with *errorp = ENOENT whenever vol_no is not in the in-memory
volume RB tree:
volume = RB_LOOKUP(hammer_vol_rb_tree, &hmp->rb_vols_root, vol_no);
if (volume == NULL) {
*errorp = ENOENT;
return(NULL);
}
It also returns NULL when hammer_load_volume fails (*errorp set,
volume = NULL at line 442).
vol_no is decoded from a zone-2 offset (HAMMER_VOL_DECODE extracts
bits 52-59) that came from hammer_blockmap_lookup
(sys/vfs/hammer/hammer.h:1508-1526). The default
vfs.hammer.verify_zone == 0 (verified on this guest) skips
hammer_blockmap_lookup_verify and just translates the zone-X offset
to zone-2 verbatim, so an attacker-controlled data_offset in a B-Tree
leaf entry delivers an arbitrary vol_no straight into
hammer_get_volume. The leaf entry's data_offset lives on disk and
is attacker-controllable in a crafted image.
Why the deref crashes
hammer_rel_volume (sys/vfs/hammer/hammer_ondesk.c:528-542) immediately
dereferences volume:
void
hammer_rel_volume(hammer_volume_t volume, int locked)
{
struct buf *bp;
if (hammer_rel_interlock(&volume->io.lock, locked)) { /* NULL deref */
...
}
}
The first instruction of hammer_rel_interlock is a load from
&volume->io.lock, which at volume == NULL is a small positive offset
from zero. The kernel faults; on this build it lands in DDB:
Fatal user address access from kernel mode from cat at ffffffff80951940 Fatal trap 12: page fault while in kernel mode fault virtual address = 0x0 Stopped at hammer_rel_interlock+0x20: movl (%r12),%ebx
The if (error == 0) guards are correct but incomplete
The reviewer's claim that the volume is dereferenced via
volume->maxbuf_off / volume->ondisk / volume->devvp only inside
if (error == 0) blocks is correct β those are properly guarded. The
bug is only the trailing, unconditional release.
Confirmed reproduction
run.sh builds a 2 GiB HAMMER v1 (-V 6) image, writes a 256 KiB file,
takes it offline, flips the vol_no byte of every zone-10 leaf entry
from 0 to 7 (recomputing the B-Tree node CRC so
hammer_crc_test_btree at hammer_ondisk.c:1315 doesn't reject the
node), re-mounts, and cats the file.
- Unpatched
#0kernel: kernel panic, guest dies in DDB. Seepanic.txt. - Patched
#1kernel (withfix.diff):catreturnsNo such file or directory(the ENOENT from the missing volume, propagated up), guest stays up.dmesgshowshammer_io_direct_read: failed @ 2070000022000000(thehdkprintf("failed @ %016jx\n", ...)athammer_io.c:1504, now reached only after the guarded release is skipped). Seefix_run.log.
Exploit chain
Not applicable. This is a NULL-pointer fetch β it crashes the kernel cleanly with no write primitive, no info leak, no privilege change. There is no escalation chain to develop. The realistic impact ceiling is local DoS of an admin who mounts an untrusted HAMMER image.
Fix
fix.diff makes the release conditional at all three call sites and
also adds a defense-in-depth NULL check inside hammer_rel_volume
itself (so any other caller that ever passes NULL cannot reintroduce
the panic). This matches the existing correct pattern in
hammer_recover.c:1082 (if (volume == NULL) ... break;).
sys/vfs/hammer/hammer_io.c:1501βif (volume != NULL) hammer_rel_volume(volume, 0);sys/vfs/hammer/hammer_io.c:1600β same guardsys/vfs/hammer/hammer_io.c:1760β same guardsys/vfs/hammer/hammer_ondesk.c:533βif (volume == NULL) return;
The fix is minimal and targeted at the root cause; it does not change the error handling or volume lookup, only the unconditional release.
PoC changes
There was no pre-existing PoC source for DF-0792 in the repo, so I
authored corrupt_hammer.c, build.sh, and run.sh from scratch.
The corruptor recomputes the B-Tree node CRC (crc32 for vol_version
<= 6) so the kernel does not reject the node before reaching the buggy
call site β without this step the kernel's hammer_crc_test_btree
check at hammer_ondisk.c:1315 would return EIO before the bug could
fire, masking the bug entirely.
Notes for the maintainer
- The trigger requires root to mount the image (
vfs.usermount=0). This is consistent with the Low severity rating. The realistic threat is an admin mounting an untrusted HAMMER image (USB, downloaded snapshot, restore of unknown provenance). - HAMMER is no longer the default filesystem (HAMMER2 is), but HAMMER v1 is still fully supported, so admins can still be presented with such images.
- The same defensive NULL check is independently useful in
hammer_rel_volumeitself β it converts any future bug of this shape into a silent no-op rather than a panic, at zero cost.
Fix verification
fixedVALIDATED: baseline NULL deref panic at hammer_rel_interlock+0x20; patched ENOENT, guest up.
BEFORE: Fatal trap 12 at hammer_rel_interlock+0x20, va=0x0, guest down. AFTER: cat ENOENT, guest up, dmesg 'hammer_io_direct_read: failed'.
Confirmed kernel references
- sys/vfs/hammer/hammer_io.c:1487
- sys/vfs/hammer/hammer_io.c:1501
- sys/vfs/hammer/hammer_io.c:1561
- sys/vfs/hammer/hammer_io.c:1600
- sys/vfs/hammer/hammer_io.c:1726
- sys/vfs/hammer/hammer_io.c:1760
- sys/vfs/hammer/hammer_ondisk.c:421
- sys/vfs/hammer/hammer_ondisk.c:428
- sys/vfs/hammer/hammer_ondisk.c:528
- sys/vfs/hammer/hammer_ondisk.c:533
- sys/vfs/hammer/hammer.h:1508
- sys/vfs/hammer/hammer_recover.c:1082
Detail
Exploit chain
none. Pure NULL-pointer read fetch (CWE-476). No write, no info leak. Local DoS.
Evidence (decisive lines)
BASELINE: Fatal trap 12 at hammer_rel_interlock+0x20 (movl (%r12),%ebx), fault va=0x0, guest down. PATCHED: cat returns ENOENT, guest up, dmesg shows 'hammer_io_direct_read: failed' (error propagated, no panic).
PoC changes
Authored corrupt_hammer.c (offline image corruptor: flips vol_no byte of zone-10 B-Tree leaves + recomputes node CRC), run.sh (end-to-end: newfs_hammer + mount + write + unmount + corrupt + remount + cat). Added fix.diff, build.sh, VERDICT.md, manifest.json.
Verified recommended fix
Add if(volume!=NULL) guards before hammer_rel_volume at hammer_io.c:1501/1600/1760, plus defense-in-depth NULL check in hammer_rel_volume itself at hammer_ondisk.c:533. Full git-apply-able diff in findings/poc/DF-0792/fix.diff.
Verdict
REPRODUCED. hammer_io_direct_read/indirect_read/direct_write call hammer_rel_volume(volume,0) unconditionally after hammer_get_volume may return NULL (vol_no from crafted image). hammer_rel_volume derefs &volume->io.lock -> page fault at va=0x0. Crafted HAMMER v1 image, root-only mount.
No comments yet.