NULL deref in h2_bulkfree_sync freemap lookup error path β live_chain->error deref when live_chain is NULL
Summary
hammer2_bulkfree.c:1039-1045 hammer2_chain_lookup(&live_parent,...&error). When ANY FREEMAP_NODE in hierarchy has CRC/I/O error lookup returns NULL with *errorp=parent->error (hammer2_chain.c:2473-2476). :1046 if(error) true. :1050 kprintf(...hammer2_error_str(live_chain->error)) β live_chain is NULL derefs NULL+offsetof(error) = panic. :1054 if(live_chain==NULL) proves developer expected NULL. Should use local error variable not live_chain->error. BULKFREE_SCAN ioctl missing privilege gate (DF-0815) so unprivileged can trigger. Trigger: crafted HAMMER2 image with corrupted FREEMAP_NODE CRC mount then BULKFREE_SCAN ioctl. Fix: hammer2_error_str(error) not live_chain->error.
Discussion (0)
PoC verification
Evidence pack
findings/poc/DF-0817 Β· 16 files| File | Type | Description | Size | |
|---|---|---|---|---|
| df0817.c | trigger-source | unprivileged BULKFREE_SCAN trigger (uses DF-0815 privilege bypass) | 5.4 KB | view raw |
| setup_image.sh | setup-script | root-side: newfs 8GB image, populate with 6GB urandom, corrupt all FREEMAP_NODE rotation slots, mount RO | 3.9 KB | view raw |
| build.sh | build-script | cc -O2 -o df0817 df0817.c | 95 B | view raw |
| run.sh | run-script | ./df0817 <mount> | 305 B | view raw |
| panic.txt | panic-signature | fatal trap 12, fault virtual address=0x170 (NULL + offsetof(hammer2_chain_t,error)), Stopped at hammer2_bulkfree_pass+0xdbe | 2.3 KB | view raw |
| baseline_run.log | run-log | decisive baseline run output (unpatched #0): panic signature | 934 B | view raw |
| baseline_boot.log | boot-log | full serial log around the baseline panic | 15.2 KB | view raw |
| fix_run.log | run-log | decisive patched-#1 run output: ioctl returns EDOM, no panic, bulkfree completes | 1.0 KB | view raw |
| fix_kernel_info.txt | environment | patched kernel kern.version (#1) + sha256 | 305 B | view raw |
| fix_build.log | build-log | full single-fix nativekernel build output (rc=0) | 5.6 MB | β download |
| fix.diff | suggested-fix | one-line: hammer2_error_str(error) instead of hammer2_error_str(live_chain->error) at hammer2_bulkfree.c:1050 | 404 B | view raw |
| env.txt | environment | guest uname, cc version, sysctls | 885 B | view raw |
| VERDICT.md | verdict | full narrative: mechanism, evidence, fix validation | 10.2 KB | β raw |
| README.md | readme | claim, build/run, expected output | 4.0 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-0817 β NULL deref in h2_bulkfree_sync freemap lookup error path
Claim (Medium, CWE-476 NULL Pointer Dereference)
h2_bulkfree_sync() (sys/vfs/hammer2/hammer2_bulkfree.c:1039-1052) calls
hammer2_chain_lookup() to find the live freemap leaf for each 4MB chunk in
its in-memory bitmap. When the lookup's parent chain has a CRC/I/O
error, hammer2_chain_lookup returns NULL with *errorp = parent->error
(sys/vfs/hammer2/hammer2_chain.c:2473-2476). Back in h2_bulkfree_sync:
live_chain = hammer2_chain_lookup(&live_parent, &key_dummy,
key, key + HAMMER2_FREEMAP_LEVEL1_MASK,
&error, HAMMER2_LOOKUP_ALWAYS);
if (error) {
kprintf("hammer2_bulkfree: freemap lookup "
"error near %016jx, error %s\n",
(intmax_t)data_off,
hammer2_error_str(live_chain->error)); /* <-- NULL DEREF */
break;
}
live_chain is NULL at this point (the lookup returned NULL), but the
kprintf dereferences live_chain->error β page fault at fixed virtual
address offsetof(hammer2_chain_t, error) = 0x170.
The very next check at :1054 (if (live_chain == NULL)) proves the
developer knew NULL was possible β the deref at :1050 is simply the wrong
variable: the local error already holds the value the message wants.
Reachability on the audit guest
The bulkfree scan path is the HAMMER2IOC_BULKFREE_SCAN ioctl. Per DF-0815
(also verified on this guest), hammer2_ioctl() computes the privilege gate
once but does not guard the BULKFREE_SCAN case with the usual
if (error == 0) β so an unprivileged user holding any fd on a hammer2 mount
reaches hammer2_ioctl_bulkfree_scan() directly.
Triggering the NULL deref additionally requires the live freemap lookup to fail β i.e. a corrupted FREEMAP_NODE block on the media. This is supplied by a crafted hammer2 image (or natural media corruption). The setup is a normal admin operation (mount an fs image), the attack is the unprivileged BULKFREE_SCAN ioctl that consumes it.
Effect: kernel panic / local DoS (fixed-offset NULL deref β pure DoS, no escalation chain).
Build / Run
Root-side setup (creates + corrupts the image, mounts it RO at /mnt/h2t):
ssh dfbsd 'chmod +x /root/setup_image.sh && /bin/sh /root/setup_image.sh'
Unprivileged trigger (as maxx, uid 1001):
cc -o df0817 df0817.c ./df0817 /mnt/h2t
Expected output
- Bug present (unpatched
#0): the guest PANICS before the ioctl returns. Serial log shows:chain 0000000000XX000f.05 meth=50 CHECK FAIL freemap.icrc XXXXXXXX icrc32 YYYYYYYY (32768) Fatal trap 12: page fault while in kernel mode fault virtual address = 0x170 Stopped at hammer2_bulkfree_pass+0xdbe: movl 0x170(%rax),%edi(h2_bulkfree_syncis inlined intohammer2_bulkfree_passby gcc, so the fault address is the inlined call site, not a separate symbol.) - Fixed (patched
#1): the ioctl returns cleanly witherrno=33 (EDOM)(which ishammer2_error_to_errno(HAMMER2_ERROR_CHECK)); dmesg shows the now-correct kprintf messagehammer2_bulkfree: freemap lookup error near ..., error Check Errorand the bulkfree scan completes with a CRC-error warning. No panic.
Files
df0817.cβ unprivileged trigger (issues HAMMER2IOC_BULKFREE_SCAN)setup_image.shβ root-side image setup (newfs + populate + corrupt + mount RO)build.sh/run.shβ exact repropanic.txtβ panic signature (proof of the NULL deref)baseline_run.logβ panic signature from the unpatched-#0 baseline runbaseline_boot.logβ full serial log around the baseline panicfix_run.logβ dmesg after the PoC on the patched-#1 kernel (no panic)fix_build.logβ full single-fix kernel build output (rc=0)fix_kernel_info.txtβ patched kernelkern.version+ sha256fix.diffβ git-apply-able one-line fix (use localerror, notlive_chain->error)env.txtβ guest environmentVERDICT.mdβ full narrativemanifest.jsonβ artifact catalog
DF-0817 β VERDICT
Verdict: REPRODUCED β FIX VALIDATED
NULL pointer dereference in h2_bulkfree_sync() freemap lookup error path.
CWE-476. Medium severity (local DoS via fixed-offset NULL deref).
The bug is real, the trigger is reachable by an unprivileged user via the DF-0815 privilege bypass on BULKFREE_SCAN, and the one-line fix is verified to close it cleanly (panic on baseline #0 β clean EDOM return on patched #1).
Mechanism (trigger β primitive β effect)
h2_bulkfree_sync() walks the in-memory bulkfree bitmap and, for each 4MB
chunk with activity, looks up the corresponding live freemap leaf:
/* sys/vfs/hammer2/hammer2_bulkfree.c:1039 */
live_chain = hammer2_chain_lookup(&live_parent, &key_dummy,
key, key + HAMMER2_FREEMAP_LEVEL1_MASK,
&error, HAMMER2_LOOKUP_ALWAYS);
if (error) { /* :1046 */
kprintf("hammer2_bulkfree: freemap lookup "
"error near %016jx, error %s\n",
(intmax_t)data_off,
hammer2_error_str(live_chain->error)); /* :1050 <-- BUG */
break;
}
hammer2_chain_lookup descends the freemap tree from live_parent (the
hmp->fchain volume-freemap root) down through any FREEMAP_NODE levels to
the FREEMAP_LEAF. The descent is in sys/vfs/hammer2/hammer2_chain.c:
/* sys/vfs/hammer2/hammer2_chain.c:2473-2476 */
if (parent->error) {
*errorp = parent->error;
return NULL;
}
When ANY FREEMAP_NODE in the descent path has its on-disk data fail the
check.freemap.icrc32 CRC (hammer2_chain.c:1070-1072, which sets
chain->error = HAMMER2_ERROR_CHECK), the lookup takes this branch on the
next iteration: parent is the corrupted FREEMAP_NODE, parent->error is
set, the function returns NULL with *errorp populated. error is now
non-zero AND live_chain is NULL β exactly the precondition for the
deref at :1050 to fault.
The fault address is fixed: live_chain == NULL, so live_chain->error is
the read at &NULL + offsetof(hammer2_chain_t, error) = 0x170
(verified against struct hammer2_chain in sys/vfs/hammer2/hammer2.h:324-342;
the error field sits at byte offset 0x170 after lock, core, rbnode,
bref, parent, hmp, pmp, diolk, dio, data, bytes, flags,
refs, lockcnt).
live_chain == NULL is explicitly contemplated by the very next statement:
/* sys/vfs/hammer2/hammer2_bulkfree.c:1054 */
if (live_chain == NULL) {
/*
* XXX if we implement a full recovery mode we need
* to create/recreate missing freemap chains ...
*/
so the missing guard at :1050 is plainly an oversight β the kprintf just
uses the wrong variable. The local error already holds exactly the value
the message wants to print.
Trigger path on the audit guest
HAMMER2IOC_BULKFREE_SCANreacheshammer2_ioctl_bulkfree_scan()(hammer2_ioctl.c:1088) which callshammer2_bulkfree_pass()(hammer2_bulkfree.c:513) which callsh2_bulkfree_sync()(:702, inlined into the caller by gcc).- Per DF-0815,
hammer2_ioctl()does NOT guard the BULKFREE_SCAN case withif (error == 0)after thecaps_priv_check(cred, SYSCAP_NOVFS_IOCTL)privilege check athammer2_ioctl.c:83. An unprivileged user holding any fd on a hammer2 mount (the audit guest's root fs is hammer2) reaches the handler directly.
Effect
Fixed-virtual-address NULL read from kernel mode β fatal trap 12, page
fault, kernel panic, guest down. Pure DoS β there is no write primitive,
no UAF, no controlled content, so there is no escalation chain. (A
fixed-offset kernel NULL read does not give an attacker a controlled write
or a pointer-leak on this guest, so per Phase 6 of the procedure this is
documented as DoS and there is no exploit.c.)
Reproduction evidence β unpatched #0 baseline
Run as unprivileged uid 1001 (maxx) against a crafted hammer2 image
(8 GB image, freemap populated with 6 GB of incompressible data so the
freemap tree has a FREEMAP_NODE level, then the FREEMAP_NODE block's CRC
broken on disk by flipping 4 bytes inside it; mounted read-only at
/mnt/h2t):
$ ./df0817 /mnt/h2t [*] uid=1001 euid=1001 opening '/mnt/h2t' on hammer2 mount [*] issuing HAMMER2IOC_BULKFREE_SCAN <guest panics here; ssh dies>
Serial log (dfbsd-qemu/boot.log):
hammer2: bulkfree buf=1M hammer2: pass 0000000000000000-0000000200000000 (all media) hammer2_bulkfree: Scanning DATA hammer2_bulkfree: Scanning LOCAL bulkfree lastdrop 1 0 hammer2_bulkfree - range 0000000014400c00-0000000200000000 chain 000000000020000f.05 meth=50 CHECK FAIL <- CRC fail on FREEMAP_NODE freemap.icrc 276fe33c icrc32 1dd2a150 (32768) dio 0xfffff80119548500 buf 0000000000200000,65536 bdata 0xfffff80055a76000/0xfffff80055a76000 Fatal user address access from kernel mode from df0817 at ffffffff8096410e Fatal trap 12: page fault while in kernel mode fault virtual address = 0x170 <- NULL + offsetof(hammer2_chain_t, error) Stopped at hammer2_bulkfree_pass+0xdbe: movl 0x170(%rax),%edi <- reads *(NULL + 0x170) = live_chain->error db>
The fault address 0x170 is the smoking gun β it is exactly
offsetof(hammer2_chain_t, error), i.e. live_chain->error with
live_chain == NULL.
Why this is NOT memory corruption β no escalation chain
The primitive is a fixed-virtual-address NULL read (the kernel reads
*(NULL + 0x170)), which on this guest immediately page-faults. There is
no write, no UAF re-claim, no controlled content, no freed-object reuse,
and the faulting instruction is a movl (load) not a store. Nothing the
attacker does between the trigger and the fault can convert this into a
controlled write or pointer forge β the read is the entire primitive, and
it always misses (NULL page is unmapped, page not present). Per Phase 6
this is a "valid hard blocker: read-only primitive" β no escalation chain
exists, by construction. Documented as DoS.
PoC changes
The seeded PoC folder did not exist. Authored fresh:
- df0817.c β unprivileged trigger (issues HAMMER2IOC_BULKFREE_SCAN via
the DF-0815 privilege bypass on a hammer2 mount).
- setup_image.sh β root-side image preparation: newfs_hammer2 on an 8 GB
sparse image, populate with 6 GB of /dev/urandom data (NOT /dev/zero β
hammer2's default lz4 compression collapses zeros and the freemap tree
ends up with no FREEMAP_NODE level, making the bug unreachable), unmount,
corrupt the FREEMAP_NODE block's data CRC in every rotation slot of every
2GB zone (the active slot is whichever the last sync picked), remount
read-only.
- Key correctness fixes during iteration:
1. Image must use random data, not zeros. Zeros compress to ~0 bytes;
the freemap never grows a FREEMAP_NODE level and the bug is unreachable.
2. Image must be >4 GB. The FREEMAP root's blockset has only 4
blockref slots (HAMMER2_SET_COUNT=4); each direct FREEMAP_LEAF
covers 1 GB. <=4 GB of allocations fits in direct leaves under the
FREEMAP root with no FREEMAP_NODE indirection, and the bug's
parent->error path is unreachable.
3. The FREEMAP_NODE block location is not fixed. hammer2 rotates the
freemap on every modifying sync across 8 rotation slots per 2GB zone
(hammer2_freemap_reserve, hammer2_freemap.c:97-110), so the
"current" FREEMAP_NODE block's device offset varies between runs. The
setup script corrupts byte 0x100 of all 8 rotation slots of all zones
that exist within the 8GB image β guaranteeing the active FREEMAP_NODE
block is hit regardless of which rotation the kernel picked.
4. DragonFly /bin/sh printf does NOT understand \xAA hex escapes
(it emits the literal characters "xaa"). The setup script uses octal
\252\253\254\255 instead.
Fix (authored, validated)
fix.diff β one-line change at hammer2_bulkfree.c:1050:
- hammer2_error_str(live_chain->error));
+ hammer2_error_str(error));
Use the local error variable (already populated by the lookup) instead of
dereferencing the NULL live_chain. This matches what the developer
clearly intended (the if (live_chain == NULL) check at :1054
demonstrates they expected NULL), and matches the established pattern in
the sibling hammer2_freemap_try_alloc error kprintf at
hammer2_freemap.c:349-351 which uses hammer2_error_str(chain->error)
only after explicitly checking chain != NULL && chain->error at :345.
(The line at :1073 β hammer2_error_str(live_chain->error) β is left
untouched; it is guarded by the if (live_chain->error) check at :1069,
so live_chain is guaranteed non-NULL there.)
Fix validation (Phase 8) β VALIDATED
unpatched #0 (bug) |
patched #1 (fix) |
|
|---|---|---|
kern.version |
#0: Thu Jul 2 06:02:54 UTC 2026 |
#1: Fri Jul 10 22:35:22 UTC 2026 |
/boot/kernel/kernel sha256 |
(audit baseline) | ba6d3e27b4908b3fb2562595a584484b9cf02148c2c1053db3def853e00ab13e |
PoC BULKFREE_SCAN rc |
(panic, no return) | rc=-1 errno=33 (EDOM) |
| Guest after PoC | down (DDB panic prompt) | up (responsive) |
| dmesg signature | Fatal trap 12 / fault virtual address = 0x170 / Stopped at hammer2_bulkfree_pass+0xdbe |
chain ... CHECK FAIL / hammer2_bulkfree: freemap lookup error near ..., error Check Error / bulkfree pass statistics (100.00% storage processed) |
Clean before/after. Both runs used the same crafted image and the
same df0817 binary β only the kernel changed. On the patched kernel
the bulkfree scan still detects and reports the CRC error (so the file-
system-defect handling is preserved), it just no longer crashes on it.
Deterministic across 2 runs.
Files
df0817.cβ trigger (unprivileged BULKFREE_SCAN via DF-0815 bypass)setup_image.shβ root-side image setupbuild.sh/run.shβ exact repropanic.txtβ panic signature (proof)baseline_run.log/baseline_boot.logβ full unpatched-#0 runfix_run.log/fix_kernel_info.txtβ patched-#1 run + kernel identityfix_build.logβ full single-fix kernel build (rc=0)fix.diffβ git-apply-able fixenv.txtβ guest environmentmanifest.jsonβ artifact catalog
Fix verification
fixedVALIDATED. Same crafted image, same df0817 binary, only the kernel changed. Unpatched #0 baseline: kernel panic (fatal trap 12, fault virtual address=0x170, Stopped at hammer2_bulkfree_pass+0xdbe: movl 0x170(%rax),%edi), guest down. Patched #1 kernel: ioctl returns errno=33 (EDOM = hammer2_error_to_errno(HAMMER2_ERROR_CHECK)), guest stays up, dmesg shows the now-correct kprintf 'freemap lookup error near 0000000014800000, error Check Error' and the bulkfree scan completes with a CRC-error warning. The fix closes the bug cleanly while preserving the filesystem-defect reporting. Deterministic across 2 runs.
BASELINE (unpatched #0): chain 000000000020000f.05 meth=50 CHECK FAIL Fatal trap 12: page fault while in kernel mode fault virtual address = 0x170 Stopped at hammer2_bulkfree_pass+0xdbe: movl 0x170(%rax),%edi db> (guest DOWN) PATCHED (#1, sha256 ba6d3e27...): [BULKFREE_SCAN] ioctl rc=-1 errno=33 (Numerical argument out of domain) dmesg: hammer2_bulkfree: freemap lookup error near 0000000014800000, error Check Error dmesg: bulkfree pass statistics (100.00% storage processed) (guest UP; df0817 exits 0)
Confirmed kernel references
- sys/vfs/hammer2/hammer2_bulkfree.c:1039
- sys/vfs/hammer2/hammer2_bulkfree.c:1046
- sys/vfs/hammer2/hammer2_bulkfree.c:1050
- sys/vfs/hammer2/hammer2_bulkfree.c:1054
- sys/vfs/hammer2/hammer2_chain.c:2473
- sys/vfs/hammer2/hammer2_chain.c:2474
- sys/vfs/hammer2/hammer2_chain.c:1070
- sys/vfs/hammer2/hammer2_chain.c:1072
- sys/vfs/hammer2/hammer2.h:340
- sys/vfs/hammer2/hammer2_ioctl.c:144
- sys/vfs/hammer2/hammer2_ioctl.c:145
Detail
Exploit chain
none -- fixed-offset kernel NULL READ (CWE-476). The faulting instruction is movl 0x170(%rax),%edi (a load), not a store; the read always faults because page 0 is unmapped. There is no write primitive, no UAF re-claim, no controlled content, and nothing the attacker does between trigger and fault can convert this into a controlled write or pointer forge. Per Phase 6 this is a valid hard blocker (read-only primitive) -- no escalation chain exists, by construction. Documented as DoS (kernel panic).
Evidence (decisive lines)
Unpatched #0 baseline (./df0817 /mnt/h2t as uid 1001 maxx): chain 000000000020000f.05 meth=50 CHECK FAIL freemap.icrc 276fe33c icrc32 1dd2a150 (32768) Fatal user address access from kernel mode from df0817 at ffffffff8096410e Fatal trap 12: page fault while in kernel mode fault virtual address = 0x170 Stopped at hammer2_bulkfree_pass+0xdbe: movl 0x170(%rax),%edi db> (guest down) Patched #1 kernel (same image + same df0817 binary): [BULKFREE_SCAN] ioctl rc=-1 errno=33 (Numerical argument out of domain) dmesg: chain 000000000020000f.05 meth=50 CHECK FAIL dmesg: hammer2_bulkfree: freemap lookup error near 0000000014800000, error Check Error dmesg: bulkfree pass statistics (100.00% storage processed) / WARNING: bulkfree encountered CRC errors (guest stays up; df0817 exits cleanly)
PoC changes
Seeded PoC folder did not exist; authored fresh. (1) df0817.c -- unprivileged trigger issuing HAMMER2IOC_BULKFREE_SCAN via the DF-0815 privilege bypass (struct hammer2_ioc_bulkfree mirrored at the correct 64-byte size so the _IOWR macro encodes the right ioctl number). (2) setup_image.sh -- root-side image prep. Key correctness discoveries during iteration: image MUST be >4GB AND populated with /dev/urandom (not /dev/zero, which lz4 compresses to ~0 bytes leaving no FREEMAP_NODE level); the FREEMAP_NODE block's device offset is not fixed (hammer2 rotates the freemap across 8 slots per 2GB zone on every modifying sync), so the script corrupts byte 0x100 of every rotation slot of every zone within the 8GB image to guarantee the active FREEMAP_NODE is hit; DragonFly /bin/sh printf does not understand \xAA hex escapes (uses \252 octal instead).
Verified recommended fix
One-line change at sys/vfs/hammer2/hammer2_bulkfree.c:1050: replace hammer2_error_str(live_chain->error) with hammer2_error_str(error) -- use the local error variable (already populated by the lookup) instead of dereferencing the NULL live_chain. Matches the developer's intent (the if(live_chain==NULL) check at :1054 shows they expected NULL) and the sibling pattern in hammer2_freemap_try_alloc at hammer2_freemap.c:345-351. The line at :1073 is left untouched (it is guarded by if(live_chain->error) at :1069 so live_chain is guaranteed non-NULL there). Matches the finding markdown's proposal. Full git-apply-able diff in findings/poc/DF-0817/fix.diff.
Verdict
REPRODUCED + FIX VALIDATED. h2_bulkfree_sync() at sys/vfs/hammer2/hammer2_bulkfree.c:1050 dereferences live_chain->error after hammer2_chain_lookup() returned NULL with *errorp set (which happens whenever any FREEMAP_NODE in the descent path has a CRC error -- sys/vfs/hammer2/hammer2_chain.c:2473-2476). The fault address is exactly offsetof(hammer2_chain_t,error)=0x170 (confirmed against struct hammer2_chain in hammer2.h:340): serial log shows 'fatal trap 12 / fault virtual address = 0x170 / Stopped at hammer2_bulkfree_pass+0xdbe: movl 0x170(%rax),%edi' from the unprivileged df0817 process. The very next line (:1054) is if (live_chain == NULL) -- the developer knew NULL was possible; the kprintf just uses the wrong variable. Trigger is reachable by an unprivileged user via the DF-0815 BULKFREE_SCAN privilege bypass (hammer2_ioctl.c:144-145 has no if(error==0) guard) once the admin has mounted a hammer2 image whose FREEMAP_NODE block has a CRC error (crafted image or natural media corruption).
No comments yet.