fix(cell)!: refuse a negative serial type instead of wrapping it into a length - #16
Merged
Conversation
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.
Open
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
A negative serial type wraps into a near-
usize::MAXlength and overflows the range arithmetic indecode_value.Found by fuzzing
ios-backup-core, whoseManifest.dbpath feeds this decoder attacker-controlled SQLite bytes.The defect
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:((n - 13) / 2) as usize-1-3i64::MIN + 1buf.get(range)reads as though the bounds check covers this. It does not — the range is constructed beforegetis handed it.getsucceeds, and the caller receives bytes that are not the valueThe release behaviour is the worse one, and it is the one that ships.
The fix
n if n >= 13, so the subtraction cannot underflow by constructionError::MalformedSerialType { serial, offset }, carrying the offending value and where it was read — folding them intoTruncatedCellwould send a reader hunting for a truncation that is not therespan()helperchecked_adds before building any evidence-derived range;read_be_u64moves onto it toospan()is the part that generalises. The same shape was already known here — line 4887 usessaturating_addfor exactly this — and it never reached its siblings.The remaining
off + 2/off + 4/off + frame_stridesites 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
Errorgains a variant and becomes#[non_exhaustive]. Both are breaking, so they land together and spend the break once. Being a0.xcrate, consumers pinned at"0.11"are not moved involuntarily.There is no
Displayimpl to update —Errorderives onlyDebugtoday. That's a real gap, worth its own change.Verification
6616da2,14bb9b5). RED failed atcore/src/lib.rs:4700with attempt to subtract with overflow — the intended reason, not a compile error--no-fail-fast), no regressionios-backup-corenow executes cleanly through a patched build-D warnings/ deny / vet all clean🤖 Generated with Claude Code