Throw TOO_LARGE_STRING_SIZE instead of aborting on a corrupt String size stream by groeneai · Pull Request #108572 · ClickHouse/ClickHouse · GitHub
Skip to content

Throw TOO_LARGE_STRING_SIZE instead of aborting on a corrupt String size stream#108572

Open
groeneai wants to merge 1 commit into
ClickHouse:masterfrom
groeneai:fix-string-size-stream-allocator-abort
Open

Throw TOO_LARGE_STRING_SIZE instead of aborting on a corrupt String size stream#108572
groeneai wants to merge 1 commit into
ClickHouse:masterfrom
groeneai:fix-string-size-stream-allocator-abort

Conversation

@groeneai

Copy link
Copy Markdown
Contributor

Related: #107196

Changelog category (leave one):

  • Bug Fix (user-visible misbehavior in an official stable release)

Changelog entry (a user-readable short description of the changes that goes into CHANGELOG.md):

Fix a server abort when reading a String column stored with the separate size-stream format (WITH_SIZE_STREAM) from a corrupt or truncated part: a garbage per-row length in the .size sub-stream could trigger an out-of-bounds allocation (Too large size ... passed to allocator) and abort the server under assert/sanitizer builds. Such a corrupt part now raises TOO_LARGE_STRING_SIZE at the point of deserialization instead.

Description

SerializationString::deserializeBinaryBulkWithSizeStream reads per-row lengths from a separate .size sub-stream, accumulates them into ColumnString offsets, then sizes the character buffer with data.resize(...) before reading any character bytes. The accumulation had no bound on the per-row size.

The size and data sub-streams are stored separately, so a corrupt part (or a desynced/seeked sizes stream) can deliver a garbage length with bit 63 set, making bytes_to_read >= 2^63. The pre-read resize reaches Allocator::checkSize (size >= 0x8000000000000000), which throws a LOGICAL_ERROR and aborts under assert/sanitizer builds. Top of the observed stack:

checkSize  src/Common/Allocator.cpp:122  ("Too large size ... passed to allocator")
Allocator<false,false>::realloc
DB::SerializationString::deserializeBinaryBulkWithSizeStream
DB::MergeTreeReaderCompact::readData
...
DB::MergeTreeSource::tryGenerate

