File size: 5,458 Bytes
626931b | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 | # Apache ORC C++ Reader β Unbounded Allocation & Integer Overflow Report
## Overview
Four (4) security findings in the Apache ORC C++ core reader (`orc::createReader()` / `RowReader::next()`) discovered via 6.5-hour AFL++ fuzzing campaign and manual PoC development.
**Target:** Apache ORC C++ v2.3.1 (commit `a6f12fd`, 2026-07-17)
**Toolchain:** clang-18 + ASan + UBSan + trace-pc-guard
**Fuzzer:** 3 AFL++ workers, 1M+ execs, 23.81% coverage
**Reproducer:** Any program calling `orc::createReader()` + `RowReader::next()` on the crafted `.orc` files
---
## Finding 1: DictionaryLoader β Integer Wraparound (CWE-190)
**File:** `c++/src/DictionaryLoader.cc:67`
**PoC:** `dictionary_wraparound_poc.orc`
`uint32_t dictSize = encoding.dictionary_size()` β no upper-bound validation. When `dictSize = 0xFFFFFFFF`, `dictSize + 1` wraps to `0` in uint32 arithmetic. `dictionaryOffset.resize(0)` allocates a zero-capacity buffer, and `lengthDecoder->next()` writes ~4.29 billion `int64_t` values past it β heap-buffer-overflow.
**Confirmation:** ASan heap-buffer-overflow WRITE (standalone harness + real ORC file)
**Prior Art:** β EnigmaConsultant/orc-dictionary-overflow (same root cause, included for completeness)
**Severity:** High (OOB write from crafted column encoding metadata)
---
## Finding 2: DictionaryLoader β Unbounded Allocation (CWE-789)
**File:** `c++/src/DictionaryLoader.cc:67`
**PoC:** `dictionary_unbounded_alloc_poc.orc`
Same missing upper bound on `dictSize`, but with a large positive value (e.g., `0xfffff9cd = 4294965709`) instead of the exact wraparound boundary. This causes `resize(4294965710)` β ~34GB allocation β ASan OOM / allocation-size-too-big crash.
**Confirmation:** ASan allocation-size-too-big (fuzz crash from 6.5h campaign)
**Novelty:** β
Not covered by EnigmaConsultant (whose PoC only tests the exact 0xFFFFFFFF wraparound)
**Severity:** Medium-High (OOM / resource exhaustion via uncontrolled allocation)
---
## Finding 3: ColumnReader β Unbounded Allocation (CWE-789)
**File:** `c++/src/ColumnReader.cc:743` (StringVectorBatch / StringDirectColumnReader)
**PoCs:** `string_vectorbatch_resize_poc.orc`, `string_direct_reader_resize_poc.orc`
Two crash files from the fuzzer that trigger `DataBuffer<char>::resize(huge)` via:
- `StringVectorBatch::resize()` called from `StringDictionaryColumnReader::next()` (745B crash)
- `StringDirectColumnReader::next()` direct blob resize (373KB crash)
Both paths decode untrusted stream lengths and allocate without upper-bound validation.
**Confirmation:** ASan heap-buffer-overflow / allocation-size-too-big (fuzz-confirmed)
**Novelty:** β
Novel β separate code path from DictionaryLoader
**Severity:** Medium (OOB write / OOM)
---
## Finding 4: StripeStream β Integer Overflow β OOB Read (CWE-190 β CWE-125)
**File:** `c++/src/StripeStream.cc:94`
**PoC:** `stripestream_offset_overflow_poc.orc`
**Confirmation:** Manually-confirmed (protobuf-valid PoC)
### Root Cause
```cpp
// StripeStream.cc:82-126
uint64_t offset = stripeStart_;
uint64_t dataEnd = stripeInfo_.offset() + stripeInfo_.index_length() + stripeInfo_.data_length();
for (int i = 0; i < footer_.streams_size(); ++i) {
const proto::Stream& stream = footer_.streams(i);
if (stream.has_kind() && stream.kind() == kind && stream.column() == columnId) {
if (offset + stream.length() > dataEnd) { /* overflow-unsafe addition */ ... }
...
}
offset += stream.length(); /* cumulative, overflow-unsafe */
}
```
`offset += stream.length()` is applied to ALL streams regardless of whether they match the search. If a non-matching stream has `length = UINT64_MAX`, the cumulative offset wraps. A subsequent matching stream then bypasses the `offset + streamLength > dataEnd` check because the wrapped value is small.
### ASan Output (from PoC)
```
READ of size 200 at 0x... (1 byte before buffer)
UBSan: addition of unsigned offset to 0x... overflowed to 0x...
Stack: MemoryInputStream::read β SeekableFileInputStream::Next
β RleDecoderV2::readByte β StringDirectColumnReader::next
```
### Impact
Heap-buffer-overflow **READ** (not WRITE). The wrapped offset causes the reader to read from memory before the allocated buffer, leaking or crashing.
**Novelty:** β
Novel β different mechanism from DictionaryLoader, manually confirmed
**Severity:** Medium (OOB read, potential info leak / crash)
---
## Fix Recommendations
1. **DictionaryLoader.cc:67** β Add upper-bound check on `dictionary_size`:
```cpp
if (dictSize > MAX_DICT_ENTRIES) throw ParseError("...");
dictionary->dictionaryOffset.resize(static_cast<uint64_t>(dictSize) + 1);
```
2. **ColumnReader.cc** β Add upper-bound check on decoded lengths before `resize()`:
```cpp
if (totalLength > MAX_BLOB_SIZE) throw ParseError("...");
byteBatch.blob.resize(totalLength);
```
3. **StripeStream.cc:94** β Use overflow-safe addition for cumulative offset and data checks:
```cpp
if (addOverflow(offset, stream.length(), &offset)) throw ParseError("...");
```
---
## Timeline
| Date | Event |
|------|-------|
| 2026-07-19 | Fuzzing campaign starts (3 AFL++ workers) |
| 2026-07-19 | 7 crashes captured, all traced to missing upper bounds |
| 2026-07-19 | StripeStream overflow manually confirmed with protobuf-valid PoC |
| 2026-07-19 | Report finalized |
## Credits
- @drogba771 β Discovery, fuzzing, PoC, analysis
|