Skip to content

fix(cell)!: refuse a negative serial type instead of wrapping it into a length - #16

Merged
h4x0r merged 2 commits into
mainfrom
fix/cell-offset-overflow
Aug 21, 2026
Merged

fix(cell)!: refuse a negative serial type instead of wrapping it into a length#16
h4x0r merged 2 commits into
mainfrom
fix/cell-offset-overflow

Conversation

@h4x0r

@h4x0r h4x0r commented Aug 21, 2026

Copy link
Copy Markdown
Contributor

A negative serial type wraps into a near-usize::MAX length and overflows the range arithmetic in decode_value.

Found by fuzzing ios-backup-core, whose Manifest.db path feeds this decoder attacker-controlled SQLite bytes.

The defect

n => {
    // odd, >= 13: text
    let len = ((n - 13) / 2) as usize;
    let bytes = buf.get(off..off + len).ok_or(Error::TruncatedCell)?;

A serial type is a varint, so a damaged record decodes one as a negative i64. Every arm above 11 is written for the positive cases, so negatives fall through to this catch-all:

serial ((n - 13) / 2) as usize
-1 18446744073709551609
-3 18446744073709551608
i64::MIN + 1 underflows at the subtraction

buf.get(range) reads as though the bounds check covers this. It does not — the range is constructed before get is handed it.

  • with overflow checks (fuzz/debug/test): panic, in a crate documented as never panicking on malformed input
  • in release: wraps to a small number, get succeeds, and the caller receives bytes that are not the value

The release behaviour is the worse one, and it is the one that ships.

The fix

  • text arm guarded n if n >= 13, so the subtraction cannot underflow by construction
  • negatives get an explicit arm returning Error::MalformedSerialType { serial, offset }, carrying the offending value and where it was read — folding them into TruncatedCell would send a reader hunting for a truncation that is not there
  • a span() helper checked_adds before building any evidence-derived range; read_be_u64 moves onto it too

span() is the part that generalises. The same shape was already known here — line 4887 uses saturating_add for exactly this — and it never reached its siblings.

The remaining off + 2 / off + 4 / off + frame_stride sites are deliberately untouched: each addend is a small constant or bounded by the enclosing slice walk, so none can overflow, and churning them would bury the two that mattered.

Breaking

Error gains a variant and becomes #[non_exhaustive]. Both are breaking, so they land together and spend the break once. Being a 0.x crate, consumers pinned at "0.11" are not moved involuntarily.

There is no Display impl to update — Error derives only Debug today. That's a real gap, worth its own change.

Verification

  • RED then GREEN as separate commits (6616da2, 14bb9b5). RED failed at core/src/lib.rs:4700 with attempt to subtract with overflow — the intended reason, not a compile error
  • a companion test pins serial 12/13 decoding to empty blob/text, so the fix cannot buy safety by rejecting the smallest legal values
  • 552 tests across 71 targets pass (--no-fail-fast), no regression
  • the 4.1 KB minimized input that crashed ios-backup-core now executes cleanly through a patched build
  • a fresh 2,214,953-run seeded session added 1,947 corpus units and produced no artifact
  • fmt / clippy -D warnings / deny / vet all clean

🤖 Generated with Claude Code

h4x0r added 2 commits August 21, 2026 21:16
Found by fuzzing ios-backup-core, whose Manifest.db path feeds this decoder
attacker-controlled SQLite bytes. Seeded with real fixtures, the target crashed
in 143,853 runs.

A serial type is a varint, so a damaged record can decode one as a negative i64.
Every arm above 11 is written for the positive cases, so a negative value falls
through to the catch-all text arm and `((n - 13) / 2) as usize` wraps: serial -1
becomes a length of 18446744073709551609.

    let len = ((n - 13) / 2) as usize;
    let bytes = buf.get(off..off + len).ok_or(Error::TruncatedCell)?;

`buf.get(range)` reads as though the bounds check makes this safe, but the range
is constructed before `get` ever sees it. Under overflow checks the arithmetic
panics; in a release build it wraps to a small number, `get` succeeds, and the
caller receives bytes that are not the value -- silently wrong evidence, which is
the worse of the two outcomes.

The test fails at the subtraction rather than the addition, which is a second
overflow site in the same expression: `i64::MIN + 1 - 13` underflows before any
range is built.

A companion test pins serial 12 and 13 decoding to empty blob/text, so the fix
cannot buy safety by rejecting the smallest legal values.
The catch-all text arm was written for "odd, >= 13" and also caught every
negative serial type, which a varint in a damaged record readily produces.
`i64::MIN - 13` underflows, and -1 gives `((-1 - 13) / 2) as usize` =
18446744073709551609, after which `off + len` overflows.

Three changes, smallest to largest:

  - the text arm is now guarded `n if n >= 13`, so its subtraction cannot
    underflow by construction rather than by hope;
  - negatives fall to an explicit arm returning the new
    `Error::MalformedSerialType { serial, offset }`, carrying the offending value
    and where it was read. Folding them into TruncatedCell would send a reader
    hunting for a truncation that is not there;
  - every evidence-derived span goes through a `span()` helper that
    `checked_add`s before building the range.

`span()` is the part that generalises. `buf.get(off..off + len)` reads as though
the bounds check covers the arithmetic, and it does not -- the range is built
before `get` is handed it. Under overflow checks that panics; in a RELEASE build
it wraps to a small number, `get` succeeds, and the caller receives bytes that
are not the value. The silent-wrong-evidence case is the worse one and it is the
one that ships.

The same shape was already known here: line 4887 uses saturating_add for exactly
this, and the knowledge never reached its siblings. read_be_u64 is moved onto
span() too. The remaining `off + 2` / `off + 4` / `off + frame_stride` sites are
left alone deliberately -- each addend is a small constant or is bounded by the
enclosing slice walk, so none can overflow, and churning them would bury the two
that mattered.

BREAKING CHANGE: `Error` gains a variant and becomes `#[non_exhaustive]`. Both
are breaking, so they land together and spend the break once. There is no
Display impl to update -- `Error` derives only Debug today, which is worth
addressing separately.

Verified beyond the unit tests: the 4.1 KB minimized input that crashed
ios-backup-core's parse_manifest target now executes cleanly through a patched
build, and a fresh 2,214,953-run seeded session over the same target added 1,947
corpus units and produced no artifact.
@h4x0r
h4x0r merged commit 2772ea5 into main Aug 21, 2026
24 checks passed
@h4x0r
h4x0r deleted the fix/cell-offset-overflow branch August 21, 2026 14:00
@h4x0r h4x0r mentioned this pull request Aug 21, 2026
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