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

OOB kernel heap read/write via crafted signed bmap->linear from filesystem image

Summary

hammer2_freemap.c:616-619 linear-iterator guard: (bmap->linear < HAMMER2_SEGSIZE) is SIGNED int comparison β€” negative passes. bmap->linear is int32_t from on-disk freemap (hammer2_disk.h:872) loaded raw at hammer2_chain.c:1100. linear=0x80001000 (int32=-2147479552): passes all 3 guards (uint32&0x3FFF=4096 fits size + nonzero + signed<SEGSIZE). :631 KKASSERT(linear>=0) no-op without INVARIANTS (systm.h:118). :634 offset=linear. :635 i=offset/(SEGSIZE/ELEMENTS) = -2147479552/524288 = -4095. :717 KKASSERT(i>=0&&i<ELEMENTS) no-op. :727/:749/:785 bmap->bitmapq[i] OOB READ at -4095. :803 bmap->bitmapq[i] |= bmmask OOB WRITE at -4095 = 32760 bytes before bitmapq[0] into kernel heap. Attack: crafted HAMMER2 image bmap_data.linear=negative forged CRC mount then file write triggers allocation in corrupted segment. Controllable write offset via different linear values. Fix: if(linear<0||linear>=SEGSIZE) skip linear iterator.

Discussion (0)

No comments yet.

PoC verification

Evidence pack

findings/poc/DF-0821 Β· 16 files
FileTypeDescriptionSize
craft_img.py trigger-source hammer2 image patcher: poisons bmap->linear to 0x80001000, recomputes leaf chain CRC + vol header CRC 10.6 KB view raw
harness.c trigger-source deterministic userspace transcription of guard + OOB array-index logic with poisoned allocator (noinv characterization) 9.9 KB view raw
harness_run.log run-log full harness build + run output showing OOB extent 3.3 KB view raw
h2_craft_821.img.gz crafted-image crafted 64MB hammer2 image with poisoned freemap (gzip-compressed) 65.0 KB ↓ download
build.sh build-script builds the harness 580 B view raw
run.sh run-script phase A (harness) + phase B (live kernel trigger) 2.7 KB view raw
fix.diff suggested-fix validated one-line guard fix: add bmap->linear >= 0 as first sub-condition 745 B view raw
fix_build.log build-log full single-fix kernel nativekernel build output (rc=0) 5.6 MB ↓ download
fix_run.log run-log fixed-kernel #1 validation: mount+write succeed, no panic 652 B view raw
run.log run-log baseline #0 reproduction: panic signature + call trace 2.4 KB view raw
panic.txt panic-signature KKASSERT panic at hammer2_freemap.c:633 from boot.log 1.8 KB view raw
env.txt environment guest uname, cc version, baseline vs fixed kernel versions 858 B view raw
VERDICT.md verdict full narrative: mechanism, evidence, impact ceiling, fix validation 9.5 KB ↓ raw
README.md readme human-facing reproduction guide 3.7 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 human-facing reproduction guide
↓ download raw

DF-0821 β€” PoC

OOB heap read/write via crafted signed bmap->linear from a hammer2 filesystem image.

Summary

The linear-iterator guard in hammer2_bmap_alloc (sys/vfs/hammer2/hammer2_freemap.c:616-619) compares the on-disk int32_t bmap->linear against HAMMER2_SEGSIZE (1<<22) with a signed <, so a negative linear (e.g. 0x80001000 = -2147479552) passes the guard. On default GENERIC (INVARIANTS ON) the downstream KKASSERT(bmap->linear >= 0 && ...) at :631-633 then panics the kernel β€” reliable DoS from a crafted hammer2 image. On an INVARIANTS-OFF kernel the KKASSERT is compiled out and the negative linear drives i = linear/(SEGSIZE/8) = -4095, making bmap->bitmapq[i] an OOB read (:727/:749/:785) and OOB write (:803) 32760 bytes before bitmapq[0] in kernel heap.

Files

