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

No radix-alignment/64KB-window validation of on-disk data_off β€” chain->data + chain->bytes overruns the DIO buffer (OOB read/write)

Field Value
ID DF-2616
Status new
Severity High
CVSS 3.1 CVSS:3.1/AV:L/AC:L/PR:L/UI:N/S:U/C:H/I:H/A:H
CWE CWE-787 Out-of-bounds Write / CWE-125 Out-of-bounds Read
File sys/vfs/hammer2/hammer2_chain.c
Lines 938-1100
Area vfs
Confidence certain
Discovered 2026-08-28
Pass 2 (GLM 5.3 second pass)
Bucket hammer2
Reported pending
Known CVE none
CVE match novel

Summary

hammer2_chain_alloc/load_data derive chain->bytes purely from the low 6 radix bits of the attacker-supplied bref->data_off and never check that the block offset is aligned to its radix or fits inside one 64KB DIO window. The only guard is a KKASSERT in hammer2_io_alloc (hammer2_io.c:122-126) which is compiled out without INVARIANTS. With a valid radix (<=16) but a misaligned or window-crossing offset (e.g. data_off=0x18010: radix 16, lbase 0x18000), hammer2_io_data() returns bp->b_data+0x8000 inside a 65536-byte buffer while chain->bytes=65536, so the last 32KB of every access to chain->data lands past the end of the kernel buffer.

Root cause

chain.c:189-192 computes bytes = 1U << (data_off & HAMMER2_OFF_MASK_RADIX) with no geometry check. chain.c:998-1000 (load_data) and 1793-1799 (modify COW) pass bref->data_off straight to hammer2_io_bread/io_new; chain.c:1048 hammer2_io_data() returns bp->b_data + ((data_off & ~HAMMER2_OFF_MASK_RADIX) - pbase) whose own KKASSERT (hammer2_io.c:565) passes because the offset itself is in-window; chain.c:1100 sets chain->data to that pointer. The window-crossing condition is only asserted at hammer2_io.c:122-126, a no-op on non-INVARIANTS builds. Consequences: (a) indirect-block parents compute count = parent->bytes/128 (chain.c:2532) so combined_find/base_find iterate base[] entries up to 64KB past the buffer (OOB read interpreted as blockrefs); (b) the COW bcopy(chain->data, bdata, chain->bytes) at chain.c:1827 copies past-buffer heap into a file's new data block (direct kernel-heap disclosure via read()); (c) hammer2_base_delete bzero(scan,...) (chain.c:5195) and hammer2_base_insert base[i]=*elm / bcopy shifts (chain.c:5303, 5323-5334) write attacker-chosen 128-byte blockrefs past the buffer end (heap corruption primitive). Check hashes (XXH64/ICRC32/SHA256 over chain->bytes at 5391-5416, 5538-5584) also over-read. Distinct from DF-0763/DF-2605 (radix magnitude 17-31): fires with a perfectly valid radix; needs an alignment/window-bounds check, not a radix range check.

Threat model & preconditions

  • Attacker position: crafted filesystem image (mount-time; same precondition class as DF-0763). The bref lives in an on-disk blockref array whose CRC the attacker computes. Any traversal that instantiates the chain (ls/stat/readdir through the crafted indirect) reaches load_data.
  • Privileges gained or impact: on INVARIANTS kernels (stock X86_64_GENERIC) a deterministic panic at hammer2_io.c:126; on non-INVARIANTS builds (MINI64, custom performance kernels) a kernel heap OOB read AND write of up to 64KB with attacker-influenced content and offset β€” info leak into file contents/readdir names plus heap corruption suitable for privilege escalation.
  • Required config or capabilities: mount of the crafted image (root, or vfs.usermount=1 + owned device).
  • Reachability: one path-resolution syscall through the crafted indirect.

Proof of concept

PoC seed: findings/poc/DF-2616/ (image-builder recipe).

Build & run

mkfs image; patch a directory inode's blockset with
{type=INDIRECT, keybits=10, methods=0x00 (CHECK_NONE), data_off=0x00018010};
recompute parent check; mount; ls /mnt/dir; touch /mnt/dir/newname

Expected output

INVARIANTS: panic at hammer2_io.c:126 ("Illegal:").
Non-INVARIANTS: readdir returns names read from past-buffer heap; the touch
performs an OOB 128-byte write past the DIO buffer; COW bcopy leaks 32KB of
adjacent heap into readable file data.

Impact

Kernel heap OOB read + write (up to 64KB) from a mounted image, on non-INVARIANTS kernels; reliable panic on stock INVARIANTS kernels. Heap corruption primitive suitable for unpriv→root on the former.

Validate the data_off geometry (radix bound, radix alignment, single 64KB window) in hammer2_chain_load_data before any I/O, and mirror the check where dedup_off is installed in hammer2_chain_modify (chain.c:1611):

--- a/sys/vfs/hammer2/hammer2_chain.c
+++ b/sys/vfs/hammer2/hammer2_chain.c
@@ -935,8 +935,43 @@ hammer2_chain_load_data(hammer2_chain_t *chain)
    if ((chain->bref.data_off & ~HAMMER2_OFF_MASK_RADIX) == 0)
-       return;
+       return;     /* embedded (validated below by type) */
+
+   /*
+    * Validate the media reference geometry.  bref fields come
+    * from untrusted on-disk data.  The radix must be within the
+    * DIO size, the block must be aligned to its radix, and the
+    * block must not cross a 64KB DIO window boundary.  The DIO
+    * layer only asserts these conditions (hammer2_io.c), which is
+    * compiled out without INVARIANTS.
+    */
+   {
+       int radix;
+       hammer2_off_t lbase;
+       hammer2_off_t lsize;
+
+       radix = (int)(chain->bref.data_off & HAMMER2_OFF_MASK_RADIX);
+       lbase = chain->bref.data_off & ~HAMMER2_OFF_MASK_RADIX;
+       lsize = (hammer2_off_t)1 << radix;
+       if (radix > HAMMER2_PBUFRADIX ||
+           (lbase & (lsize - 1)) != 0 ||
+           (lbase & (hammer2_off_t)HAMMER2_PBUFMASK) + lsize >
+            (hammer2_off_t)HAMMER2_PBUFSIZE) {
+           chain->error = HAMMER2_ERROR_CHECK;
+           krateprintf(&krate_h2chk,
+                   "chain %016jx.%02x bad data_off "
+                   "geometry\n",
+                   chain->bref.data_off,
+                   chain->bref.type);
+           return;
+       }
+   }

    hmp = chain->hmp;

References

  • DF-0763 / DF-2605 (radix magnitude class β€” this is the geometry class)
  • hammer2_io.c:122-126 (assert-only guard), hammer2_io.c:565 (in-window check)

Timeline

  • 2026-08-28 Discovered during automated audit (pass 2, GLM 5.3).

Discussion (0)

No comments yet.

PoC verification

Evidence pack

findings/poc/DF-2616 Β· 27 files
FileTypeDescriptionSize
README.md β€” 4.1 KB ↓ raw
VERDICT.md β€” 6.5 KB ↓ raw
mkbase.sh β€” 837 B view raw
forge_E.py β€” 8.2 KB view raw
forge_df2616.py β€” 11.0 KB view raw
h2_A_sroot_cross.img β€” 64.0 MB ↓ download
h2_B_data_cross.img β€” 64.0 MB ↓ download
h2_E_groom10.img β€” 64.0 MB ↓ download
h2_E_groom.img β€” 64.0 MB ↓ download
h2_E_flushed.img β€” 64.0 MB ↓ download
trigger_leak.sh β€” 718 B view raw
trigger_write.sh β€” 1.3 KB view raw
trigger_D.sh β€” 1.7 KB view raw
trigger_E.sh β€” 1.9 KB view raw
panic_A.txt β€” 2.1 KB view raw
panic_B.txt β€” 1.2 KB view raw
fault_B_noinv.txt β€” 710 B view raw
run_E_noinv.log β€” 3.0 KB view raw
forensic_E.txt β€” 1.3 KB view raw
env.txt β€” 678 B view raw
build_noinv.log β€” 5.6 MB ↓ download
build_fix.log β€” 5.7 MB ↓ download
fix.diff β€” 3.8 KB view raw
fix_v1_too_strict.txt β€” 1.2 KB view raw
fix_run.log β€” 862 B view raw
code_hashes.txt β€” 231 B view raw
verdict.json β€” 7.7 KB view raw

DF-2616 β€” OOB read/write past the 64KB DIO buffer from crafted hammer2 data_off geometry

