Maia DAO - Ulysses - Arz's results

Harnessing the power of Arbitrum, Ulysses Omnichain specializes in Virtualized Liquidity Management.

General Information

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

Maia DAO

Findings Distribution

Researcher Performance

Rank: 7/175

Findings: 2

Award: $5,707.71

🌟 Selected for report: 1

πŸš€ Solo Findings: 1

Findings Information

🌟 Selected for report: Arz

Labels

bug
2 (Med Risk)
disagree with severity
primary issue
satisfactory
selected for report
sponsor confirmed
sufficient quality report
M-01

Awards

5613.8892 USDC - $5,613.89

External Links

Lines of code

https://github.com/code-423n4/2023-09-maia/blob/f5ba4de628836b2a29f9b5fff59499690008c463/src/RootPort.sol#L493

Vulnerability details

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():

https://github.com/code-423n4/2023-09-maia/blob/f5ba4de628836b2a29f9b5fff59499690008c463/src/RootPort.sol#L252

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:

https://github.com/code-423n4/2023-09-maia/blob/f5ba4de628836b2a29f9b5fff59499690008c463/src/RootPort.sol#L493


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.

Impact

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.

Proof of Concept

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

Tools Used

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();
}

Assessed type

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.

Findings Information

Labels

bug
2 (Med Risk)
high quality report
satisfactory
duplicate-518

Awards

93.8182 USDC - $93.82

External Links

Lines of code

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

Vulnerability details

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:

  1. Two users want to add the same global token and they both call addGlobalToken() on the branch chain. When addGlobalToken() is used then you need to airdrop the gas for 2 transactions
  2. The transaction that was first caught by LayerZero will be executed succesfuly on the destination chain
  3. However the second transaction will fail because the token is already added and the _execute() without fallback was used
  4. The gas that was sent to the root chain will be locked in the RootBridgeAgent and it can then be used by other users

Please not that this doesnt only have to be a system request it can also be a non-signed operation or when fallback isnt toggled.

Impact

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.

Proof of Concept

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);
    }

Tools Used

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;
        }
    }

Assessed type

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

AuditHub

A portfolio for auditors, a security profile for protocols, a hub for web3 security.

Built bymalatrax Β© 2024

Auditors

Browse

Contests

Browse

Get in touch

ContactTwitter