diff --git a/Cargo.lock b/Cargo.lock index 416dd653..e81f6326 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -674,9 +674,9 @@ checksum = "4858a9d740c5007a9069007c3b4e91152d0506f13c1b31dd49051fd537656156" [[package]] name = "astral-tokio-tar" -version = "0.6.1" +version = "0.6.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4ce73b17c62717c4b6a9af10b43e87c578b0cac27e00666d48304d3b7d2c0693" +checksum = "cb50a7aae84a03bf55b067832bc376f4961b790c97e64d3eacee97d389b90277" dependencies = [ "filetime", "futures-core", @@ -2262,7 +2262,7 @@ dependencies = [ "libc", "option-ext", "redox_users", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -2582,7 +2582,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.59.0", + "windows-sys 0.52.0", ] [[package]] @@ -3561,7 +3561,7 @@ dependencies = [ "libc", "percent-encoding", "pin-project-lite", - "socket2 0.6.3", + "socket2 0.5.10", "tokio", "tower-service", "tracing", @@ -6381,7 +6381,7 @@ version = "0.50.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" dependencies = [ - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -7235,7 +7235,7 @@ dependencies = [ "quinn-udp", "rustc-hash", "rustls", - "socket2 0.6.3", + "socket2 0.5.10", "thiserror 2.0.18", "tokio", "tracing", @@ -7272,9 +7272,9 @@ dependencies = [ "cfg_aliases", "libc", "once_cell", - "socket2 0.6.3", + "socket2 0.5.10", "tracing", - "windows-sys 0.59.0", + "windows-sys 0.52.0", ] [[package]] @@ -8167,7 +8167,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys", - "windows-sys 0.59.0", + "windows-sys 0.52.0", ] [[package]] @@ -8225,7 +8225,7 @@ dependencies = [ "security-framework", "security-framework-sys", "webpki-root-certs 0.26.11", - "windows-sys 0.59.0", + "windows-sys 0.52.0", ] [[package]] @@ -9138,7 +9138,7 @@ dependencies = [ "getrandom 0.4.2", "once_cell", "rustix", - "windows-sys 0.59.0", + "windows-sys 0.52.0", ] [[package]] @@ -10469,7 +10469,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.59.0", + "windows-sys 0.52.0", ] [[package]] @@ -10608,15 +10608,6 @@ dependencies = [ "windows-targets 0.52.6", ] -[[package]] -name = "windows-sys" -version = "0.59.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" -dependencies = [ - "windows-targets 0.52.6", -] - [[package]] name = "windows-sys" version = "0.61.2" diff --git a/artifacts/test_program_methods/malicious_injector.bin b/artifacts/test_program_methods/malicious_injector.bin new file mode 100644 index 00000000..40e57b81 Binary files /dev/null and b/artifacts/test_program_methods/malicious_injector.bin differ diff --git a/artifacts/test_program_methods/malicious_launderer.bin b/artifacts/test_program_methods/malicious_launderer.bin new file mode 100644 index 00000000..cdae83a3 Binary files /dev/null and b/artifacts/test_program_methods/malicious_launderer.bin differ diff --git a/nssa/src/program.rs b/nssa/src/program.rs index 1aff3bc9..c3c92f1e 100644 --- a/nssa/src/program.rs +++ b/nssa/src/program.rs @@ -469,6 +469,24 @@ mod tests { use test_program_methods::PINATA_COOLDOWN_ELF; Self::new(PINATA_COOLDOWN_ELF.to_vec()).unwrap() } + + #[must_use] + pub fn malicious_injector() -> Self { + use test_program_methods::{MALICIOUS_INJECTOR_ELF, MALICIOUS_INJECTOR_ID}; + Self { + id: MALICIOUS_INJECTOR_ID, + elf: MALICIOUS_INJECTOR_ELF.to_vec(), + } + } + + #[must_use] + pub fn malicious_launderer() -> Self { + use test_program_methods::{MALICIOUS_LAUNDERER_ELF, MALICIOUS_LAUNDERER_ID}; + Self { + id: MALICIOUS_LAUNDERER_ID, + elf: MALICIOUS_LAUNDERER_ELF.to_vec(), + } + } } #[test] diff --git a/nssa/src/validated_state_diff.rs b/nssa/src/validated_state_diff.rs index 4bd5fb05..87bde206 100644 --- a/nssa/src/validated_state_diff.rs +++ b/nssa/src/validated_state_diff.rs @@ -265,7 +265,11 @@ impl ValidatedStateDiff { state_diff.insert(pre.account_id, post.account().clone()); } - let authorized_accounts: HashSet<_> = chained_call + // Source from `program_output.pre_states`, not `chained_call.pre_states`: + // the loop above already gates program_output's `is_authorized` via the + // `!pre.is_authorized || is_indeed_authorized` check, while `chained_call. + // pre_states` is caller-controlled and can be forged (audit-issue 91). + let authorized_accounts: HashSet<_> = program_output .pre_states .iter() .filter(|pre| pre.is_authorized) @@ -488,3 +492,427 @@ fn n_unique(data: &[T]) -> usize { let set: HashSet<&T> = data.iter().collect(); set.len() } + +#[cfg(test)] +mod tests { + use nssa_core::account::{AccountId, Nonce}; + + use crate::{ + PrivateKey, PublicKey, V03State, + error::{InvalidProgramBehaviorError, NssaError}, + program::Program, + public_transaction::{Message, WitnessSet}, + validated_state_diff::ValidatedStateDiff, + }; + + /// Privacy-path version of the authorization-injection attack. The test passes when the + /// attack is rejected and the victim's balance is left untouched. + /// + /// `execute_and_prove` succeeds because each inner receipt is individually valid and the + /// outer circuit faithfully commits whatever the attacker's program output says, including + /// `victim(is_authorized=true)`. The circuit has no access to chain state and cannot know + /// the victim never signed. + /// + /// The host-side validator is what catches the attack: it independently reconstructs + /// `public_pre_states` from chain state using `signer_account_ids.contains(victim_id) = false`, + /// so it expects `victim(is_authorized=false)`. The committed journal and the reconstructed + /// expected output diverge, `receipt.verify` fails, and `from_privacy_preserving_transaction` + /// returns an error before any state is applied. + #[test] + fn privacy_malicious_programs_cannot_drain_public_victim() { + use nssa_core::{ + Commitment, InputAccountIdentity, SharedSecretKey, + account::{Account, AccountWithMetadata}, + encryption::EphemeralPublicKey, + }; + + use crate::{ + PrivacyPreservingTransaction, + privacy_preserving_transaction::{ + circuit::{ProgramWithDependencies, execute_and_prove}, + message::Message, + witness_set::WitnessSet, + }, + state::{CommitmentSet, tests::test_private_account_keys_1}, + }; + + type InjectorInstruction = ( + nssa_core::program::ProgramId, // p2_id + nssa_core::program::ProgramId, // auth_transfer_id + [u8; 32], // victim_id_raw + u128, // victim_balance + u128, // victim_nonce + nssa_core::program::ProgramId, // victim_program_owner + [u8; 32], // recipient_id_raw + u128, // amount + ); + + // Attacker controls a private account. + let attacker_keys = test_private_account_keys_1(); + let attacker_id = AccountId::for_regular_private_account(&attacker_keys.npk(), 0); + let attacker_esk = [12_u8; 32]; + let attacker_ssk = SharedSecretKey::new(attacker_esk, &attacker_keys.vpk()); + let attacker_epk = EphemeralPublicKey::from_scalar(attacker_esk); + + let victim_id = AccountId::new([20_u8; 32]); + let recipient_id = AccountId::new([42_u8; 32]); + let victim_balance = 5_000_u128; + + // genesis sets program_owner = authenticated_transfer_program.id() on all accounts. + let mut state = V03State::new_with_genesis_accounts( + &[(victim_id, victim_balance), (recipient_id, 0)], + vec![], + 0, + ); + state.insert_program(Program::malicious_injector()); + state.insert_program(Program::malicious_launderer()); + + // Build attacker's private account and its local commitment tree. + let attacker_account = Account { + program_owner: Program::authenticated_transfer_program().id(), + balance: 100, + ..Account::default() + }; + let attacker_commitment = Commitment::new(&attacker_id, &attacker_account); + let mut commitment_set = CommitmentSet::with_capacity(1); + commitment_set.extend(std::slice::from_ref(&attacker_commitment)); + let membership_proof = commitment_set + .get_proof_for(&attacker_commitment) + .expect("attacker commitment must be in the set"); + + let attacker_pre = AccountWithMetadata::new(attacker_account, true, attacker_id); + + let victim_account = state.get_account_by_id(victim_id); + let instruction: InjectorInstruction = ( + Program::malicious_launderer().id(), + Program::authenticated_transfer_program().id(), + *victim_id.value(), + victim_account.balance, + victim_account.nonce.0, + victim_account.program_owner, + *recipient_id.value(), + victim_balance, + ); + let instruction_data = Program::serialize_instruction(instruction).unwrap(); + + let p2 = Program::malicious_launderer(); + let at = Program::authenticated_transfer_program(); + let program_with_deps = ProgramWithDependencies::new( + Program::malicious_injector(), + [(p2.id(), p2), (at.id(), at)].into(), + ); + + // account_identities order must match self.pre_states as built by the circuit: + // [0] attacker — first seen in P1's program_output.pre_states + // [1] victim — first seen in authenticated_transfer's program_output.pre_states + // [2] recipient — first seen in authenticated_transfer's program_output.pre_states + let account_identities = vec![ + InputAccountIdentity::PrivateAuthorizedUpdate { + ssk: attacker_ssk, + nsk: attacker_keys.nsk, + membership_proof, + identifier: 0, + }, + InputAccountIdentity::Public, // victim + InputAccountIdentity::Public, // recipient + ]; + + // execute_and_prove succeeds: all inner receipts are valid. + // The outer circuit commits victim(is_authorized=true) to its journal. + let (circuit_output, proof) = execute_and_prove( + vec![attacker_pre], + instruction_data, + account_identities, + &program_with_deps, + ) + .expect("execute_and_prove should succeed \u{2014} the programs execute correctly"); + + // public_account_ids lists the Public entries from account_identities, in order. + // The single ciphertext belongs to attacker's private account update. + let message = Message::try_from_circuit_output( + vec![victim_id, recipient_id], + vec![], // no public signers, no nonces + vec![(attacker_keys.npk(), attacker_keys.vpk(), attacker_epk)], + circuit_output, + ) + .unwrap(); + + let witness_set = WitnessSet::for_message(&message, proof, &[]); // no signatures + let tx = PrivacyPreservingTransaction::new(message, witness_set); + + let result = ValidatedStateDiff::from_privacy_preserving_transaction(&tx, &state, 1, 0); + + assert!( + matches!(result, Err(NssaError::InvalidPrivacyPreservingProof)), + "attack privacy transaction should be rejected with InvalidPrivacyPreservingProof" + ); + assert_eq!(state.get_account_by_id(victim_id).balance, victim_balance); + assert_eq!(state.get_account_by_id(recipient_id).balance, 0); + } + + /// Private-victim variant of the authorization-injection attack. The test passes when the + /// attack is rejected and the recipient's balance remains zero. + /// + /// After the circuit's Vacant branch accepts the injected `victim(is_authorized=true)` + /// verbatim, the attacker must choose how to declare the victim in `account_identities`. + /// There are two routes, both closed: + /// + /// - **mask=1 (`PrivateAuthorizedUpdate`)**: the circuit derives `account_id = + /// AccountId::for_regular_private_account(&npk_from(nsk), identifier)` and asserts it matches + /// `pre_state.account_id`. Passing this check requires the victim's `nsk`, which the attacker + /// does not have. `execute_and_prove` panics inside the ZKVM and no proof is produced. + /// + /// - **mask=0 (`Public`)**: the circuit places the account in `public_pre_states` and + /// `execute_and_prove` succeeds. The host-side validator then reconstructs + /// `public_pre_states` from chain state; `state.get_account_by_id(victim_id)` returns the + /// default account (balance=0) because the victim has no public state entry. The committed + /// journal and the reconstructed expected output diverge, `receipt.verify` fails, and + /// `from_privacy_preserving_transaction` returns an error before any state is applied. This + /// test exercises this route. + #[test] + fn privacy_malicious_programs_cannot_drain_private_victim() { + use nssa_core::{ + Commitment, InputAccountIdentity, SharedSecretKey, + account::{Account, AccountWithMetadata}, + encryption::EphemeralPublicKey, + }; + + use crate::{ + PrivacyPreservingTransaction, + privacy_preserving_transaction::{ + circuit::{ProgramWithDependencies, execute_and_prove}, + message::Message, + witness_set::WitnessSet, + }, + state::{ + CommitmentSet, + tests::{test_private_account_keys_1, test_private_account_keys_2}, + }, + }; + + type InjectorInstruction = ( + nssa_core::program::ProgramId, // p2_id + nssa_core::program::ProgramId, // auth_transfer_id + [u8; 32], // victim_id_raw + u128, // victim_balance + u128, // victim_nonce + nssa_core::program::ProgramId, // victim_program_owner + [u8; 32], // recipient_id_raw + u128, // amount + ); + + // Attacker controls a private account. + let attacker_keys = test_private_account_keys_1(); + let attacker_id = AccountId::for_regular_private_account(&attacker_keys.npk(), 0); + let attacker_esk = [12_u8; 32]; + let attacker_ssk = SharedSecretKey::new(attacker_esk, &attacker_keys.vpk()); + let attacker_epk = EphemeralPublicKey::from_scalar(attacker_esk); + + // Victim is a private account — not registered in public chain state. + let victim_keys = test_private_account_keys_2(); + let victim_id = AccountId::for_regular_private_account(&victim_keys.npk(), 0); + let victim_balance = 5_000_u128; + + let recipient_id = AccountId::new([42_u8; 32]); + + // Victim has no public state entry; only recipient is registered at genesis. + let mut state = V03State::new_with_genesis_accounts(&[(recipient_id, 0)], vec![], 0); + state.insert_program(Program::malicious_injector()); + state.insert_program(Program::malicious_launderer()); + + // Build attacker's private account and its local commitment tree. + let attacker_account = Account { + program_owner: Program::authenticated_transfer_program().id(), + balance: 100, + ..Account::default() + }; + let attacker_commitment = Commitment::new(&attacker_id, &attacker_account); + let mut commitment_set = CommitmentSet::with_capacity(1); + commitment_set.extend(std::slice::from_ref(&attacker_commitment)); + let membership_proof = commitment_set + .get_proof_for(&attacker_commitment) + .expect("attacker commitment must be in the set"); + + let attacker_pre = AccountWithMetadata::new(attacker_account, true, attacker_id); + + // The attacker supplies the victim's account data directly — it cannot be read from + // public state. The injected balance and program_owner allow authenticated_transfer + // to succeed inside the circuit, which has no access to chain state and cannot detect + // that these values are fabricated. + let instruction: InjectorInstruction = ( + Program::malicious_launderer().id(), + Program::authenticated_transfer_program().id(), + *victim_id.value(), + victim_balance, + 0_u128, // nonce + Program::authenticated_transfer_program().id(), // program_owner + *recipient_id.value(), + victim_balance, + ); + let instruction_data = Program::serialize_instruction(instruction).unwrap(); + + let p2 = Program::malicious_launderer(); + let at = Program::authenticated_transfer_program(); + let program_with_deps = ProgramWithDependencies::new( + Program::malicious_injector(), + [(p2.id(), p2), (at.id(), at)].into(), + ); + + // account_identities order must match self.pre_states as built by the circuit: + // [0] attacker — first seen in P1's program_output.pre_states + // [1] victim — first seen in authenticated_transfer's program_output.pre_states + // [2] recipient — first seen in authenticated_transfer's program_output.pre_states + // + // Victim is marked Public: the attacker has no nsk for the victim's private account, + // so PrivateAuthorizedUpdate is not an option. + let account_identities = vec![ + InputAccountIdentity::PrivateAuthorizedUpdate { + ssk: attacker_ssk, + nsk: attacker_keys.nsk, + membership_proof, + identifier: 0, + }, + InputAccountIdentity::Public, // victim — attacker lacks victim's nsk + InputAccountIdentity::Public, // recipient + ]; + + // execute_and_prove succeeds: authenticated_transfer runs against the injected + // victim(balance=5000, is_authorized=true) and produces valid inner receipts. + // The outer circuit commits victim(is_authorized=true) to public_pre_states. + let (circuit_output, proof) = execute_and_prove( + vec![attacker_pre], + instruction_data, + account_identities, + &program_with_deps, + ) + .expect("execute_and_prove should succeed \u{2014} the programs execute correctly"); + + // public_account_ids lists the Public entries from account_identities, in order. + // The single ciphertext belongs to attacker's private account update. + let message = Message::try_from_circuit_output( + vec![victim_id, recipient_id], + vec![], // no public signers, no nonces + vec![(attacker_keys.npk(), attacker_keys.vpk(), attacker_epk)], + circuit_output, + ) + .unwrap(); + + let witness_set = WitnessSet::for_message(&message, proof, &[]); // no signatures + let tx = PrivacyPreservingTransaction::new(message, witness_set); + + let result = ValidatedStateDiff::from_privacy_preserving_transaction(&tx, &state, 1, 0); + + assert!( + matches!(result, Err(NssaError::InvalidPrivacyPreservingProof)), + "attack on private victim should be rejected with InvalidPrivacyPreservingProof" + ); + // Victim has no public balance to check; confirming the recipient received nothing + // is sufficient to show no funds moved. + assert_eq!(state.get_account_by_id(recipient_id).balance, 0); + } + + /// Two malicious programs (injector + launderer) attempt to drain a victim's balance + /// without the victim signing anything. The test passes when the attack is rejected + /// and the victim's balance is left untouched. + /// + /// Attack flow: + /// Transaction (attacker signs) → P1 (`malicious_injector`) + /// → injects `victim(is_authorized=true)` into chained-call `pre_states` for P2 + /// P2 (`malicious_launderer`) + /// → outputs empty pre/post states, forwarding the forged flag to `authenticated_transfer` + /// → if `authorized_accounts` were built from the injected `pre_states`, + /// `{victim}.contains(victim)` would pass and the transfer would execute. + /// + /// The validator must reject this: `authorized_accounts` must be derived from the + /// parent program's own validated `program_output.pre_states`, not from the chained-call + /// input, so a forged `is_authorized=true` flag is never trusted. + #[test] + fn malicious_programs_cannot_drain_victim_without_signature() { + // p2_id, auth_transfer_id, victim_id_raw, victim_balance, victim_nonce, + // victim_program_owner, recipient_id_raw, amount. + // Primitives only — AccountId/Account cannot round-trip through instruction_data + // via risc0_zkvm::serde (SerializeDisplay issue). + type InjectorInstruction = ( + nssa_core::program::ProgramId, // p2_id + nssa_core::program::ProgramId, // auth_transfer_id + [u8; 32], // victim_id_raw + u128, // victim_balance + u128, // victim_nonce + nssa_core::program::ProgramId, // victim_program_owner + [u8; 32], // recipient_id_raw + u128, // amount + ); + + let attacker_key = PrivateKey::try_new([10; 32]).unwrap(); + let attacker_id = AccountId::from(&PublicKey::new_from_private_key(&attacker_key)); + + let victim_key = PrivateKey::try_new([20; 32]).unwrap(); + let victim_id = AccountId::from(&PublicKey::new_from_private_key(&victim_key)); + + let recipient_id = AccountId::new([42; 32]); + + let victim_balance = 5_000_u128; + let mut state = V03State::new_with_genesis_accounts( + &[ + (attacker_id, 100), + (victim_id, victim_balance), + (recipient_id, 0), + ], + vec![], + 0, + ); + + state.insert_program(Program::malicious_injector()); + state.insert_program(Program::malicious_launderer()); + + // Read victim state from chain, exactly as the attacker would. + let victim_account = state.get_account_by_id(victim_id); + + let instruction: InjectorInstruction = ( + Program::malicious_launderer().id(), + Program::authenticated_transfer_program().id(), + *victim_id.value(), + victim_account.balance, + victim_account.nonce.0, + victim_account.program_owner, + *recipient_id.value(), + victim_balance, + ); + + let message = Message::try_new( + Program::malicious_injector().id(), + vec![attacker_id], + vec![Nonce(0)], + instruction, + ) + .unwrap(); + + let witness_set = WitnessSet::for_message(&message, &[&attacker_key]); + let tx = crate::PublicTransaction::new(message, witness_set); + + let result = ValidatedStateDiff::from_public_transaction(&tx, &state, 1, 0); + + assert!( + matches!( + result, + Err(NssaError::InvalidProgramBehavior( + InvalidProgramBehaviorError::InvalidAccountAuthorization { account_id } + )) if account_id == victim_id + ), + "attack transaction should be rejected with InvalidAccountAuthorization for the victim" + ); + + // Confirm the victim's balance is untouched. + let victim_balance_after = state.get_account_by_id(victim_id).balance; + let recipient_balance_after = state.get_account_by_id(recipient_id).balance; + + assert_eq!( + victim_balance_after, victim_balance, + "victim balance should be unchanged" + ); + assert_eq!( + recipient_balance_after, 0, + "recipient should receive nothing" + ); + } +} diff --git a/test_program_methods/guest/src/bin/malicious_injector.rs b/test_program_methods/guest/src/bin/malicious_injector.rs new file mode 100644 index 00000000..4e7300a2 --- /dev/null +++ b/test_program_methods/guest/src/bin/malicious_injector.rs @@ -0,0 +1,105 @@ +use nssa_core::{ + account::{Account, AccountId, AccountWithMetadata, Data, Nonce}, + program::{ + AccountPostState, ChainedCall, ProgramId, ProgramInput, ProgramOutput, read_nssa_inputs, + }, +}; + +/// Instruction uses only risc0-serde-compatible primitives — no `AccountId`/`Account` structs, +/// which use `SerializeDisplay`/`DeserializeFromStr` and cannot round-trip through +/// `instruction_data`. +/// +/// Fields: +/// `p2_id`: program ID of the launderer (P2) +/// `auth_transfer_id`: program ID of `authenticated_transfer`, forwarded to P2 +/// `victim_id_raw`: raw `[u8; 32]` of the victim `AccountId` +/// `victim_balance`: victim's current balance +/// `victim_nonce`: victim's current nonce (inner `u128`) +/// `victim_program_owner`: victim account's `program_owner` field +/// `recipient_id_raw`: raw `[u8; 32]` of the recipient `AccountId` +/// `amount`: balance to transfer out of the victim. +type Instruction = ( + ProgramId, + ProgramId, + [u8; 32], + u128, + u128, + ProgramId, + [u8; 32], + u128, +); + +fn main() { + let ( + ProgramInput { + self_program_id, + caller_program_id, + pre_states, + instruction: + ( + p2_id, + auth_transfer_id, + victim_id_raw, + victim_balance, + victim_nonce, + victim_program_owner, + recipient_id_raw, + amount, + ), + }, + instruction_words, + ) = read_nssa_inputs::(); + + // Echo own pre_states (attacker's account) unchanged. + let post_states = pre_states + .iter() + .map(|p| AccountPostState::new(p.account.clone())) + .collect(); + + // Construct victim AccountWithMetadata from primitives, stamping is_authorized=true. + // Victim has not signed anything — this flag is forged entirely by P1's logic. + let victim = AccountWithMetadata { + account: Account { + program_owner: victim_program_owner, + balance: victim_balance, + data: Data::default(), + nonce: Nonce(victim_nonce), + }, + is_authorized: true, + account_id: AccountId::new(victim_id_raw), + }; + + // Recipient is already initialized under authenticated_transfer (program_owner = + // auth_transfer_id, balance = 0). Using the default account would trigger + // Claim::Authorized inside authenticated_transfer, which requires is_authorized=true + // on the recipient — a check that would block the transfer. + let recipient = AccountWithMetadata { + account: Account { + program_owner: auth_transfer_id, + balance: 0, + data: Data::default(), + nonce: Nonce(0), + }, + is_authorized: false, + account_id: AccountId::new(recipient_id_raw), + }; + + // Forward auth_transfer_id and amount to P2 so it can call authenticated_transfer. + let p2_instruction = risc0_zkvm::serde::to_vec(&(auth_transfer_id, amount)) + .expect("serialization is infallible"); + + ProgramOutput::new( + self_program_id, + caller_program_id, + instruction_words, + pre_states, + post_states, + ) + .with_chained_calls(vec![ChainedCall { + program_id: p2_id, + pre_states: vec![victim, recipient], + instruction_data: p2_instruction, + pda_seeds: vec![], + }]) + .write(); +} diff --git a/test_program_methods/guest/src/bin/malicious_launderer.rs b/test_program_methods/guest/src/bin/malicious_launderer.rs new file mode 100644 index 00000000..6d0568fd --- /dev/null +++ b/test_program_methods/guest/src/bin/malicious_launderer.rs @@ -0,0 +1,43 @@ +use nssa_core::program::{ChainedCall, ProgramId, ProgramInput, ProgramOutput, read_nssa_inputs}; + +/// Instruction: (`auth_transfer_id`, `amount`) — both primitive, safe for `risc0_zkvm::serde`. +type Instruction = (ProgramId, u128); + +fn main() { + let ( + ProgramInput { + self_program_id, + caller_program_id, + pre_states, + instruction: (auth_transfer_id, amount), + }, + instruction_words, + ) = read_nssa_inputs::(); + + // Output empty pre/post states. P2 processes no accounts itself, so the + // authorization check at validated_state_diff.rs:158-182 runs over nothing. + // Victim is never compared against caller_data.authorized_accounts = {attacker}. + // + // The bug: authorized_accounts for authenticated_transfer is built from + // chained_call.pre_states (this call's inputs, set by P1), which contains + // victim(is_authorized=true). So authorized_accounts = {victim}, and the + // subsequent check passes. + let auth_transfer_instruction = + risc0_zkvm::serde::to_vec(&authenticated_transfer_core::Instruction::Transfer { amount }) + .expect("serialization is infallible"); + + ProgramOutput::new( + self_program_id, + caller_program_id, + instruction_words, + vec![], + vec![], + ) + .with_chained_calls(vec![ChainedCall { + program_id: auth_transfer_id, + pre_states, + instruction_data: auth_transfer_instruction, + pda_seeds: vec![], + }]) + .write(); +}