fix(pdf417): bound bitstream parser control operands by the length descriptor - #95
Open
ginit64 wants to merge 1 commit into
Open
fix(pdf417): bound bitstream parser control operands by the length descriptor#95ginit64 wants to merge 1 commit into
ginit64 wants to merge 1 commit into
Conversation
…scriptor The PDF417 bitstream parser mixes two different bounds. Most loops stop at `codewords[0]`, the Symbol Length Descriptor, which is correct: everything after the declared data codewords is error correction data. But several control sequences index the array directly, without checking anything. `pdf_417_scanning_decoder` corrects errors before it parses, so a malformed symbol with valid error correction reaches the parser intact and can drive those unchecked transitions. The observable results on stable 0.9.2 are: - `decode` panics on an empty slice, and on `codewords[0] == 0` it computes `codewords[0] - codeIndex` for the text compaction buffer length, which underflows and then sizes an allocation; - Mode Shift to Byte Compaction (913) and the charset ECI (927) read their operand unconditionally, in `decode`, in `textCompaction` and in `byteCompaction`. On the last data codeword that is either an out of range index or an error correction codeword decoded as content. In `textCompaction` it additionally underflows the reallocated buffer length; - `decodeMacroBlock` tests `codewords[codeIndex] == 923` after the file ID loop with no bound at all. That is the read `testStandardSample3` works around with its "Final dummy ECC codeword required to avoid ArrayIndexOutOfBounds" note. When the codeword there is 923, the optional field length underflows and the following slice range panics; - the optional field latch reads its field identifier unconditionally, then starts a text compaction run one past the boundary, underflowing again; - the general purpose and user defined ECIs advance past operands without checking they exist. Express every bound as the declared data codeword count instead: - `dataCodewordCount` validates the descriptor once per entry point and rejects an absent, zero, or oversized declaration as `FORMAT`; - `operandAt` reads a control operand only from inside that range; - `skipOperands` proves skipped operands exist before advancing; - `textCompactionCapacity` uses `checked_sub`, so no buffer length can be derived from an underflow. Valid symbol semantics, error correction, character sets, the detector and the supported symbologies are untouched: the full test suite passes unchanged, including the Annex H standard samples, the ECI suites, the permutation suites and every blackbox suite. Tests: the three truncated Macro cases were `#[should_panic]`, which cannot tell a returned error from a crash, so they now assert `Err(FORMAT)` explicitly. New cases cover the unusable descriptor, a file ID ending exactly at the boundary (with and without the dummy codeword, which is no longer needed), a truncated optional field latch/identifier/payload, a truncated 913, and truncated ECI sequences. Each also asserts that decoding a vector with its error correction tail gives the same result as decoding its declared data codewords alone, which is the general statement of the bug.
Collaborator
|
I will attempt to review the PR this week. |
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.
Summary
The PDF417 bitstream parser uses two different bounds. Most loops stop at
codewords[0], the Symbol Length Descriptor, which is correct — everything after the declared data codewords is error correction data. But several control sequences index the codeword array directly, with no check at all.pdf_417_scanning_decoder::decodeCodewordscallscorrectErrorsbeforeverifyCodewordCountand before parsing, so a malformed symbol whose error correction is valid reaches the parser intact and can drive those unchecked transitions.verifyCodewordCountalso rewrites a zero descriptor tolen - numECCodewords, so the error correction tail inside the slice is expected and normal.Two consequences, both reachable from image input:
usizesubtraction underflow, and an allocation sized from the underflowed value.Measured baseline on v0.9.2
Harness calls
decoded_bit_stream_parser::decode(ordecodeMacroBlock) per vector undercatch_unwind,default-features = false, features = ["decoders", "pdf417", "encoding_rs"].Release profile is the same 13 panics, with the underflow surfacing where the value is consumed:
The six non-panicking rows are the corruption case.
[3, 477, 913, 1000, 1000]declares three data codewords, so1000is error correction data — yetU+03E8appears in the decoded text. Decoding[3, 477, 913]alone panics, so that character comes only from the tail.Defects
decode:codewords[0]on an empty slice;textCompaction(codewords, 1, ..)computescodewords[0] - 1for the buffer length, so a zero descriptor underflows and the result sizes avec!.decode:MODE_SHIFT_TO_BYTE_COMPACTION_MODE(913) andECI_CHARSET(927) readcodewords[codeIndex]unconditionally.decode:ECI_GENERAL_PURPOSE(926) andECI_USER_DEFINED(925) advance past operands without checking they exist.decodeMacroBlock:if codewords[codeIndex] == BEGIN_MACRO_PDF417_OPTIONAL_FIELDafter the file-ID loop has no bound. This is the readtestStandardSample3works around with "Final dummy ECC codeword required to avoid ArrayIndexOutOfBounds". If that codeword is923,optionalFieldsStartis set pastcodeIndexandcodeIndex - optionalFieldsStartunderflows.decodeMacroBlock: the optional-field latch reads its field identifier unconditionally, then callstextCompaction(codewords, codeIndex + 1, ..), which underflows the buffer length when the identifier came from the tail.decodeMacroBlock:optionalFieldsLength -= 1is unchecked.textCompaction: both buffer lengths usecodewords[0] as usize - codeIndex; the 913 and 927 arms read operands unconditionally, and the 927 arm reallocates from the same unchecked subtraction after advancing.byteCompaction: the leading-ECI loop and the innercode == ECI_CHARSETbranch read operands unconditionally.Fix
Express every bound as the declared data-codeword count:
dataCodewordCountvalidates the descriptor once per entry point; absent, zero, or larger than the slice isExceptions::FORMAT.verifyCodewordCountalready guarantees1..=lenfor the scanning-decoder path, so this only constrains direct callers.operandAtreads a control operand only from inside the declared range.skipOperandsproves skipped operands exist before advancing.textCompactionCapacityuseschecked_sub, so no buffer length is ever derived from an underflow.The optional-field check after the file-ID loop is now bounded, and the optional-field copy uses checked subtraction.
Nothing else changes: no valid-symbol semantics, no error-correction algorithms, no character-set behaviour, no detector behaviour, no symbology support. The now-redundant
codeIndex < codewords.len()in the file-ID loop is dropped becausedataCodewordCount <= codewords.len()already holds, and keeping two different bounds side by side is what caused this bug class.Tests
cargo test --release: 629 lib tests + every blackbox suite pass, 0 failed. Annex H standard samples, ECI suites, permutation suites,testBinaryData, and the PDF417 blackbox suites are unchanged.cargo clippy --lib --all-targetsreports the same 89 warnings as the unpatched tag.testStandardSample1/Sample2already carry1000, 1000, 1000past their declared boundary with the comment "we should never reach these". That is now asserted rather than assumed.Three existing tests were
#[should_panic], which cannot distinguish a returned error from a crash — exactly the property at issue — so they now assertErr(Exceptions::FORMAT):testSampleWithBadSequenceIndexMacrotestSampleWithNoFileIdMacrotestSampleWithNoDataNoMacroNew tests:
testUnusableSymbolLengthDescriptortestMacroFileIdEndingAtDataBoundary— also shows the dummy codewordtestStandardSample3needs is no longer requiredtestTruncatedMacroOptionalFieldtestMacroOptionalFieldPayloadEndingAtDataBoundarytestTruncatedModeShiftToByteCompactiontestTruncatedEciControlSequencetestValidSamplesIgnoreTheErrorCorrectionTailEach also asserts the general property via
assertErrorCorrectionTailIsNotPayload: decoding a vector together with its error correction tail must equal decoding&codewords[..codewords[0]]alone. That fails for any read past the boundary, not only for the reads a specific assertion anticipated.Notes
Based exactly on
v0.9.2so it can be cherry-picked ontomain; happy to rebase or split the test-only changes if you prefer.catch_unwindis not a viable mitigation for downstream users onwasm32or withpanic = "abort", which is why this is proposed as a parser fix rather than a caller-side guard.