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

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)

No comments yet.

PoC verification

Evidence pack

findings/poc/DF-0817 Β· 16 files
FileTypeDescriptionSize
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
README.md readme claim, build/run, expected output
↓ download 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_sync is inlined into hammer2_bulkfree_pass by gcc, so the fault address is the inlined call site, not a separate symbol.)
  • Fixed (patched #1): the ioctl returns cleanly with errno=33 (EDOM) (which is hammer2_error_to_errno(HAMMER2_ERROR_CHECK)); dmesg shows the now-correct kprintf message hammer2_bulkfree: freemap lookup error near ..., error Check Error and 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 repro
  • panic.txt β€” panic signature (proof of the NULL deref)
  • baseline_run.log β€” panic signature from the unpatched-#0 baseline run
  • baseline_boot.log β€” full serial log around the baseline panic
  • fix_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 kernel kern.version + sha256
  • fix.diff β€” git-apply-able one-line fix (use local error, not live_chain->error)
  • env.txt β€” guest environment
  • VERDICT.md β€” full narrative
  • manifest.json β€” artifact catalog
VERDICT.md verdict full narrative: mechanism, evidence, fix validation
↓ download raw

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_SCAN reaches hammer2_ioctl_bulkfree_scan() (hammer2_ioctl.c:1088) which calls hammer2_bulkfree_pass() (hammer2_bulkfree.c:513) which calls h2_bulkfree_sync() (:702, inlined into the caller by gcc).
  • Per DF-0815, hammer2_ioctl() does NOT guard the BULKFREE_SCAN case with if (error == 0) after the caps_priv_check(cred, SYSCAP_NOVFS_IOCTL) privilege check at hammer2_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 setup
  • build.sh / run.sh β€” exact repro
  • panic.txt β€” panic signature (proof)
  • baseline_run.log / baseline_boot.log β€” full unpatched-#0 run
  • fix_run.log / fix_kernel_info.txt β€” patched-#1 run + kernel identity
  • fix_build.log β€” full single-fix kernel build (rc=0)
  • fix.diff β€” git-apply-able fix
  • env.txt β€” guest environment
  • manifest.json β€” artifact catalog

Fix verification

fixed
baseline reproduced→ patch + rebuild →patched clean

VALIDATED. 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)
↓ fix.diffDragonFly 6.5-DEVELOPMENT #1: Fri Jul 10 22:35:22 UTC 2026 root@dfbsd:/usr/obj/usr/src/sys/X86_64_GENERIC x86_64 (sha256 ba6d3e27b4908b3fb2562595a584484b9cf02148c2c1053db3def853e00ab13e)

Confirmed kernel references

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