Stream: git-wasmtime

Topic: wasmtime / issue #13802 Wasmtime fuel consumption is lost...


view this post on Zulip Wasmtime GitHub notifications bot (Jul 02 2026 at 09:07):

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 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:

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:

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) em
[message truncated]

view this post on Zulip Wasmtime GitHub notifications bot (Jul 02 2026 at 09:07):

sdjasj added the bug label to Issue #13802.

view this post on Zulip Wasmtime GitHub notifications bot (Jul 02 2026 at 16:16):

fitzgen added the wasmtime label to Issue #13802.

view this post on Zulip Wasmtime GitHub notifications bot (Jul 02 2026 at 19:35):

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 Config option) 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