Finding: DF-2616 (High, hammer2 bucket) β€” hammer2_chain_alloc/ hammer2_chain_load_data derive chain->bytes from the radix of the attacker-supplied bref.data_off and never validate the geometry: the offset's alignment to its radix or containment within one 64KB DIO window. The only guard is a KKASSERT in hammer2_io_alloc (hammer2_io.c:126), compiled out without INVARIANTS. A crafted image with a perfectly valid radix but a misaligned / window-crossing offset makes chain->data + chain->bytes overrun the kernel DIO buffer.

What is in this pack

file what it is
mkbase.sh guest-side script: builds a clean 64MB hammer2 image with f1 + N marker files
forge_E.py host-side forger: walks volhdr→sroot→PFS→file-inode blocksets/indirects, relocates each w file's 1KB data block into its own 64KB window (valid geometry), crosses f1's DATA data_off (lbase\|0xFF00, radix kept), sets methods=0x00 (CHECK_NONE) + modify_tid (in-place path), CHECK_NONEs all ancestor brefs, recomputes the 3 volhdr CRC32Cs
forge_df2616.py earlier forger for variants A/B/C (small direct-blockset fs)
h2_A_sroot_cross.img variant A: volhdr.sroot_blockset[0].data_off = 0x180fd0a (radix 10 valid, misaligned+crossing) β€” mount-time trigger
h2_B_data_cross.img variant B: file f1's DATA bref crossed (0x1c0ff0a), CHECK_NONE
h2_E_groom10.img variant E: f1 crossed (0x200ff0a, radix 10) + 32 marker files relocated one-per-window (grooming)
h2_E_groom.img variant E with f1 = 64KB file (radix-16 crossed, OOB reach 0xFF00)
trigger_leak.sh, trigger_write.sh, trigger_D.sh, trigger_E.sh guest-side trigger sequences
panic_A.txt mount-time panic on stock INVARIANTS kernel (hammer2_io.c:126)
panic_B.txt panic loading the crafted FILE data chain on stock kernel
fault_B_noinv.txt kernel page fault in memmove (OOB bcopy source) on the no-INVARIANTS kernel
run_E_noinv.log groomed run on no-INVARIANTS kernel: crossed read survives (mapped neighbor), full sequence
forensic_E.txt post-run image forensics: attacker pattern planted through the crossed pointer at 0x200ff05
h2_E_flushed.img the flushed crafted image (evidence)
build_noinv.log full build log of the no-INVARIANTS kernel #1
fix.diff the verified fix (geometry validation in load_data/modify/dedup + io.c log guard)
build_fix.log / fix_run_*.log fix kernel build + before/after validation runs
verdict.json / manifest.json / VERDICT.md machine + human verdicts

Reproduce

  1. Base image: run mkbase.sh on the guest (root), pull base.img to the host.
  2. Forge: python3 forge_E.py base.img h2_E_groom.img (needs python3 on host).
  3. Push back, then on the guest as root: sh trigger_E.sh.
  4. Stock INVARIANTS kernel: mounting variant A (or reading f1 on B/E) panics at hammer2_io.c:126 β€” see panic_A.txt / panic_B.txt.
  5. no-INVARIANTS kernel (options INVARIANTS commented out, make -j6 nativekernel KERNCONF=X86_64_GENERIC): the KKASSERT is gone; the crossed access either page-faults in memmove (ungroomed: fault_B_noinv.txt) or, with the 32-marker grooming, reads/writes the kernel buffer in the adjacent KVA slot (run_E_noinv.log + forensic_E.txt).
  6. Fix: git apply fix.diff, rebuild, reboot β€” mount of A fails cleanly, reading f1 on E fails cleanly with illegal data_off geometry (see fix_run_*.log), no panic, no fault.

Expected observable results

  • INVARIANTS kernel: Illegal: ... + panic: assertion "pbase != 0 && ..." at hammer2_io_alloc β€” deterministic.
  • no-INVARIANTS + ungroomed: Fatal trap 12: page fault while in kernel mode ... memmove+0x28 (DDB stop).
  • no-INVARIANTS + groomed: no fault; read(2) of f1 returns bytes from beyond the DIO buffer; write(2) plants attacker content through the crossed pointer (on-media proof at image offset 0x200ff05).
  • Fixed kernel: clean EINVAL/EIO failures, illegal data_off geometry console message.
VERDICT.md
↓ download raw

DF-2616 β€” Verdict

