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)
PoC verification
Evidence pack
findings/poc/DF-0821 Β· 16 files| File | Type | Description | Size | |
|---|---|---|---|---|
| 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 |
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)
truncate -s 64M clean.img && newfs_hammer2 -L testvol clean.img- Mount + write 5 small files + unmount (populates freemap leaves on disk).
python3 craft_img.py clean.img crafted.img 0x80001000: - Parses volume header at offset 0; readsfreemap_blockset[0]βFREEMAP_LEAFat disk offset0x10000(32KB, 256bmap_dataentries). - Poisons up to 10 entries withavail > 0by settinglinear = 0x80001000(negative int32 that passes all 3 guards). - RecomputesHAMMER2_CHECK_FREEMAPCRC (iscsi_crc32over 32KB leaf) and stores it infreemap_blockset[0].check.freemap.icrc32. - Recomputesicrc_volheaderover the 64KB volume header.vnconfig -c vn1 crafted.img && mount -t hammer2 /dev/vn1@testvol /mntecho trigger > /mnt/poison_fileβ freemap allocation β panic.
Preconditions
- Root (or
vfs.usermount=1+ user-owned device) to mount the crafted image. - Default
X86_64_GENERICkernel (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).
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):
truncate -s 64M clean.img && newfs_hammer2 -L testvol clean.img- Mount + write 5 small (10KB) files + unmount β freemap leaves with real
bmap_dataentries (non-zeroavail) now exist on disk. craft_img.pyparses the volume header (freemap_blockset[0]βFREEMAP_LEAFat disk offset0x10000, 32KB), picks the 10bmap_dataentries withavail > 0, and sets eachlinearfield to0x80001000. It recomputes: - the leaf chain CRC (HAMMER2_CHECK_FREEMAP=iscsi_crc32over the 32KB leaf buffer,hammer2_chain.c:5414-5416), stored infreemap_blockset[0].check.freemap.icrc32(vol-header offset0x840); -icrc_volheader(iscsi_crc32over 65532 bytes, stored at0xFFFC).icrc_sects[7](sect0) andicrc_sects[6](sect1) are untouched (the freemap_blockset lives in sector 4, which is only covered byicrc_volheader, not by sect0/sect1 β verified againsthammer2_ondisk.c:528-558which validates only sect0, sect1, volheader).vnconfig -c vn1 crafted.img && mount -t hammer2 /dev/vn1@testvol /mnt/h2821thenecho 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: poisonsbmap->linearto0x80001000in up to 10 freemap leaf entries, recomputes the leaf chain CRC andicrc_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 byrun.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 fromboot.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
fixedVALIDATED 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.
Confirmed kernel references
- sys/vfs/hammer2/hammer2_freemap.c:616
- sys/vfs/hammer2/hammer2_freemap.c:619
- sys/vfs/hammer2/hammer2_freemap.c:631
- sys/vfs/hammer2/hammer2_freemap.c:633
- sys/vfs/hammer2/hammer2_freemap.c:635
- sys/vfs/hammer2/hammer2_freemap.c:727
- sys/vfs/hammer2/hammer2_freemap.c:749
- sys/vfs/hammer2/hammer2_freemap.c:785
- sys/vfs/hammer2/hammer2_freemap.c:803
- sys/vfs/hammer2/hammer2_chain.c:1100
- sys/vfs/hammer2/hammer2_disk.h:872
- sys/vfs/hammer2/hammer2_disk.h:102
- sys/vfs/hammer2/hammer2_disk.h:341
- sys/sys/systm.h:94
- sys/sys/systm.h:118
- sys/config/X86_64_GENERIC:56
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 int32
No comments yet.