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)
- Ngôn ngữ chính
- Rust
- Star
- 132
- Fork
- 167
- Merge trung bình
- 1 ngày 23 giờ
- Pull request đã merge (30 ngày)
- 110
Mô tả
## 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.
Hướng dẫn đóng góp
Đánh giá
Issue này chưa được đánh giá.