sdjasj opened issue #13802:
Test Case
No upload is needed. The vulnerable workload is a tiny Wasm module built inline by the reproduction script in Steps to Reproduce. It is shown first so the report is fully self-contained:
(module (memory 1) ;; Normal successful call: proves fuel is active. (func (export "ok") i32.const 1 drop) ;; Traps via integer division by zero (i32.div_s). (func (export "trap_div") i32.const 1234 i32.const 0 i32.div_s drop) ;; Traps via out-of-bounds memory load. (func (export "trap_oob") i32.const 65536 i32.load drop) ;; Does some fuel-consuming work (a countdown loop) and THEN traps. ;; This is the "shift work into a trapping tail" shape. (func (export "burn_then_trap") (param $n i32) (block $done (loop $loop local.get $n i32.eqz br_if $done local.get $n i32.const 1 i32.sub local.set $n br $loop)) i32.const 1234 i32.const 0 i32.div_s drop))The host driver uses only the public
wasmtimeRust API:use wasmtime::{Config, Engine, Instance, Module, Store}; fn main() -> Result<(), Box<dyn std::error::Error>> { let wat = include_str!("bug.wat"); // <-- the module above, inlined by the script let mut config = Config::new(); config.consume_fuel(true); let engine = Engine::new(&config)?; let module = Module::new(&engine, wat)?; let mut store = Store::new(&engine, ()); store.set_fuel(20)?; let instance = Instance::new(&mut store, &module, &[])?; let ok = instance.get_typed_func::<(), ()>(&mut store, "ok")?; let trap_div = instance.get_typed_func::<(), ()>(&mut store, "trap_div")?; let trap_oob = instance.get_typed_func::<(), ()>(&mut store, "trap_oob")?; let burn_then_trap = instance.get_typed_func::<i32, ()>(&mut store, "burn_then_trap")?; println!("initial fuel: {}", store.get_fuel()?); ok.call(&mut store, ())?; let baseline = store.get_fuel()?; println!("after ok: {baseline}"); if baseline >= 20 { eprintln!("fuel did not decrease for a normal successful call"); std::process::exit(1); } let mut unchanged = true; for i in 0..3 { let r = trap_div.call(&mut store, ()); let f = store.get_fuel()?; println!("after trap_div #{i}: err={} fuel={f}", r.is_err()); unchanged &= r.is_err() && f == baseline; } for i in 0..3 { let r = trap_oob.call(&mut store, ()); let f = store.get_fuel()?; println!("after trap_oob #{i}: err={} fuel={f}", r.is_err()); unchanged &= r.is_err() && f == baseline; } for i in 0..20 { let r = burn_then_trap.call(&mut store, 2); let f = store.get_fuel()?; println!("after burn_then_trap #{i}: err={} fuel={f}", r.is_err()); unchanged &= r.is_err() && f == baseline; } if unchanged { println!("BUG: trapped guest executions did not reduce Store fuel"); Ok(()) } else { eprintln!("not reproduced: fuel changed after trapped guest execution"); std::process::exit(1); } }Steps to Reproduce
Copy-paste the entire script below into a file (e.g.
poc.sh) and run it from a Wasmtime source checkout. It needs onlycargo,rustc, and a C toolchain. It writes the WAT module and Rust host driver to a temp directory, links against the localcrates/wasmtime, builds, and runs.#!/usr/bin/env bash set -euo pipefail # Run from a Wasmtime source checkout, or: WASMTIME_ROOT=/path/to/wasmtime ./poc.sh ROOT="${WASMTIME_ROOT:-$(pwd)}" if [[ ! -d "$ROOT/crates/wasmtime" ]]; then echo "error: $ROOT is not a Wasmtime checkout (set WASMTIME_ROOT)" >&2 exit 1 fi cd "$ROOT" WORK="$(mktemp -d "${TMPDIR:-/tmp}/wasmtime-fuel-trap-poc.XXXXXX")" trap 'rm -rf "$WORK"' EXIT POC_DIR="$WORK/poc" mkdir -p "$POC_DIR/src" cat > "$POC_DIR/Cargo.toml" <<EOF [package] name = "fuel-trap-poc" version = "0.1.0" edition = "2021" [workspace] [dependencies] wasmtime = { path = "$ROOT/crates/wasmtime" } EOF cat > "$POC_DIR/src/bug.wat" <<'EOF' (module (memory 1) (func (export "ok") i32.const 1 drop) (func (export "trap_div") i32.const 1234 i32.const 0 i32.div_s drop) (func (export "trap_oob") i32.const 65536 i32.load drop) (func (export "burn_then_trap") (param $n i32) (block $done (loop $loop local.get $n i32.eqz br_if $done local.get $n i32.const 1 i32.sub local.set $n br $loop)) i32.const 1234 i32.const 0 i32.div_s drop)) EOF cat > "$POC_DIR/src/main.rs" <<'EOF' use wasmtime::{Config, Engine, Instance, Module, Store}; fn main() -> Result<(), Box<dyn std::error::Error>> { let wat = include_str!("bug.wat"); let mut config = Config::new(); config.consume_fuel(true); let engine = Engine::new(&config)?; let module = Module::new(&engine, wat)?; let mut store = Store::new(&engine, ()); store.set_fuel(20)?; let instance = Instance::new(&mut store, &module, &[])?; let ok = instance.get_typed_func::<(), ()>(&mut store, "ok")?; let trap_div = instance.get_typed_func::<(), ()>(&mut store, "trap_div")?; let trap_oob = instance.get_typed_func::<(), ()>(&mut store, "trap_oob")?; let burn_then_trap = instance.get_typed_func::<i32, ()>(&mut store, "burn_then_trap")?; println!("initial fuel: {}", store.get_fuel()?); ok.call(&mut store, ())?; let baseline = store.get_fuel()?; println!("after ok: {baseline}"); if baseline >= 20 { eprintln!("fuel did not decrease for a normal successful call"); std::process::exit(1); } let mut unchanged = true; for i in 0..3 { let r = trap_div.call(&mut store, ()); let f = store.get_fuel()?; println!("after trap_div #{i}: err={} fuel={f}", r.is_err()); unchanged &= r.is_err() && f == baseline; } for i in 0..3 { let r = trap_oob.call(&mut store, ()); let f = store.get_fuel()?; println!("after trap_oob #{i}: err={} fuel={f}", r.is_err()); unchanged &= r.is_err() && f == baseline; } for i in 0..20 { let r = burn_then_trap.call(&mut store, 2); let f = store.get_fuel()?; println!("after burn_then_trap #{i}: err={} fuel={f}", r.is_err()); unchanged &= r.is_err() && f == baseline; } if unchanged { println!("BUG: trapped guest executions did not reduce Store fuel"); Ok(()) } else { eprintln!("not reproduced: fuel changed after trapped guest execution"); std::process::exit(1); } } EOF echo "[*] Building the PoC against the local wasmtime crate" cargo run --manifest-path "$POC_DIR/Cargo.toml" --quietExpected Results
Per the
Config::consume_fuelcontract, fuel is meant to account for guest execution. So whenever a guest invocation actually executes instructions:
- the store's remaining fuel should decrease by roughly the executed cost, or
- the invocation should eventually trap with an out-of-fuel trap.
In particular, a trapped invocation that still executed real work (a loop plus a division, or an OOB load) must not silently leave
Store::get_fuel()identical to a call that never ran any guest code.Actual Results
Every trapped invocation leaves the store's fuel unchanged at the post-
okbaseline. Theokcall proves fuel accounting is active (20 → 18). The divide-by-zero trap, the OOB-load trap, and the "burn a loop THEN divide by zero" trap all execute but charge nothing; repeated trapped calls keepfuel=18forever. Verified output (from a fresh run against currentmain):initial fuel: 20 after ok: 18 after trap_div #0: err=true fuel=18 after trap_div #1: err=true fuel=18 after trap_div #2: err=true fuel=18 after trap_oob #0: err=true fuel=18 after trap_oob #1: err=true fuel=18 after trap_oob #2: err=true fuel=18 after burn_then_trap #0: err=true fuel=18 ... after burn_then_trap #19: err=true fuel=18 BUG: trapped guest executions did not reduce Store fuelThis reproduces on the default configuration (x86-64 Linux,
signals_based_traps = true, soi32.div_s/ OOB load are native traps).Versions and Environment
Wasmtime version or commit:
dev-74-g2753ee7393(commit2753ee7393, main)Operating system: Linux (Ubuntu, kernel
5.15.0-139-generic)Architecture: x86-64
Toolchain: stable Rust, a C toolchain.
Extra Info
Root cause — the Cranelift fuel cache is not flushed before a trap can leave generated code. (Line numbers against current
main.)
crates/cranelift/src/func_environ.rs:
fuel_function_entry(line 460) loads fuel into a function-localself.fuel_var.fuel_function_exit(line 471) is the only guaranteed save back toVMStoreContext.
fuel_before_op(line 478) callsfuel_increment_var+fuel_save_from_varonly forUnreachable | Return | Call* | Throw*(lines 504–514). For ordinary instructions — including ones that can trap — it falls into the_ => {}arm. The code's own comment (lines 548–558) acknowledges this:"Note that we generally ignore instructions which may trap and therefore result in exiting a block early. … For 100% precise counting, however, we'd probably need to not only increment but also save the fuel amount more often around trapping instructions."
So a
fuel_consumed/fuel_vardelta accrued since the last boundary stays in the local variable when a trapping op fires.
crates/cranelift/src/trap.rs— the trap helpers do not flush the fuel cache before raising the trap. In the defaultclif_instruction_traps_enabled() == truecase,trapz/trapnz(lines 64–84) em
[message truncated]
sdjasj added the bug label to Issue #13802.
fitzgen added the wasmtime label to Issue #13802.
alexcrichton commented on issue #13802:
This is currently a known behavior of wasmtime's fuel system, although one we should probably document more prominently. This also seems worthwhile to fix, but it's likely going to be relatively difficult to fix. One option is to flush fuel counters more often, but this would require all wasm loads/stores to do a flush for example which could quickly become quite inefficient. Another possible option would be to have side tables for "if this traps consume N fuel" or something like that, but that's also a pretty significant amount of complexity. Another option would be to have a tunable (aka
Configoption) to always flush fuel so embedders could opt-in as desired.Overall I think it would be best to (a) document this more prominently and then (b) wait for an embedder to have a use case for this to guide the decision of what implementation is best.
Last updated: Jul 29 2026 at 05:03 UTC