# DF-2652 — wrong bitmap-pair index (`& 15` vs 32 blocks) in hammer2_freemap_adjust()

**status: reproduced** | **impact: dos (silent cross-file data corruption / file destruction; content bleed on no-check fs)** | **confidence: certain**

## Root cause

`hammer2_freemap_adjust()` computes the bit position of a 16KB chunk
inside its 512KB `bitmapq[]` element as (freemap.c:1073):

```c
start = ((int)(data_off >> HAMMER2_FREEMAP_BLOCK_RADIX) & 15) * 2;
```

Each element holds **32** chunks (5 bits: `HAMMER2_BMAP_BLOCKS_PER_ELEMENT`;
see the allocator's own math at freemap.c:636 `j = (offset / 8192) & 62`
and bulkfree's staging at bulkfree.c:921-924
`((data_off & HAMMER2_BMAP_INDEX_MASK) >> BLOCK_RADIX) << 1`, both of
which use all 5 bits).  The `& 15` truncates the 5-bit block number to
4 bits, so for chunks 16..31 of every 512KB element (data_off bit 18 set)
every DORECOVER fixup marks the pair of chunk (n-16) — 256KB lower, same
element — instead of chunk n:

* the wrong (possibly free) chunk is set to 11 with
  `bmap->avail -= 16K` and `voldata.allocator_free -= 16K` (leak +
  accounting drift), and
* **the actually-referenced chunk is left unmarked (00)** → the allocator
  (`hammer2_bmap_alloc` scans for 00 pairs, freemap.c:694-705) hands the
  still-live block to the next allocation → overlapping allocations.

Reachable with **no crafted image at all** from either of the two
DORECOVER callers:
* mount-time crash recovery (vfsops.c:2234/:2325) — "Updates to the free
  block table are allowed to lag flushes by one transaction" (vfsops.c
  comment): any crash with unflushed allocations whose blocks sit in the
  upper 256KB of a 512KB element (~50% of blocks) are mismarked on the
  next mount;
* the dedup re-registration path (chain.c:1610-1627, `dedup_enable=1` by
  default, vfsops.c:71).

The PoC forges the crash-recovery condition deterministically for one
fileA chain (X0 = 0x1c40000): its 4 bit-pairs are cleared 11→00 in the
on-disk leaf and fileA's ancestry mirror_tids are bumped above
freemap_tid so recovery re-adjusts them.

## Decisive run (stock INVARIANTS kernel #0)

1. Mount forged image (mount succeeds — the mismark is silent).
2. `cp fileB.bin /mnt/h2t/fileB` (1.5MB, 24×64K unique chains); sync; umount.
3. Host-side inspection of the post-run image (`after2652.img`):

```
disk@X0 (0x1c40000): b'FILEB-CHUNK-000000-...'        <- fileB chain 0 landed ON X0
FILEA chunk 16..19 header on disk: NOT FOUND           <- fileA's block overwritten
FILEB chunk 0..3 header on disk: 0x1c40000..0x1c4c000  <- the overlapping allocs
leaf: bmap[7] avail 0x200000 -> 0x80000                <- accounting follows the lying bitmap
```

4. Remount + `md5 /mnt/h2t/fileA` → **I/O error** (fileA's iscsi32 CRC no
   longer matches because its live block was overwritten by fileB) — the
   victim's file is silently destroyed (data loss).
5. Content-bleed variant (forge2652b.img: fileA's DATA brefs CHECK_NONE,
   as on a no-check hammer2 fs): remounting and reading fileA chunks
   16..19 returns **FILEB-CHUNK-000000..000003** — fileA serves another
   file's bytes:

```
=== fileA chunks 16..19 after remount (cross-file bleed?):
FILEB-CHUNK-000000
FILEB-CHUNK-000001
FILEB-CHUNK-000002
FILEB-CHUNK-000003
```

## Fix validation (kernel #1 with hunk 3 of fix_all_three_findings.diff)

`start = ((int)(data_off >> BLOCK_RADIX) & (HAMMER2_BMAP_BLOCKS_PER_ELEMENT - 1)) * 2`

| kernel | forged image + fileB write | fileA after remount |
|---|---|---|
| #0 stock | fileB chain 0 allocated at 0x1c40000 (over fileA) | chunks 16-19 destroyed → EIO / FILEB content |
| #1 patched | fileB allocates fresh space | md5 4511730a267863dfcb88504c3068b2f2 == pristine fileA.bin; chunks read FILEA-CHUNK-000016..19 |

No panic in either run (this bug is silent corruption, not a crash).

## Threat model

Unprivileged multi-user system: user B's writes (after any crash-recovery
mismark of user A's blocks, or a dedup-adjust on an affected offset) are
allocated on top of user A's live data → destruction/disclosure of A's
file content; no mount privilege required for the crash-recovery trigger.
Crafted-image mount makes it fully deterministic (this PoC).

## Artifacts

- `forge_2652.py` (pair-clear + mtid-bump forge), `forge2652.img.gz`,
  `forge2652b.img.gz` (CHECK_NONE-bref bleed variant, see build.sh),
  `after2652.img.gz` (post-corruption image), `fileA.bin.gz`/`fileB.bin.gz`.
- `run.log` — full session (corruption + EIO + bleed).
- `fix.diff` — the `& 31` fix; shared validation kernel log in DF-2651's
  `fix_all_three_findings.diff` / `fix_build.log` / `fix_run.log`.
