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: 7/175
Findings: 2
Award: $5,707.71
π Selected for report: 1
π Solo Findings: 1
π Selected for report: Arz
5613.8892 USDC - $5,613.89
Ecosystem tokens are tokens that dont have an underlying token address in any branch and only the global representation exists. The governance adds them by calling addEcosystemToken()
where the _ecoTokenGlobalAddress
will be the Maia or Hermes token as the sponsor mentioned or any other tokens that could be added in the future.
The problem is that anyone can create a hToken where the underlying asset is the ecosystem token and then this mapping will get updated in setAddresses()
:
252: getLocalTokenFromUnderlying[_underlyingAddress][_srcChainId] = _localAddress;
The _underlyingAddress
equals to the _ecoTokenGlobalAddress
and when the admin calls addEcosystemToken()
then it will revert because of this check that is incorrect:
493: if (getLocalTokenFromUnderlying[_ecoTokenGlobalAddress][localChainId] != address(0)) { 494: revert AlreadyAddedEcosystemToken(); 495: }
getLocalTokenFromUnderlying[_ecoTokenGlobalAddress]
wont be a zero address because it was set when the attacker added the hToken.
This check here is redundant and even if someone creates a hToken where the underlying asset will be the ecosystem token then it will not be tied to the ecosystem token in any way because it has a different global address and a different local address.
The governance will fail to add the ecosystem tokens and they will not work with this part of Ulysses because an attacker can easily create a hToken before the ecosystem token is set.
Add this to RootTest.t.sol
function testAddEcosystemTokenAttack() public { //arbitrumMockToken will be the Maia or Hermes token hevm.deal(address(this), 1 ether); //Attacker adds the Maia or Hermes token arbitrumCoreRouter.addLocalToken{value: 0.0005 ether}( address(arbitrumMockToken), GasParams(0.5 ether, 0.5 ether) ); newArbitrumAssetGlobalAddress = RootPort(rootPort).getLocalTokenFromUnderlying(address(arbitrumMockToken), rootChainId); require( RootPort(rootPort).getGlobalTokenFromLocal(address(newArbitrumAssetGlobalAddress), rootChainId) == address(newArbitrumAssetGlobalAddress), "Token should be added" ); require( RootPort(rootPort).getLocalTokenFromGlobal(newArbitrumAssetGlobalAddress, rootChainId) == address(newArbitrumAssetGlobalAddress), "Token should be added" ); require( RootPort(rootPort).getUnderlyingTokenFromLocal(address(newArbitrumAssetGlobalAddress), rootChainId) == address(arbitrumMockToken), "Token should be added" ); //The admin will then fail to add an ecosystem token because the tx reverts rootPort.addEcosystemToken(address(arbitrumMockToken)); }
As you can see here the tx reverts because of that check and the admin will fail to add the ecosystem token
Foundry
Remove this check as its redundant, setting the ecosystem tokens when initializing is not the best solution because other tokens can be added in the future.
if (getLocalTokenFromUnderlying[_ecoTokenGlobalAddress][localChainId] != address(0)) { revert AlreadyAddedEcosystemToken(); }
DoS
#0 - c4-pre-sort
2023-10-10T07:33:12Z
0xA5DF marked the issue as primary issue
#1 - c4-pre-sort
2023-10-10T07:33:17Z
0xA5DF marked the issue as sufficient quality report
#2 - 0xBugsy
2023-10-17T20:33:45Z
If the token has not yet been added to the system a new one can simply be deployed. In addition this can be done in a multicall paired with the token deployment itself for added security. Removing this check would lead to added governance power / responsibility.
#3 - c4-sponsor
2023-10-17T20:33:49Z
0xBugsy (sponsor) disputed
#4 - alcueca
2023-10-25T12:51:56Z
Regardless of the mitigation, which might not be great, the issue is that if an ecosystem token is not set, an attacker can add it as an underlying of some other token and then it will not be possible to set it anymore as a global address (because the ecosystem token already is an underlying token).
#5 - 0xLightt
2023-10-25T13:33:57Z
The attacker would have to act between token deployment and adding it as an ecosystem token, only way for that to happen would need to come from a governance/setup mistake.
The check cannot be overridden if there is any deposits of that underlying or there would be funds lost. Mitigation could be improved by this, allow an ecosystem token to override a global token if it's total supply is zero. This way, any mistake can be circumvented without any redeployment (if the ecosystem token is not distributed yet) and our setup can be more flexible.
#6 - alcueca
2023-10-25T14:45:07Z
Adding an ecosystem token immediately after creation is not obvious, or in the documentation. This is then a valid DoS attack, which you can mitigate with careful governance.
#7 - c4-judge
2023-10-25T14:46:43Z
alcueca marked the issue as satisfactory
#8 - c4-judge
2023-10-27T10:20:39Z
alcueca marked the issue as selected for report
#9 - stalinMacias
2023-10-31T05:28:49Z
Hey @alcueca , I think this issue is more of a QA, based on the sponsor's comment "only way for that to happen would need to come from a governance/setup mistake. All of the functions that are able to update the state of ecosystem & global tokens have an access control that can only be called by governance, how can an attacker simply go ahead and set any token they wish as an ecosystemToken?
From the PoC, I can see the code is not running a prank to simulate the execution of the addLocalToken() to be made from an attacker, instead, the PoC executes that call as the owner of the contract, that's why it doesn't fail, but in production, attacker can't just simply gain access to the owner account and do that types of calls.
Could you revisit this issue again?
Also, @0xLightt , could you correct me if what I've just said is incorrect, thanks a lot for your time
#10 - QiuhaoLi
2023-10-31T08:02:00Z
Hi @stalinMacias , the attacker is not calling rootPort's onlyOwner setters, but from the arbitrumCoreRouter (a branch chain). By doing that he can make _srcChainId
of rootPort:setAddresses
same as the localChainId
of the root chain (Arbitrum).
As for the PoC, you can add the prank
to simulate a better scenario, but it will still work:
function testAddEcosystemTokenAttack() public { hevm.startPrank(address(123)); hevm.deal(address(123), 1 ether); //Attacker adds the Maia or Hermes token arbitrumCoreRouter.addLocalToken{value: 0.0005 ether}( address(arbitrumMockToken), GasParams(0.5 ether, 0.5 ether) ); hevm.stopPrank(); hevm.startPrank(rootPort.owner()); //The admin will then fail to add an ecosystem token because the tx reverts rootPort.addEcosystemToken(address(arbitrumMockToken)); }
Seems like a good catch. CC: @alcueca
#11 - peakbolt
2023-10-31T08:05:03Z
I have to agree with the sponsor that this issue is a governance error and is a good QA. The issue is more of a lack of proper documentation on setup and adding ecosystem token.
Otherwise, governance error issues like #345 would be valid if we go by the argument that the correct use is not obvious and not in the doc. Furthermore, #345 also explained that the error exists within the original test case, which means that the test case itself is demonstrating the error as a correct usage.
#12 - c4-sponsor
2023-11-01T18:19:38Z
0xLightt (sponsor) confirmed
#13 - c4-sponsor
2023-11-01T18:22:44Z
0xLightt marked the issue as disagree with severity
#14 - 0xLightt
2023-11-01T18:23:00Z
Updated review and feedback to reflect our opinion on this issue.
#15 - alcueca
2023-11-03T12:29:04Z
Thanks for your feedback, but this stays as Medium.
Someone correct me if I'm wrong, but as an "ecosystem token" I understand something like MAIA. A token that is not necessarily used only for Ulysses. As such, I would expect that the creation of an ecosystem token be independent from the Ulysses deployment and management, unless explicitly set in the documentation and enforced somehow.
The attacker would have to act between token deployment and adding it as an ecosystem token, only way for that to happen would need to come from a governance/setup mistake.
My take here is that unless specifically stated in the docs, I see this as a very easy mistake to make. In other words, if this vulnerability wouldn't have been reported, why would MaiaDAO add ecosystem tokens to Ulysses in the same transaction that they are deployed?
The comparison to #345 is not reasonable. In #345 governance is required to enter non-sensical parameters into a call in order for the protocol to suffer. In this action, governance doesn't need to do anything obviously wrong.
#16 - 0xBugsy
2023-11-12T17:47:17Z
π Selected for report: nobody2018
Also found by: 0xStalin, 0xnev, 3docSec, Arz, Koolex, Kow, OMEN, gumgumzum, minhtrng, perseverancesuccess, rvierdiiev, windhustler
93.8182 USDC - $93.82
https://github.com/code-423n4/2023-09-maia/blob/f5ba4de628836b2a29f9b5fff59499690008c463/src/RootBridgeAgent.sol#L749 https://github.com/code-423n4/2023-09-maia/blob/f5ba4de628836b2a29f9b5fff59499690008c463/src/BranchBridgeAgent.sol#L712
If a tx on the destination chain calls back the chain from where the transaction was initiated by the user, the first transaction on the source chain needs to "airdrop" gas to the destination chain so it is able to call back the source chain.
The problem is that when the transaction on the destination chain fails and there is no fallback, gas that was sent from the source chain isnt refunded and it will be stuck on the destinations chain bridge agent.
Not every operation type allows you to use the fallback like system requests or operations that are not signed so if the transaction fails then the airdropped gas will be stuck and it will be used by other users.
Example:
addGlobalToken()
on the branch chain. When addGlobalToken()
is used then you need to airdrop the gas for 2 transactions_execute()
without fallback was usedRootBridgeAgent
and it can then be used by other usersPlease not that this doesnt only have to be a system request it can also be a non-signed operation or when fallback isnt toggled.
Ether will be stuck in the BridgeAgent
contract and the user that called the transaction wont be able to get it back and it will then be taken by other users when they for example execute _performFallbackCall()
where the LayerZero will refund them the contract balance.
Add this to RootForkTest.t.sol
, the poc shows that if the second transaction fails then funds will be stuck in the RootBridgeAgent
.
function testAddGlobalTokenFails() public { //Add Local Token from Avax testAddLocalToken(); console2.log("BALANCE OF ROOT BRIDGE AGENT BEFORE", address(coreRootBridgeAgent).balance); //the tx fails on the second iteration, because token already added for(uint256 i; i < 2; i++) { switchToLzChain(avaxChainId); vm.deal(address(this), 1000 ether); GasParams[3] memory gasParams = [GasParams(15_000_000, 0.1 ether), GasParams(2_000_000, 3 ether), GasParams(200_000, 0)]; avaxCoreRouter.addGlobalToken{value: 1000 ether}(newAvaxAssetGlobalAddress, ftmChainId, gasParams); //Switch Chain and Execute Incoming Packets switchToLzChain(rootChainId); //Switch Chain and Execute Incoming Packets switchToLzChain(ftmChainId); //Switch Chain and Execute Incoming Packets switchToLzChain(rootChainId); newAvaxAssetFtmLocalToken = rootPort.getLocalTokenFromGlobal(newAvaxAssetGlobalAddress, ftmChainId); } console2.log("BALANCE OF ROOT BRIDGE AGENT AFTER", address(coreRootBridgeAgent).balance); }
Foundry
The first _execute
function should be refactored so that when the call to bridgeAgentExecutorAddress
fails, the tx shouldnt be reverted but instead gas will be refunded and the executionState
will be set back to 0 as that was the only state change in the BridgeAgent
.
If the localChainId == _srcChainId then the transaction should be reverted as we are not using LayerZero.
function _execute(uint256 _depositNonce, bytes memory _calldata, uint16 _srcChainId, address _refundee) private { //Update tx state as executed executionState[_srcChainId][_depositNonce] = STATUS_DONE; //Try to execute the remote request (bool success,) = bridgeAgentExecutorAddress.call{value: address(this).balance}(_calldata); // No fallback is requested revert allowing for retry. if (!success) { if(localChainId == _srcChainId) revert ExecutionFailure(); //REFUND GAS (bool success2,) = _refundee.call{value: address(this).balance}(""); require(success2); //REVERT STATE CHANGES, OTHER STATE CHANGES IN bridgeAgentExecutorAddress ARE REVERTED WHEN THE TX FAILS THERE executionState[_srcChainId][_depositNonce] = STATUS_READY; } }
Other
#0 - c4-pre-sort
2023-10-10T12:08:55Z
0xA5DF marked the issue as duplicate of #887
#1 - c4-pre-sort
2023-10-10T12:09:24Z
0xA5DF marked the issue as sufficient quality report
#2 - c4-pre-sort
2023-10-10T12:09:36Z
0xA5DF marked the issue as high quality report
#3 - c4-judge
2023-10-25T09:44:32Z
alcueca marked the issue as satisfactory
#4 - c4-judge
2023-11-03T10:53:09Z
alcueca marked the issue as duplicate of #518