Open c4-submissions opened 1 year ago
0xA5DF marked the issue as primary issue
0xA5DF marked the issue as sufficient quality report
Leaving open for sponsor to review this
0xBugsy (sponsor) confirmed
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);
}
As I understand it:
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
Screenshotting duplicates in case my judging is reverted:
alcueca changed the severity to QA (Quality Assurance)
alcueca marked the issue as grade-a
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,
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.
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".
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 themsg.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 correspondingMulticallRootRouter
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: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 theBranchBridgeAgent.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
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 inMulticallRootRouter
for this non-signed function. (It would be executed inVirtualAccount
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:callOutAndBridge()
is called with empty_params
in the branch bridge agent.payload is created with
0x02
flag and the cross-chain call is performed.payload is received in the root bridge agent, with
0x02
flag.RootBridgeAgentExecutor
will be called withexecuteWithDeposit.
selector
, andlocalRouterAddress
RootBridgeAgentExecutor
will call the_bridgeIn()
with the router address and transfer funds to the router.RootBridgeAgentExecutor
will call the router contract to execute additional data if there is additional data.MulticallRootRouter
will always revert if it is called.The problem is that the
RootBridgeAgentExecutor
will never call theMulticallRootRouter
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
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-signedexecute()
function is triggered when the call is made without deposit (You remember the 3 function types we mentioned above in the beginning, right?). Thisexecute()
function does not take funds from any user or any virtual account, funds are directly transferred from the router torootPort
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
As you can see above this function also calls the
_approveAndCallOut
to bridge funds out, and all theoutputParams
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 theBranchBridgeAgent::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:
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 fundsThey 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
Here are the test results:
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 withsignedExecute()
or other signed functions.Assessed type
Invalid Validation