trevdatastreams's picture
Add Apache ORC LIST length corruption PoC
7753f48 verified
|
Raw
History Blame Contribute Delete
5.29 kB
# Apache ORC C++ LIST length child-count corruption
Status: **reproduced on current main; prior-art gate open**.
Apache ORC's C++ `ListColumnReader` decodes the untrusted LIST `LENGTH`
stream as unsigned integers into `int64_t` slots, then casts each slot to
`uint64_t` and accumulates it without validating the value or the sum.
A 289-byte ORC file can therefore drive either an impossible child allocation
or wrapped, negative collection offsets.
Tested at Apache ORC commit
`af4cbf36b051c176f0d13ae5a7ac436ada8aeccf` (2026-07-29).
## Root cause
The LIST reader selects unsigned RLE:
```cpp
rle_ = createRleDecoder(std::move(stream), false, vers, memoryPool, metrics);
```
The decoded bits are stored in `int64_t`, cast back to `uint64_t`, and added
without range or overflow checks:
```cpp
rle_->next(offsets, numValues, notNull);
uint64_t totalChildren = 0;
for (size_t i = 0; i < numValues; ++i) {
uint64_t tmp = static_cast<uint64_t>(offsets[i]);
offsets[i] = static_cast<int64_t>(totalChildren);
totalChildren += tmp;
}
offsets[numValues] = static_cast<int64_t>(totalChildren);
childReader->next(*(listBatch.elements.get()), totalChildren, nullptr);
```
`DataBuffer<T>::reserve()` then trusts the impossible capacity, does not check
`sizeof(T) * newCapacity`, and does not check `malloc()` before copying:
```cpp
buf_ = reinterpret_cast<T*>(
memoryPool_.malloc(sizeof(T) * newCapacity));
memcpy(buf_, buf_old, sizeof(T) * currentSize_);
```
The same unchecked accumulation exists in `ListColumnReader::skip()` and in
the parallel Map reader.
## Differential proof
| Fixture | LIST lengths | Result with official `orc-contents` |
| --- | --- | --- |
| `control-one-row.orc` | `[1]` | Prints `{"xs": [7]}`; exit 0 |
| `trigger-huge-child-count.orc` | `[2^63]` | SIGSEGV in child-batch resize |
| `control-two-rows.orc` | `[1, 1]` | Prints both rows; exit 0 |
| `trigger-wrapped-offsets.orc` | `[2^63, 2^63]` | Wrapped offsets; downstream OOB read/SIGBUS |
The one-row ASan/UBSan build crashes with `EXC_BAD_ACCESS` at a null write:
```text
_platform_memmove
orc::DataBuffer<char>::reserve(unsigned long long) MemoryPool.cc:119
orc::DataBuffer<char>::resize(unsigned long long) MemoryPool.cc:167
orc::ColumnVectorBatch::resize(unsigned long long) Vector.cc:49
orc::IntegerVectorBatch<long long>::resize(...) Vector.hh:119
orc::ColumnReader::next(...) ColumnReader.cc:83
orc::ListColumnReader::nextInternal<false>(...) ColumnReader.cc:991
```
The two-row trigger makes `totalChildren` wrap to zero and returns offsets
`[0, INT64_MIN, 0]`. The official printer later starts the second row at
`INT64_MIN`, casts the index to `uint64_t`, and crashes reading the child
vector:
```text
orc::LongColumnPrinter::printRow(unsigned long long) ColumnPrinter.cc:329
orc::ListColumnPrinter::printRow(unsigned long long) ColumnPrinter.cc:481
```
This is not merely an allocation-pressure test: the second fixture
demonstrates parser-generated invalid offsets and a native out-of-bounds read
in an ordinary consumer.
## Reproduce
Build the pinned current source with the project sanitizer options:
```bash
cmake -S orc -B orc-asan \
-DCMAKE_BUILD_TYPE=DEBUG \
-DBUILD_JAVA=OFF \
-DBUILD_CPP_TESTS=OFF \
-DBUILD_TOOLS=ON \
-DORC_ENABLE_ASAN=ON \
-DORC_ENABLE_UBSAN=ON
cmake --build orc-asan --target orc-contents --parallel 4
```
Then:
```bash
ORC_CONTENTS=/path/to/orc-asan/tools/src/orc-contents ./reproduce.sh
```
`generate_fixtures.py` materializes the controls and applies the exact LIST
stream mutations. It asserts all four SHA-256 hashes before testing.
## Impact
Applications that deserialize attacker-supplied ORC LIST or MAP columns can
crash during ordinary row reads. Depending on the decoded lengths, the C++
reader can:
- pass an attacker-controlled impossible capacity into native buffer resizing,
causing a null-pointer write after allocation failure;
- wrap `totalChildren` and return negative/non-monotonic offsets to callers;
- induce out-of-bounds reads in consumers that trust those offsets.
The demonstrated impact is deterministic denial of service and native
out-of-bounds read. This report does not claim code execution.
## Suggested remediation
Reject any decoded collection length that is negative when represented as
`int64_t`, and reject every addition that would overflow `uint64_t` or exceed
the reader's practical allocation limit. Keep offsets representable and
monotonically nondecreasing in `int64_t`. Apply the checks to LIST and MAP
`next`, `nextEncoded`, and `skip`, then add malformed unsigned RLEv1/RLEv2
regression fixtures.
Separately, harden `DataBuffer<T>::reserve()` against multiplication overflow
and allocation failure.
## Novelty
The fresh exact scan found no Hugging Face repository, Apache ORC issue/PR, or
local report for LIST/MAP child-count accumulation. Public adjacent findings
are different occurrences:
- ORC-2192: direct STRING byte-length handling in
`StringDirectColumnReader::computeSize`;
- `poc-orc-rlev2-patchedbase-oob-cwe125`: PATCHED_BASE decoder OOB;
- ORC-414: malformed footer protobuf indexing;
- CVE-2018-8015: recursively nested schemas.
None covers `ListColumnReader`/`MapColumnReader`, `totalChildren` wrap, or
collection offsets.