bytecodealliance / bytecodealliance/wasmtime

Wasmtime fuel consumption is lost when guest code traps — `Config::consume_fuel` does not charge work on a recoverable trap

Open
#13,802 1 comment 0 reactions 0 assignees View on GitHub
bug wasmtime wasmtime:fuel
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

Open the contributing 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

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.