File Purpose
craft_img.py Poisons bmap->linear in a hammer2 image, recomputes CRCs
harness.c Deterministic userspace transcription of guard + OOB logic
build.sh Builds the harness
run.sh Reproduces (harness + live kernel trigger)
fix.diff Validated one-line guard fix
VERDICT.md Full narrative: mechanism, evidence, impact ceiling

Reproduce

Phase A β€” deterministic harness (safe, no panic, any user)

ssh dfbsd-maxx   # or any DragonFlyBSD user with cc
cd findings/poc/DF-0821 && ./build.sh && ./harness

Expected: the harness shows that linear=0x80001000 passes all 3 guards, would panic at KKASSERT :631-633 on GENERIC, and drives bitmapq[-4095] (32760-byte OOB) on a noinv kernel.

Phase B β€” live kernel trigger (root; PANICS the unpatched guest)

ssh dfbsd        # root
cd findings/poc/DF-0821 && ./run.sh live

On unpatched #0 (INVARIANTS ON): echo trigger > /mnt/h2821/poison_file panics the guest at hammer2_freemap.c:633. Guest enters DDB; ssh dies; panic is captured in dfbsd-qemu/boot.log.

On fixed #1: mount + write succeed; file is written correctly; guest stays up. No panic.

Image crafting (what run.sh live does, step by step)

  1. truncate -s 64M clean.img && newfs_hammer2 -L testvol clean.img
  2. Mount + write 5 small files + unmount (populates freemap leaves on disk).
  3. python3 craft_img.py clean.img crafted.img 0x80001000: - Parses volume header at offset 0; reads freemap_blockset[0] β†’ FREEMAP_LEAF at disk offset 0x10000 (32KB, 256 bmap_data entries). - Poisons up to 10 entries with avail > 0 by setting linear = 0x80001000 (negative int32 that passes all 3 guards). - Recomputes HAMMER2_CHECK_FREEMAP CRC (iscsi_crc32 over 32KB leaf) and stores it in freemap_blockset[0].check.freemap.icrc32. - Recomputes icrc_volheader over the 64KB volume header.
  4. vnconfig -c vn1 crafted.img && mount -t hammer2 /dev/vn1@testvol /mnt
  5. echo trigger > /mnt/poison_file β†’ freemap allocation β†’ panic.

Preconditions

  • Root (or vfs.usermount=1 + user-owned device) to mount the crafted image.
  • Default X86_64_GENERIC kernel (HAMMER2 compiled in; it is the root FS).
  • The crafted image's freemap leaf CRC and volume-header CRC are recomputed so the kernel trusts the poisoned leaf.

Fix

fix.diff: add bmap->linear >= 0 && as the first sub-condition of the guard at hammer2_freemap.c:616-619. When the guard fails (negative or oversized linear), the allocator falls through to the bitmap-scan path, which does not use linear for array indexing. Validated on a single-fix #1 kernel (see VERDICT.md).

VERDICT.md verdict full narrative: mechanism, evidence, impact ceiling, fix validation
↓ download raw

DF-0821 β€” Verdict

REPRODUCED β€” Signed-comparison gap in hammer2 freemap linear-iterator guard allows a negative on-disk bmap->linear to pass the guard and drive an OOB array index. On the default GENERIC kernel (INVARIANTS ON) the KKASSERT at hammer2_freemap.c:633 catches it and panics (reliable DoS from a crafted filesystem image). On an INVARIANTS-OFF (production) kernel the KKASSERT is compiled out and the same primitive becomes a silent heap OOB read+write (bmap->bitmapq[-4095], 32760 bytes before bitmapq[0]), characterized below by the deterministic harness.

Impact: panic (DoS) on default GENERIC; corruption (OOB heap R/W) on production/no-inv kernels. Mount precondition is root-only on this guest (vfs.usermount=0); realistic threat model is an admin (or auto-mounter / vfs.usermount=1 user) who mounts a crafted hammer2 image.

The bug, confirmed

hammer2_bmap_alloc (sys/vfs/hammer2/hammer2_freemap.c:616-619) gates the linear-iterator fast path with three sub-conditions:

