You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
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) #3768
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:112call.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
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 (fix: pay the transaction fee in guarded and smart multisig auth #3758) but require a guardian co-signature on any output-note-creating tx (guarded_multisig.masm:88), making them a two-party variant.
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).
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.
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-2AuthMultisig 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 fix: pay the transaction fee in guarded and smart multisig auth #3758MAX_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)
#[tokio::test]asyncfnreduced_quorum_sponsorship_drains_the_vault() -> anyhow::Result<()>{constMULTISIG_FEE_BALANCE:u64 = 5_000_000;constDRAIN:u64 = 3_000_000;letmut 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())?;letmut 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<Asset> = 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.letmut 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.
Symptom
fee::pay_feemoves an unbounded, target-priced amount out of the fee-paying account's vault (theFEE_SPONSORSHIPoutflow), 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 bycompute_transaction_thresholdfrom 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_MARGINbound 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 insidepay_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_feerunsfees::create_network_note_sponsorshipsat fee/mod.masm:398, beforetx::compute_feeat :412.total_sponsored_fee_amountis carried past the margin logic (movdn.5, :401) and returned at :419/:479 with no bound. The margin check (:454, :466-469) applies only topayment_amountfor the TX_FEE note.NetworkAccountTargetattachment (is_network_notekeys on the attachment, not on assets — fees/mod.masm:597-603), the amount is priced by an FPI call to the target account'sestimate_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-89reads 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_notewithdraws it from the native vault vianative_account::remove_asset(fees/mod.masm:786); the only ceiling is the vault balance (panics on underflow).exec.fee::pay_fee dropat components/auth/multisig/multisig.masm:51 discardstotal_sponsored_fee_amountbeforeexec.multisig::auth_txat :57. OnlyAuthNetworkAccountconsumes it and asserts on it (collected >= sponsored, network_account.masm:131, whensponsor_at_most_collected_feesis 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 intocompute_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, elsedefault_thresholdat :1079-1083), enforced inauth_tx(:925-934).native_account::was_procedure_calleddoes not track procedures invoked from within the auth procedure, sopay_fee's internalremove_asset/output_note::createadd no counted contribution.create_note: send_notes/wallet.masm:112call.note_creator::create_noteis unconditional,move_asset_to_note(:142) sits inside theassets_left != 0loop (:129) and is skipped, and theNetworkAccountTargetattachment is added via uncountedexec.common::add_attachments(:171, kerneloutput_note::add_attachment, not an accountcall). So the threshold collapses tooverride(create_note), which can be1.create_noteandmove_asset_to_noteare distinct, independently-overridable roots. Acreate_note-only override cannot move assets directly (that needsmove_asset_to_noteat default) but CAN trigger the sponsorship drain — a value-exfiltration capability the direct note path does not grant at reduced quorum.Preconditions
AuthMultisigis 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 (fix: pay the transaction fee in guarded and smart multisig auth #3758) but require a guardian co-signature on any output-note-creating tx (guarded_multisig.masm:88), making them a two-party variant.create_note(asset-free), withmove_asset_to_noteleft 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 viaAccountBuilder+AuthMultisig::new, which rejects only overrides above theset_procedure_thresholdguard, never below (auth/multisig.rs:282-291). Thecreate_multisig_wallethelper'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 — withreceive_asset, the exemption being root-agnostic). The override can alternatively be lowered later by aset_procedure_thresholdtx, which — carrying no override of its own — requires the default threshold (auth/multisig.masm:325-337 floors only at<= num_approvers).NetworkAccountTargetattachment pointing at an attacker-deployed network account whoseBasicConstantFeePolicyprices that note's script root arbitrarily high in the native fee asset, supplied as a foreign-account input so the FPI resolves.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_SPONSORSHIPnote is public and its consumption rights are inherited from the paired feature note, whoseRECIPIENTthe attacker supplies verbatim in the send-notes payload — so the attacker makes the feature note self-consumable and, viacollect_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_notesetsreclaim_block_height = 0(push.0, fees/mod.masm:807) andreclaim_noteaborts withERR_FEE_SPONSORSHIP_RECLAIM_DISABLED(fee_sponsorship.masm:71).collect_sponsored_feesis 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_notethen contributesdefault_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
nextcode (with the #3758 fix present). A single signature on a public 2-of-2AuthMultisigmoved 3,000,000 native-fee-asset units out of the victim's vault via the FEE_SPONSORSHIP note. Observed (all assertions below pass):AuthMultisig+BasicWallet, default threshold 2,proc_threshold_map = [(BasicWallet::create_note_root(), 1)], funded with 5,000,000 of the native fee asset.NetworkAccountTargetattachment pointing at an attacker-deployed network account whoseBasicConstantFeePolicyprices that note's script root at 3,000,000. Signed by one approver.execute()returnsOk(authorized with 1 of 2 signatures); the emitted FEE_SPONSORSHIP note carries exactlyfee_asset(3_000_000); the victim's vault balance drops by3_000_000 + own_fee, whereown_feeis the margin-bounded TX_FEE note (3_000_000 > 100 × own_fee). The fix: pay the transaction fee in guarded and smart multisig auth #3758MAX_FEE_PAYMENT_MARGINbound does not apply to the sponsorship outflow.The PoC drops into
crates/miden-testing/tests/auth/fee_payment/sponsorship.rs(reusing that file'snetwork_account/fee_asset/fee_faucet_idhelpers), adding these imports:Approver, ApproverSet, MultisigAuthArgs(frommiden_standards::account::auth),miden_standards::testing::note::NoteBuilder,miden_tx::auth::{SigningInputs, TransactionAuthenticator}, andsuper::super::multisig::{MultisigAuthArgsExt, setup_keys_and_authenticators_with_scheme}.PoC test (passes = the drain is authorized by one signature)
Remediation
Primary (threshold-side, no amount cap): thread
pay_fee'stotal_sponsored_fee_amountinto every signature component's auth flow instead of dropping it, and requiretransaction_threshold >= default_thresholdwhenever it is> 0— mirroring whatAuthNetworkAccountalready 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'scompute_fee, so a2×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_wallethelper). Docs:set_procedure_threshold/fee/mod.masm:353-360should state that aNetworkAccountTargetoutput note triggers a vault outflow duringpay_fee, socreate_noteis not value-safe when choosing an override — the existing override-footgun docs (auth/multisig.rs:205-241) never mention this.