[Bug] ReadBlock block size wraparound bypasses truncated block validation and causes out of bounds access
- Dominant language
- C++
- Stars
- 39.4k
- Forks
- 8.2k
- PR merge metrics
- No merged PRs in 30d
Description
# ReadBlock block size wraparound bypasses truncated block validation and causes out of bounds access
## 1. Basic Information
- **Repository**: https://github.com/google/leveldb
- **Affected commit**: `7ee830d02b623e8ffe0b95d59a74db1e58da04c5`
- **Affected version**: leveldb 1.23.0 / HEAD@`7ee830d02b62`
- **Component**: generic SSTable block parsing logic
- **Primary sink**: `table/format.cc` → `ReadBlock()`
- **Related path**: `db/dumpfile.cc` → `Table::Open()` → `ReadBlock()`
- **Vulnerability type**: CWE-190 Integer Overflow / Wraparound, leading to OOB access / DoS
## 2. Vulnerability Summary
`ReadBlock()` narrows attacker-controlled `BlockHandle.size` to `size_t` and then immediately uses it in `n + kBlockTrailerSize`. When `size = UINT64_MAX - 4`, `n + 5` wraps to `0` on a 64-bit build, which means:
1. `new char[n + 5]` allocates using the wrapped tiny length;
2. `file->Read(..., n + 5, ...)` reads using the same wrapped tiny length;
3. the truncated-block check `contents.size() != n + 5` also uses the same wrapped value;
4. the safety check is therefore bypassed in a self-consistent way;
5. the code still performs `switch (data[n])` using the attacker-influenced huge logical size;
6. execution reaches an out-of-bounds pointer computation at `table/format.cc:102`, terminating the process.
This is not limited to test-only code. The bug sits in LevelDB's generic SSTable parsing path, so any program that opens attacker-supplied `.ldb/.sst` files may hit it.
## 3. Affected Code
```c++
size_t n = static_cast(handle.size());
char* buf = new char[n + kBlockTrailerSize];
Slice contents;
Status s = file->Read(handle.offset(), n + kBlockTrailerSize, &contents, buf);
...
if (contents.size() != n + kBlockTrailerSize) {
delete[] buf;
return Status::Corruption("truncated block read");
}
...
switch (data[n]) {
```
## 4. Technical Details
The core issue is that attacker controlled `BlockHandle.size` is narrowed to `size_t`, then used in `n + kBlockTrailerSize`. The wrapped result is reused for allocation reading and truncated block validation, which bypasses the intended check and eventually reaches an out of bounds access at `data[n]`.
## 5. Contents of This Submission Bundle
Files in this folder:
- `ReadBlock_Size_Wrap_OOB.md`: report
- `poc/exploit.py`: minimal PoC generator for the malicious `000125.ldb`
- `poc/000125.ldb`: pre-generated 48-byte sample payload (can be used directly or regenerated)
- `poc/reproduce.sh`: reproduction script that performs clone build and trigger
Sample file details:
- size: `48` bytes
- SHA-256: `e417635f193420e34e33c8c6e37dbceb67815d7aa28681ee11fa9ee1ddd305ea`
### Prerequisites
#### Required tools
Please ensure the target environment has:
- `git`
- `cmake`
- a working C/C++ compiler (preferably `clang/clang++`; `gcc/g++` is also fine if sanitizer support is available)
- `python3`
#### Working directory
First enter this submission folder and record its absolute path:
```bash
cd /path/to/ReadBlock_Size_Wrap_OOB/submit
SUBMIT_DIR="$(pwd)"
```
All relative paths below are based on that directory.
## 6. Reproduction Steps
### One click reproduction
Run the following command from this folder:
```bash
bash ./poc/reproduce.sh
```
The script will automatically:
- regenerate `poc/000125.ldb`;
- clone `leveldb` and check out the affected commit;
- build `leveldbutil` with ASAN and UBSAN;
- trigger the bug and print the run log;
- verify that the log contains `format.cc:102:11`, `ReadBlock`, `Table::Open`, and `DumpFile`.
If the script prints `[*] reproduction succeeded`, the one click reproduction completed successfully.
A non zero exit from the trigger command is expected here because `ASAN_OPTIONS=abort_on_error=1` and `UBSAN_OPTIONS=halt_on_error=1` intentionally abort the process after the bug is hit. The script therefore checks the run log for the expected stack markers and returns success when they are present.
### Manual reproduction
### Step 1. Obtain the affected source tree
```bash
WORKDIR="$(mktemp -d)"
git clone https://github.com/google/leveldb.git "$WORKDIR/leveldb"
cd "$WORKDIR/leveldb"
git checkout 7ee830d02b623e8ffe0b95d59a74db1e58da04c5
```
**Validation**:
```bash
git rev-parse HEAD
```
Expected output:
```text
7ee830d02b623e8ffe0b95d59a74db1e58da04c5
```
### Step 2. Build `leveldbutil` with ASAN/UBSAN
> Building with sanitizers makes the out of bounds access easier to observe at runtime.
If `clang/clang++` is available, run:
```bash
export CC=clang
export CXX=clang++
```
Then build:
```bash
cmake -S . -B build \
-DCMAKE_BUILD_TYPE=RelWithDebInfo \
-DLEVELDB_BUILD_TESTS=OFF \
-DLEVELDB_BUILD_BENCHMARKS=OFF \
-DCMAKE_C_FLAGS='-fsanitize=address,undefined -fno-omit-frame-pointer' \
-DCMAKE_CXX_FLAGS='-fsanitize=address,undefined -fno-omit-frame-pointer' \
-DCMAKE_EXE_LINKER_FLAGS='-fsanitize=address,undefined'
cmake --build build -j2
```
**Validation**: the following binary should exist after the build:
```bash
ls -l ./build/leveldbutil
```
### Step 3. Return to the submission folder and generate the malicious SSTable
Go back to the submission folder:
```bash
cd "$SUBMIT_DIR"
```
Generate the payload using the included PoC:
```bash
python3 ./poc/exploit.py ./poc/000125.ldb
ls -l ./poc/000125.ldb
shasum -a 256 ./poc/000125.ldb
```
**Expected result**:
- the script prints `poc/000125.ldb`
- the file size is `48` bytes
- the SHA-256 is `e417635f193420e34e33c8c6e37dbceb67815d7aa28681ee11fa9ee1ddd305ea`
> Important: the file name must remain in a LevelDB-recognized table-file form such as `000125.ldb`. Renaming it to something like `poc.ldb` will make `leveldbutil` reject it as `unknown file type`, preventing execution from reaching the vulnerable path.
### Step 4. Trigger the bug
From the submission folder, run:
```bash
ASAN_OPTIONS=abort_on_error=1:detect_leaks=0 \
UBSAN_OPTIONS=halt_on_error=1:print_stacktrace=1 \
"$WORKDIR/leveldb/build/leveldbutil" dump ./poc/000125.ldb
```
### Step 5. Verify successful reproduction
A successful trigger should show all of the following:
1. the report points to `table/format.cc:102:11`
2. the output contains `runtime error: addition of unsigned offset ... overflowed`
3. the stack includes:
- `leveldb::ReadBlock(...)`
- `leveldb::Table::Open(...)`
- `leveldb::DumpFile(...)`
A representative output from one local verification run is:
```text
.../table/format.cc:102:11: runtime error: addition of unsigned offset ... overflowed ...
#0 ... in leveldb::ReadBlock(...) format.cc:102
#1 ... in leveldb::Table::Open(...) table.cc:61
#2 ... in leveldb::DumpFile(...) dumpfile.cc:225
SUMMARY: UndefinedBehaviorSanitizer: undefined-behavior .../table/format.cc:102:11
```
### Expected vs Actual Behavior
- **Expected behavior**: the parser should reject an oversized `BlockHandle.size` before allocation, reading, or validation, and return `Corruption` (or similar) instead of continuing with a wrapped length.
- **Actual behavior**: integer wraparound in `n + 5` influences allocation, reading, and truncated-block validation at the same time, bypassing the intended safety check; the code then dereferences `data[n]` and reaches an out-of-bounds access detected by the sanitizer.
## 7. Root Cause
This is not merely a missing bounds check. It is a **logic bypass caused by integer wraparound**:
1. the attacker fully controls `handle.size()`;
2. the code narrows `uint64_t` to `size_t` and then computes `n + kBlockTrailerSize`;
3. the wrapped value is reused for allocation, reading, and `contents.size()` validation;
4. those three operations become self consistent around the wrapped value, so the truncation check is bypassed;
5. the code still uses the huge logical index `data[n]`, which leads to out of bounds access.
## 8. Suggested Fix
Validate the original `uint64_t` before any addition, allocation, or read length calculation:
```c++
uint64_t raw_size = handle.size();
if (raw_size > std::numeric_limits::max() - kBlockTrailerSize) {
return Status::Corruption("block is too large");
}
size_t n = static_cast(raw_size);
size_t read_len = n + kBlockTrailerSize;
char* buf = new char[read_len];
Status s = file->Read(handle.offset(), read_len, &contents, buf);
if (contents.size() != read_len) {
delete[] buf;
return Status::Corruption("truncated block read");
}
```
[poc.zip](https://github.com/user-attachments/files/26166968/poc.zip)
Contributor guide
Assessment
This issue has not been assessed yet.