Griefing (e.g. no profit motive for an attacker, but damage to the users or the protocol)
Description
Brief/Intro
The BridgeRouter system allows unauthorized reversal of failed transactions, enabling attackers to disrupt operations and force users to incur unnecessary costs. This vulnerability could lead to financial losses, prevent legitimate transactions.
Vulnerability Details
The reverse message functionality in the BridgeRouter system is designed to cancle failed cross-chain messages. Here's how it operates:
When a message fails during execution on the destination chain, it's stored in the BridgeRouter contract as a failed message.
The reverseMessage function in the BridgeRouter can be called with the adapterId and messageId of the failed message: `
This function retrieves the failed message, clears it from storage, and calls the reverseMessage function on the corresponding handler (typically the Hub contract).
functionreverseMessage(uint16 adapterId, bytes32 messageId, bytes memory extraArgs) externalpayable {// some code ..tryBridgeMessenger(handler).reverseMessage(message, extraArgs) {// clear failure and emit message reverse as suceeded emit MessageReverseSucceeded(adapterId,message.messageId); } catch (bytes memory err) {// store and emit message reverse as failed failedMessages[adapterId][message.messageId] = message; emit MessageReverseFailed(adapterId,message.messageId, err); } }
In the Hub contract, the _reverseMessage function processes the reversal:
The Hub verifies the token receipt from the original transaction and initiates a token return to the user on the source chain.
To complete the reversal, the Hub calls back to the BridgeRouter to send a cross-chain message returning the tokens.
function_reverseMessage(Messages.MessageReceived memory message, bytes memory extraArgs) internaloverride {Messages.MessagePayload memory payload =Messages.decodeActionPayload(message.payload);// check sender has permission for relevant operations, overriding account id if neccessary bytes32 accountId =extraArgs.length==0?payload.accountId :extraArgs.toBytes32(0); bool isRegistered =accountManager.isAddressRegisteredToAccount(accountId,message.sourceChainId,payload.userAddress);if (!isRegistered) { revert IAccountManager.NotRegisteredToAccount(accountId,message.sourceChainId,payload.userAddress); }// some code . .sendTokenToUser(message.returnAdapterId,message.returnGasLimit, accountId,payload.userAddress,SendToken({poolId: poolId, chainId:message.sourceChainId, amount: amount}) ); }functionsendTokenToUser( uint16 adapterId, uint256 gasLimit, bytes32 accountId, bytes32 recipient, SendToken memory sendToken ) internal {// some code ..// send message (balance for user account already present in bridge router)>>_sendMessage(messageToSend,0); }
The BridgeRouter sends this cross-chain message, using fees from the balance associated with the original user's accountId.
functionsendMessage(Messages.MessageToSend memory message)/*...*/ {// check if have sufficient funds to pay fee (can come from existing balance and/or msg.value) bytes32 userId =_getUserId(Messages.decodeActionPayload(message.payload));>> uint256 userBalance = balances[userId];if (msg.value + userBalance < fee) revert NotEnoughFunds(userId);// update user balance considering fee and msg.value>> balances[userId] = userBalance +msg.value - fee;// call given adapter to send messageadapter.sendMessage{value: fee}(message); }
The core issue lies in the lack of propore access control and the ability for anyone to call the reverseMessage() function in bridgeRouter contract for any failed messages (for allowed actions like deposit/repay ..ect), usurping the rightful decision-making power of the original message sender:
This vulnerability allows malicious actors to force the reversal of transactions against the wishes of the original sender This is problematic for various reasons:
User Autonomy: The users who initiate cross-chain messages are the only ones who should have the authority to decide whether to retry or reverse a failed message. They have the context of their intended operation and are best positioned to make this decision. Moreover, they bear the financial consequences of both the initial transaction and any subsequent actions.
Financial Implications: When a reversal is initiated, the fees for the return message are deducted from the user's balance in the BridgeRouter. This can lead to unexpected costs for the user, especially if the reversal is to a high-fee network like Ethereum mainnet.
Exploitation of Failed Transactions: Even in cases where a transaction fails due to easily rectifiable issues (like slightly insufficient gas), an attacker can force a costly reversal instead of allowing a simple retry.
Smart Contract integration: Contracts interacting with this system may not be designed to handle unexpected reversals, potentially leading to locked funds or corrupted contract states.
The lack of restrictions on who can call reverseMessage() and the absence of a mechanism to prioritize the original sender's intentions make this a severe vulnerability in the current system design.
Impact Details
This vulnerability allows malicious users to reverse transactions sent by others, even if those transactions are retryable. When a transaction is reversed:
The original user loses the cost of sending the initial transaction from the spoke chain.
The user incurs the cost of reversing the transaction, which is deducted from their balance on the hub chain.
Users lose control over the decision to retry or reverse their transactions.
For actions that allow reversal, users can be griefed, preventing them from ever successfully retrying their transactions.
The attacker only needs to pay gas fees on the hub chain (Avalanche), which are relatively cheap. This creates an asymmetric situation where the attacker can cause significant financial damage to users at a low cost to themselves, potentially disrupting the entire cross-chain messaging system.
References
BridgeRouter.sol
Hub.sol
SpokeToken.sol
CCIPTokenAdapter.sol
Proof of concept
Proof of Concept
We have added a proof of concept, in foundry that forks avalanche fugi and interacts with the deployed version of the protocol in testnet. To run the proof of concept please add the following files under tests. Please also make sure foundry is initialized in the project, and declare the custom remapping @forge-std
Second File (includes the poc): test/pocs/forktest.t.sol
// SPDX-License-Identifier: UNLICENSEDpragmasolidity ^0.8.23;import"./base_test.sol";eventMessageSucceeded(uint16 adapterId, bytes32indexed messageId);eventMessageFailed(uint16 adapterId, bytes32indexed messageId, bytes reason);contractPocsisbaseTest {////////////////////////////////////////////////////////////////////////////////////////////// @remind : poc of non access controle on reverse messages :functiontest_noAccessControlOnReverse() public {// get bob balance before action :uint256 bobBalanceBefore =IERC20(USDC_TOKEN).balanceOf(bob);bytes32 loanId = aliceLoanIds[0];bytes32 msgId =_getMsgId();_approveUsdc(bob,address(spokeUsdc),1000e6);_createLoanAndDeposit(bobAccountId, bob, loanId, STABELE_LOAN_TYPE_ID,1000e6, spokeUsdc); BridgeRouter router =BridgeRouter(hub.getBridgeRouter());assertTrue(router.seenMessages(1, msgId));uint256 bobBalanceAfter =IERC20(USDC_TOKEN).balanceOf(bob);assertTrue(bobBalanceBefore - bobBalanceAfter ==1000e6);// reverse the tx by a random address : vm.prank(address(32423)); router.reverseMessage(1, msgId,"");assertTrue(bobBalanceBefore ==IERC20(USDC_TOKEN).balanceOf(bob)); }}
This is the resul if we execute the test with forge test --mt test_noAccessControlOnReverse -vv
Ran 1 test for test/pocs/forktest.t.sol:Pocs[PASS] test_noAccessControlOnReverse() (gas: 577286)Suite result: ok. 1 passed; 0 failed; 0 skipped; finished in 589.91ms (4.21ms CPU time)Ran 1 test suite in 592.58ms (589.91ms CPU time): 1 tests passed, 0 failed, 0 skipped (1 total tests)