REPRODUCED β€” Missing geometry validation of on-disk bref.data_off in hammer2 chain loading. Impact demonstrated end-to-end:

  1. Stock kernel (X86_64_GENERIC, INVARIANTS ON): deterministic panic (mount-time and file-read-time) at the KKASSERT in hammer2_io_alloc (hammer2_io.c:126) β€” reliable local DoS from a crafted image.
  2. Non-INVARIANTS (release-style) kernel: kernel heap OOB read AND write past the 64KB DIO buffer, plus a kernel-mode page fault (DoS) when the adjacent KVA slot is unmapped.

uid=0 escalation was NOT achieved within this run; the write primitive was characterized (below) but the adjacent-object placement could not be controlled deterministically on this guest (see "Exploit chain").

The bug, confirmed in source

  • hammer2_chain_alloc (hammer2_chain.c:189-192) derives chain->bytes purely from the low 6 radix bits of the attacker-controlled bref.data_off (HAMMER2_OFF_MASK_RADIX = 0x3F, hammer2_disk.h:461). No geometry check anywhere on the load path.
  • hammer2_chain_load_data (hammer2_chain.c:919-1102) only checks the embedded case (data_off & ~MASK == 0, line 938-939), then issues I/O (994-1000) and installs chain->data = hammer2_io_data(dio, data_off) (line 1048, 1100) = bp->b_data + (lbase & 0xFFFF).
  • The only geometry guard is hammer2_io_alloc hammer2_io.c:122-126: an unconditional kprintf("Illegal: ...") (so the pass-through is visible on release kernels) followed by a KKASSERT that is compiled out without INVARIANTS. On non-INVARIANTS builds execution continues and chain->data + chain->bytes runs past the end of the 64KB DIO buffer.
  • Distinct from DF-0763/DF-2605 (radix magnitude 17-63): DF-2616 fires with a perfectly valid radix (10 or 16) β€” the violation is the geometry (offset not contained in one 64KB window). Note one refinement to the finding text discovered during verification: hammer2's freemap allocates at 1KB granularity, so a radix-N block at a merely-1KB-aligned offset is legal on real filesystems (the over-strict alignment check in fix v1 broke the root fs boot β€” see below). The actual unchecked invariant is exactly what hammer2_io.c:126 asserts: pbase != 0 and [lbase, lbase+lsize) within one 64KB window (plus radix ≀ 16).

Demonstrated sinks

  • OOB read to userspace: hammer2_strategy_read_completion (hammer2_strategy.c:487) bcopy(data, bp->b_data, focus->bytes) with data = chain->data crossed; read(2) of the crafted file returns bytes from beyond the DIO buffer.
  • OOB write: hammer2_write_bp (hammer2_strategy.c:1310-1356): hammer2_io_newnz(... data_off ...) β†’ hammer2_io_data() β†’ bcopy(data, bdata, chain->bytes) writes attacker file data through the crossed pointer β€” in-window part lands at the crossed media offset (forensically recovered), past-end part lands in the adjacent kernel buffer. The overwrite-in-place path (hammer2_chain_modify, chain.c:1504-1516, CHECK_NONE + modify_tid > pfs_lsnap_tid) keeps the crossed data_off instead of COWing away from it.

Reproduction summary (full logs in this pack)

  • panic_A.txt β€” variant A (sroot bref 0x180fd0a: radix 10 VALID, lbase 0x180fd00, misaligned + crossing), stock kernel: Illegal: 0000000001800000 000000000180fd00+00000400 β†’ panic: assertion ... hammer2_io_alloc at hammer2_io.c:126 at MOUNT time.
  • panic_B.txt β€” variant B (file f1 DATA bref 0x1c0ff0a): mount OK, first read of /mnt/h2/f1 β†’ same panic via hammer2_chain_load_data.
  • fault_B_noinv.txt β€” same variant B on kernel #1 (INVARIANTS off): Fatal trap 12: page fault while in kernel mode ... memmove+0x28 movq (%rsi),%rdx, fault VA 0xfffff8006be66000 = bp->b_data + 0x10000 β€” the OOB bcopy source ran off the end of the 64KB buffer onto an unmapped page. Kernel stopped in DDB; ssh dead (DoS).
  • run_E_noinv.log + forensic_E.txt β€” variant E on kernel #1 with 32 marker files groomed into their own windows (one live DIO buffer each): the crossed read survived (adjacent KVA slot mapped β€” returned its content, zeros in this run, to userspace: the OOB read primitive), and the crossed write planted the attacker pattern through the crossed pointer β€” recovered from the flushed image at media offset 0x200ff05 (inside the crossed window tail, exactly where chain->data pointed). The Illegal: console marker fired on every crossed access (proving the kernel sailed past the compiled-out guard).