616:  if (((uint32_t)bmap->linear & HAMMER2_FREEMAP_BLOCK_MASK) + size <=
617:      HAMMER2_FREEMAP_BLOCK_SIZE &&
618:      (bmap->linear & HAMMER2_FREEMAP_BLOCK_MASK) &&
619:      bmap->linear < HAMMER2_SEGSIZE) {

bmap->linear is int32_t from the on-disk freemap leaf (hammer2_disk.h:872 struct hammer2_bmap_data.linear), loaded raw into kernel memory at hammer2_chain.c:1100 (chain->data = (void *)bdata) with no range validation of the linear field. The third sub-condition (:619) is a signed int < int comparison: HAMMER2_SEGSIZE is 1<<22 = 4194304 (hammer2_disk.h:102,341). A negative linear is always < 4194304, so it passes.

For linear = 0x80001000 (int32 = -2147479552): - :616 (uint32_t)0x80001000 & 0x3FFF = 0x1000 = 4096; 4096 + 1024 = 5120 <= 16384 βœ“ - :618 0x80001000 & 0x3FFF = 0x1000 (nonzero, int promoted to unsigned for &) βœ“ - :619 -2147479552 < 4194304 βœ“ (signed)

All three pass. The linear-iterator path is taken. Then:

631:  KKASSERT(bmap->linear >= 0 &&
632:           bmap->linear + size <= HAMMER2_SEGSIZE &&
633:           (bmap->linear & (HAMMER2_ALLOC_MIN - 1)) == 0);
634:  offset = bmap->linear;
635:  i = offset / (HAMMER2_SEGSIZE / HAMMER2_BMAP_ELEMENTS);

KKASSERT is gated by INVARIANTS (sys/sys/systm.h:94-118). On default X86_64_GENERIC (options INVARIANTS, sys/config/X86_64_GENERIC:56) the first sub-condition bmap->linear >= 0 is false β†’ panic at :633.

On an INVARIANTS-OFF kernel KKASSERT compiles to do { } while (0) (systm.h:118), the negative offset flows into the array index: i = -2147479552 / 524288 = -4095 (C truncates toward zero). Then :727, :749, :785 read bmap->bitmapq[i] = bmap->bitmapq[-4095] = 32760 bytes before bitmapq[0] in kernel heap, and :803 writes it (bmap->bitmapq[i] |= bmmask). The OOB extent is attacker-tunable by choosing different negative linear values.

Reproduction (live, on the real kernel)

HAMMER2 is the root filesystem on this guest, so the VFS code is compiled in and live. Steps (automated by run.sh live, root required for vnconfig+mount_hammer2):

  1. truncate -s 64M clean.img && newfs_hammer2 -L testvol clean.img
  2. Mount + write 5 small (10KB) files + unmount β†’ freemap leaves with real bmap_data entries (non-zero avail) now exist on disk.
  3. craft_img.py parses the volume header (freemap_blockset[0] β†’ FREEMAP_LEAF at disk offset 0x10000, 32KB), picks the 10 bmap_data entries with avail > 0, and sets each linear field to 0x80001000. It recomputes: - the leaf chain CRC (HAMMER2_CHECK_FREEMAP = iscsi_crc32 over the 32KB leaf buffer, hammer2_chain.c:5414-5416), stored in freemap_blockset[0].check.freemap.icrc32 (vol-header offset 0x840); - icrc_volheader (iscsi_crc32 over 65532 bytes, stored at 0xFFFC). icrc_sects[7] (sect0) and icrc_sects[6] (sect1) are untouched (the freemap_blockset lives in sector 4, which is only covered by icrc_volheader, not by sect0/sect1 β€” verified against hammer2_ondisk.c:528-558 which validates only sect0, sect1, volheader).
  4. vnconfig -c vn1 crafted.img && mount -t hammer2 /dev/vn1@testvol /mnt/h2821 then echo trigger > /mnt/h2821/poison_file.

Baseline result (#0 unpatched, INVARIANTS ON): the file write panics immediately β€” guest enters DDB, ssh dies, vm.sh status β‡’ down. Serial-log panic signature (panic.txt):

panic: assertion "bmap->linear >= 0 && bmap->linear + size <= HAMMER2_SEGSIZE
       && (bmap->linear & (HAMMER2_ALLOC_MIN - 1)) == 0" failed
       in hammer2_bmap_alloc at /usr/src/sys/vfs/hammer2/hammer2_freemap.c:633
hammer2_bmap_alloc.constprop.2() at hammer2_bmap_alloc.constprop.2+0x3a5
hammer2_freemap_alloc() at hammer2_freemap_alloc+0x45c
hammer2_chain_modify() at hammer2_chain_modify+0x60c
hammer2_chain_create() at hammer2_chain_create+0xe24
hammer2_xop_inode_create_det() at hammer2_xop_inode_create_det+0x125

Reproduced deterministically on 2 independent fresh-reset runs on #0.

Deterministic harness (noinv / production characterization)

harness.c transcribes the guard (:616-619) + index computation (:634-636) + OOB read (:727) + OOB write (:803) verbatim, with the KKASSERT disabled to simulate INVARIANTS-OFF. A poisoned allocator places bitmapq[] at the end of a canary-filled buffer so the OOB read into bitmapq[-4095] visibly returns canary bytes and the OOB write visibly mutates them. Output for linear=0x80001000:

guard1 PASS  guard2 PASS  guard3 PASS  => linear-iterator path TAKEN: YES (BUG)
[GENERIC] KKASSERT(bmap->linear >= 0) would PANIC here (line 631-633)
[noinv]   continuing to OOB array index...
offset = -2147479552   i = -4095
*** OOB ARRAY INDEX: bitmapq[-4095] is 32760 bytes BEFORE bitmapq[0] ***
bitmapq[-4095] @ bitmapq[0] + -32760 bytes = 0xcdcdcdcdcdcdcdcd
wrote 0x0000000000000003 into bitmapq[-4095]
canary region BEFORE bitmapq[0] modified: YES (OOB write confirmed)

This confirms the OOB extent and that both the read (:727/:749/:785) and the write (:803) sinks are reachable on a noinv kernel.

Impact ceiling & why escalation stops at panic on GENERIC

On the default X86_64_GENERIC (INVARIANTS ON) the KKASSERT at :631-633 fires before any OOB access β€” the primitive never materializes as memory corruption, only as a panic. This is a valid hard blocker for uid=0 escalation on the realistic target: the write primitive the chain would need is gated behind INVARIANTS, which catches the negative linear at the assertion before i is even computed. The realistic impact is therefore panic / DoS from a crafted filesystem image (mount + first allocation in the poisoned segment).

On a hypothetical INVARIANTS-OFF kernel the same trigger yields a genuine heap OOB read+write primitive (32KB-class freemap leaf buffer, attacker-chosen negative offset into the preceding kernel heap). That is in principle groomable for privilege escalation, but only on a non-default kernel build. Per the bright-line rule this is reported as panic for the default GENERIC target with the non-default primitive characterized by the harness, not escalated.

Threat model. Realistic precondition: an attacker supplies a crafted hammer2 filesystem image and a victim mounts it (USB stick, downloaded image, auto-mounter, or vfs.usermount=1 with a user-owned device). On default DragonFlyBSD this yields immediate kernel panic / DoS. No privilege beyond image supply is required.

The fix (validated)

fix.diff adds bmap->linear >= 0 as the FIRST sub-condition of the guard at :616-619, short-circuiting the linear-iterator path before any other evaluation when linear is negative (or, via the existing bmap->linear < HAMMER2_SEGSIZE, when it is >= SEGSIZE). When the guard fails the allocator falls through to the bitmap-scan path (:642-714), which does not use bmap->linear for array indexing and handles the allocation correctly. The fix is minimal, targeted at the root cause, and does not change any on-disk format.

Phase 8 validation (single-fix kernel #1, make -j6 nativekernel):

Kernel Crafted linear=0x80001000 image Clean image
#0 unpatched (INVARIANTS ON) PANIC hammer2_freemap.c:633 KKASSERT mounts, works
#1 patched (INVARIANTS ON) mount + file write succeed, no panic mounts, works

No regression: on #1 the crafted image mounts, files are written and read correctly, unmount succeeds, guest stays up. Verified on 2 consecutive runs.

PoC artifacts

  • craft_img.py β€” host-side image patcher: poisons bmap->linear to 0x80001000 in up to 10 freemap leaf entries, recomputes the leaf chain CRC and icrc_volheader (CRC32C / iscsi_crc32, self-test verified).
  • harness.c β€” deterministic userspace transcription of the guard + OOB logic; shows the OOB extent on a noinv kernel without needing a mount.
  • h2_821.img (generated by run.sh) β€” clean populated image (intermediate).
  • h2_craft_821.img β€” crafted image with poisoned freemap.
  • build.sh / run.sh β€” reproducible build & trigger (harness + live).
  • panic.txt β€” kernel panic signature from boot.log (crash proof).
  • harness_run.log β€” full harness output.
  • fix.diff β€” the validated one-line guard fix.
  • fix_build.log / fix_run.log β€” Phase 8 build + validation logs.

Fix verification

fixed
baseline reproduced→ patch + rebuild →patched clean

VALIDATED the fix: on the unpatched #0 baseline (INVARIANTS ON), mount + echo trigger > /mnt/h2821/poison_file PANICS at hammer2_freemap.c:633 (KKASSERT bmap->linear >= 0). On the single-fix #1 kernel (only change = fix.diff to hammer2_freemap.c, built via make -j6 nativekernel), the same mount + write SUCCEEDS (file written, content verified, unmount clean, guest stays up) with NO panic and NO freemap error in dmesg -- the poisoned negative linear is rejected by the new bmap->linear >= 0 guard and the allocator falls through to the bitmap-scan path. No regression on a clean image. Confirmed deterministic across 2 consecutive runs on #1.

baseline #0: panic: assertion "bmap->linear >= 0 && ..." failed in hammer2_bmap_alloc at hammer2_freemap.c:633 / guest DOWN. patched #1: MOUNT_RC=0 WRITE_RC=0 / trigger_fix (file written) / ls shows poison_file + poison_file2 + poison_file_fix all present / umount clean / guest UP. before/after contrast is clean panic -> clean success.
↓ fix.diffDragonFly 6.5-DEVELOPMENT #1: Sun Jul 5 21:20:01 UTC 2026 root@dfbsd:/usr/obj/usr/src/sys/X86_64_GENERIC x86_64

Confirmed kernel references

Detail

Exploit chain

Memory-corruption primitive, but on the default GENERIC target (INVARIANTS ON) it is gated to panic: KKASSERT(bmap->linear >= 0) at hammer2_freemap.c:631-633 fires before the OOB array index at :635 is computed, so no write primitive materializes on the realistic target (valid hard blocker: INVARIANTS traps it at the assertion). On INVARIANTS-OFF the primitive is: bucket = 32KB freemap-leaf buffer (kmalloc-class via hammer2 DIO/buffer cache); victim = bmap->bitmapq[-4095] = 32760 bytes BEFORE bitmapq[0] in the same kernel heap slab, with attacker-tunable offset via different negative linear values; OOB read at :727/:749/:785 (bitmapq[i] & bmmask), OOB write at :803 (bitmapq[i] |= bmmask, bmmask = ((1<<bmradix)-1)<<j, controllable via radix/j). harness.c transcribes the guard+index+read+write verbatim with a poisoned allocator and confirms the OOB extent (canary read = 0xcdcdcdcdcdcdcdcd, canary write confirmed). Mount precondition is root-only on this guest (vfs.usermount=0); realistic threat model = admin/auto-mounter/usermount supplies crafted image. Reported as panic for GENERIC with the noinv primitive characterized, not escalated (INVARIANTS-OFF-only escalation is a non-default-kernel result per the bright-line rule). Files: harness.c (OOB characterization), craft_img.py (image poisoner + CRC recompute).

Evidence (decisive lines)

panic: assertion "bmap->linear >= 0 && bmap->linear + size <= HAMMER2_SEGSIZE && (bmap->linear & (HAMMER2_ALLOC_MIN - 1)) == 0" failed in hammer2_bmap_alloc at /usr/src/sys/vfs/hammer2/hammer2_freemap.c:633 / hammer2_bmap_alloc.constprop.2() at hammer2_bmap_alloc.constprop.2+0x3a5 / hammer2_freemap_alloc() at hammer2_freemap_alloc+0x45c / hammer2_chain_modify() at hammer2_chain_modify+0x60c / hammer2_chain_create() at hammer2_chain_create+0xe24 / hammer2_xop_inode_create_det() at hammer2_xop_inode_create_det+0x125 / Stopped at Debugger+0x7c: movb $0,0xbdaf09(%rip). --- harness (noinv): --- *** OOB ARRAY INDEX: bitmapq[-4095] is 32760 bytes BEFORE bitmapq[0] *** / bitmapq[-4095] @ bitmapq[0] + -32760 bytes = 0xcdcdcdcdcdcdcdcd / wrote 0x0000000000000003 into bitmapq[-4095] -> canary modified: YES

PoC changes

Built the entire evidence pack from scratch (folder was empty). craft_img.py: parses hammer2 volume header, follows freemap_blockset[0] -> FREEMAP_LEAF (32KB at disk offset 0x10000 for <=1GB images), poisons up to 10 bmap_data entries with avail>0 by setting linear=0x80001000, recomputes HAMMER2_CHECK_FREEMAP leaf chain CRC (iscsi_crc32, CRC32C self-test verified against 0xE3069283) stored in freemap_blockset[0].check.freemap.icrc32, and recomputes icrc_volheader over the 64KB header; sect0/sect1 CRCs left untouched (freemap_blockset is in sector 4, only covered by icrc_volheader per hammer2_ondisk.c:528-558). harness.c: deterministic userspace transcription of the guard (:616-619), KKASSERT (:631-633, disabled to simulate noinv), index math (:634-636), OOB read (:727), OOB write (:803) with a poisoned allocator showing the OOB extent into a canary buffer. fix.diff: one-line guard fix. build.sh/run.sh: reproducible build+trigger. All logs saved untrimmed.

Verified recommended fix

In sys/vfs/hammer2/hammer2_freemap.c:616-619, add bmap->linear >= 0 && as the FIRST sub-condition of the linear-iterator guard (short-circuiting before the other checks). When linear is negative (or >= SEGSIZE via the existing check), the guard fails and the allocator falls through to the bitmap-scan path at :642-714, which does not use bmap->linear for array indexing and handles the allocation correctly. Minimal, targeted at the root cause, no on-disk format change. Matches the finding proposal ('validate linear as unsigned / check linear >= 0 && linear < SEGSIZE'). Full git-apply-able diff in findings/poc/DF-0821/fix.diff.

Verdict

REPRODUCED. The signed-comparison gap at hammer2_freemap.c:619 (bmap->linear < HAMMER2_SEGSIZE is int32linear is int32_t loaded raw from the on-disk freemap leaf at hammer2_chain.c:1100 with no range validation. craft_img.py poisons bmap->linear to 0x80001000 (int32 -2147479552), which passes all 3 guards at :616-619 ((uint32)0x80001000 & 0x3FFF = 0x1000, fits+nonzero; -2147479552 < 4194304 signed), recomputes the HAMMER2_CHECK_FREEMAP leaf chain CRC (iscsi_crc32 over 32KB) and icrc_volheader, and the image mounts cleanly. The first allocation in the poisoned segment (file write -> hammer2_xop_inode_create_det -> hammer2_chain_create -> hammer2_chain_modify -> hammer2_freemap_alloc -> hammer2_bmap_alloc) hits KKASSERT(bmap->linear >= 0 && ...) at :631-633, which fires because bmap->linear is negative. KKASSERT is INVARIANTS-gated (sys/sys/systm.h:94-118; options INVARIANTS in sys/config/X86_64_GENERIC:56). On the default GENERIC kernel this is a reliable panic/DoS; on an INVARIANTS-OFF kernel the KKASSERT is compiled out and the negative linear drives i = linear/(SEGSIZE/8) = -4095, making bmap->bitmapq[i] an OOB read (:727/:749/:785) and OOB write (:803) 32760 bytes before bitmapq[0] in kernel heap (characterized by harness.c, not escalated per the bright-line rule since GENERIC masks the write primitive).