| # apache/orc C++ reader: integer overflow in DictionaryLoader leads to heap out-of-bounds write |
|
|
| Integer overflow (CWE-190) of the attacker-controlled `dictionary_size` field in |
| `apache/orc`'s C++ core reader leads to a zero-capacity heap allocation followed by |
| an out-of-bounds write (CWE-787) while loading a string-dictionary-encoded column |
| from a crafted `.orc` file. |
|
|
| - Component: apache/orc, C++ core reader (`liborc`) |
| - File: `c++/src/DictionaryLoader.cc`, function `orc::loadStringDictionary` |
| - Format: ORC (Optimized Row Columnar) |
| - Class: CWE-190 (Integer Overflow or Wraparound) leading to CWE-787 (Out-of-bounds Write) |
| - Impact: heap out-of-bounds write when a consumer opens/reads an attacker-supplied `.orc` file |
|
|
| ## Root cause |
|
|
| `loadStringDictionary` (added 2025-11-05, commit `cf85da12`, JIRA ORC-2022) reads the |
| dictionary size straight from the stripe-footer `ColumnEncoding` protobuf message with |
| no upper-bound validation: |
|
|
| ```cpp |
| // c++/src/DictionaryLoader.cc (main) |
| uint32_t dictSize = encoding.dictionary_size(); // line 50 attacker-controlled uint32 |
| |
| std::unique_ptr<SeekableInputStream> stream = |
| stripe.getStream(columnId, proto::Stream_Kind_LENGTH, false); |
| if (dictSize > 0 && stream == nullptr) { ... } // line 58 only a null-stream check |
| |
| dictionary->dictionaryOffset.resize(dictSize + 1); // line 67 dictSize + 1 wraps at 0xFFFFFFFF |
| int64_t* lengthArray = dictionary->dictionaryOffset.data(); |
| lengthDecoder->next(lengthArray + 1, dictSize, nullptr); // line 69 writes dictSize int64 values |
| ``` |
|
|
| `dictionaryOffset` is `orc::DataBuffer<int64_t>` (`c++/include/orc/Vector.hh:237`). |
|
|
| `dictSize` is `uint32_t`. When an attacker sets `ColumnEncoding.dictionary_size` (proto |
| field 2) to `0xFFFFFFFF`, the expression `dictSize + 1` is computed in `uint32_t` and |
| **wraps around to 0**. `dictionaryOffset.resize(0)` therefore allocates a zero-capacity |
| buffer. Line 69 then decodes `dictSize` (~4.29 billion) `int64_t` values from the LENGTH |
| stream into `lengthArray + 1`, writing past the zero-capacity allocation on the very |
| first decoded value. |
|
|
| The caller `StringDictionaryColumnReader` (`c++/src/ColumnReader.cc:547`) passes the |
| untrusted `dictSize` through with no validation of its own. |
|
|
| ## Reachability |
|
|
| `dictionary_size` is a field of the `ColumnEncoding` message stored in each stripe's |
| footer. Any consumer that opens a crafted `.orc` file and reads a `StringDictionary` |
| (`DICTIONARY` / `DICTIONARY_V2`) encoded string column reaches `loadStringDictionary`, |
| which is triggered during normal column-reader initialization. No special API use is |
| required beyond reading the file. |
|
|
| `malicious_column_encoding.bin` in this repo is the exact proto2 wire encoding of the |
| malicious `ColumnEncoding` (`kind = DICTIONARY_V2`, `dictionary_size = 0xFFFFFFFF`, |
| hex `0803 10ff ffff ff0f`) that a crafted `.orc` embeds in its stripe footer. |
| `craft_encoding.py` regenerates it and documents the field layout. |
|
|
| ## Proof of concept |
|
|
| Because a full `apache/orc` build vendors protobuf/snappy/zstd/zlib via ExternalProject, |
| this PoC uses a faithful standalone AddressSanitizer harness that **compiles the real |
| `apache/orc` allocator verbatim** (`c++/src/MemoryPool.cc` at `main`, providing the |
| genuine `orc::DataBuffer<int64_t>` `resize`/`reserve`/`MemoryPoolImpl::malloc`) and |
| executes the exact vulnerable statements of `loadStringDictionary` (lines 50, 67-69) |
| driven by the attacker-controlled `dictionary_size = 0xFFFFFFFF`. |
|
|
| `build/harness.cc` reproduces `resize(dictSize + 1)` on a real `DataBuffer<int64_t>` and |
| then calls the length-stream decode (`next(lengthArray + 1, dictSize, ...)`). The RLE |
| `next()` stand-in writes `numValues` sequential `int64` values exactly as ORC's |
| `RleDecoderV1::next` does when filling a caller-provided buffer; only the first write is |
| needed to prove the overflow. |
|
|
| ### Build and run |
|
|
| ``` |
| cd build |
| ./build_and_run.sh |
| ``` |
|
|
| (Requires clang++ or g++ with AddressSanitizer and curl. The script fetches the three |
| real ORC source files it compiles verbatim.) |
|
|
| ### Observed AddressSanitizer output |
|
|
| See `asan_output.txt`. Key lines: |
|
|
| ``` |
| [*] attacker dictionary_size = 0xffffffff (4294967295) |
| [*] dictSize + 1 (uint32_t) = 0 <-- integer overflow to 0 |
| [*] dictionaryOffset.size()=0 capacity=0 (zero-capacity buffer) |
| |
| ==ERROR: AddressSanitizer: heap-buffer-overflow ... WRITE of size 8 ... |
| 0x... is located 7 bytes after 1-byte region |
| allocated by thread T0 here: |
| #1 orc::MemoryPoolImpl::malloc(unsigned long) MemoryPool.cc:44 |
| #2 orc::DataBuffer<long>::reserve(unsigned long) MemoryPool.cc:122 |
| #3 orc::DataBuffer<long>::DataBuffer(...) MemoryPool.cc:59 |
| ``` |
|
|
| The write of size 8 (one `int64_t`) lands past a heap region allocated by ORC's own |
| `MemoryPoolImpl::malloc` / `DataBuffer<int64_t>` code, confirming the heap out-of-bounds |
| write in the real allocation path. |
|
|
| ## Distinctness |
|
|
| This is a **different file and different mechanism** from the separately reported |
| `apache/orc` `RleDecoderV2::adjustGapAndPatch` RLEv2 out-of-bounds READ. This report is |
| a `DictionaryLoader.cc` `dictionary_size` integer-overflow leading to an out-of-bounds |
| WRITE. |
|
|
| The only published ORC CVEs are CVE-2018-8015 (type-parsing recursion / DoS) and |
| CVE-2025-47436 (LZO decompression). Neither touches `DictionaryLoader.cc`, which was |
| added on 2025-11-05 (ORC-2022) and is not in OSS-Fuzz. |
|
|
| ## Suggested fix |
|
|
| Validate `dictSize` against a sane bound before use, and compute the `+ 1` in a 64-bit |
| type, e.g.: |
|
|
| ```cpp |
| uint64_t dictSize = encoding.dictionary_size(); |
| // reject implausible sizes and avoid 32-bit wraparound |
| dictionary->dictionaryOffset.resize(dictSize + 1); |
| ``` |
|
|
| plus an upper-bound check tied to the available LENGTH-stream data. |
|
|
| ## Files |
|
|
| - `build/harness.cc` - faithful ASan harness driving the real vulnerable path |
| - `build/MemoryPool.cc`, `build/orc/*.hh` - real apache/orc allocator, compiled verbatim |
| - `build/build_and_run.sh` - build + run script |
| - `asan_output.txt` - captured AddressSanitizer heap-buffer-overflow WRITE |
| - `craft_encoding.py` - generates the malicious `ColumnEncoding` payload |
| - `malicious_column_encoding.bin` - the attacker-controlled stripe-footer bytes |
|
|