Exploit chain (what was and was not achieved)

Demonstrated: crafted image β†’ mount β†’ read(2) returns out-of-bounds kernel-buffer memory; write(2) plants attacker-controlled (LZ4-wrapped) content at a chosen out-of-bounds offset relative to a 64KB kernel buffer (0x300 bytes past its end in the radix-10 forge; up to 0xFF00 with a radix-16 chain β€” that variant faulted on an unmapped intermediate slot).

NOT achieved: deterministic placement of a chosen victim object in the adjacent KVA slot (so no uid=0). The adjacent slot identity is set by the fixed per-header KVA scheme (vfs_bio.c:638, b_kvabase = vm_map_min(buffer_map) + MAXBSIZE*n) and the per-CPU buffer queues, which the attacker cannot steer precisely from a mounted filesystem in this setup; in the successful run the slot held a clean, zero-filled buffer (mapped but never flushed), so the OOB write could not be recovered from media. The primitives (chosen-offset read/write beyond a kernel buffer object with attacker content) are in place; weaponization requires a victim-object grooming strategy against the buffer cache arena.

Fix validation

  • fix v1 (alignment-strict) was WRONG: it rejected legitimate radix-N blocks at 1KB-aligned offsets that real filesystems contain (root fs boot logged dozens of illegal data_off geometry 0x...0b/0c/0d rejections β€” radix 11/12/13 blocks at 1KB granularity β€” and degraded the boot). This is itself a verification artifact: the check hooks every chain load.
  • fix v2 (fix.diff in this pack) enforces exactly the io.c invariant: radix 0-with-offset / radix > 16 / lbase < 64KB / window-crossing rejected in hammer2_chain_load_data (sets chain->error = HAMMER2_ERROR_CHECK, cleanly failing reads via strategy.c:350 and writes via strategy.c:809), the same check for OPTDATA modify (chain.c:1525+) before hammer2_write_bp can issue I/O, a guard on dedup_off installation, and a widened diagnostic condition in hammer2_io_alloc. See fix_run_*.log for the before/after.

Fix verification

fixed
baseline reproduced→ patch + rebuild →patched clean

fix v2 (data_off geometry validation at every I/O issue point: radix 1..16, lbase>=64KB, block contained in one 64KB window) VALIDATED on guest: root fs boots with zero false rejections (legit radix-11/12/13 at 1KB granularity accepted), crafted mount fails cleanly EINVAL, crossed file reads fail cleanly EIO, no panic, no fault. v1 alignment-based predicate was too strict and rejected legal blocks (fix_v1_too_strict.txt).

fix_build.log (rc=0); fix_run.log (EINVAL/EIO clean rejections, root fs OK); fix_v1_too_strict.txt (why v1 wrong); fix.diff
↓ fix.diffDragonFly 6.5-DEVELOPMENT no-INVARIANTS + fix v2 (built in-guest from /usr/src, build_fix.log)

Confirmed kernel references

Detail

Exploit chain

crafted hammer2 image (root or vfs.usermount+owned device) -> mount -> chain load of crossed bref -> [INVARIANTS: panic; release: OOB] -> read(2) discloses bytes past the 64KB DIO buffer; write(2) via CHECK_NONE in-place path (chain.c:1504-1516) + hammer2_write_bp (strategy.c:1356) plants chosen content 0x300-0xFF00 bytes past the buffer end into the adjacent kernel buffer object. Demonstrated mechanically + forensically (in-window half on media); deterministic victim-object placement (->uid0) not achieved.

Evidence (decisive lines)

