AI-powered fuzzing for WAMR: What AI Found and What Expertise Confirmed

AI-assisted security tooling is not a novelty anymore. Ask a model to build test infrastructure, triage results, or trace through an unfamiliar codebase, and it can do so quickly and often competently. What's harder to tell is when its output is actually right.
We recently used AI to find bugs in WebAssembly (Wasm) runtimes through differential fuzzing. The project also gave us a chance to see how far AI can go: when asked to differentially fuzz two Wasm runtimes, can it find actual bugs, or does it need human supervision and guidance?
The idea of differential fuzzing is simple: run the same input through two implementations that are supposed to behave the same way and compare the results. A divergence is a potential bug, assuming the test code is correct. It's the same core technique our colleague Natalie Klaus used for catching a real defect in the Linux kernel, applied there to a C-to-Rust port. We've also used differential fuzzing directly on Wasm before, as part of a security audit of the Wasmi interpreter for Stellar Network.
What the fuzzer does
The harness has three main pieces. A module generator built on wasm-smith produces random but spec-valid WebAssembly modules. Two oracles, wrapping WAMR and Wasmtime, implement the same interface: load a module, call its exported functions, and read back memory, table, and global state. A comparison layer runs each generated module through both oracles and checks that return values, traps, and state match between the two engines.

AI handled the build competently. It wrote the module generation config, the engine abstractions, and the comparison logic: useful boilerplate that went from design sketch to running harness in a fraction of the time it would take by hand. After the boilerplate was in place, quick trial fuzzing rounds turned up bugs in the harness itself, and AI fixed most of them quickly. Some of those fixes required tracing through WAMR's actual source, the kind of groundwork that's slow by hand and fast with a model that can read a lot of C quickly.
AI's first instinct for testing those fixes was a handful of hand-written regression tests. We pushed it to generalize them into property-based tests instead. A regression test only proves a known bug stays fixed for a set of test cases; a property test checks a rule across the full input space. This made the tests useful beyond the specific bugs that had already been found.
This was the first time we had to step in and push AI beyond its initial implementation. It got the basic approach right, but the result was not rigorous enough for us to rely on. Later, we encountered a more serious problem: AI confidently settled on an answer that was wrong.
Where the AI's first read was wrong
A few hours in, a fuzzing session found a divergence. AI shrinked the generated Wasm module down to a minimal reproduction: a single table.grow instruction. A WebAssembly table is a resizable array of references, typically function references. Tables are declared with an initial size and an optional maximum, and a program can grow a table at runtime using table.grow, up to that declared maximum. For the exact same call, WAMR failed to grow the table while Wasmtime succeeded. AI ran a binary search on the growth amount and found WAMR failing consistently above 1024 elements, even when the table's declared maximum was larger.
AI initially identified this as a WAMR bug. But checking the WebAssembly specification showed that this behavior is allowed. A table can fail to grow because of implementation-defined resource limits; the declared maximum only defines the upper bound that an implementation must respect. In other words, a failure below that maximum is not necessarily a specification violation.
That distinction mattered. Without expert review, this would have been reported as a bug with a convincing minimal reproduction and a precise failure threshold. Instead, it turned out to be a legitimate implementation-specific limit.
This also exposed a broader problem with differential fuzzing: a divergence is not always evidence of a bug. Some differences are allowed by the specification, while others come from environmental or resource constraints. The fuzzer needs to distinguish these cases before they become findings.
We therefore started classifying known sources of legitimate divergence and refining the harness to avoid reporting them as bugs. For example, we constrained generated table sizes to stay within WAMR's practical limits rather than changing WAMR just to make the two implementations agree. This kept the comparison focused on behavior that should actually be equivalent.
AI did well at the parts of this investigation where the goal was clear and mechanically checkable: minimizing the testcase, finding the exact growth threshold, and tracing the relevant behavior. The mistake came when it had to interpret those facts against the specification.
The bug that was real
The fuzzing also uncovered a WAMR bug involving null references in tables. A table entry initialized with ref.null inside an active element segment comes back as non-null when checked with ref.is_null. This affects every null-initialized table entry, for both of WebAssembly reference types, externref and funcref. Here is a minimal example that shows it. Both functions should return 1. On a non-GC build of WAMR, both return 0:
(module (table $t_extern 1 externref) (table $t_extern 1 externref) (elem (table $t_extern) (i32.const 0) externref (ref.null extern)) (table $t_func 1 funcref) (elem (table $t_func) (i32.const 0) funcref (ref.null func)) (func (export "is_null_externref") (result i32) (ref.is_null (table.get $t_extern (i32.const 0)))) (func (export "is_null_funcref") (result i32) (ref.is_null (table.get $t_func (i32.const 0)))))
How does WAMR represent null references, and do table initialization and ref.is_null use that same representation? AI quickly located the implementation of ref.is_null in the WAMR codebase. It turns out to check a value against a constant called NULL_REF:
HANDLE_OP(WASM_OP_REF_IS_NULL) { #if WASM_ENABLE_GC == 0 uint32 ref_val; ref_val = POP_I32(); #else void *ref_val; ref_val = POP_REF(); #endif PUSH_I32(ref_val == NULL_REF ? 1 : 0); // <-- here HANDLE_OP_END(); }
That constant, NULL_REF, is defined conditionally, and both its value and its C type depend on whether WAMR is built with garbage collection support:
#if WASM_ENABLE_GC == 0 typedef uintptr_t table_elem_type_t; #define NULL_REF (0xFFFFFFFF) #else typedef void *table_elem_type_t; #define NULL_REF (NULL) #endif
With GC disabled, a table element is a uintptr_t and null is represented by the sentinel value 0xFFFFFFFF. With GC enabled, it is void*, and null is the ordinary C NULL (0). So the next question was whether table initialization used the same representation. The table initialization uses NULL unconditionally:
void *ref = NULL; ... switch (flag) { case INIT_EXPR_TYPE_REFNULL_CONST: ref = NULL; // <-- always a void* NULL, regardless of GC toggle break; ... } *(table_data + offset_value.i32 + j) = (table_elem_type_t)ref; // <-- cast writes 0 into a uintptr_t slot when GC is off
In a non-GC build, the final assignment writes NULL into a uintptr_t table slot through a cast that hides the type mismatch from the compiler, while ref.is_null checks for 0xFFFFFFFF. The entry is consequently reported as non-null. The combination of preprocessor-heavy, GC-toggle-dependent code and a cast that overrides the compiler's own type checking is what let the bug through.
The consequence goes further than a wrong boolean. Calling through a null reference fetched from a table entry is supposed to always trap. However, a null table entry becomes indistinguishable from a valid function reference at index 0, which can then be executed via call_indirect. We can demonstrate the consequence with the following module:
(module ;; Type used for the indirect call below. (type $f (func (result i32))) ;; A table of function references, one slot. (table $t_func 1 funcref) ;; table[0] is explicitly initialized to a null function reference. (elem (table $t_func) (i32.const 0) funcref (ref.null func)) ;; Never reachable through leaks_secret's own logic. (func $secret_number (result i32) (i32.const 0xAABBCCDD)) ;; table[0] is null, so this should always return 0. (func (export "leaks_secret") (result i32) (if (result i32) (ref.is_null (table.get $t_func (i32.const 0))) (then ;; table[0] is null: return 0. (i32.const 0)) (else ;; Unreachable: table[0] is null, so ref.is_null should be true. (call_indirect (type $f) (i32.const 0))))) )
When leaks_secret is called, ref.is_null reports the table entry as non-null, causing execution to enter the else branch. The call_indirect then uses the same function reference to call $secret_number. In a more realistic scenario, this bug lets an external caller reach a private function and leak sensitive data or perform an unauthorized operation.
AI was particularly effective at two parts of the investigation. It reduced the original generated testcase to a small, understandable reproduction, and it walked through an unfamiliar codebase quickly enough to trace the divergence from ref.is_null to the table initialization code. Without prior familiarity with WAMR's internals, doing the same source-level investigation manually would have taken considerably longer.
Once we understood the root cause, we reported it to the WAMR maintainers in GitHub issue #5060. We then prepared a fix that replaced the incorrect void */NULL representation with WAMR's table_elem_type_t/NULL_REF representation. The fix was submitted as pull request #5096 and was merged into WAMR's main branch.
What this says about the work
The experiment showed that AI can significantly accelerate bug hunting, especially when the work can be turned into a systematic process with deterministic, reproducible, and machine-checkable results. AI built the harness, generated tests, reduced failures to useful reproductions, and navigated an unfamiliar codebase quickly, and wrote small scripts to investigate individual findings. Some of those started as one-off scripts and became permanent tools for exploring and classifying fuzzing results.
That suggests a useful way to work with AI in software testing: rather than asking it to look at a piece of code and decide what is wrong, ask it to build a process that can produce evidence. A fuzzing harness can systematically test a system with a wide variety of inputs. A minimized testcase can make a divergence reproducible. A binary search can establish exactly where a failure begins. A property test can check a behavior across an entire input space. A comparison between two independent implementations can provide an oracle. These are all things that can be built with AI and checked independently of the AI's explanation.
But producing evidence is not the same as judging it. AI can identify suspicious behavior, move quickly through large amounts of code, suggest explanations, and report findings, but those findings are not automatically bugs. Determining what is actually wrong requires understanding the relevant specification, implementation, and context. AI can make that investigation faster, but an expert still needs to decide what the evidence means.
