fix(parquet): size a page from what its data says, not what its header claims (FB-4054) - #46
moshap-firebolt wants to merge 1 commit into
Conversation
…r claims (FB-4054)
A page header declares its uncompressed size and nothing checked it against the
data present, so SerializedPageReader::DecompressIfNeeded sized its output
buffer straight from the file: a 1358-byte input requested 2 GiB. Found by
PackDB's parquet_input fuzzer. Not memory-unsafe -- the allocation succeeds or
fails cleanly -- but a small file that costs gigabytes is a denial of service on
a reader that takes files from users, and this path is reachable from an
external table.
Rather than invent a plausible expansion ratio, ask the data. Codec gains
DecompressedLength(), which snappy answers from its varint prefix and zstd from
its frame header; every other codec keeps the base implementation and is
unaffected.
Three outcomes, and the third is the one that closes this:
- a value the data states its length, so the header must agree with it
- nullopt the format records no length (lz4 raw, gzip). Nothing learned,
carry on as before
- an error the format does record one and this input is too damaged to read
it, so it cannot decompress at all and there is nothing to
allocate for
The reproducer is the third case exactly: five bytes of noise declaring 2 GiB.
zstd cannot parse a frame header out of it, and the page is refused before the
buffer is sized. Measured, not assumed -- an earlier version of this change only
compared lengths when the codec could report one, and the reproducer walked
straight through it because a corrupt frame reports nothing.
Verified: the reproducer is now rejected; the parquet fuzz corpus runs 11,773
executions under a strict allocator with no artifacts and coverage still
climbing (6249 -> 6954); 1432 Parquet, codec and compression tests pass,
including every codec and the valid pages of the reproducer's own file, one of
which states 11 bytes against a declared 11.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
de06b15 to
d81fab7
Compare
|
Closing — the check does not do what it needs to. Reviewing this properly: every quantity it relied on is attacker-controlled. The earlier ratio version had the right anchor ( What actually bounds this is already in place. In production PackDB's MemoryTracker fails the query cleanly on budget; under the fuzzing build ASAN's allocator returns null and the reader reports a clean error. The exposure is a query wasting its own budget on a file that never contained the data — not a crash, and not unbounded. So this is a wrong-severity finding rather than a missing check, and the right outcome is no change to Arrow. FB-4054 closed as won't-fix with the reasoning recorded there. |
Found by PackDB's
parquet_inputfuzzer.A page header declares its uncompressed size, and nothing checked it against the data present.
SerializedPageReader::DecompressIfNeededsized its output buffer straight from the file, so a 1358-byte input requested 2 GiB:Not memory-unsafe — the allocation succeeds or fails cleanly — but a small file that costs gigabytes is a denial of service on a reader that takes files from users, and this path is reachable from an external table.
Ask the data, don't guess
The first version of this PR bounded the declared size by an invented expansion ratio. That was rightly pushed back on in review: nobody can say what ratio is safe, and getting it wrong rejects valid files.
So instead:
CodecgainsDecompressedLength(), which snappy answers from its varint prefix and zstd from its frame header. Every other codec keeps the base implementation and is unaffected.Three outcomes, and the third is what closes this:
nulloptThe reproducer is the third case exactly — five bytes of noise declaring 2 GiB. zstd can't parse a frame header out of it, so the page is refused before the buffer is sized.
That distinction was found by measurement, not design. An earlier revision only compared lengths when the codec could report one, and the reproducer walked straight through:
Treating "codec cannot read this" as a rejection rather than a shrug is the whole fix.
Verification
Coverage still climbing is the part that matters: the check isn't quietly rejecting real files. Note the valid pages of the reproducer's own file still read — one states 11 bytes against a declared 11.
Same shape as the FB-3438 work (#44, #45).
Note
Medium Risk
Touches untrusted Parquet decompression and allocation sizing; behavior changes for malicious or mismatched headers, with intentional strictness for Snappy/Zstd.
Overview
Adds
Codec::DecompressedLengthso callers can read the decompressed size embedded in Snappy/Zstd payloads (defaultnulloptfor codecs that do not record it).Parquet
SerializedPageReader::DecompressIfNeedednow queries that length on the compressed page bytes before resizing the decompression buffer: corrupt/unreadable compressed data fails fast instead of allocating from the header alone, and when the codec reports a size it must match the page header’s claimed uncompressed size (after level bytes). Corded-buffer and no-embedded-length codecs are unchanged.Reviewed by Cursor Bugbot for commit d81fab7. Bugbot is set up for automated code reviews on this repo. Configure here.