["panic_A.txt: mount-time panic, 'Illegal: 0000000001800000 000000000180fd00+00000400' + KKASSERT panic at hammer2_io.c:126 (stock kernel, valid radix 10)", 'panic_B.txt: same panic loading the crafted FILE DATA chain (mount OK, cat /mnt/h2/f1)', 'fault_B_noinv.txt: no-INVARIANTS kernel #1, Fatal trap 12 in memmove+0x28, fault VA 0xfffff8006be66000 = bp->b_data+0x10000 (OOB bcopy source off the 64KB buffer)', 'run_E_noinv.log: groomed run on kernel #1 - crossed read survives (ILLEGAL_AFTER_READ=1), full write sequence, clean umount', 'forensic_E.txt + h2_E_flushed.img: attacker pattern DF2616OOBWRITE recovered at media 0x200ff05 (write went through chain->data = bdata+0xFF00)', 'fix_v1_too_strict.txt: legitimate radix-11/12/13 brefs at 1KB granularity that the over-strict v1 alignment check rejected (proof of the real invariant)', 'fix_run.log: fixed kernel (#1 Aug 28 13:14, no-INVARIANTS + fix v2): variant A mount -> EINVAL clean; E10/E16 crossed reads -> EIO clean; root fs zero false rejections; guest stays up', 'build_noinv.log / build_fix.log: full kernel build logs (rc=0)']

PoC changes

Seed sketch replaced entirely: wrote a python3 image forger that walks volhdr->sroot->PFS-dir (incl. INDIRECT blockset arrays, BREF_TYPE_INDIRECT=2) to the file DATA brefs, sets methods=0x00 (CHECK_NONE, testcheck returns 1 - avoids all CRC recomputation) + modify_tid (enables overwrite-in-place), crosses data_off to lbase|0xFF00 with the ORIGINAL VALID radix, and recomputes only the three volhdr CRC32Cs (DF-0763's proven technique). Added grooming: 32 marker files each relocated (valid geometry) into their own 64KB window so their DIO buffers occupy adjacent KVA slots. Learned en route: hammer2 compresses small files (markers must be incompressible), radix-16 allocations burn ~2MB freemap each on small volumes, and buffer KVA slots are preassigned per header (vfs_bio.c:638).

Verified recommended fix

Validate data_off geometry (radix<=16 and nonzero, lbase>=64KB, block within one 64KB window - exactly the io.c KKASSERT invariant) in hammer2_chain_load_data, the OPTDATA path of hammer2_chain_modify, and dedup_off installation; fix.diff validated on-guest.

Verdict

REPRODUCED end-to-end. (1) Stock X86_64_GENERIC (INVARIANTS ON): a crafted hammer2 image whose sroot_blockset[0].data_off = 0x180fd0a (radix 10 = PERFECTLY VALID, lbase 0x180fd00 misaligned and crossing the 64KB DIO window) panics the kernel at mount time at hammer2_io.c:126 (panic_A.txt); the file-data variant (data_off=0x1c0ff0a) panics on the first read of the file (panic_B.txt) - deterministic local DoS from a mounted image. (2) A rebuilt kernel without INVARIANTS (release-style, build_noinv.log) removes the KKASSERT: the unconditional 'Illegal:' kprintf at hammer2_io.c:122-125 fires and execution continues, giving chain->data = bp->b_data + 0xFF00 with chain->bytes = 0x400: the OOB bcopy either page-faults in kernel mode (fault_B_noinv.txt: Fatal trap 12 at memmove+0x28, fault VA = bp->b_data+0x10000, DDB stop, ssh dead - DoS) or, with 32 marker files groomed one-per-64KB-window so the adjacent buffer KVA slot is mapped, read(2) returns out-of-bounds kernel-buffer bytes to userspace and write(2) plants attacker content through the crossed pointer (run_E_noinv.log + forensic_E.txt: pattern recovered from the flushed image at media offset 0x200ff05, exactly where chain->data pointed). uid=0 was NOT achieved: the adjacent-slot victim object could not be placed deterministically (fixed per-header KVA scheme, vfs_bio.c:638; the observed neighbor was a mapped but clean zero-filled buffer, so the past-end write half could not be recovered from media). One refinement to the finding discovered during verification: hammer2's freemap allocates at 1KB granularity, so radix-N blocks at merely-1KB-aligned offsets are LEGAL on real filesystems - the actually-missing invariant is exactly what io.c asserts (pbase!=0, radix<=16, no 64KB-window crossing); an alignment-based fix (v1) broke the root fs boot (fix_v1_too_strict.txt). Fix v2 (fix.diff) implements the correct predicate at every I/O issue point and was VALIDATED on the guest: root fs boots with zero false rejections, variant A mount fails cleanly (EINVAL), crossed file reads fail cleanly (EIO, 'illegal data_off geometry'), no panic, no fault (fix_run.log).