The existing readBigStrict hardening (#107196) catches the opposite desync (data stream short, sizes intact) but runs after the resize, so it does not help when the sizes stream is the corrupt one.

The fix bounds the per-row size at the accumulation point in appendStringSizesToColumnStringOffsets and throws TOO_LARGE_STRING_SIZE, mirroring the bound the single-stream path already enforces in deserializeBinaryImpl (max_string_size = 16_GiB).

A unit test WithSizeStreamCorruptSizeStreamThrows was added (symmetric to the existing WithSizeStreamShortDataStreamThrows): it corrupts the first .size entry with a bit-63 length and asserts the deserializer throws instead of aborting. Verified the test aborts (SIGABRT) on the unfixed code and passes with the fix; all existing StringSerialization.* unit tests still pass.

…ize stream

SerializationString::deserializeBinaryBulkWithSizeStream reads per-row
lengths from a separate `.size` sub-stream (WITH_SIZE_STREAM format) and
accumulates them into ColumnString offsets in
appendStringSizesToColumnStringOffsets, then computes
`bytes_to_read = offsets.back() - prev_last_offset` and calls
`data.resize(...)` BEFORE reading any character bytes.

The accumulation had no bound on the per-row size. A corrupt or desynced
sizes sub-stream (the sizes and data streams are stored separately, so a
bad granule / version skew / seek can desync them) can deliver a garbage
length with bit 63 set, making bytes_to_read >= 2^63. The pre-read resize
then reaches Allocator::checkSize, which throws a LOGICAL_ERROR
("Too large size ... passed to allocator") that aborts the server under
assert/sanitizer builds.

The existing readBigStrict hardening (ClickHouse#107196) catches the opposite
desync (data stream short, sizes intact) but runs after the resize, so it
does not help when the sizes stream is the corrupt one.

Bound the per-row size at the accumulation point and throw
TOO_LARGE_STRING_SIZE on a corrupt part, mirroring the bound the
single-stream path already enforces in deserializeBinaryImpl
(max_string_size = 16_GiB). A corrupt part now fails loudly at the point
of deserialization instead of aborting.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@groeneai

Copy link
Copy Markdown
Contributor Author

@groeneai

Copy link
Copy Markdown
Contributor Author

cc @Avogar @Ergus — could you review this? It adds the missing per-row size bound on the WITH_SIZE_STREAM String read path so a corrupt/desynced .size sub-stream throws TOO_LARGE_STRING_SIZE instead of aborting in the allocator. This is the complement to the readBigStrict hardening in #107196 (which covers the data-stream-short desync, after the resize); here the sizes stream is the corrupt one and the abort happens at the pre-read data.resize().

@Ergus Ergus self-assigned this Jun 26, 2026
@clickhouse-gh

clickhouse-gh Bot commented Jun 26, 2026

Copy link
Copy Markdown
Contributor

Workflow [PR], commit [c387724]

Summary:

job_name test_name status info comment
Stress test (amd_tsan) FAIL
Hung check failed, possible deadlock found FAIL cidb, issue
Stress test (amd_msan) FAIL
Cannot start clickhouse-server FAIL cidb
Logical error: 'Unexpected exception in refresh scheduling' (STID: 2508-3e7b) FAIL cidb, issue
Check failed FAIL cidb
Performance Comparison (arm_release, master_head, 4/6) FAIL Performance dashboard
grace_hash_join #1::old FAIL query history
grace_hash_join #1::new FAIL query history
grace_hash_join #5::old FAIL query history
grace_hash_join #5::new FAIL query history
group_by_sundy_li #1::old FAIL query history
group_by_sundy_li #1::new FAIL query history
like_perfect_affix_rewrite #1::old FAIL query history
like_perfect_affix_rewrite #1::new FAIL query history
like_perfect_affix_rewrite #3::old FAIL query history
like_perfect_affix_rewrite #3::new FAIL query history
18 more test cases not shown
Stateless tests (amd_llvm_coverage, ParallelReplicas, s3 storage, parallel) ERROR
LLVM Coverage DROPPED

AI Review

Summary

This PR hardens WITH_SIZE_STREAM String deserialization by rejecting oversized per-row lengths before they can drive ColumnString char-buffer allocation. The direction matches the single-stream path, and the PR metadata is appropriate, but the same untrusted size stream is still consumed unchecked for seeked reads, so the corrupt-size-stream fix is incomplete.

Missing context / blind spots
  • ⚠️ I could not run the new gtest locally because this checkout has no build directory or test binary. The Praktika CI report for c387724d currently shows no failed checks.
Findings

⚠️ Majors

  • [src/DataTypes/Serializations/SerializationString.cpp:675] The new TOO_LARGE_STRING_SIZE guard only covers sizes appended for result rows. In deserializeBinaryBulkWithSizeStream, rows_offset sizes are accumulated into bytes_to_skip before this helper and are never checked. If two skipped corrupt sizes are both 1ULL << 63, the skip sum wraps to zero on 64-bit; the guarded helper then validates only the requested rows, ignore(0) leaves the data stream at row 0, and the seeked read can return bytes from the wrong position instead of rejecting the corrupt size stream. Validate skipped sizes with the same per-row bound and checked addition before ignore.
Tests
  • ⚠️ Add a focused WITH_SIZE_STREAM regression that corrupts one or more skipped .size entries and reads with rows_offset > 0, proving the deserializer throws TOO_LARGE_STRING_SIZE instead of throwing EOF or returning misaligned data.
Final Verdict

Status: ⚠️ Request changes

Minimum required actions: apply the oversized-size validation to skipped rows as well, guard bytes_to_skip against overflow, and add the seeked-read regression.

@clickhouse-gh clickhouse-gh Bot added the pr-bugfix Pull request with bugfix, not backported by default label Jun 26, 2026

@Ergus Ergus left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

The channge looks good to me. The limit is pretty high, so not sure if it could be hit in a realistic use case. @Avogar WDYT?

@nikitamikhaylov nikitamikhaylov added the can be tested Allows running workflows for external contributors label Jun 26, 2026
/// would otherwise reach `data.resize()` and abort in Allocator::checkSize. Same bound as the
/// single-stream path (deserializeBinaryImpl).
static constexpr size_t max_string_size = 16_GiB;
if (size > max_string_size)

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.

The new bound only applies to rows passed into appendStringSizesToColumnStringOffsets, but deserializeBinaryBulkWithSizeStream consumes the skipped prefix first as bytes_to_skip += sizes_data[i] without the same validation. A seeked read can therefore skip over the corrupt sizes. For example, if the first two skipped UInt64 sizes are both 1ULL << 63 and the read uses rows_offset = 2, the skip sum wraps to zero on 64-bit; this helper only validates the requested rows, ignore(0) leaves the data stream at row 0, and the result row is read from the wrong byte position instead of throwing TOO_LARGE_STRING_SIZE. With one huge skipped size it will usually throw EOF from ignore, so the promised error is still bypassed.

Please validate the skipped sizes with the same per-row bound, and use checked addition for bytes_to_skip before using it to seek the data stream. A focused regression with rows_offset > 0 would close this path.

@groeneai

Copy link
Copy Markdown
Contributor Author

CI finish ledger — c387724

Every failure below has an owner; none is in this PR's diff (SerializationString.cpp, gtest_string_serialization.cpp), so none is PR-caused.

Check / test Reason Owner / fixing PR
Stress test (amd_msan) / refresh scheduling LOGICAL_ERROR (STID 2508-3e7b; the Cannot-start-server + Check-failed rows are downstream of that crash) RefreshTask MULTI_READ NOT_IMPLEMENTED on ATTACH/restore #108441 (ours, fixes STID 2508-3e7b)
Stress test (amd_tsan) / Hung check failed, possible deadlock chronic hung-check deadlock family #101680 / #105905 (ours)
Config Workflow / Pre Hooks transient pre-hook step; Config Workflow re-ran SUCCESS on this head transient infra (self-resolved)
Mergeable Check / PR aggregators reflecting the reds above n/a
CH Inc sync private fork mirror CH Inc sync (not actionable by us)

Session id: cron:our-pr-ci-monitor:20260626-173000

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

can be tested Allows running workflows for external contributors pr-bugfix Pull request with bugfix, not backported by default

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants