Skip to content

fix(arrow-avro): cap shift in VLQDecoder::long to avoid overflow panic - #9887

Closed
masumi-ryugo wants to merge 1 commit into
apache:mainfrom
masumi-ryugo:fix/arrow-avro-vlq-shift-overflow
Closed

fix(arrow-avro): cap shift in VLQDecoder::long to avoid overflow panic#9887
masumi-ryugo wants to merge 1 commit into
apache:mainfrom
masumi-ryugo:fix/arrow-avro-vlq-shift-overflow

Conversation

@masumi-ryugo

Copy link
Copy Markdown
Contributor

What

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 self.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

Repro

The cargo-fuzz avro_reader harness 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 13 0xFF continuation bytes and a terminator:

0000  4f 62 6a 01 ff ff ff ff  ff ff ff ff ff ff ff ff  |Obj.............|
0010  4f 06 b0 6a b3                                     |O..j.|

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, 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.

Fix

Switch the shift 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.

self.in_progress |= ((byte & 0x7F) as u64).checked_shl(self.shift).unwrap_or(0);
self.shift = self.shift.saturating_add(7);

Tests

Adds reader::vlq::tests::test_long_overlong_varint_does_not_panic driving 12 continuation bytes plus a terminator (which would otherwise reach shift = 84), and asserts the subsequent call decodes a fresh varint cleanly so the decoder's state was reset on the malformed run. The existing test_varint continues to pass.

Alternatives considered

  • Track byte count and bail at 10: matches what the stateless read_varint_* paths do. Would need an extra count: u8 field on VLQDecoder and 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.
  • Saturate to u64::MAX: same panic-free property, but masks the malformed-input signal. The checked_shl form 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

`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>
@github-actions github-actions Bot added arrow Changes to the arrow crate arrow-avro arrow-avro crate labels May 4, 2026

@alamb alamb left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

@github-actions

github-actions Bot commented Jul 6, 2026

Copy link
Copy Markdown

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.

@github-actions github-actions Bot added the Stale label Jul 6, 2026
@github-actions github-actions Bot closed this Jul 14, 2026
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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

arrow Changes to the arrow crate arrow-avro arrow-avro crate Stale

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants