0xMiden / 0xMiden/protocol

Reduced-quorum vault drain: pay_fee's sponsorship outflow is unbounded and not sized by the auth threshold (sibling of #3763, not closed by #3758)

Abierto
#3,768 0 comentarios 0 reacciones 0 asignados Ver en GitHub
fees standards
Lenguaje dominante
Rust
Estrellas
132
Forks
167
Merge medio
1 d 23 h
PR fusionados (30 d)
110

Descripción

## Symptom

`fee::pay_fee` moves an unbounded, target-priced amount out of the fee-paying account's vault (the `FEE_SPONSORSHIP` outflow), and nothing sizes the authorization quorum to that amount. The outflow *is* bound into the transaction summary the approvers sign — this is **not** an unsigned drain — but the **number of signatures** required is computed by `compute_transaction_threshold` from the *called procedures*, which never accounts for the sponsorship magnitude. On an account configured with a below-default per-procedure threshold override on an asset-free note-creating procedure (`create_note`), a **single sub-quorum signature** authorizes a drain of the entire native-fee-asset vault balance into an attacker-controlled note — defeating the M-of-N guarantee on value movement. It is a threshold-**sizing** defect, not an auth bypass.

The `MAX_FEE_PAYMENT_MARGIN` bound added in #3758 constrains only the TX_FEE note, not this outflow. This is the structural twin of the fee-note drain #3758 closed for #3763 — same class (an outflow inside `pay_fee`, at a reduced quorum, magnitude set by an attacker-influenced input), one procedure earlier, left unbounded and unaccounted-for by the threshold.

## Mechanism (all verified in source, cross-checked by three independent reviewers)

