fix(arrow-avro): cap shift in VLQDecoder::long to avoid overflow panic - #9887
Closed
masumi-ryugo wants to merge 1 commit into
Closed
fix(arrow-avro): cap shift in VLQDecoder::long to avoid overflow panic#9887masumi-ryugo wants to merge 1 commit into
masumi-ryugo wants to merge 1 commit into
Conversation
`VLQDecoder::long` accumulates a stateful zig-zag varint across multiple calls and shifts the next 7-bit payload by the running shift count. The shift wasn't bounded, so an overlong varint that keeps emitting continuation bytes drives the shift past 63 and panics inside `<< self.shift`: thread '<unnamed>' panicked at arrow-avro/src/reader/vlq.rs:35:33: attempt to shift left with overflow The cargo-fuzz `avro_reader` harness being prototyped for apache#5332 reproduces this in single-digit minutes from an empty corpus with a 19-byte input (the Avro file magic followed by 13 0xFF continuation bytes and a terminator). `read_varint`, `read_varint_array`, `read_varint_slow`, and `skip_varint*` in the same file already cap at 10 bytes / shift 63 and so are not affected; only the streaming `VLQDecoder::long` path was missing the bound. Switch to `checked_shl(self.shift).unwrap_or(0)` so contributions past bit 63 are silently dropped (matching what the eventual `u64` would have to do anyway), and `saturating_add(7)` so a truly malicious continuation run can't wrap `self.shift` past `u32::MAX` either. The terminator byte (high bit clear) still ends the varint, so a malformed varint that never terminates either runs out of input (returns `None`) or yields a truncated `u64` from the trailing terminator. Includes a regression test in `reader::vlq::tests` driving an overlong continuation run + terminator and asserting that the subsequent call decodes a fresh varint cleanly (state reset). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
alamb
reviewed
May 5, 2026
alamb
left a comment
Contributor
There was a problem hiding this comment.
Thank you for this @masumi-ryugo
| // the shift is at most 63. Use `checked_shl` to silently drop | ||
| // contributions past bit 63 instead of panicking with | ||
| // "attempt to shift left with overflow", and saturate the | ||
| // shift counter so a stream of continuation bytes can't wrap |
Contributor
There was a problem hiding this comment.
I wonder if it would be better to return an error here on invalid input rather than silently ignoring it 🤔
@mzabaluev or @jecsand838 I wonder if you have an opinion
|
Thank you for your contribution. Unfortunately, this pull request is stale because it has been open 60 days with no activity. Please remove the stale label or comment or this will be closed in 7 days. |
Jefffrey
added a commit
that referenced
this pull request
Jul 25, 2026
# Which issue does this PR close? - Closes #10290. # Rationale for this change A malformed or malicious Avro file with an unterminated run of varint continuation bytes drives `VLQDecoder::long`'s `self.shift` past 63, panicking inside `<< self.shift` ("attempt to shift left with overflow") in debug builds. In release builds without overflow-checks the shift is silently masked instead — arguably worse, since the decoder keeps running and can produce a wrong value rather than fail loudly. Any service that reads attacker-controlled Avro bytes through `ReaderBuilder::build` can hit this from the public API. There's a prior attempt at this in #9887 (closed by the stale bot, not rejected — no activity for 60 days). That PR used `checked_shl(self.shift).unwrap_or(0)` to silently drop overflowing contribution bits, which stops the panic but places no bound on how many continuation bytes it will consume, and @alamb [asked](#9887 (comment)) whether malformed input should return an error instead of being silently absorbed — that question was never answered before the PR went stale. # What changes are included in this PR? - `VLQDecoder::long` now bounds the varint to 10 bytes, mirroring the bound already enforced by `read_varint_array`'s handling of its 10th byte a few lines below in the same file, and returns `AvroError::ParseError` on an overlong/malformed varint instead of panicking or silently truncating. The decoder's internal state is reset on error so a subsequent call decodes a fresh varint cleanly. - This changes `long`'s signature from `Option<i64>` to `Result<Option<i64>, AvroError>`, updating its 6 call sites in `block.rs`/`header.rs`. All of them already sit inside functions returning `Result<_, AvroError>` and already propagate sibling parse errors via `?`, so each call site only needed a `?` added. This is a purely internal, non-breaking change — `vlq`, `block`, and `header` are all private (non-`pub`) modules within `arrow-avro`, not part of the crate's public API. # Are these changes tested? - Added `test_long_overlong_varint_returns_error` reproducing the exact 19-byte input from the issue's cargo-fuzz repro (Avro magic + 13 `0xFF` continuation bytes), asserting the decoder now errors instead of panicking, and that it resets cleanly for the next call. - Added `test_long_roundtrip`, a direct unit test for `VLQDecoder::long`'s happy path (zig-zag decode), which had no dedicated test before this change — only the unsigned `read_varint`/`read_varint_array` siblings were covered by `test_varint`. - Verified the exact fuzzer input from the issue no longer panics end-to-end through the public `ReaderBuilder::build` API (ad-hoc integration test, not committed). - `cargo test -p arrow-avro --lib` (402 passed), `cargo clippy -p arrow-avro --all-targets -- -D warnings`, and `cargo fmt -p arrow-avro -- --check` all clean. # Are there any user-facing changes? No public API changes (`vlq`/`block`/`header` are private modules). Behaviorally, malformed/malicious Avro input that previously panicked (or silently produced garbage in a release build) now returns a `AvroError::ParseError` from the public `decode`/`ReaderBuilder::build` path, which is the intended, documented behavior for malformed input elsewhere in these same decoders. --- This PR was prepared with AI assistance (Claude Code). I reviewed the root cause in the source, traced every call site of `VLQDecoder::long` to confirm the signature change is safe and non-breaking, verified the module-privacy claim before choosing this approach, and ran the fuzzer repro from the issue against the built fix myself before opening this PR. --------- Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com> Co-authored-by: Jeffrey Vo <jeffrey.vo.australia@gmail.com>
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.
What
VLQDecoder::longaccumulates a stateful zig-zag varint across multiple calls and shifts the next 7-bit payload by the running shift count. The shift wasn't bounded, so an overlong varint that keeps emitting continuation bytes drivesself.shiftpast 63 and panics inside<< self.shift:Repro
The cargo-fuzz
avro_readerharness being prototyped for #5332 reproduces this in single-digit minutes from an empty corpus with a 19-byte input — the Avro file magic followed by 130xFFcontinuation bytes and a terminator:Reachable from
arrow_avro::reader::ReaderBuilder::new().build(buf_reader)on attacker-controlled bytes — i.e. any service that consumes Avro through the public API will crash on this input.read_varint,read_varint_array,read_varint_slow, andskip_varint*in the same file already cap at 10 bytes / shift 63 and so are not affected; only the streamingVLQDecoder::longpath was missing the bound.Fix
Switch the shift to
checked_shl(self.shift).unwrap_or(0)so contributions past bit 63 are silently dropped (matching what the eventualu64would have to do anyway), andsaturating_add(7)so a truly malicious continuation run can't wrapself.shiftpastu32::MAXeither. The terminator byte (high bit clear) still ends the varint, so a malformed varint that never terminates either runs out of input (returnsNone) or yields a truncatedu64from the trailing terminator.Tests
Adds
reader::vlq::tests::test_long_overlong_varint_does_not_panicdriving 12 continuation bytes plus a terminator (which would otherwise reachshift = 84), and asserts the subsequent call decodes a fresh varint cleanly so the decoder's state was reset on the malformed run. The existingtest_varintcontinues to pass.Alternatives considered
read_varint_*paths do. Would need an extracount: u8field onVLQDecoderand a way to surface "malformed" to the caller. Bigger API change for the same observable behavior under valid input. Happy to switch if you prefer it.u64::MAX: same panic-free property, but masks the malformed-input signal. Thechecked_shlform drops the contribution silently while still letting the terminator byte end the varint, which I think is the smallest-touch fix that keeps the public API unchanged.xref #5332