Skip to content

Bound decompressed block sizes and read compressed blocks as they arrive - #592

Open
iliaal wants to merge 2 commits into
ClickHouse:masterfrom
iliaal:fix/compressed-frame-allocations
Open

iliaal wants to merge 2 commits into
ClickHouse:masterfrom
iliaal:fix/compressed-frame-allocations

Conversation

@iliaal

@iliaal iliaal commented Oct 18, 2026

Copy link
Copy Markdown
Contributor

Builds on #586. Only the last commit is new here.

CompressedInput::Decompress allocates a buffer of the declared compressed size before reading the block, and zero-fills one of the declared decompressed size before decompressing. A ~30-byte frame header from the server can therefore make the client commit up to 1 GiB, through the compressed size (already capped by #566), or 4 GiB through the decompressed size, which has no cap.

Fix:

  • Reject decompressed sizes above 1 GiB. That is DBMS_MAX_DECOMPRESSED_SIZE, the limit the server applies to the same header in CompressedReadBufferBase.
  • Read the compressed payload in steps that start at 4 MiB and reserve eightfold ahead.
  • Keep the decompression output buffer uninitialized and reuse it across blocks.

The exact-length check from #586 is what stops stale bytes in the reused buffer from being returned, which is why this is stacked on it.

Reproducer: an LZ4 frame header declaring an original size of 0xffffffff, or 2^31, followed by a short payload.

Tests:

  • ut/stream_ut.cpp covers declared sizes above the limit, and truncated payloads with large declared sizes that must throw without any single allocation above 8 MiB. These fail on Validate what CompressedInput decompresses #586's head.
  • LargeBlocksRoundTrip uses 9 MiB + 17 bytes of incompressible data, so the growth path runs.
  • The allocation tracker is the same as in the column-body PR, with its self-check.

A compressed block with an original size of 0 made
CompressedInput::Decompress allocate Buffer(0), whose data() is null, and pass
it as the LZ4/ZSTD destination; LZ4 does pointer arithmetic on it before it
checks the capacity, which is undefined behaviour. A well-formed empty block
also left CompressedInput::DoNext without data, so it reported end of stream,
and Decompress ignored how many bytes LZ4/ZSTD actually produced, so a block
that decoded short exposed a zero-filled tail as data. Decompress empty blocks
into a zero-capacity stack target, keep reading blocks until one yields data,
and reject output whose length differs from the declared original size.
CompressedInput::Decompress allocated a buffer of the declared
compressed size before reading the block and zero-filled one of the
declared decompressed size before decompressing it, so a few bytes from
the server could make the client commit up to 1 GiB or 4 GiB.

I now reject decompressed sizes above 1 GiB, the
DBMS_MAX_DECOMPRESSED_SIZE limit the server applies to the same header,
read the compressed bytes in steps that start at 4 MiB and reserve
eightfold ahead, and keep the decompression buffer uninitialized and
reused across blocks. The check that a block decompresses to exactly
its declared size is what keeps stale bytes in the reused buffer from
being returned.
@iliaal
iliaal requested review from mzitnik and slabko as code owners October 18, 2026 20:16
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant