Platform: Code4rena
Start Date: 30/05/2023
Pot Size: $300,500 USDC
Total HM: 79
Participants: 101
Period: about 1 month
Judge: Trust
Total Solo HM: 36
Id: 242
League: ETH
Rank: 9/101
Findings: 5
Award: $9,112.31
π Selected for report: 3
π Solo Findings: 1
2568.2552 USDC - $2,568.26
Attacker will cause user's funds to be collected and locked on Branch chain without it being recorded on root chain.
Anyone can call BranchBridgeAgent#retrieveDeposit
, with an invalid _depositNonce
:
function retrieveDeposit( uint32 _depositNonce ) external payable lock requiresFallbackGas { //Encode Data for cross-chain call. bytes memory packedData = abi.encodePacked( bytes1(0x08), _depositNonce, msg.value.toUint128(), uint128(0) ); //Update State and Perform Call _sendRetrieveOrRetry(packedData); }
If for example, global depositNonce is x, attacker can call retrieveDeposit(x+y)
.
RootBridgeAgent#anyExecute
will be called, and the executionHistory for the depositNonce that the attacker specified would be updated to true:
function anyExecute(bytes calldata data){ ... /// DEPOSIT FLAG: 8 (retrieveDeposit) else if (flag == 0x08) { //Get nonce uint32 nonce = uint32(bytes4(data[1:5])); //Check if tx has already been executed if (!executionHistory[fromChainId][uint32(bytes4(data[1:5]))]) { //Toggle Nonce as executed executionHistory[fromChainId][nonce] = true; //Retry failed fallback (success, result) = (false, ""); } else { _forceRevert(); //Return true to avoid triggering anyFallback in case of `_forceRevert()` failure return (true, "already executed tx"); } } ... }
This means that when a user makes a deposit on that BranchBridgeAgent and his Deposit gets assigned a depositNonce which attacker previously called retrieveDeposit for, his tokens would be collected on that BranchBridgeAgent, but would not succeed on RootBridgeAgent because executionHistory for that depositNonce has already been maliciously set to true.
Manual Review
A very simple and effective solution is to ensure that in the BranchBridgeAgent#retrieveDepoit
function, msg.sender==getDeposit[_depositNonce].owner
just like it was done in BranchBridgeAgent#retryDeposit
Invalid Validation
#0 - c4-judge
2023-07-11T10:39:39Z
trust1995 marked the issue as primary issue
#1 - c4-judge
2023-07-11T10:39:44Z
trust1995 marked the issue as satisfactory
#2 - c4-sponsor
2023-07-12T16:27:20Z
0xBugsy marked the issue as sponsor confirmed
#3 - c4-judge
2023-07-25T13:07:05Z
trust1995 marked the issue as selected for report
#4 - 0xLightt
2023-09-06T17:28:32Z
2568.2552 USDC - $2,568.26
https://github.com/code-423n4/2023-05-maia/blob/main/src/ulysses-omnichain/RootBridgeAgent.sol#L58
A malicious user would make a deposit specifying an hToken of a high value(say hEther), and a depositToken of relatively lower value(say USDC), and for that user, RootBridgeAgent would increment his hToken balance by the amount of depositTokens he sent
Here is the checkParams
function:
function checkParams(address _localPortAddress, DepositParams memory _dParams, uint24 _fromChain) internal view returns (bool) { if ( (_dParams.amount < _dParams.deposit) //Deposit can't be greater than amount. || (_dParams.amount > 0 && !IPort(_localPortAddress).isLocalToken(_dParams.hToken, _fromChain)) //Check local exists. || (_dParams.deposit > 0 && !IPort(_localPortAddress).isUnderlyingToken(_dParams.token, _fromChain)) //Check underlying exists. ) { return false; } return true; }
The function performs 3 checks:
The PROBLEM is that the check only requires that getLocalTokenFromUnder[_dParams.token]
!=address(0)
, but does not check that getLocalTokenFromUnder[_dParams.token]
==_dParams.hToken
:
function isUnderlyingToken( address _underlyingToken, uint24 _fromChain ) external view returns (bool) { return getLocalTokenFromUnder[_underlyingToken][_fromChain] != address(0); }
The checkParams function is used in the RootBridgeAgent#bridgeIn
function.
This allows a user to call BranchBridgeAgent#callOutAndBridge
with a hToken
and token
that are not related
BranchBridgeAgent#callOutAndBridge
on ethereum with the following as DepositInput(_dParams):
BranchPort#bridgeOut
transfers 10 USDC from user to BranchPort, and anyCall call is made to RootBridgeAgentRootBridgeAgent#bridgeIn
is called which calls CheckParamsLib.checkParams
checkParams
verifies that _dParams.amount(0) is less than or equal to _dParams.deposit(10)β
RootBridgeAgent#bridgeIn
calls RootPort#bridgeToRoot
which mints 10 global hEther to user if (_deposit > 0) mint(_recipient, _hToken, _deposit, _fromChainId);
Execution flow:
BranchBridgeAgent#callOutAndBridge
->BranchBridgeAgent#_callOutAndBridge
->BranchBridgeAgent#_depositAndCall
->BranchBridgeAgent#_performCall
->RootBridgeAgent#anyExecute
->RootBridgeAgentExecutor#executeWithDeposit
->RootBridgeAgentExecutor#_bridgeIn
->RootBridgeAgent#bridgeIn
Manual Review
Currently, the protocol only checks if the token is recognized by rootport as an underlying token by checking that the registered local token for _dParams.token
is not zero address.
_dParams.token
is equal to _dParams.hToken
.Invalid Validation
#0 - c4-judge
2023-07-11T10:38:14Z
trust1995 marked the issue as duplicate of #285
#1 - c4-judge
2023-07-11T10:38:21Z
trust1995 marked the issue as satisfactory
#2 - c4-judge
2023-07-25T13:38:31Z
trust1995 marked the issue as selected for report
#3 - trust1995
2023-07-25T13:38:43Z
Better POC
#4 - 0xBugsy
2023-07-25T18:09:22Z
@trust1995 Same as #273
#5 - c4-sponsor
2023-07-25T18:09:26Z
0xBugsy marked the issue as sponsor confirmed
#6 - c4-judge
2023-07-26T09:22:11Z
trust1995 marked the issue as duplicate of #273
#7 - c4-judge
2023-07-31T14:34:43Z
trust1995 marked the issue as not a duplicate
#8 - c4-judge
2023-07-31T14:34:49Z
trust1995 marked the issue as primary issue
#9 - 0xLightt
2023-09-06T17:30:42Z
288.0397 USDC - $288.04
https://github.com/code-423n4/2023-05-maia/blob/main/src/ulysses-omnichain/BranchBridgeAgent.sol#L433-L439 https://github.com/code-423n4/2023-05-maia/blob/main/src/ulysses-omnichain/BranchBridgeAgent.sol#L1243-L1306
There is a function retrieveDeposit
in BranchBridgeAgent
. But this function is totally unusable and does not serve its intended purpose because there was an omission in implementing its logic.
retrieveDeposit
attaches a 0x08 flag:
function retrieveDeposit(uint32 _depositNonce) external payable lock requiresFallbackGas { //Encode Data for cross-chain call. bytes memory packedData = abi.encodePacked(bytes1(0x08), _depositNonce, msg.value.toUint128(), uint128(0)); //Update State and Perform Call _sendRetrieveOrRetry(packedData); }
For the 0x08 flag in RootBridgeAgent#anyExecute
, function doesn't do anything on Root chain even when depositNonce is valid, but simply returns a "false" success value to trigger BranchBridgeAgent#anyFallback
:
else if (flag == 0x08) { //Get nonce uint32 nonce = uint32(bytes4(data[1:5])); //Check if tx has already been executed if (!executionHistory[fromChainId][uint32(bytes4(data[1:5]))]) { //Toggle Nonce as executed executionHistory[fromChainId][nonce] = true; //Retry failed fallback (success, result) = (false, ""); } else { _forceRevert(); //Return true to avoid triggering anyFallback in case of `_forceRevert()` failure return (true, "already executed tx"); } //Unrecognized Function Selector }
But if you check all if statements in BranchBridgeAgent#anyFallback
, there is no code that handles 0x08 flag.
https://github.com/code-423n4/2023-05-maia/blob/main/src/ulysses-omnichain/BranchBridgeAgent.sol#L1243-L1306
Therfore, the call would return (false, "unknown selector");
, meaning that retrieveDeposit
is unusable.
Manual Review
According to the sponsor, it was an omission. In BranchBridgeAgent#anyFallback
, instead of if ((flag == 0x00) || (flag == 0x01) || (flag == 0x02))
, it should be if ((flag == 0x00) || (flag == 0x01) || (flag == 0x02) || (flag == 0x08))
Error
#0 - c4-judge
2023-07-11T10:27:16Z
trust1995 marked the issue as duplicate of #356
#1 - c4-judge
2023-07-11T10:27:21Z
trust1995 marked the issue as satisfactory
#2 - c4-judge
2023-07-27T14:47:53Z
trust1995 changed the severity to 3 (High Risk)
#3 - c4-judge
2023-07-28T11:27:21Z
trust1995 marked the issue as partial-50
#4 - trust1995
2023-07-28T11:27:47Z
Partial scoring for finding 1/2 of primary's findings.
1975.5809 USDC - $1,975.58
https://github.com/code-423n4/2023-05-maia/blob/main/src/ulysses-omnichain/BranchBridgeAgent.sol#L1158 https://github.com/code-423n4/2023-05-maia/blob/main/src/ulysses-omnichain/BranchBridgeAgent.sol#L1182 https://github.com/code-423n4/2023-05-maia/blob/main/src/ulysses-omnichain/BranchBridgeAgent.sol#L1206
It will be impossible to retry a settlement once it fails on ArbitrumBranchBridgeAgent, which could lead to loss of funds.
RootBridgeAgent calls ArbitrumBranchBridgeAgent#anyExecute which would bridge in assets. The execution flow for settlement on a BranchBridgeAgent on a chain apart from Arbitrum, goes as follows:
anyFallback
in RootBridgeAgent
when it sees that success value is false.To the best of my understanding, this assumption is correct if BranchBridgeAgent
is not ArbitrumBranchBridgeAgent
.
But for ArbitrumBranchBridgeAgent
, RootBridgeAgent
calls ArbitrumBranchBridgeAgent#anyExecute
directly, and does not use Multichain's anyCall because both of the contracts are on the same chain.
Therefore, returning a "false" success value when RootBridgeAgent calls ArbitrumBranchBridgeAgent
will not trigger anyFallback
, and users settlement will not be reopened for retrying or redemption.
Manual Review
ArbitrumBranchBridgeAgent
should call RootBridgeAgent#anyFallback
whenever a settlement is unsuccessful.
Other
#0 - c4-judge
2023-07-11T10:32:08Z
trust1995 marked the issue as unsatisfactory: Insufficient proof
#1 - Emedudu
2023-07-26T13:48:39Z
Please have a look at this @trust1995 , @0xBugsy
#2 - c4-judge
2023-07-26T14:04:14Z
trust1995 marked the issue as satisfactory
#3 - c4-judge
2023-07-26T14:04:27Z
trust1995 marked the issue as duplicate of #266
#4 - c4-judge
2023-07-26T14:04:34Z
trust1995 marked the issue as partial-50
#5 - Emedudu
2023-07-26T14:05:22Z
I don't think this is a duplicate of issue 266 Please have a closer look
#6 - trust1995
2023-07-26T14:32:18Z
Hi, both the sponsor and I are of the opinion it is the same underlying issue.
#7 - c4-judge
2023-07-28T11:48:10Z
trust1995 marked the issue as full credit
#8 - c4-judge
2023-07-31T14:29:13Z
trust1995 marked the issue as satisfactory
1975.5809 USDC - $1,975.58
https://github.com/code-423n4/2023-05-maia/blob/main/src/ulysses-omnichain/RootBridgeAgent.sol#L981 https://github.com/code-423n4/2023-05-maia/blob/main/src/ulysses-omnichain/RootBridgeAgent.sol#L1005 https://github.com/code-423n4/2023-05-maia/blob/main/src/ulysses-omnichain/RootBridgeAgent.sol#L1073 https://github.com/code-423n4/2023-05-maia/blob/main/src/ulysses-omnichain/RootBridgeAgent.sol#L1108 https://github.com/code-423n4/2023-05-maia/blob/main/src/ulysses-omnichain/RootBridgeAgent.sol#L1134
This causes loss of users funds because when they deposit on ArbitrumBranchBridgeAgent but the deposit was unsuccessful on RootBridgeAgent, the call will not revert(because of try-catch), but rether, returns a "false" success value, and since multicall is not the one that called anyExecute, returning a "false" success value will not trigger anyFallback(which clears users' deposit for redemption) on ArbitrumBranchBridgeAgent.
The execution flow for making a deposit from a BranchBridgeAgent on a chain apart from Arbitrum, to RootBridgeAgent goes as follows:
To the best of my understanding, this assumption is correct if BranchBridgeAgent
is not ArbitrumBranchBridgeAgent
.
But for ArbitrumBranchBridgeAgent
, RootBridgeAgent#anyExecute
is called directly by ArbitrumBranchBridgeAgent
, and does not use Multichain's anyCall because both of the contracts are on the same chain.
Therefore, returning a "false" success value when ArbitrumBranchBridgeAgent
calls RootBridgeAgent will not trigger anyFallback
, and users' deposit will not be cleared, but locked forever.
Manual Review
RootBridgeAgent
should call ArbitrumBranchBridgeAgent#anyFallback
whenever a deposit from Arbitrum branch was unsuccessful.
Other
#0 - c4-judge
2023-07-11T10:32:37Z
trust1995 marked the issue as unsatisfactory: Insufficient proof
#1 - Emedudu
2023-07-26T05:45:43Z
If a deposit from a branch chain does not get recorded on root chain, Multichain calls anyFallback on the branch chain to allow users to claim their funds
ArbitrumBranchBridgeAgent also has an anyFallback function which was inherited from BranchBridgeAgent, but this function will not be called when deposits fail in RootBranchBridgeAgent. The reason for this is that the call does not use Multichain because both ArbitrumBranchBridgeAgent and RootBrigdeAgent are on the same chain. So users that make a deposit through ArbitrumBranchBridgeAgent will not be able to claim their deposits if it fails on RootBridgeAgent.
The major operations in RootBridgeAgent#anyExecute is wrapped in a try-catch block, and if there is an error(i.e. a failed deposit), a success value of "false" is returned(instead of reverting).
When Multichain sees the "false" return value, it calls anyFallback on the source branch chain to clear the user's deposit. But for ArbitrumBranchBridgeAgent, the "false" return value has no effect because it does not trigger anyFallback
and does not revert.
Therefore user's deposits from ArbitrumBranchBridgeAgent, which fails on RootBridgeAgent, are lost.
#2 - trust1995
2023-07-26T09:56:09Z
@0xBugsy
#3 - 0xBugsy
2023-07-26T12:56:27Z
If a deposit from a branch chain does not get recorded on root chain, Multichain calls anyFallback on the branch chain to allow users to claim their funds ArbitrumBranchBridgeAgent also has an anyFallback function which was inherited from BranchBridgeAgent, but this function will not be called when deposits fail in RootBranchBridgeAgent. The reason for this is that the call does not use Multichain because both ArbitrumBranchBridgeAgent and RootBrigdeAgent are on the same chain. So users that make a deposit through ArbitrumBranchBridgeAgent will not be able to claim their deposits if it fails on RootBridgeAgent. The major operations in RootBridgeAgent#anyExecute is wrapped in a try-catch block, and if there is an error(i.e. a failed deposit), a success value of "false" is returned(instead of reverting). When Multichain sees the "false" return value, it calls anyFallback on the source branch chain to clear the user's deposit. But for ArbitrumBranchBridgeAgent, the "false" return value has no effect because it does not trigger
anyFallback
and does not revert. Therefore user's deposits from ArbitrumBranchBridgeAgent, which fails on RootBridgeAgent, are lost.
@trust1995 I believe this is the same as #266, ArbitrumBranchBridgeAgent
would have it's anyFallback
function called in reaction to a _performCall
that failed, due to this we only need to revert execution in such cases since ArbBranch -> Root execution is either started from an EOA or already encapsulated in a try-catch in the Root so reversal is caught and gas properly managed
#4 - c4-judge
2023-07-26T13:22:16Z
trust1995 marked the issue as satisfactory
#5 - c4-judge
2023-07-26T13:22:30Z
trust1995 marked the issue as duplicate of #266
π Selected for report: Emmanuel
1712.1701 USDC - $1,712.17
https://github.com/code-423n4/2023-05-maia/blob/main/src/ulysses-omnichain/RootBridgeAgent.sol#L873-L890 https://github.com/code-423n4/2023-05-maia/blob/main/src/ulysses-omnichain/RootBridgeAgent.sol#L922 https://github.com/code-423n4/2023-05-maia/blob/main/src/ulysses-omnichain/RootBridgeAgent.sol#L246 https://github.com/code-423n4/2023-05-maia/blob/main/src/ulysses-omnichain/RootBridgeAgent.sol#L571
Wrong userFeeInfo will be used when retrySettlement
is called directly.
Here is retrySettlement
function:
function retrySettlement( uint32 _settlementNonce, uint128 _remoteExecutionGas ) external payable { //Update User Gas available. if (initialGas == 0) { userFeeInfo.depositedGas = uint128(msg.value); userFeeInfo.gasToBridgeOut = _remoteExecutionGas; } //Clear Settlement with updated gas. _retrySettlement(_settlementNonce); }
The assumption here is that if initialGas is not 0, then retrySettlement
is being called by RootBridgeAgent#anyExecute
, which has already set values for initialGas and userFeeInfo(which would later be deleted at end of the anycall function), but if it is 0, then retrySettlement is being called directly by a user, so user should specify _remoteExecutionGas
and send some msg.value
with the call, which would make up the userFeeInfo.
But this assumption is not completely correct because whenever RootBridgeAgent#anyExecute
is called with a depositNonce that has been recorded in executionHistory, the function returns early, which prevents other parts of the anyExecute function from being executed.
At the beginning of anyExecute, initialGas and userFeeInfo values are set and at the end of anyExecute call, if initialGas>0, _payExecutionGas
sets initialGas and userFeeInfo to 0.
So when the function returns earlier, before _payExecutionGas
is called, initialGas and userFeeInfo are not updated.
If a user calls retrySettlement
immediately after that, the call will use a wrong userFeeInfo(i.e. userFeeInfo set when anyExecute was called with a depositNonce that has already been recorded) because initialGas!=0. Whereas, it was meant to use values sent by caller of retrySettlement
Looking at a part of _manageGasOut
logic which is called in _retrySettlement
,
if (_initialGas > 0) { if ( userFeeInfo.gasToBridgeOut <= MIN_FALLBACK_RESERVE * tx.gasprice ) revert InsufficientGasForFees(); (amountOut, gasToken) = _gasSwapOut( userFeeInfo.gasToBridgeOut, _toChain ); } else { if (msg.value <= MIN_FALLBACK_RESERVE * tx.gasprice) revert InsufficientGasForFees(); wrappedNativeToken.deposit{value: msg.value}(); (amountOut, gasToken) = _gasSwapOut(msg.value, _toChain); }
This could cause one of these:
retrySettlement
call would revert if userFeeInfo.gasToBridgeOut(which user does not have control over) is less than MIN_FALLBACK_RESERVE * tx.gasprice
retrySettlement
transactionManual Review
Consider implementing one of these:
//Check if tx has already been executed if (executionHistory[fromChainId][nonce]) { _forceRevert(); delete initialGas; delete userFeeInfo; //Return true to avoid triggering anyFallback in case of `_forceRevert()` failure return (true, "already executed tx"); }
Error
#0 - c4-judge
2023-07-11T10:29:57Z
trust1995 marked the issue as primary issue
#1 - c4-judge
2023-07-11T10:30:02Z
trust1995 marked the issue as satisfactory
#2 - 0xBugsy
2023-07-12T16:18:23Z
This would be the best route to amend this in our opinion delete initialGas and userFeeInfo before return is called if nonce has been executed before
#3 - c4-sponsor
2023-07-12T16:18:28Z
0xBugsy marked the issue as sponsor confirmed
#4 - c4-judge
2023-07-25T13:07:32Z
trust1995 marked the issue as selected for report
#5 - c4-sponsor
2023-07-27T19:06:40Z
0xBugsy marked the issue as sponsor acknowledged
#6 - c4-sponsor
2023-07-28T16:02:48Z
0xBugsy marked the issue as sponsor confirmed
#7 - 0xBugsy
2023-07-28T16:02:53Z
We recognize the audit's findings on Anycall Gas Management. These will not be rectified due to the upcoming migration of this section to LayerZero.