YAML Metadata Warning:empty or missing yaml metadata in repo card
Check out the documentation for more information.
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, functionorc::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
.orcfile
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:
// 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.:
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 pathbuild/MemoryPool.cc,build/orc/*.hh- real apache/orc allocator, compiled verbatimbuild/build_and_run.sh- build + run scriptasan_output.txt- captured AddressSanitizer heap-buffer-overflow WRITEcraft_encoding.py- generates the maliciousColumnEncodingpayloadmalicious_column_encoding.bin- the attacker-controlled stripe-footer bytes