bytecodealliance / bytecodealliance/wasmtime
Wasmtime fuel consumption is lost when guest code traps — `Config::consume_fuel` does not charge work on a recoverable trap
- Dominant language
- Rust
- Stars
- 18.6k
- Forks
- 1.8k
- Avg merge
- 1d 18h
- Merged PRs (30d)
- 126
Description
### 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:
```wat
(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:
```rust
use wasmtime::{Config, Engine, Instance, Module, Store};
fn main() -> Result<(), Box> {
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::(&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.
```sh
#!/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" < "$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> {
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::(&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`](https://docs.rs/wasmtime/*/wasmtime/struct.Config.html#method.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`):
```text
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.
Contributor guide
Research direction
Start with fuel_function_entry, fuel_function_exit, and fuel_before_op in crates/cranelift/src/func_environ.rs, then inspect trap, trapz, trapnz, and conditionally_trap in crates/cranelift/src/trap.rs. Run the supplied PoC first; done means trapped guest executions no longer leave Store::get_fuel() unchanged after consuming work, including the loop-then-trap case.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- rust
- Domain
- compilers
- Issue type
- Bug
- Difficulty
- 4/5
- Estimated time
- 3-5 days
- Activity status
- Quiet
- Clarity
- Clearly specified
- Newbie friendliness
- 48/100