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 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..62dd6744f9 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; @@ -539,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 800224f082..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); @@ -1418,6 +1421,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 +1439,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 +1743,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 +1756,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 +1765,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 +1902,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 +1919,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++) { @@ -1898,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; } @@ -1951,6 +2004,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 +2021,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++) { diff --git a/product-mini/platforms/posix/main.c b/product-mini/platforms/posix/main.c index 53ad467dc0..e03a2c679b 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; } @@ -956,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 }; @@ -1003,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")) { @@ -1368,7 +1384,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 +1422,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/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..898f47b112 --- /dev/null +++ b/tests/fuzz/component-fuzz/CMakeLists.txt @@ -0,0 +1,132 @@ +# 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() + +# 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) + +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() + +# 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>) + 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; +} 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