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 wasmtime Rust 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 only cargo, rustc, and a C toolchain. It writes the WAT module and Rust host driver to a temp directory, links against the local crates/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" --quiet
Expected Results
Per the Config::consume_fuel contract, 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-ok baseline. The ok call 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 keep fuel=18 forever. Verified output (from a fresh run against current main):
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 fuel
This reproduces on the default configuration (x86-64 Linux, signals_based_traps = true, so i32.div_s / OOB load are native traps).
Versions and Environment
Wasmtime version or commit: dev-74-g2753ee7393 (commit 2753ee7393, 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-local self.fuel_var.
-
fuel_function_exit (line 471) is the only guaranteed save back to VMStoreContext.
-
fuel_before_op (line 478) calls fuel_increment_var + fuel_save_from_var only for Unreachable | 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_var delta 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 default clif_instruction_traps_enabled() == true case, trapz/trapnz (lines 64–84) emit native trapz/trapnz CLIF instructions directly; trap (line 36) emits a native trap or, when libcall traps are configured, calls the trap/raise builtins. None of these go through fuel_increment_var/fuel_save_from_var first. conditionally_trap (line 106) just branches to trap_block and calls self.trap(...). The result: a trapping op unwinds/leaves the function with the cached fuel never written to VMStoreContext, so Store::get_fuel() reflects only the last committed boundary.
Suggested fix: ensure pending fuel is committed to VMStoreContext before any guest trap can leave generated code. Concretely, wrap the Cranelift trap emission (trap, trapz, trapnz, uadd_overflow_trap, and the conditionally_trap trap block) so that, when fuel is enabled, it first runs fuel_increment_var + fuel_save_from_var. Native signalling traps (division-by-zero, OOB memory access) need either an equivalent pre-commit on every potentially-trapping op or a runtime unwind/signal hook that commits the active frame's cached fuel before the trap is surfaced to the embedder. Merely saving at fuel_function_exit is insufficient because a trap exits the function without reaching that path.
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:
The host driver uses only the public
wasmtimeRust API: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.Expected Results
Per the
Config::consume_fuelcontract, fuel is meant to account for guest execution. So whenever a guest invocation actually executes instructions: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):This 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: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) emit nativetrapz/trapnzCLIF instructions directly;trap(line 36) emits a nativetrapor, when libcall traps are configured, calls thetrap/raisebuiltins. None of these go throughfuel_increment_var/fuel_save_from_varfirst.conditionally_trap(line 106) just branches totrap_blockand callsself.trap(...). The result: a trapping op unwinds/leaves the function with the cached fuel never written toVMStoreContext, soStore::get_fuel()reflects only the last committed boundary.Suggested fix: ensure pending fuel is committed to
VMStoreContextbefore any guest trap can leave generated code. Concretely, wrap the Cranelift trap emission (trap,trapz,trapnz,uadd_overflow_trap, and theconditionally_traptrap block) so that, when fuel is enabled, it first runsfuel_increment_var+fuel_save_from_var. Native signalling traps (division-by-zero, OOB memory access) need either an equivalent pre-commit on every potentially-trapping op or a runtime unwind/signal hook that commits the active frame's cached fuel before the trap is surfaced to the embedder. Merely saving atfuel_function_exitis insufficient because a trap exits the function without reaching that path.