From c095781e2e2014a1e674d450d11eefaa8426d1d2 Mon Sep 17 00:00:00 2001 From: Matt Hargett Date: Sat, 13 Jun 2026 23:05:25 +0200 Subject: [PATCH 1/8] component-model: fix error-path memory-safety bugs in type-section parser Found by a new libFuzzer target for wasm_component_parse_sections (tests/fuzz/component-fuzz). Four crashing inputs reduced to three distinct defects in the type-section parser's partial-parse cleanup, all in this file: 1. Double-free / heap-use-after-free in parse_component_decl_export. Once (*out)->export_name = export_name transfers ownership to *out, the extern_desc allocation/parse error paths still called wasm_runtime_free(export_name). *out is returned to the caller, whose centralized cleanup (free_component_instance_decl -> free_component_export_name) then freed export_name a second time. Fix: drop the local frees of objects already owned by *out. 2. Wild dereference walking uninitialized count-arrays. parse_func_type (param list), parse_component_instance_type (instance decls) and parse_component_type (component decls) each set the element count to the full LEB-declared value, then allocated the array WITHOUT zeroing it. A parse failure partway through the populate loop left an uninitialized tail; the cleanup walk reads tag / label pointers for every `count` entry (recursively, at arbitrary nesting depth) and dereferenced garbage. Fix: memset each array to zero after allocation. 3. Unbounded LEB-decoded element counts. The same three sites multiplied the count by sizeof(element) with no bound. On 32-bit size_t targets (arm64_32-apple-watchos, i686, ESP32) the multiply can wrap and under-allocate; on any target a huge count is a cheap allocation DoS. Fix: reject a count larger than the remaining input (each element is at least one byte), which bounds the allocation and prevents the wrap. Verified: the four reproducers no longer crash and a 300s ASan+UBSan campaign (111k execs) over the in-tree component fixtures is clean. --- .../wasm_component_types_section.c | 65 ++++++++++++++++++- 1 file changed, 63 insertions(+), 2 deletions(-) diff --git a/core/iwasm/common/component-model/wasm_component_types_section.c b/core/iwasm/common/component-model/wasm_component_types_section.c index 800224f082..5174f7a2e9 100644 --- a/core/iwasm/common/component-model/wasm_component_types_section.c +++ b/core/iwasm/common/component-model/wasm_component_types_section.c @@ -1418,6 +1418,17 @@ parse_param_list(const uint8_t **payload, const uint8_t *end, // Allocate memory for the param list if (param_count > 0) { + /* Each param occupies at least one byte in the payload, so a count + * larger than the remaining input is malformed. Rejecting it here + * also bounds the allocation and prevents the size_t multiply below + * from overflowing on 32-bit targets (where it would wrap and + * under-allocate). */ + if (param_count > (uint32_t)(end - p)) { + set_error_buf_ex(error_buf, error_buf_size, + "param count %u exceeds remaining input", + param_count); + return false; + } (*out)->params = wasm_runtime_malloc(sizeof(WASMComponentLabelValType) * param_count); if (!(*out)->params) { @@ -1425,6 +1436,13 @@ parse_param_list(const uint8_t **payload, const uint8_t *end, "Failed to allocate memory for param list"); return false; } + /* Zero the array so that if parse_labelvaltype fails partway through + * the loop below, the unparsed tail stays NULL. The cleanup path + * (free_component_types_entry) walks all `count` entries and would + * otherwise dereference uninitialized label / value_type pointers + * (found by the component-parser fuzz target). */ + memset((*out)->params, 0, + sizeof(WASMComponentLabelValType) * param_count); // Parse the param list for (uint32_t i = 0; i < param_count; i++) { @@ -1722,6 +1740,12 @@ parse_component_decl_export(const uint8_t **payload, const uint8_t *end, return false; } + /* Ownership of export_name now belongs to *out; on any later error + * path it is freed by the centralized cleanup + * (free_component_instance_decl -> free_component_export_name), which + * walks (*out)->export_name. Freeing it locally below as well caused a + * double-free / heap-use-after-free (found by the component-parser + * fuzz target). */ (*out)->export_name = export_name; WASMComponentExternDesc *extern_desc = @@ -1729,7 +1753,7 @@ parse_component_decl_export(const uint8_t **payload, const uint8_t *end, if (!extern_desc) { set_error_buf_ex(error_buf, error_buf_size, "Failed to allocate memory for extern desc"); - wasm_runtime_free(export_name); + /* export_name is owned by *out; the caller frees it. */ return false; } memset(extern_desc, 0, sizeof(WASMComponentExternDesc)); @@ -1738,8 +1762,9 @@ parse_component_decl_export(const uint8_t **payload, const uint8_t *end, if (!parse_extern_desc(&p, end, extern_desc, error_buf, error_buf_size)) { set_error_buf_ex(error_buf, error_buf_size, "Failed to parse extern desc"); + /* extern_desc is not yet owned by *out -> free it here. + * export_name IS owned by *out -> the caller frees it. */ wasm_runtime_free(extern_desc); - wasm_runtime_free(export_name); return false; } @@ -1874,6 +1899,16 @@ parse_component_type(const uint8_t **payload, const uint8_t *end, // Allocate memory for the component list if (component_count > 0) { + /* Each componentdecl is at least one byte, so a count larger than the + * remaining input is malformed. This also bounds the allocation and + * prevents the size_t multiply below from overflowing on 32-bit + * targets. */ + if (component_count > (uint32_t)(end - p)) { + set_error_buf_ex(error_buf, error_buf_size, + "component count %u exceeds remaining input", + component_count); + return false; + } (*out)->component_decls = wasm_runtime_malloc( sizeof(WASMComponentComponentDecl) * component_count); if (!(*out)->component_decls) { @@ -1881,6 +1916,14 @@ parse_component_type(const uint8_t **payload, const uint8_t *end, "Failed to allocate memory for component list"); return false; } + /* Zero the array: (*out)->count is already the full declared count, so + * if parse_component_decl fails partway the caller's cleanup + * (free_component_component_type -> free_component_decl) still walks all + * `count` entries. Without this the unparsed tail is uninitialized and + * free_component_decl dereferences a wild decl->tag (found by the + * component-parser fuzz target). */ + memset((*out)->component_decls, 0, + sizeof(WASMComponentComponentDecl) * component_count); // Parse the component list for (uint32_t i = 0; i < component_count; i++) { @@ -1951,6 +1994,16 @@ parse_component_instance_type(const uint8_t **payload, const uint8_t *end, // Allocate memory for the instance list if (instance_count > 0) { + /* Each instancedecl is at least one byte, so a count larger than the + * remaining input is malformed. This also bounds the allocation and + * prevents the size_t multiply below from overflowing on 32-bit + * targets. */ + if (instance_count > (uint32_t)(end - p)) { + set_error_buf_ex(error_buf, error_buf_size, + "instance count %u exceeds remaining input", + instance_count); + goto fail; + } instance_decls = wasm_runtime_malloc(sizeof(WASMComponentInstDecl) * instance_count); if (!instance_decls) { @@ -1958,6 +2011,14 @@ parse_component_instance_type(const uint8_t **payload, const uint8_t *end, "Failed to allocate memory for instance list"); goto fail; } + /* Zero the array so a parse failure partway through the loop leaves the + * unparsed tail as zeroed decls. This function recurses (an instancedecl + * may itself be an instance type), and the cleanup walk + * (free_component_instance_decl) reads decl->tag for every entry; an + * uninitialized tail produced a wild dereference at arbitrary nesting + * depth (found by the component-parser fuzz target). */ + memset(instance_decls, 0, + sizeof(WASMComponentInstDecl) * instance_count); // Parse the instance list for (uint32_t i = 0; i < instance_count; i++) { From 318b584796487560dd9de5aa6f8621e5914adc4f Mon Sep 17 00:00:00 2001 From: Matt Hargett Date: Sat, 13 Jun 2026 23:05:34 +0200 Subject: [PATCH 2/8] component-model: fix use-after-free of pre-allocated name in instances parse Found by the component-parser fuzz target (tests/fuzz/component-fuzz). The inline-export loop in wasm_component_parse_instances_section pre-allocated a WASMComponentCoreName and passed it to parse_core_name. parse_core_name allocates its own result and only writes *out on success, so: - on success the pre-allocated struct leaked (overwritten by parse_core_name's own allocation); - on failure *out was left pointing at the still-uninitialized pre-allocation, and the error path called free_core_name(name) -> wasm_runtime_free(name->name), dereferencing an uninitialized pointer (ASan: SEGV / heap-use-after-free in free_core_name). Fix: initialize name to NULL and let parse_core_name own the allocation and its own cleanup on failure, matching every other parse_core_name call site in the component model. Verified against the reducing input; clean under the ASan+UBSan fuzz campaign. --- .../wasm_component_instances_section.c | 22 +++++++++---------- 1 file changed, 10 insertions(+), 12 deletions(-) diff --git a/core/iwasm/common/component-model/wasm_component_instances_section.c b/core/iwasm/common/component-model/wasm_component_instances_section.c index abc0134fd9..97e66a93a8 100644 --- a/core/iwasm/common/component-model/wasm_component_instances_section.c +++ b/core/iwasm/common/component-model/wasm_component_instances_section.c @@ -213,23 +213,21 @@ wasm_component_parse_instances_section(const uint8_t **payload, for (uint32_t j = 0; j < inline_expr_len; j++) { // inlineexport ::= n: si: - WASMComponentCoreName *name = wasm_runtime_malloc( - sizeof(WASMComponentCoreName)); - if (!name) { - set_error_buf_ex(error_buf, error_buf_size, - "Failed to allocate memory " - "for component export name"); - if (consumed_len) - *consumed_len = (uint32_t)(p - *payload); - return false; - } + /* parse_core_name() allocates the result itself and + * only writes *out on success. Pre-allocating here + * leaked that struct on success and, worse, left + * `name` pointing at uninitialized memory whose + * ->name field free_core_name() then dereferenced on + * the failure path (heap-use-after-free found by the + * component-parser fuzz target). Pass NULL and let + * parse_core_name own the allocation + its own + * cleanup on failure. */ + WASMComponentCoreName *name = NULL; // Parse export name (component-level name) bool name_parse_success = parse_core_name( &p, end, &name, error_buf, error_buf_size); if (!name_parse_success) { - free_core_name(name); - wasm_runtime_free(name); if (consumed_len) *consumed_len = (uint32_t)(p - *payload); return false; From 4830ce9b47e4b1d57d5553c322df5a0eafcb2228 Mon Sep 17 00:00:00 2001 From: Matt Hargett Date: Sat, 13 Jun 2026 23:05:45 +0200 Subject: [PATCH 3/8] tests/fuzz: add Component Model binary-parser fuzz target The existing wasm-mutator-fuzz target only loads core wasm modules and never reaches the component-model binary parser, which decodes a fully untrusted buffer across all 13 section types. This adds a libFuzzer target for wasm_component_parse_sections. It is LLVM-free: an interpreter-only build with the component model on and the WASI Preview 2 host layer off (WAMR_BUILD_LIBC_WASI=0), so only the parser / validator / WAVE helpers are exercised. Dead-strip removes the unreferenced instantiation/canonical-ABI/host code; two inert stubs (host_resolve_stubs.c) satisfy the WASI-resolution symbols that wasm_resolve_imports_WASI references but the parser never calls. ASan + UBSan + libFuzzer are on by default (auto-skipped under oss-fuzz). See README.md for build/run, corpus seeding from the in-tree component fixtures, and how to retarget the harness at a 32-bit toolchain to also fuzz the ILP32 size_t-overflow path. This target found the four memory-safety bugs fixed in the two preceding commits. --- tests/fuzz/component-fuzz/.gitignore | 8 ++ tests/fuzz/component-fuzz/CMakeLists.txt | 109 ++++++++++++++++++ tests/fuzz/component-fuzz/README.md | 100 ++++++++++++++++ .../component-parser/component_parser_fuzz.cc | 103 +++++++++++++++++ .../component-parser/host_resolve_stubs.c | 39 +++++++ 5 files changed, 359 insertions(+) create mode 100644 tests/fuzz/component-fuzz/.gitignore create mode 100644 tests/fuzz/component-fuzz/CMakeLists.txt create mode 100644 tests/fuzz/component-fuzz/README.md create mode 100644 tests/fuzz/component-fuzz/component-parser/component_parser_fuzz.cc create mode 100644 tests/fuzz/component-fuzz/component-parser/host_resolve_stubs.c diff --git a/tests/fuzz/component-fuzz/.gitignore b/tests/fuzz/component-fuzz/.gitignore new file mode 100644 index 0000000000..875f3b4d72 --- /dev/null +++ b/tests/fuzz/component-fuzz/.gitignore @@ -0,0 +1,8 @@ +build/ +corpus/ +corpus_verify/ +findings/ +crash-* +oom-* +leak-* +timeout-* diff --git a/tests/fuzz/component-fuzz/CMakeLists.txt b/tests/fuzz/component-fuzz/CMakeLists.txt new file mode 100644 index 0000000000..e5772017a4 --- /dev/null +++ b/tests/fuzz/component-fuzz/CMakeLists.txt @@ -0,0 +1,109 @@ +# Copyright (C) 2026 Rebecker Specialties. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 WITH LLVM-exception + +cmake_minimum_required(VERSION 3.14) + +project(wamr_component_fuzzing LANGUAGES ASM C CXX) + +# libFuzzer requires Clang. +if(NOT CMAKE_C_COMPILER_ID MATCHES ".*Clang") + message(FATAL_ERROR "Please use Clang as the C compiler for libFuzzer compatibility.") +endif() + +set(CMAKE_BUILD_TYPE Debug) +set(CMAKE_C_STANDARD 11) +set(CMAKE_CXX_STANDARD 17) + +string(TOLOWER ${CMAKE_HOST_SYSTEM_NAME} WAMR_BUILD_PLATFORM) + +set(REPO_ROOT_DIR ${CMAKE_CURRENT_LIST_DIR}/../../..) + +# Build target detection (no 32-bit cross here; for a real ILP32 run, see +# the README — point this at an i686 / armv7 toolchain to also catch the +# size_t-is-32-bit overflow class). +if(NOT DEFINED WAMR_BUILD_TARGET) + if(CMAKE_SYSTEM_PROCESSOR MATCHES "^(arm64|aarch64)") + set(WAMR_BUILD_TARGET "AARCH64") + elseif(CMAKE_SIZEOF_VOID_P EQUAL 8) + set(WAMR_BUILD_TARGET "X86_64") + elseif(CMAKE_SIZEOF_VOID_P EQUAL 4) + set(WAMR_BUILD_TARGET "X86_32") + else() + message(FATAL_ERROR "Unsupported build target platform!") + endif() +endif() + +if(APPLE) + add_definitions(-DBH_PLATFORM_DARWIN) +endif() + +# +# Interpreter-only, component-model-on, host-layer-off configuration. +# No LLVM / AOT / JIT: this target only exercises the binary parser, +# validator and WAVE helpers. The WASI Preview 2 host layer is excluded +# (LIBC_WASI=0); any unreferenced instantiation / canonical-ABI / host +# code is removed by dead-strip below, so no host symbols are needed. +# +set(WAMR_BUILD_INTERP 1) +set(WAMR_BUILD_FAST_INTERP 1) +set(WAMR_BUILD_AOT 0) +set(WAMR_BUILD_JIT 0) +set(WAMR_BUILD_FAST_JIT 0) +set(WAMR_BUILD_LIBC_BUILTIN 0) +set(WAMR_BUILD_LIBC_WASI 0) +set(WAMR_BUILD_COMPONENT_MODEL 1) +set(WAMR_BUILD_REF_TYPES 1) +set(WAMR_BUILD_SIMD 1) +set(WAMR_BUILD_GC 0) +set(WAMR_BUILD_MULTI_MODULE 0) +set(WAMR_DISABLE_HW_BOUND_CHECK 1) + +add_definitions(-DWASM_ENABLE_FUZZ_TEST=1) +add_compile_options(-Wno-unused-command-line-argument) + +# +# Sanitizers: AddressSanitizer + UndefinedBehaviorSanitizer + libFuzzer. +# Skip when oss-fuzz drives its own instrumentation +# (-DFUZZING_BUILD_MODE_UNSAFE_FOR_PRODUCTION present in CFLAGS). +# +set(CFLAGS_ENV $ENV{CFLAGS}) +string(FIND "${CFLAGS_ENV}" "-DFUZZING_BUILD_MODE_UNSAFE_FOR_PRODUCTION" FUZZ_POS) +if(FUZZ_POS GREATER -1) + set(IN_OSS_FUZZ 1) +else() + set(IN_OSS_FUZZ 0) +endif() + +if(NOT IN_OSS_FUZZ) + message(STATUS "Enabling ASan + UBSan for the component-model fuzz target") + add_compile_options(-g -O1 -fno-omit-frame-pointer) + add_compile_options(-fsanitize=address -fsanitize-address-use-after-scope) + add_compile_options( + -fsanitize=array-bounds,bool,builtin,enum,integer-divide-by-zero,null,object-size,return,returns-nonnull-attribute,shift,signed-integer-overflow,unreachable,vla-bound + -fno-sanitize-recover=array-bounds,bool,builtin,enum,integer-divide-by-zero,null,object-size,return,returns-nonnull-attribute,shift,signed-integer-overflow,unreachable,vla-bound) + add_link_options(-fsanitize=address,undefined) +endif() + +# libFuzzer driver (no vptr: RTTI is off in the runtime build). +add_compile_options(-fsanitize=fuzzer -fno-sanitize=vptr) +add_link_options(-fsanitize=fuzzer -fno-sanitize=vptr) + +# Drop unreferenced functions (the instantiation / canonical-ABI / host +# paths the parser fuzzer never calls) so the link needs no host symbols. +if(APPLE) + add_link_options(-Wl,-dead_strip) +else() + add_compile_options(-ffunction-sections -fdata-sections) + add_link_options(-Wl,--gc-sections) +endif() + +include(${REPO_ROOT_DIR}/build-scripts/runtime_lib.cmake) +include(${REPO_ROOT_DIR}/core/shared/utils/uncommon/shared_uncommon.cmake) + +add_library(vmlib STATIC ${WAMR_RUNTIME_LIB_SOURCE}) +target_include_directories(vmlib PUBLIC ${RUNTIME_LIB_HEADER_LIST}) + +add_executable(component_parser_fuzz + component-parser/component_parser_fuzz.cc + component-parser/host_resolve_stubs.c) +target_link_libraries(component_parser_fuzz PRIVATE vmlib m) diff --git a/tests/fuzz/component-fuzz/README.md b/tests/fuzz/component-fuzz/README.md new file mode 100644 index 0000000000..5893e73277 --- /dev/null +++ b/tests/fuzz/component-fuzz/README.md @@ -0,0 +1,100 @@ +# Component Model binary-parser fuzzing + +A libFuzzer target for the WebAssembly [Component Model](https://github.com/WebAssembly/component-model) +binary parser (`wasm_component_parse_sections`). + +The existing `tests/fuzz/wasm-mutator-fuzz` target only loads **core** +wasm modules; it never reaches the component parser. The component +parser, however, decodes a fully untrusted binary across all 13 +section types and drives a large number of LEB-count-controlled +allocations and pointer walks — exactly the surface a fuzzer should +cover. This target closes that gap. + +It is intentionally **LLVM-free**: it builds an interpreter-only +runtime with the component model enabled and the WASI Preview 2 host +layer disabled (`WAMR_BUILD_LIBC_WASI=0`), so only the parser, +validator and WAVE helpers are exercised. Unreferenced instantiation / +canonical-ABI / host code is removed by dead-strip; two inert host +stubs (`component-parser/host_resolve_stubs.c`) satisfy the symbols +that `wasm_resolve_imports_WASI` references but the parser never calls. + +## Build + +Requires Clang (for libFuzzer) and a modern `bison` / `flex` (the WAVE +parser is generated). On macOS: + +```sh +brew install llvm bison flex +export PATH="$(brew --prefix bison)/bin:$(brew --prefix flex)/bin:$PATH" +``` + +```sh +cd tests/fuzz/component-fuzz +mkdir build && cd build +CC=clang CXX=clang++ cmake .. +cmake --build . -j +``` + +ASan + UBSan + libFuzzer are enabled by default (skipped automatically +under oss-fuzz, which provides its own instrumentation). + +## Run + +Seed the corpus from the component fixtures already in the tree, then +fuzz: + +```sh +mkdir -p corpus +find ../../../unit -name '*.wasm' \( -path '*component*' -o -path '*canonical*' \) \ + -exec cp {} corpus/ \; + +ASAN_OPTIONS=detect_leaks=0 ./component_parser_fuzz corpus/ -close_fd_mask=1 +``` + +`-close_fd_mask=1` suppresses the parser's debug output. + +Embedded **core** modules (section 0x01) are handed to the core wasm +loader, which can allocate proportionally to a declared (large) +function count and trip libFuzzer's RSS limit. Those OOMs are core-loader +behaviour, not component-parser defects (the `wasm-mutator-fuzz` target +already covers the core loader); pass `-ignore_ooms=1 -rss_limit_mb=3000` +to keep hunting component-parser bugs past them. + +## ILP32 / 32-bit `size_t` + +Several parser allocations are of the form +`wasm_runtime_malloc(sizeof(T) * count)` with a 32-bit `count` decoded +from the input. On a 64-bit host the multiply cannot overflow (`size_t` +is 64-bit) and a huge `count` simply fails the allocation. On a 32-bit +target (`arm64_32-apple-watchos`, `i686`, ESP32, …) `size_t` is 32-bit +and the multiply can wrap, under-allocating before the populate loop +runs. The count-bounds checks this work adds (a count cannot exceed the +remaining input) defend both cases. To also *fuzz* the ILP32 path, +point `CC`/`CXX` at a 32-bit toolchain (e.g. `clang -m32` on Linux) and +rebuild — the harness is target-independent. + +## Findings + +The first runs of this target found four distinct memory-safety bugs in +the parser's partial-parse / error-path cleanup, all fixed in the same +change set: + +1. **Double-free** in `parse_component_decl_export`: `export_name` was + freed locally on the `extern_desc` error paths after ownership had + already transferred to `*out`, so the centralized cleanup freed it + again. +2. **Use-after-free** in the instances-section inline-export parse: a + `WASMComponentCoreName` was pre-allocated and passed to + `parse_core_name` (which allocates its own and only writes `*out` on + success); on failure the uninitialized pre-allocation's `->name` + field was dereferenced by `free_core_name` (and the struct leaked on + success). +3. **Uninitialized count-arrays** in the type-section parsers + (`parse_func_type` params, `parse_component_instance_type` decls, + `parse_component_type` decls): the array's element count was set to + the full declared count before population, but the array was not + zeroed, so a parse failure partway left an uninitialized tail that + the cleanup walk dereferenced (`free_*` reading a wild `tag` / + `label` pointer at arbitrary nesting depth). +4. Each of those allocation sites also lacked a bound on the + LEB-decoded count (the ILP32 overflow class above). diff --git a/tests/fuzz/component-fuzz/component-parser/component_parser_fuzz.cc b/tests/fuzz/component-fuzz/component-parser/component_parser_fuzz.cc new file mode 100644 index 0000000000..0a48146a66 --- /dev/null +++ b/tests/fuzz/component-fuzz/component-parser/component_parser_fuzz.cc @@ -0,0 +1,103 @@ +/* + * Copyright (C) 2026 Rebecker Specialties. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 WITH LLVM-exception + */ + +/* + * libFuzzer target for the WebAssembly Component Model binary parser. + * + * The component parser ingests a fully untrusted binary buffer + * (wasm_component_parse_sections) and decodes all 13 section types, + * driving a large number of LEB-count-controlled allocations and + * pointer walks. That is exactly the surface a fuzzer should cover and + * the existing wasm-mutator fuzz target does not reach it (it only + * loads core wasm modules). + * + * This target is intentionally LLVM-free: it builds an interpreter-only + * runtime with the component model enabled and the WASI Preview 2 host + * layer disabled (LIBC_WASI=0), so the only code exercised is the + * parser + validator + WAVE helpers. Unreferenced instantiation / + * canonical-ABI / host functions are removed by dead-strip, so no host + * symbols are required to link. + * + * Build + run: see tests/fuzz/component-fuzz/README.md + */ + +#include "wasm_export.h" +#include "wasm_component.h" + +#include +#include +#include +#include + +static bool +ensure_runtime(void) +{ + /* Initialize the runtime once for the whole campaign. Use the + * system allocator (not a fixed pool) so AddressSanitizer + * instruments every component allocation and can see overflows, + * use-after-free, and leaks in the parser. */ + static bool inited = false; + if (inited) + return true; + + RuntimeInitArgs init_args; + memset(&init_args, 0, sizeof(init_args)); + init_args.mem_alloc_type = Alloc_With_System_Allocator; + + if (!wasm_runtime_full_init(&init_args)) + return false; + inited = true; + return true; +} + +extern "C" int +LLVMFuzzerTestOneInput(const uint8_t *data, size_t size) +{ + /* The 8-byte header is decoded before section parsing; shorter + * inputs are uninteresting for this target. */ + if (size < 8 || size > UINT32_MAX) + return 0; + + if (!ensure_runtime()) + return 0; + + /* The parser delegates embedded core modules (section 0x01) to the + * core wasm loader, which mutates its input buffer in place. Work + * on a private copy so we never write through libFuzzer's const + * input and so repeated runs over the same input are deterministic. */ + std::vector buf(data, data + size); + + WASMHeader header; + if (!wasm_decode_header(buf.data(), (uint32_t)buf.size(), &header)) + return 0; + + /* Only feed actual components to the component parser; core modules + * are covered by the existing wasm-mutator target. */ + if (!is_wasm_component(header)) + return 0; + + WASMComponent *component = + (WASMComponent *)wasm_runtime_malloc(sizeof(WASMComponent)); + if (!component) + return 0; + memset(component, 0, sizeof(WASMComponent)); + + /* LoadArgs.name must be a non-NULL, mutable buffer (the embedded + * core-module loader copies from it). */ + char name_buf[] = "component-fuzz"; + LoadArgs load_args; + memset(&load_args, 0, sizeof(load_args)); + load_args.name = name_buf; + load_args.is_component = true; + + /* On both success and failure wasm_component_free must leave the + * parsed-section state clean; the top-level struct is owned by us. */ + wasm_component_parse_sections(buf.data(), (uint32_t)buf.size(), + component, &load_args, 0); + wasm_component_free(component); + wasm_runtime_free(component); + + return 0; +} diff --git a/tests/fuzz/component-fuzz/component-parser/host_resolve_stubs.c b/tests/fuzz/component-fuzz/component-parser/host_resolve_stubs.c new file mode 100644 index 0000000000..adb0a7e67c --- /dev/null +++ b/tests/fuzz/component-fuzz/component-parser/host_resolve_stubs.c @@ -0,0 +1,39 @@ +/* + * Copyright (C) 2026 Rebecker Specialties. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 WITH LLVM-exception + */ + +/* + * Inert stubs for the WASI Preview 2 host-resolution symbols. + * + * This fuzz target builds the component model with the WASI Preview 2 + * host layer disabled (LIBC_WASI=0) so that only the binary parser / + * validator / WAVE helpers are exercised. The binary parser never + * resolves imports — that is an instantiation-time concern in + * wasm_resolve_imports_WASI() — but that function references the two + * symbols below and is not always removed by dead-strip (it is reached + * through a native-registration table the linker cannot prove dead). + * + * These stubs let the parser-only target link without pulling in the + * (Linux-only) WASI Preview 2 wrappers. They are never called on the + * parse path the fuzzer drives; if import resolution is ever exercised + * the conservative "unavailable" return keeps the failure clean. + */ + +#include + +bool +wasm_check_wasi_p2_version(const char *required_interface) +{ + (void)required_interface; + return false; +} + +bool +wasm_native_register_wasi_p2_module_func(const char *module_name, + const char *func_name) +{ + (void)module_name; + (void)func_name; + return false; +} From 482c759987aff64e20eca8e9be8b49a97bd2b949 Mon Sep 17 00:00:00 2001 From: Matt Hargett Date: Sun, 14 Jun 2026 13:16:05 +0200 Subject: [PATCH 4/8] fixup: clang-format-14 the component-parser memory-safety fixes The error-path fixes touched a few lines that clang-format-14 (the Coding Guidelines CI gate) reformats. Whitespace only, no behaviour change. --- .../wasm_component_instances_section.c | 11 ++++++----- .../component-model/wasm_component_types_section.c | 10 +++++----- 2 files changed, 11 insertions(+), 10 deletions(-) diff --git a/core/iwasm/common/component-model/wasm_component_instances_section.c b/core/iwasm/common/component-model/wasm_component_instances_section.c index 97e66a93a8..62dd6744f9 100644 --- a/core/iwasm/common/component-model/wasm_component_instances_section.c +++ b/core/iwasm/common/component-model/wasm_component_instances_section.c @@ -217,10 +217,10 @@ wasm_component_parse_instances_section(const uint8_t **payload, * only writes *out on success. Pre-allocating here * leaked that struct on success and, worse, left * `name` pointing at uninitialized memory whose - * ->name field free_core_name() then dereferenced on - * the failure path (heap-use-after-free found by the - * component-parser fuzz target). Pass NULL and let - * parse_core_name own the allocation + its own + * ->name field free_core_name() then dereferenced + * on the failure path (heap-use-after-free found by + * the component-parser fuzz target). Pass NULL and + * let parse_core_name own the allocation + its own * cleanup on failure. */ WASMComponentCoreName *name = NULL; @@ -537,7 +537,8 @@ wasm_resolve_instance(struct WASMComponentInstSection *instance_section, fail_inst: inst_ok = false; done_inst: - if (instance_expression.args) wasm_runtime_free(instance_expression.args); + if (instance_expression.args) + wasm_runtime_free(instance_expression.args); instance_expression.args = NULL; if (!inst_ok) return false; diff --git a/core/iwasm/common/component-model/wasm_component_types_section.c b/core/iwasm/common/component-model/wasm_component_types_section.c index 5174f7a2e9..c1ebafc67d 100644 --- a/core/iwasm/common/component-model/wasm_component_types_section.c +++ b/core/iwasm/common/component-model/wasm_component_types_section.c @@ -1918,9 +1918,9 @@ parse_component_type(const uint8_t **payload, const uint8_t *end, } /* Zero the array: (*out)->count is already the full declared count, so * if parse_component_decl fails partway the caller's cleanup - * (free_component_component_type -> free_component_decl) still walks all - * `count` entries. Without this the unparsed tail is uninitialized and - * free_component_decl dereferences a wild decl->tag (found by the + * (free_component_component_type -> free_component_decl) still walks + * all `count` entries. Without this the unparsed tail is uninitialized + * and free_component_decl dereferences a wild decl->tag (found by the * component-parser fuzz target). */ memset((*out)->component_decls, 0, sizeof(WASMComponentComponentDecl) * component_count); @@ -2012,8 +2012,8 @@ parse_component_instance_type(const uint8_t **payload, const uint8_t *end, goto fail; } /* Zero the array so a parse failure partway through the loop leaves the - * unparsed tail as zeroed decls. This function recurses (an instancedecl - * may itself be an instance type), and the cleanup walk + * unparsed tail as zeroed decls. This function recurses (an + * instancedecl may itself be an instance type), and the cleanup walk * (free_component_instance_decl) reads decl->tag for every entry; an * uninitialized tail produced a wild dereference at arbitrary nesting * depth (found by the component-parser fuzz target). */ From dbb928bbadc1297c5a65fc06d8934e613b42f045 Mon Sep 17 00:00:00 2001 From: Matt Hargett Date: Mon, 15 Jun 2026 14:26:06 +0200 Subject: [PATCH 5/8] component-model: fix decl-content leak and scope fuzz sanitizer flags to C/CXX Two fixes uncovered by review of the component-parser fuzz target. 1. core/iwasm/common/component-model/wasm_component_types_section.c In parse_component_type's decl loop, a failed parse_component_decl freed the transient component_decl shell with a bare wasm_runtime_free(), without first freeing the decl's contents. parse_component_decl can populate those contents (for an import decl: import_name and its string) before failing on a later sub-parse such as the extern-desc, so that memory leaked. The centralized cleanup (free_component_component_type -> free_component_decl) never reaches this shell because component_decls[i] is still zeroed. Mirror the parse_component_instance_type fail path: call free_component_decl(component_decl) to release the contents before wasm_runtime_free(component_decl). free_component_decl was only defined later in the file, so add a forward declaration alongside the existing free_component_instance_decl / free_component_types_entry prototypes. 2. tests/fuzz/component-fuzz/CMakeLists.txt The ASan/UBSan/libFuzzer add_compile_options() were directory-scoped and so also reached the runtime's hand-written .s assembly TUs (e.g. invokeNative_em64*.s on x86_64), where the sanitizer/fuzzer flags are meaningless and a non-Clang assembler can reject them. Scope every instrumentation compile flag to C and CXX with $<$:...> so the ASM TUs see none of them, while the C/C++ translation units keep full ASan + UBSan + libFuzzer coverage. add_link_options is left unscoped (it is per-link, not per-language). Also require a Clang ASM compiler via CMAKE_ASM_COMPILER_ID, matching the existing C-compiler Clang guard, so the assembler stays consistent with the Clang driver flags CMake forwards. Verified the component-model translation unit compiles in the requested configuration (darwin product-mini, COMPONENT_MODEL=1, LIBC_WASI=1, FAST_INTERP=1) and that the fuzz CMake configures with the sanitizer/fuzzer flags present on C/CXX flags and absent from ASM flags. --- .../wasm_component_types_section.c | 10 ++++++ tests/fuzz/component-fuzz/CMakeLists.txt | 33 ++++++++++++++++--- 2 files changed, 38 insertions(+), 5 deletions(-) diff --git a/core/iwasm/common/component-model/wasm_component_types_section.c b/core/iwasm/common/component-model/wasm_component_types_section.c index c1ebafc67d..5c77453ee0 100644 --- a/core/iwasm/common/component-model/wasm_component_types_section.c +++ b/core/iwasm/common/component-model/wasm_component_types_section.c @@ -115,6 +115,9 @@ static WASMComponentTypeInstance primitive_type_error_context = { static void free_component_instance_decl(WASMComponentInstDecl *decl); +static void +free_component_decl(WASMComponentComponentDecl *decl); + // Free helpers for nested component/instance/resource types static void free_component_types_entry(WASMComponentTypes *type); @@ -1941,6 +1944,13 @@ parse_component_type(const uint8_t **payload, const uint8_t *end, error_buf_size)) { set_error_buf_ex(error_buf, error_buf_size, "Failed to parse component %d", i); + /* parse_component_decl may have populated decl contents + * (e.g. an import's import_name) before failing on a later + * sub-parse such as the extern-desc; free those contents + * first, mirroring the parse_component_instance_type fail + * path. component_decls[i] is still zeroed, so the + * centralized cleanup never reaches this transient shell. */ + free_component_decl(component_decl); wasm_runtime_free(component_decl); return false; } diff --git a/tests/fuzz/component-fuzz/CMakeLists.txt b/tests/fuzz/component-fuzz/CMakeLists.txt index e5772017a4..898f47b112 100644 --- a/tests/fuzz/component-fuzz/CMakeLists.txt +++ b/tests/fuzz/component-fuzz/CMakeLists.txt @@ -10,6 +10,16 @@ if(NOT CMAKE_C_COMPILER_ID MATCHES ".*Clang") message(FATAL_ERROR "Please use Clang as the C compiler for libFuzzer compatibility.") endif() +# The sanitizer/fuzzer compile flags below are emitted only for C/CXX (see the +# COMPILE_LANGUAGE generator expressions), so they never reach the .s assembly +# TUs (e.g. invokeNative_em64*.s on x86_64). But the ASM compiler must still be +# Clang: the runtime's .s sources are assembled by ${CMAKE_ASM_COMPILER}, and a +# non-Clang assembler (e.g. system gcc/as) can choke on the Clang-driver flags +# CMake still forwards. Require Clang for ASM so the build stays self-consistent. +if(NOT CMAKE_ASM_COMPILER_ID MATCHES ".*Clang") + message(FATAL_ERROR "Please use Clang as the ASM compiler for libFuzzer compatibility.") +endif() + set(CMAKE_BUILD_TYPE Debug) set(CMAKE_C_STANDARD 11) set(CMAKE_CXX_STANDARD 17) @@ -74,18 +84,31 @@ else() set(IN_OSS_FUZZ 0) endif() +# The instrumentation flags below are scoped to C and CXX via +# $<$:...>. The runtime pulls in hand-written assembly +# (e.g. invokeNative_em64*.s on x86_64); the sanitizer/fuzzer flags are +# meaningless there and a non-Clang assembler can reject them, so they must not +# leak onto the ASM TUs. ASan+UBSan+libFuzzer stay fully applied to every C/C++ +# TU. add_link_options is left unscoped: it is per-link, not per-language. if(NOT IN_OSS_FUZZ) message(STATUS "Enabling ASan + UBSan for the component-model fuzz target") - add_compile_options(-g -O1 -fno-omit-frame-pointer) - add_compile_options(-fsanitize=address -fsanitize-address-use-after-scope) add_compile_options( - -fsanitize=array-bounds,bool,builtin,enum,integer-divide-by-zero,null,object-size,return,returns-nonnull-attribute,shift,signed-integer-overflow,unreachable,vla-bound - -fno-sanitize-recover=array-bounds,bool,builtin,enum,integer-divide-by-zero,null,object-size,return,returns-nonnull-attribute,shift,signed-integer-overflow,unreachable,vla-bound) + $<$:-g> + $<$:-O1> + $<$:-fno-omit-frame-pointer>) + add_compile_options( + $<$:-fsanitize=address> + $<$:-fsanitize-address-use-after-scope>) + add_compile_options( + $<$:-fsanitize=array-bounds,bool,builtin,enum,integer-divide-by-zero,null,object-size,return,returns-nonnull-attribute,shift,signed-integer-overflow,unreachable,vla-bound> + $<$:-fno-sanitize-recover=array-bounds,bool,builtin,enum,integer-divide-by-zero,null,object-size,return,returns-nonnull-attribute,shift,signed-integer-overflow,unreachable,vla-bound>) add_link_options(-fsanitize=address,undefined) endif() # libFuzzer driver (no vptr: RTTI is off in the runtime build). -add_compile_options(-fsanitize=fuzzer -fno-sanitize=vptr) +add_compile_options( + $<$:-fsanitize=fuzzer> + $<$:-fno-sanitize=vptr>) add_link_options(-fsanitize=fuzzer -fno-sanitize=vptr) # Drop unreferenced functions (the instantiation / canonical-ABI / host From 50a77eb1568e6af5824cc22adc2d7c0754f9e131 Mon Sep 17 00:00:00 2001 From: Matt Hargett Date: Mon, 15 Jun 2026 17:11:21 +0200 Subject: [PATCH 6/8] product-mini/tests: backport AOT build fixes (guard interpreter wasm.h, resolve wat2wasm from /opt/wabt) Matches the fixes already on the WASIp2 base so AOT-only build_iwasm and the unit-test AoT fixtures build on this branch; no interpreter behavior change. --- product-mini/platforms/posix/main.c | 43 +++++++++++++++++------ tests/unit/linear-memory-aot/build_aot.sh | 4 ++- tests/unit/runtime-common/build_aot.sh | 4 ++- 3 files changed, 39 insertions(+), 12 deletions(-) diff --git a/product-mini/platforms/posix/main.c b/product-mini/platforms/posix/main.c index 53ad467dc0..2bfda5bd9e 100644 --- a/product-mini/platforms/posix/main.c +++ b/product-mini/platforms/posix/main.c @@ -14,13 +14,18 @@ #include "bh_read_file.h" #include "wasm_export.h" #if WASM_ENABLE_COMPONENT_MODEL != 0 +/* The component-model headers below pull in core-module types from the + * interpreter's wasm.h. That header lives in core/iwasm/interpreter, which is + * only on the include path when WAMR_BUILD_INTERP=1, so keep this include + * inside the component-model guard: a pure AOT-only build (INTERP=0) does not + * build the component model and must not reference wasm.h. */ +#include "wasm.h" #include "wasm_component.h" #include "wasm_component_runtime.h" #include "component-model/wasm_component_host_resource.h" #include "wasm_component_export.h" #include "component-model/wasm_component_validate.h" #endif -#include "wasm.h" #if WASM_ENABLE_LIBC_WASI != 0 #include "../common/libc_wasi.c" #endif @@ -646,7 +651,7 @@ execute_wasm_module(uint8 *wasm_file_buf, uint32 wasm_file_size, #if WASM_CONFIGURABLE_BOUNDS_CHECKS != 0 bool disable_bounds_checks, #endif - char *error_buf, int32 *ret_value, + char *error_buf, uint32 error_buf_size, int32 *ret_value, wasm_module_t *wasm_module_out, wasm_module_inst_t *wasm_module_inst_out, int argc, char *argv[] @@ -682,14 +687,14 @@ execute_wasm_module(uint8 *wasm_file_buf, uint32 wasm_file_size, #endif if (!(wasm_module = wasm_runtime_load(wasm_file_buf, wasm_file_size, - error_buf, sizeof(error_buf)))) { + error_buf, error_buf_size))) { printf("%s\n", error_buf); return false; } #if WASM_ENABLE_DYNAMIC_AOT_DEBUG != 0 if (!wasm_runtime_set_module_name(wasm_module, wasm_file, error_buf, - sizeof(error_buf))) { + error_buf_size)) { printf("set aot module name failed in dynamic aot debug mode, %s\n", error_buf); wasm_runtime_unload(wasm_module); @@ -711,11 +716,18 @@ execute_wasm_module(uint8 *wasm_file_buf, uint32 wasm_file_size, libc_wasi_set_init_args(inst_args, argc, argv, wasi_parse_ctx); #endif - wasm_module_inst = wasm_runtime_instantiate_ex2( - wasm_module, inst_args, error_buf, sizeof(error_buf)); + wasm_module_inst = wasm_runtime_instantiate_ex2(wasm_module, inst_args, + error_buf, error_buf_size); wasm_runtime_instantiation_args_destroy(inst_args); if (!wasm_module_inst) { - LOG_ERROR("%s\n", error_buf); + /* Print the instantiation error directly (matching upstream and the + * module-load error path above). LOG_ERROR routes through bh_log, + * which prefixes a "[time - tid]:" banner that pollutes iwasm's + * stdout; the spec-test harness reads that stream for the + * "webassembly> " prompt / expected trap text, so the banner makes + * data/elem/start (and the ba-issues regression suite) spuriously + * fail. */ + printf("%s\n", error_buf); wasm_runtime_unload(wasm_module); return false; } @@ -1368,7 +1380,17 @@ main(int argc, char *argv[]) } else #endif - if (is_wasm_module(header)) { + if (is_wasm_module(header) +#if WASM_ENABLE_AOT != 0 + /* AoT files carry the "\0aot" magic, so is_wasm_module() (which + * only matches the "\0asm" bytecode magic) rejects them. Route + * them through the same execute_wasm_module()/wasm_runtime_load() + * path, which dispatches AoT internally — otherwise `iwasm + * foo.aot` falls through to the "Unknown WASM file type" error. */ + || get_package_type(wasm_file_buf, wasm_file_size) + == Wasm_Module_AoT +#endif + ) { #if WASM_ENABLE_COMPONENT_MODEL != 0 if (component_func_invoke) { ret = print_help(); @@ -1396,8 +1418,9 @@ main(int argc, char *argv[]) #if WASM_CONFIGURABLE_BOUNDS_CHECKS != 0 disable_bounds_checks, #endif - error_buf, &ret, &wasm_module, - &wasm_module_inst, app_argc, app_argv + error_buf, sizeof(error_buf), &ret, + &wasm_module, &wasm_module_inst, app_argc, + app_argv #if WASM_ENABLE_LIBC_WASI != 0 , &wasi_parse_ctx diff --git a/tests/unit/linear-memory-aot/build_aot.sh b/tests/unit/linear-memory-aot/build_aot.sh index e0a3e66be4..73fc2a7979 100755 --- a/tests/unit/linear-memory-aot/build_aot.sh +++ b/tests/unit/linear-memory-aot/build_aot.sh @@ -14,7 +14,9 @@ file_names=("mem_grow_out_of_bounds_01" "mem_grow_out_of_bounds_02" WORKDIR="$PWD" WAMRC_ROOT_DIR="${WORKDIR}/../../../wamr-compiler" WAMRC="${WAMRC_ROOT_DIR}/build/wamrc" -WAST2WASM="$(command -v wat2wasm)" || { echo "wat2wasm not found"; exit 1; } +# CI installs wabt under /opt/wabt (not on PATH); prefer it, fall back to PATH. +WAST2WASM="/opt/wabt/bin/wat2wasm" +if [ ! -x "$WAST2WASM" ]; then WAST2WASM="$(command -v wat2wasm)"; fi # build wamrc if not exist if [ ! -s "$WAMRC" ]; then diff --git a/tests/unit/runtime-common/build_aot.sh b/tests/unit/runtime-common/build_aot.sh index e8f9f7d7d1..aad2b9fac9 100755 --- a/tests/unit/runtime-common/build_aot.sh +++ b/tests/unit/runtime-common/build_aot.sh @@ -11,7 +11,9 @@ file_names=("main") WORKDIR="$PWD" WAMRC_ROOT_DIR="${WORKDIR}/../../../wamr-compiler" WAMRC="${WAMRC_ROOT_DIR}/build/wamrc" -WAST2WASM="$(command -v wat2wasm)" || { echo "wat2wasm not found"; exit 1; } +# CI installs wabt under /opt/wabt (not on PATH); prefer it, fall back to PATH. +WAST2WASM="/opt/wabt/bin/wat2wasm" +if [ ! -x "$WAST2WASM" ]; then WAST2WASM="$(command -v wat2wasm)"; fi # build wamrc if not exist if [ ! -s "$WAMRC" ]; then From f7ee8111cc1dd82a71d80cbfc81dacc764b18b82 Mon Sep 17 00:00:00 2001 From: Matt Hargett Date: Mon, 15 Jun 2026 18:10:49 +0200 Subject: [PATCH 7/8] ci(macos): install modern bison for the wave-parser sample build The runner ships GNU bison 2.3, which can't parse the component-model wave-parser grammar (Bison 2.4+ %code blocks); put Homebrew bison on PATH before build_samples_wasm_c_api. --- .github/workflows/compilation_on_macos.yml | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/.github/workflows/compilation_on_macos.yml b/.github/workflows/compilation_on_macos.yml index 30c9b0565b..a791c30ef7 100644 --- a/.github/workflows/compilation_on_macos.yml +++ b/.github/workflows/compilation_on_macos.yml @@ -285,6 +285,14 @@ jobs: cmake --build . --config Release --parallel 4 working-directory: wamr-compiler + # The macOS runner ships Apple/Xcode GNU bison 2.3, which cannot parse the + # component-model wave-parser grammar (it uses Bison 2.4+ %code blocks). + # Put Homebrew bison (3.x) ahead of /usr/bin/bison on PATH. + - name: Setup modern bison + run: | + brew install bison + echo "$(brew --prefix bison)/bin" >> "$GITHUB_PATH" + - name: Build Sample [wasm-c-api] run: | VERBOSE=1 From b5564a402f3dcd591aac27560df674718b7d2ec9 Mon Sep 17 00:00:00 2001 From: Matt Hargett Date: Thu, 18 Jun 2026 14:37:50 +0200 Subject: [PATCH 8/8] product-mini: guard module_func_invoke under WASM_ENABLE_COMPONENT_MODEL module_func_invoke is declared and set unconditionally but only read inside WASM_ENABLE_COMPONENT_MODEL blocks, so a component-model-off build (e.g. the nuttx AOT config) fails under gcc -Werror=unused-but-set-variable. Guard its declaration and assignment to match its sibling component_func_invoke. --- product-mini/platforms/posix/main.c | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/product-mini/platforms/posix/main.c b/product-mini/platforms/posix/main.c index 2bfda5bd9e..e03a2c679b 100644 --- a/product-mini/platforms/posix/main.c +++ b/product-mini/platforms/posix/main.c @@ -968,7 +968,9 @@ main(int argc, char *argv[]) bool component_loaded = false; bool component_func_invoke = false; // 'true' if component function is invoked #endif +#if WASM_ENABLE_COMPONENT_MODEL != 0 bool module_func_invoke = false; // 'true' if module function is invoked +#endif RunningMode running_mode = 0; RuntimeInitArgs init_args; char error_buf[128] = { 0 }; @@ -1015,7 +1017,9 @@ main(int argc, char *argv[]) return print_help(); } func_name = argv[0]; +#if WASM_ENABLE_COMPONENT_MODEL != 0 module_func_invoke = true; +#endif } #if WASM_ENABLE_COMPONENT_MODEL != 0 else if (!strcmp(argv[0], "-i") || !strcmp(argv[0], "--invoke")) {