| # 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. |
| |