Skip to content

fix(af): bound the anti-forensic stripe count (third fuzz-found cost axis) - #6

Merged
h4x0r merged 2 commits into
mainfrom
fix/af-stripes-bound
Aug 4, 2026
Merged

fix(af): bound the anti-forensic stripe count (third fuzz-found cost axis)#6
h4x0r merged 2 commits into
mainfrom
fix/af-stripes-bound

Conversation

@h4x0r

@h4x0r h4x0r commented Aug 4, 2026

Copy link
Copy Markdown
Contributor

Why there was still a timeout

The nightly unlock fuzz target kept timing out after the PBKDF2 and Argon2
bounds landed, with a new artifact. Those bounds were necessary and not
sufficient
— key derivation isn't the only cost the header chooses.

KeySlot::stripes is a u32 read straight from the header (be_u32 at offset
44 for LUKS1, the af.stripes JSON field for LUKS2) and nothing validated
it
. It reached two unbounded places:

// volume.rs — the header sizes an allocation
let material_len = af::material_len(key_bytes, slot.stripes as usize);
let mut material = vec![0u8; material_len];

// af.rs — and the trip count of the merge loop
for i in 0..stripes.saturating_sub(1) { ... }

64 bytes × u32::MAX stripes is roughly 275 GB, requested on the
container's say-so.

The bound

MAX_STRIPES = 1 << 20. LUKS1 fixes the count at 4000 and cryptsetup writes the
same for LUKS2, so that's two orders of magnitude of headroom over anything
genuine.

Enforced in two places, because there are two ways in:

  • material_len is now fallible — it computes a size the caller immediately
    allocates, so an implausible count is refused here rather than returned as an
    allocation request. The multiply is checked too: block_size * stripes can
    wrap, and a wrapped product means a small allocation followed by
    out-of-bounds reads — worse than a large one.
  • merge validates independently — it's re-exported as the public
    af_merge, so it can't assume a caller came through material_len; its loop
    runs stripes - 1 times regardless of the material length.

The refusal names the count and the buffer size it would have produced, so an
examiner sees what the container asked for rather than a bare "invalid".

Third axis, same class

This is the third header-chosen cost in the unlock path: PBKDF2 iterations,
Argon2 time/memory, now AF stripes. I reported the earlier PR as fixing the DoS
when it had fixed two of three — the surviving fuzz timeout is what pointed
here.

Verification

  • cargo fmt --all -- --check — clean
  • cargo clippy --workspace --all-targets -- -D warnings — clean
  • cargo test --workspace7 suites, 0 failures
  • coverage gate — 0 uncovered lines
  • a real 4000-stripe split still round-trips, so the bound doesn't reject
    genuine containers

h4x0r added 2 commits August 4, 2026 02:11
The nightly `unlock` fuzz target still times out after the PBKDF2 and Argon2
bounds landed, with a new artifact. Those bounds were necessary and not
sufficient: key derivation is not the only cost the header chooses.

`KeySlot::stripes` is a `u32` read straight from the header -- `be_u32` at
offset 44 for LUKS1, the `af.stripes` JSON field for LUKS2 -- and nothing
validates it. It reaches two unbounded places:

  * `af::material_len` multiplies `block_size * stripes` and the caller
    immediately does `vec![0u8; material_len]`. 64 bytes times u32::MAX
    stripes is roughly 275 GB, allocated on the container's say-so.
  * `af_merge` loops `stripes - 1` times, hashing each round.

Real containers use 4000 -- LUKS1 fixes it there and cryptsetup writes the same
for LUKS2 -- so a bound far above that costs nothing real.

Three tests: `material_len` must refuse an absurd count and name it; `merge`
must refuse independently, because it is re-exported as the public `af_merge`
and so cannot assume a caller went through `material_len` first; and a real
4000-stripe split must still round-trip, since a bound that rejects genuine
containers would be worse than the bug.

RED: `material_len` returns `usize` with no ceiling, and
`LuksError::ImplausibleStripes` does not exist, so this does not compile.
`MAX_STRIPES` caps the count at 1 << 20. LUKS1 fixes it at 4000 and cryptsetup
writes the same for LUKS2, so that is two orders of magnitude of headroom over
anything genuine while keeping both costs bounded.

Enforced in two places, because there are two ways in:

  * `material_len` is now fallible. It computes the size of a buffer the caller
    immediately allocates, so an implausible count has to be refused here rather
    than handed back as an allocation request. The multiply is checked too --
    `block_size * stripes` can wrap, and a wrapped product is a *small*
    allocation followed by out-of-bounds reads, which is worse than a large one.
  * `merge` validates independently. It is re-exported as the public
    `af_merge`, so it cannot assume a caller came through `material_len`: its
    loop runs `stripes - 1` times whatever the material length says.

The two call sites in `volume.rs` propagate with `?`. Both already sat directly
in front of `vec![0u8; material_len]`, which was the allocation being sized by
the header.

Refusal names the count and the buffer size it would have produced, so an
examiner sees what the container asked for rather than a bare "invalid".

This is the third cost axis in the unlock path that the header chooses; the
PBKDF2 iteration count and the Argon2 time/memory costs were the first two. The
fuzz timeout that survived those bounds is what pointed here.

Full workspace: 7 suites, 0 failures; fmt, clippy and the coverage gate clean.
@h4x0r
h4x0r merged commit cd7e817 into main Aug 4, 2026
17 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant