How to Fix: potential gc fuzzbug: ASAN hits overflow and Segmentation fault
Wasmtime gc_ops fuzz crash: fixing the ASAN overflow and segmentation fault triggered by tight struct.new allocation loops
A guest Wasm program that repeatedly executes struct.new can push Wasmtime’s GC paths into a bad state where an internal size, offset, or accounting value overflows, and the runtime eventually performs an invalid memory access. Under ASAN, that shows up as an overflow report followed by a SEGV. The practical fix is to harden allocation and GC bookkeeping with checked arithmetic, explicit bounds validation, and fail-fast traps instead of letting corrupted state flow into memory accesses.
Understanding the Root Cause
This bug pattern is typical in runtimes that recently added or expanded GC-managed reference types. A fuzzed Wasm module allocates objects in a tight loop, which stresses three areas at once:
- Allocation size computation for GC objects such as structs, arrays, headers, alignment padding, and field layouts.
- Heap growth and object accounting, especially when object counts or byte totals are accumulated in integers that may wrap.
- Collector scanning or forwarding logic, where a previously overflowed value becomes a bogus pointer, offset, or span length.
In the gc_ops target, repeated struct.new allocations can generate enough pressure that one unchecked arithmetic path produces an invalid value. Common examples include:
- Adding object header size to payload size without checked_add.
- Rounding allocation sizes up for alignment with formulas that overflow near integer limits.
- Multiplying field counts, array lengths, or bitmap words without checked_mul.
- Converting between Wasm-level lengths and host pointer-sized integers without validating ranges.
Once that happens, the runtime may reserve too little memory, compute a pointer past the end of a region, or scan invalid metadata during GC. ASAN catches the resulting out-of-bounds access, but the real defect is earlier: an unchecked arithmetic or validation failure in the allocation/GC pipeline.
Why does a simple guest loop trigger a host crash instead of a guest trap? Because this is not a semantic Wasm error like an out-of-bounds table access. It is a runtime implementation bug. The engine should reject impossible allocation sizes, signal out-of-memory or trap cleanly, and never let malformed internal state reach raw memory operations.
Step-by-Step Solution
The safest remediation is to audit every path from struct.new lowering to GC allocation and scanning, then replace implicit arithmetic with checked operations and explicit guards.
1. Reproduce the crash with sanitizers enabled
Build Wasmtime with ASAN so you can verify the fix against the original fuzz case.
export RUSTFLAGS="-Zsanitizer=address
g cargo build --target x86_64-unknown-linux-gnu --features gc
cargo fuzz run gc_ops
If your local setup uses a different sanitizer workflow, keep the same goal: reproduce the exact allocation-heavy case and preserve the crashing input.
2. Trace the allocation path for struct.new
Find the code path that computes the in-memory size and layout of a GC struct. Review:
- Object header size
- Field offsets
- Alignment rounding
- Total allocation bytes
- Nursery or heap reservation limits
Anywhere you see arithmetic like a + b, a * b, or alignment formulas, convert it to checked variants.
fn checked_align_up(value: usize, align: usize) -> Option<usize> {
let mask = align.checked_sub(1)?;
value.checked_add(mask).map(|v| v & !mask)
}
fn compute_struct_size(header: usize, payload: usize, align: usize) -> Result<usize, Trap> {
let raw = header.checked_add(payload)
.ok_or(Trap::AllocationTooLarge)?;
checked_align_up(raw, align)
.ok_or(Trap::AllocationTooLarge)
}
The important part is not the exact function name but the behavior: if size math exceeds representable bounds, return a controlled error such as AllocationTooLarge or map it to Wasmtime’s existing trap/error type.
3. Add hard limits before touching the heap
Even checked arithmetic is not enough if a valid integer still represents an impossible allocation for the configured heap. Validate requested sizes against:
- Current heap capacity
- Maximum GC object size
- Per-allocation guard limits
- Target-specific pointer constraints
fn validate_allocation_request(size: usize, max_object_size: usize, heap_remaining: usize) -> Result<(), Trap> {
if size == 0 {
return Err(Trap::InvalidAllocation);
}
if size > max_object_size {
return Err(Trap::AllocationTooLarge);
}
if size > heap_remaining {
return Err(Trap::OutOfMemory);
}
Ok(())
}
This prevents a later fast-path allocator from assuming the request is already safe.
4. Audit GC metadata and scanning code
Allocation bugs often surface during collection rather than at allocation time. Review code that:
- Walks object fields using computed offsets
- Reads type descriptors or layout tables
- Computes bitmap sizes for references/non-references
- Copies or forwards objects during collection
Add assertions and range checks before dereferencing computed addresses.
fn field_addr(base: usize, offset: usize, object_size: usize) -> Result<usize, Trap> {
if offset >= object_size {
return Err(Trap::CorruptGcLayout);
}
base.checked_add(offset).ok_or(Trap::CorruptGcLayout)
}
In release builds, convert impossible states into internal errors instead of unchecked pointer math. In debug and fuzz builds, keep strong assertions to catch regressions early.
5. Ensure failure becomes a trap, not memory corruption
The runtime must stop object creation cleanly when validation fails. That means the caller of the allocation path should propagate an engine error rather than continue with a partial object.
match compute_struct_size(header_size, payload_size, align) {
Ok(size) => {
validate_allocation_request(size, max_object_size, heap_remaining)?;
allocate_gc_object(size)?
}
Err(e) => return Err(e),
}
If your current code stores intermediate results in a partially initialized object before all checks pass, refactor so validation completes first.
6. Add a regression test using the minimized fuzz input
Once the fuzzed Wasm case is minimized, add it as a regression test so the issue cannot silently return.
#[test]
fn regression_gc_struct_new_allocation_loop_does_not_crash() {
let wasm = load_fixture("gc-struct-new-tight-loop.wasm");
let result = run_wasm_with_gc_enabled(&wasm);
assert!(result.is_err() || result.is_ok());
assert_no_process_crash();
}
The exact assertion depends on intended behavior. The key contract is: no host crash. A trap or out-of-memory error is acceptable; a segmentation fault is not.
7. Fuzz again after patching
Re-run the target long enough to stress the fixed path.
cargo fuzz run gc_ops -- -max_total_time=3600
Also consider enabling UBSAN and debug assertions to catch non-crashing integer and pointer issues before they become security bugs.
8. Patch pattern to apply in Wasmtime code review
When reviewing the actual fix, look for these concrete improvements:
- Replacement of raw arithmetic with checked_add, checked_mul, and checked alignment helpers
- Validation of object layout invariants before allocation
- Bounds checks when scanning fields or metadata
- Error propagation that returns traps/internal errors instead of continuing execution
- A dedicated regression test for the minimized fuzz artifact
If the issue sits in a lower-level allocator shared by arrays and structs, fix it there once rather than adding ad hoc guards only around struct.new.
Common Edge Cases
- Alignment overflow: code like (size + align – 1) & !(align – 1) can wrap before masking. Always use checked math.
- Zero-sized or malformed layouts: even if Wasm types should prevent this, fuzzing may expose internal assumptions. Reject impossible layouts explicitly.
- 32-bit host targets: conversions from Wasm sizes to host usize are more fragile on smaller pointer widths.
- Array and struct parity bugs: a fix applied only to struct.new may leave array.new or related GC instructions vulnerable through the same allocator.
- Partial initialization: if GC can observe an object before all fields and headers are valid, collection may crash even when allocation size math is correct.
- OOM masking: some code incorrectly treats impossible size requests as ordinary memory pressure. Distinguish invalid allocation math from real out-of-memory conditions.
FAQ
Is this a guest Wasm bug or a Wasmtime bug?
It is a Wasmtime runtime bug. A malicious or fuzzed guest can trigger it, but the engine must never crash the host process due to unchecked GC allocation or scanning logic.
Why does ASAN report both an overflow and a segmentation fault?
The overflow is usually the earlier root event, such as wrapped size arithmetic. The later segmentation fault happens when that corrupted value is used in a memory access, pointer computation, copy, or GC scan.
What should the correct runtime behavior be after the fix?
The program should either complete normally, hit a well-defined Wasm trap, or return an out-of-memory/internal error. It should not corrupt memory or terminate the host process.
For maintainers, the most durable resolution is simple: treat every GC object size and offset as untrusted until proven safe, use checked arithmetic everywhere in the allocation pipeline, and lock the fix in with a regression test derived from the original gc_ops fuzz case.