- `pay_fee` runs `fees::create_network_note_sponsorships` at **fee/mod.masm:398**, before `tx::compute_fee` at **:412**. `total_sponsored_fee_amount` is carried past the margin logic (`movdn.5`, **:401**) and returned at **:419**/**:479** with no bound. The margin check (**:454**, **:466-469**) applies only to `payment_amount` for the TX_FEE note.
- For each output note carrying a `NetworkAccountTarget` attachment (`is_network_note` keys on the attachment, not on assets — **fees/mod.masm:597-603**), the amount is priced by an FPI call to the **target** account's `estimate_note_fee` (**fees/mod.masm:731,739**). The only validation is a zero-skip (**:648**) and fee-asset-ID equality (**:657-658**) — no magnitude cap; `basic_constant_fee.masm:81-89` reads the felt straight from the target's fee-schedule map (only a set-marker assert). The loop at **:286-339** sponsors **every** network output note with no per-transaction aggregate cap, so the drain is not limited to a single note.
- `create_sponsorship_note` withdraws it from the native vault via `native_account::remove_asset` (**fees/mod.masm:786**); the only ceiling is the vault balance (panics on underflow).
- In every signature auth component the sponsored return is **not fed into the threshold decision**: `exec.fee::pay_fee drop` at **components/auth/multisig/multisig.masm:51** discards `total_sponsored_fee_amount` before `exec.multisig::auth_tx` at **:57**. Only `AuthNetworkAccount` consumes it and asserts on it (`collected >= sponsored`, **network_account.masm:131**, when `sponsor_at_most_collected_fees` is set) — and that protects the network/target account, not a victim multisig. That assertion is exactly the missing bound: no signature component threads the sponsored amount into `compute_transaction_threshold`.

## Reduced-quorum escalation (verified)

- `compute_transaction_threshold` (**auth/multisig.masm:1015-1114**) = MAX over CALLED non-auth procedures of (override if set at **:1069**, else `default_threshold` at **:1079-1083**), enforced in `auth_tx` (**:925-934**). `native_account::was_procedure_called` does not track procedures invoked from within the auth procedure, so `pay_fee`'s internal `remove_asset`/`output_note::create` add no counted contribution.
- A **zero-asset** network note calls only `create_note`: **send_notes/wallet.masm:112** `call.note_creator::create_note` is unconditional, `move_asset_to_note` (**:142**) sits inside the `assets_left != 0` loop (**:129**) and is skipped, and the `NetworkAccountTarget` attachment is added via uncounted `exec.common::add_attachments` (**:171**, kernel `output_note::add_attachment`, not an account `call`). So the threshold collapses to `override(create_note)`, which can be `1`.
- `create_note` and `move_asset_to_note` are distinct, independently-overridable roots. A `create_note`-only override cannot move assets directly (that needs `move_asset_to_note` at default) but CAN trigger the sponsorship drain — a value-exfiltration capability the direct note path does not grant at reduced quorum.

## Preconditions

1. Victim uses a fee-paying signature auth component. Plain public `AuthMultisig` is the clean single-key vector: no guardian, no tx-script allowlist, no output-note policy (**components/auth/multisig/multisig.masm:37-62**). Guarded/smart multisig also pay the fee (#3758) but require a guardian co-signature on any output-note-creating tx (**guarded_multisig.masm:88**), making them a two-party variant.
2. A below-default per-procedure threshold override on `create_note` (asset-free), with `move_asset_to_note` left at default. This is an **owner opt-in**, not an external-attacker capability, but it does **not** require a full-quorum transaction: a public account can be **created** with the override directly (`create_multisig_wallet`), and a **private** account can too via `AccountBuilder` + `AuthMultisig::new`, which rejects only overrides *above* the `set_procedure_threshold` guard, never below (**auth/multisig.rs:282-291**). The `create_multisig_wallet` helper's below-default rejection applies only to Private accounts (**wallets/mod.rs:181-191**; public exemption at **:173**), and it is helper-level, not component-level (the constructor-level test at **wallets/mod.rs:284-293** shows a below-default override accepted — with `receive_asset`, the exemption being root-agnostic). The override can alternatively be lowered later by a `set_procedure_threshold` tx, which — carrying no override of its own — requires the default threshold (**auth/multisig.masm:325-337** floors only at `<= num_approvers`).
3. The tx creates a zero-asset output note with a `NetworkAccountTarget` attachment pointing at an attacker-deployed network account whose `BasicConstantFeePolicy` prices that note's script root arbitrarily high in the native fee asset, supplied as a foreign-account input so the FPI resolves.
4. The victim vault holds the native fee asset (the drain ceiling).

## Impact

A single compromised or reduced-quorum key drains up to the victim's entire native-fee-asset vault balance, defeating the M-of-N guarantee. The loss is **attacker-recoverable (theft), not mere griefing**: the `FEE_SPONSORSHIP` note is public and its consumption rights are inherited from the paired feature note, whose `RECIPIENT` the attacker supplies verbatim in the send-notes payload — so the attacker makes the feature note self-consumable and, via `collect_sponsored_fees`, credits the sponsored asset into the attacker's own network-account vault (**notes/fee_sponsorship.masm:99-101**; **fees/mod.masm:196-203**). Victim reclaim is closed off: `create_sponsorship_note` sets `reclaim_block_height = 0` (`push.0`, **fees/mod.masm:807**) and `reclaim_note` aborts with `ERR_FEE_SPONSORSHIP_RECLAIM_DISABLED` (**fee_sponsorship.masm:71**). `collect_sponsored_fees` is the credit step into the target vault, not a direct EOA payout — the attacker bakes the sweep into that account.

**The default configuration (no override) is safe** — `create_note` then contributes `default_threshold`, so any network-note-creating tx requires full quorum and the outflow is full-quorum-authorized spending. The vector is strictly a per-procedure-override amplifier.

## Repro status — REPRODUCED end-to-end

Reproduced on the current `next` code (with the #3758 fix present). A **single** signature on a public **2-of-2** `AuthMultisig` moved **3,000,000** native-fee-asset units out of the victim's vault via the FEE_SPONSORSHIP note. Observed (all assertions below pass):

- Victim: public 2-of-2 `AuthMultisig` + `BasicWallet`, default threshold 2, `proc_threshold_map = [(BasicWallet::create_note_root(), 1)]`, funded with 5,000,000 of the native fee asset.
- Transaction: one **zero-asset** output note carrying a `NetworkAccountTarget` attachment pointing at an attacker-deployed network account whose `BasicConstantFeePolicy` prices that note's script root at 3,000,000. Signed by **one** approver.
- Result: `execute()` returns `Ok` (**authorized with 1 of 2 signatures**); the emitted FEE_SPONSORSHIP note carries exactly `fee_asset(3_000_000)`; the victim's vault balance drops by `3_000_000 + own_fee`, where `own_fee` is the margin-bounded TX_FEE note (`3_000_000 > 100 × own_fee`). The #3758 `MAX_FEE_PAYMENT_MARGIN` bound does not apply to the sponsorship outflow.

The PoC drops into `crates/miden-testing/tests/auth/fee_payment/sponsorship.rs` (reusing that file's `network_account` / `fee_asset` / `fee_faucet_id` helpers), adding these imports: `Approver, ApproverSet, MultisigAuthArgs` (from `miden_standards::account::auth`), `miden_standards::testing::note::NoteBuilder`, `miden_tx::auth::{SigningInputs, TransactionAuthenticator}`, and `super::super::multisig::{MultisigAuthArgsExt, setup_keys_and_authenticators_with_scheme}`.

PoC test (passes = the drain is authorized by one signature)

```rust
#[tokio::test]
async fn reduced_quorum_sponsorship_drains_the_vault() -> anyhow::Result<()> {
const MULTISIG_FEE_BALANCE: u64 = 5_000_000;
const DRAIN: u64 = 3_000_000;

let mut builder = MockChain::builder().verification_base_fee(VERIFICATION_BASE_FEE);

// Victim: a public 2-of-2 AuthMultisig + BasicWallet, default threshold 2 but `create_note`
// overridden to 1, funded with the native fee asset.
let (_secret_keys, auth_schemes, public_keys, authenticators) =
setup_keys_and_authenticators_with_scheme(2, 1, AuthScheme::Falcon512Poseidon2)?;
let approvers = public_keys
.iter()
.zip(auth_schemes.iter())
.map(|(pk, scheme)| Approver::new(pk.to_commitment(), *scheme))
.collect();
let approver_set = ApproverSet::new(approvers, 2)?;
let multisig = builder.add_existing_wallet_with_assets(
Auth::Multisig {
approver_set,
proc_threshold_map: vec![(BasicWallet::create_note_root(), 1)],
},
[fee_asset(MULTISIG_FEE_BALANCE)?],
)?;

// The attacker network account's id is independent of its fee schedule.
let target_id = network_account([2; 32], [], &[], [], SponsorshipPolicy::default())?.id();

// The victim's ZERO-ASSET network note, targeting the attacker.
let network_note = NoteBuilder::new(multisig.id(), &mut rand::rng())
.note_type(NoteType::Public)
.attachment(NetworkAccountTarget::new(target_id, NoteExecutionHint::Always)?)
.build()?;
let note_root = network_note.script().root();

// The attacker prices that note root at DRAIN in the native fee asset.
let target = network_account(
[2; 32],
[note_root, FeeSponsorshipNote::script_root()],
&[(note_root, DRAIN)],
[],
SponsorshipPolicy::default(),
)?;
assert_eq!(target.id(), target_id, "network account id must not depend on its schedule");
builder.add_account(target.clone())?;

let mut mock_chain = builder.build()?;
mock_chain.prove_next_block()?;

let tx_script = SendNotesTransactionScript::new(
&multisig.code().interface(multisig.id()),
&[PartialNote::from(network_note.clone())],
)?;
let foreign_target = mock_chain.get_foreign_account_inputs(target.id())?;
let auth_args = MultisigAuthArgs::new(
mock_chain.latest_block_header().block_num(),
Word::from([13u32, 14, 15, 16]),
)
.with_conversion_info(FeeConversionInfo::one_to_one(fee_faucet_id()?));

let tx_builder = mock_chain
.build_transaction(multisig.id())
.foreign_accounts([foreign_target])
.send_notes_script(&tx_script)
.multisig_auth_args(auth_args)
.expected_output_note(RawOutputNote::Full(network_note.clone()));

// One unsigned run to obtain the summary the approvers sign.
let tx_summary = tx_builder
.clone()
.build()?
.execute()
.await
.unwrap_err()
.unwrap_unauthorized_err();
let msg = tx_summary.as_ref().to_commitment();
let signing_inputs = SigningInputs::TransactionSummary(tx_summary);

// A SINGLE approver signature authorizes the transaction (the bug).
let signature = authenticators[0]
.get_signature(public_keys[0].to_commitment(), &signing_inputs)
.await?;
let executed = tx_builder
.add_signature(public_keys[0].to_commitment(), msg, signature)
.build()?
.execute()
.await?;

// The sponsorship note carries the full attacker-priced DRAIN, funded from the multisig vault.
let sponsorship = executed
.output_notes()
.iter()
.find(|note| {
note.recipient().map(|recipient| recipient.script().root())
== Some(FeeSponsorshipNote::script_root())
})
.expect("pay_fee should sponsor the network note");
let sponsored: Vec = sponsorship.assets().iter().copied().collect();
assert_eq!(sponsored, vec![fee_asset(DRAIN)?], "the full attacker-priced amount is sponsored");

// The multisig's own TX_FEE note is bounded by #3758; the sponsorship dwarfs it.
let own_fee = executed
.output_notes()
.iter()
.find(|note| note.metadata().tag() == TxFeeNote::TAG)
.expect("the multisig pays its own fee note")
.assets()
.iter()
.next()
.expect("fee note carries one asset")
.unwrap_fungible()
.amount()
.as_u64();
assert!(
DRAIN > 100 * own_fee,
"the sponsorship outflow ({DRAIN}) dwarfs the margin-bounded fee note ({own_fee})",
);

// The vault dropped by DRAIN + the own fee — authorized by a single signature.
let mut drained = multisig.clone();
drained.apply_patch(executed.account_patch())?;
let after = drained.vault().get_balance(AssetId::new_fungible(fee_faucet_id()?))?.as_u64();
assert_eq!(
after,
MULTISIG_FEE_BALANCE - DRAIN - own_fee,
"the multisig vault was drained by the sponsorship outflow",
);

Ok(())
}
```

## Remediation

Primary (threshold-side, no amount cap): thread `pay_fee`'s `total_sponsored_fee_amount` into every signature component's auth flow instead of dropping it, and require `transaction_threshold >= default_threshold` whenever it is `> 0` — mirroring what `AuthNetworkAccount` already does with that same return value (**network_account.masm:106-131**). No-op at full quorum (the sponsorship note + vault delta are already in the signed summary), closes the reduced-quorum window, and avoids an amount cap — the sponsored amount is the target's own per-note fee, legitimately unrelated to and far larger than this tx's `compute_fee`, so a `2×compute_fee`-style bound would break ordinary sends to network accounts.

Defense-in-depth: refuse to lower a note-creating procedure's override below default even on public accounts (and at the component level, not only in the `create_multisig_wallet` helper). Docs: `set_procedure_threshold` / `fee/mod.masm:353-360` should state that a `NetworkAccountTarget` output note triggers a vault outflow during `pay_fee`, so `create_note` is not value-safe when choosing an override — the existing override-footgun docs (**auth/multisig.rs:205-241**) never mention this.

Guía de contribución

Abrir la guía de contribución

Evaluación

Este issue todavía no se ha evaluado.

Recibe los nuevos issues en tu correo

Un resumen breve de issues de GitHub para principiantes.