diff --git a/.changeset/hip-pans-bow.md b/.changeset/hip-pans-bow.md new file mode 100644 index 00000000..23a5c801 --- /dev/null +++ b/.changeset/hip-pans-bow.md @@ -0,0 +1,5 @@ +--- +'@fuel-bridge/fungible-token': patch +--- + +Add reentrancy unit test for l2 proxy-bridge diff --git a/Forc.lock b/Forc.lock index 8f7fd8dc..d651d5fe 100644 --- a/Forc.lock +++ b/Forc.lock @@ -49,6 +49,15 @@ dependencies = [ "std", ] +[[package]] +name = "reentrancy-attacker" +source = "member" +dependencies = [ + "contract_message_receiver", + "standards git+https://github.com/FuelLabs/sway-standards?tag=v0.5.0#348f7175df4c012b23c86cdb18aab79025ca1f18", + "std", +] + [[package]] name = "standards" source = "git+https://github.com/FuelLabs/sway-standards?tag=v0.4.3#6f63eb7dff2458a7d976184e565b5cbf26f61da2" diff --git a/Forc.toml b/Forc.toml index b63b7874..76635b4b 100644 --- a/Forc.toml +++ b/Forc.toml @@ -4,6 +4,7 @@ members = [ "packages/fungible-token/bridge-fungible-token/interface", "packages/fungible-token/bridge-fungible-token/proxy", "packages/fungible-token/bridge-fungible-token/implementation", + "packages/fungible-token/bridge-fungible-token/reentrancy-attacker", "packages/message-predicates/contract-message-predicate", "packages/message-predicates/contract-message-receiver", "packages/base-asset", diff --git a/packages/fungible-token/bridge-fungible-token/reentrancy-attacker/Forc.toml b/packages/fungible-token/bridge-fungible-token/reentrancy-attacker/Forc.toml new file mode 100644 index 00000000..73ea87ba --- /dev/null +++ b/packages/fungible-token/bridge-fungible-token/reentrancy-attacker/Forc.toml @@ -0,0 +1,9 @@ +[project] +authors = ["Fuel Labs "] +entry = "reentrancy-attacker.sw" +license = "Apache-2.0" +name = "reentrancy-attacker" + +[dependencies] +contract_message_receiver = { path = "../../../message-predicates/contract-message-receiver" } +standards = { git = "https://github.com/FuelLabs/sway-standards", tag = "v0.5.0" } diff --git a/packages/fungible-token/bridge-fungible-token/reentrancy-attacker/src/reentrancy-attacker.sw b/packages/fungible-token/bridge-fungible-token/reentrancy-attacker/src/reentrancy-attacker.sw new file mode 100644 index 00000000..6b1cbe05 --- /dev/null +++ b/packages/fungible-token/bridge-fungible-token/reentrancy-attacker/src/reentrancy-attacker.sw @@ -0,0 +1,53 @@ +contract; + +use std::execution::run_external; +use standards::{src14::SRC14, src5::{AccessError, State}}; +use contract_message_receiver::MessageReceiver; + +abi ReentrancyAttacker { + #[storage(read, write)] + fn process_message(msg_idx: u64); + + #[storage(read)] + fn get_success() -> bool; +} + +pub enum AttackStage { + Attacking: (), + Success: (), + Finished: (), +} + +configurable { + TARGET: ContractId = ContractId::zero(), +} + +#[namespace(SRC14)] +storage { + attacking: bool = false, + success: bool = false, +} + +impl ReentrancyAttacker for Contract { + #[storage(read, write)] + fn process_message(msg_idx: u64) { + if storage.success.read() { + log(AttackStage::Finished); + return; + } + + log(AttackStage::Attacking); + storage.attacking.write(true); + + let target = abi(MessageReceiver, TARGET.into()); + target.process_message(msg_idx); + + storage.success.write(true); + log(AttackStage::Success); + } + + #[storage(read)] + fn get_success() -> bool { + storage.success.read() + } +} diff --git a/packages/fungible-token/bridge-fungible-token/tests/functions/message_receiver/mod.rs b/packages/fungible-token/bridge-fungible-token/tests/functions/message_receiver/mod.rs index b0bd4e93..4a067d02 100644 --- a/packages/fungible-token/bridge-fungible-token/tests/functions/message_receiver/mod.rs +++ b/packages/fungible-token/bridge-fungible-token/tests/functions/message_receiver/mod.rs @@ -1101,9 +1101,16 @@ mod success { } mod revert { - use fuels::types::tx_status::TxStatus; + use fuels::{ + accounts::wallet::WalletUnlocked, + programs::contract::SettableContract, + types::{tx_status::TxStatus, U256}, + }; - use crate::utils::setup::get_contract_ids; + use crate::utils::setup::{ + create_reentrancy_attacker_contract, get_contract_ids, precalculate_reentrant_attacker_id, + AttackStage, ReentrancyAttacker, + }; use super::*; @@ -1159,4 +1166,74 @@ mod revert { } } } + + #[tokio::test] + async fn rejects_reentrancy_attempts() { + let mut wallet = create_wallet(); + let configurables: Option = None; + let (proxy_id, _implementation_contract_id) = + get_contract_ids(&wallet, configurables.clone()); + let deposit_contract_id = precalculate_reentrant_attacker_id(proxy_id.clone()).await; + let amount = u64::MAX; + + let (message, coin, deposit_contract) = create_deposit_message( + BRIDGED_TOKEN, + BRIDGED_TOKEN_ID, + FROM, + *deposit_contract_id, + U256::from(amount), + BRIDGED_TOKEN_DECIMALS, + proxy_id, + true, + Some(vec![11u8, 42u8, 69u8]), + ) + .await; + + let (_, _, utxo_inputs) = setup_environment( + &mut wallet, + vec![coin], + vec![message], + deposit_contract, + None, + configurables, + ) + .await; + + let provider = wallet.provider().expect("Needs provider"); + + let reentrant_attacker: ReentrancyAttacker = + create_reentrancy_attacker_contract(wallet.clone(), proxy_id.clone()).await; + + // Relay the test message to the bridge contract + let tx_id = relay_message_to_contract( + &wallet, + utxo_inputs.message[0].clone(), + utxo_inputs.contract, + ) + .await; + + let tx_status = provider.tx_status(&tx_id).await.unwrap(); + assert!(matches!(tx_status, TxStatus::Revert { .. })); + + let receipts: Vec = + provider.tx_status(&tx_id).await.unwrap().take_receipts(); + + let attack_stages = reentrant_attacker + .log_decoder() + .decode_logs_with_type::(&receipts) + .unwrap(); + + assert_eq!(attack_stages.len(), 1); + assert_eq!(attack_stages[0], AttackStage::Attacking); + + let attack_successful = reentrant_attacker + .methods() + .get_success() + .call() + .await + .unwrap() + .value; + + assert!(!attack_successful); + } } diff --git a/packages/fungible-token/bridge-fungible-token/tests/utils/constants.rs b/packages/fungible-token/bridge-fungible-token/tests/utils/constants.rs index 35542d70..be902ade 100644 --- a/packages/fungible-token/bridge-fungible-token/tests/utils/constants.rs +++ b/packages/fungible-token/bridge-fungible-token/tests/utils/constants.rs @@ -7,6 +7,8 @@ pub(crate) const BRIDGE_FUNGIBLE_TOKEN_CONTRACT_BINARY: &str = pub(crate) const DEPOSIT_RECIPIENT_CONTRACT_BINARY: &str = "../test-deposit-recipient-contract/out/release/test_deposit_recipient_contract.bin"; pub(crate) const BRIDGE_PROXY_BINARY: &str = "../bridge-fungible-token/proxy/out/release/proxy.bin"; +pub(crate) const REENTRANCY_ATTACKER_BINARY: &str = + "../bridge-fungible-token/reentrancy-attacker/out/release/reentrancy-attacker.bin"; pub(crate) const BRIDGED_TOKEN: &str = "0x00000000000000000000000000000000000000000000000000000000deadbeef"; diff --git a/packages/fungible-token/bridge-fungible-token/tests/utils/setup.rs b/packages/fungible-token/bridge-fungible-token/tests/utils/setup.rs index 61532278..5729bf94 100644 --- a/packages/fungible-token/bridge-fungible-token/tests/utils/setup.rs +++ b/packages/fungible-token/bridge-fungible-token/tests/utils/setup.rs @@ -28,6 +28,7 @@ use std::{mem::size_of, num::ParseIntError, result::Result as StdResult, str::Fr use super::constants::{ BRIDGED_TOKEN, BRIDGED_TOKEN_ID, BRIDGE_PROXY_BINARY, DEPOSIT_TO_ADDRESS_FLAG, DEPOSIT_TO_CONTRACT_FLAG, DEPOSIT_WITH_DATA_FLAG, FROM, METADATA_MESSAGE_FLAG, + REENTRANCY_ATTACKER_BINARY, }; abigen!( @@ -43,6 +44,10 @@ abigen!( Contract( name = "BridgeProxy", abi = "packages/fungible-token/bridge-fungible-token/proxy/out/release/proxy-abi.json", + ), + Contract( + name = "ReentrancyAttacker", + abi = "packages/fungible-token/bridge-fungible-token/reentrancy-attacker/out/release/reentrancy-attacker-abi.json", ) ); @@ -344,6 +349,20 @@ pub(crate) async fn precalculate_deposit_id() -> ContractId { compiled.contract_id() } +pub(crate) async fn precalculate_reentrant_attacker_id(target: ContractId) -> ContractId { + let configurables = ReentrancyAttackerConfigurables::default() + .with_TARGET(target) + .unwrap(); + + let compiled = Contract::load_from( + REENTRANCY_ATTACKER_BINARY, + LoadConfiguration::default().with_configurables(configurables), + ) + .unwrap(); + + compiled.contract_id() +} + /// Prefixes the given bytes with the test contract ID pub(crate) fn prefix_contract_id(mut data: Vec, contract_id: ContractId) -> Vec { // Turn contract id into array with the given data appended to it @@ -368,6 +387,26 @@ pub(crate) async fn create_recipient_contract( DepositRecipientContract::new(id, wallet) } +pub(crate) async fn create_reentrancy_attacker_contract( + wallet: WalletUnlocked, + target: ContractId, +) -> ReentrancyAttacker { + let configurables = ReentrancyAttackerConfigurables::default() + .with_TARGET(target) + .unwrap(); + + let id = Contract::load_from( + REENTRANCY_ATTACKER_BINARY, + LoadConfiguration::default().with_configurables(configurables), + ) + .unwrap() + .deploy(&wallet, TxPolicies::default()) + .await + .unwrap(); + + ReentrancyAttacker::new(id, wallet) +} + /// Quickly converts the given hex string into a u8 vector pub(crate) fn decode_hex(s: &str) -> Vec { let data: StdResult, ParseIntError> = (2..s.len())