Skip to content

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

Description

@sdjasj

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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugIncorrect behavior in the current implementation that needs fixingwasmtimeIssues about wasmtime that don't fall into another label

    Type

    No type

    Fields

    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions