Closed c4-submissions closed 1 year ago
0xA5DF marked the issue as duplicate of #685
0xA5DF marked the issue as sufficient quality report
alcueca changed the severity to QA (Quality Assurance)
The Router is not expected to hold funds, and callers of unsigned functions should know that. They are minted in the Router to be immediately used. If they make an error and leave their tokens in the Router, then it not expected that they will be protected.
alcueca marked the issue as grade-c
Lines of code
https://github.com/code-423n4/2023-09-maia/blob/f5ba4de628836b2a29f9b5fff59499690008c463/src/RootBridgeAgent.sol#L987
Vulnerability details
Impact
This finding can allow an attacker to steal ALL of the user funds deposited into the protocol.
Proof of Concept
I attached PoC code at the bottom.
To be able to explain the attack, I first need to explain the execution flow of deposits and settlements and how they work.
Imagine a user in Avalanche chain wants to deposit 1,000 USDC tokens to the protocol. Deposit essentially means that the user tokens are moved from the branch to the root chain. The user's underlying 1,000 USDC tokens will be taken from him, locked in the Branch's port and he will be given/mintedminted 1,000 global hUSDC tokens sitting on the root.
User will first have to create a
DepositInput
object, specifying the address of the underlying token he wants to spend and the address of the local branch chain representation of that underlying tokenhToken
and usingMultiCall
he will then call thecallOutAndBridge()
function inBaseBranchRouter
orCoreBranchRouter
orBranchBridgeAgent
and supply to the function theDepositInput
which he has created. Keep in mind that the user has to approve any of those 3 components to spend his 1,000 underlying USDC for this operation to work.If the user called the
callOutAndBridge()
function inBaseBranchRouter
orCoreBranchRouter
. They will simply transfer the tokens from the user to the Branch's router and approve the branch's port to spend those tokens on the router's behalf. If the user called thecallOutAndBridge()
function inBranchBridgeAgent
then the user will have to approve the branch's port to spend those tokens on his behalf. The user calls this function in any of the components and what happens next is that theBranchPort
simply takes those 1,000 underlying USDC from the user and a request to theBranchBridgeAgent
is sent, asking it to reach out toRootBridgeAgent
to mint global 1,000 hUSDC tokens.⚠️ First important note. Those 1,000 global hUSDC tokens minted will be owned by the Root Router
MulticallRootRouter
(same goes forCoreRootRouter
). They will not be owned by the user but rather theRoot Router
. And at root level, there isn't any mechanism to tell if those 1,000 global hUSDC tokens belong to the user. They are owned by the Root's router now, and only it can move those tokens anywhere. And that's exactly what the attacker will exploitWhen it comes to settlements. Settlements are same as deposits but instead of tokens moving from the branch to root like deposits, it works the other way around, tokens are moved from Root to Branch. If the user wants his tokens back, he will have to execute a settlement operation. After the user initiates it, a request from branch bridge agent will be sent to the root asking it to unlock the user's 1,000 underlying tokens from the branch port and burn the 1,000 global tokens burnt. The root receives the request, burns 1,000 global hUSDC tokens and sends a request to the branch asking it to "clear" or "unlock" the user's underlying 1,000 USDC tokens which were locked inside the branch port
The Vulnerability
The attacker now wants to steal the user's funds, so he will need to execute a settlement operation:
He will first have to create a payload with a
OutputParams
object which will be needed for initiating the settlement process in theMulticallRouter
. TheOutputParams
object needs 4 things:recipient
, this tells the root router who to move tokens to. The attacker will specify himself.outputToken
, this is the global token address. The attacker will supply the address of the global hUSDC token.amountOut
, the total amount of tokens that the requester wants to receive. The attacker will specify1000
.depositOut
, this is the amount of underlying tokens the requester wants to receive. The attacker will specify1000
The attacker will create this
OutputParams
object, add it to the payload and add the destination branch chain ID which will receive the underlying USDC to the payload, in this case it's the chain ID of avalanche (since that's where the user had his 1,000 underlying USDC in the first place), and the attacker will add the function ID "2" to his payload to trigger this conditional inMulticallRootRouter
Attacker will send this payload to the function
callOut
inBaseBranchRouter
orBranchBridgeAgent
which will simply add the deposit flag "1" to the payload and then the request will be sent to rootNow we are in RootBridgeAgent, In
lzReceiveNonBlocking
function, this conditional will be triggered because the deposit flag of the payload was "1"RootBridgeAgent will trigger the
executeNoDeposit
function inRootBridgeAgentExecutor
and hand it the payload.The
RootBridgeAgentExecutor
will call theexecute
function inMulticallRootRouter
and hand it the payloadThe
execute
function inMulticallRootRouter
will be executed and the second conditional would be triggered becuase our payload had function ID "2".In the
execute
function, a dummy multicall specified by the attacker in the payload will be sent and then theexecute
function will parse theOutputParams
object the attacker created and will call the internal function_approveAndCallOut
supplied with the following values as parameters:Inside the
_approveAndCallOut
function, theMulticallRouter
(which holds the global minted tokens) will approve the root bridge agent to spend those tokens, check line: https://github.com/code-423n4/2023-09-maia/blob/f5ba4de628836b2a29f9b5fff59499690008c463/src/MulticallRootRouter.sol#L521callOutAndBridge
will be executed in theRootBridgeAgent
(https://github.com/code-423n4/2023-09-maia/blob/f5ba4de628836b2a29f9b5fff59499690008c463/src/RootBridgeAgent.sol#L175) and it will call the internal functioncreateSettlement
and supply it with the following values as arguments:The
_createSettlement
internal function inRootBridgeAgent
will be executed and it will do the following:_updateStateOnBridgeOut
and supply the following parameters:In the internal function
_updateStateOnBridgeOut
. The second conditional will be executed "_deposit > 0" (https://github.com/code-423n4/2023-09-maia/blob/f5ba4de628836b2a29f9b5fff59499690008c463/src/RootBridgeAgent.sol#L1156C3-L1156C3). Then it will reach out to RootPort asking it to burn the 1,000 global hUSDC tokens.Execution will return back to
callOutAndBridge
now that_createSettlement
is done.Function
callOutAndBridge
(which is inRootBridgeAgent
) will perform a call to the Avalanche branch bridge agentIn
BranchBridgeAgent
, deposit flag 1 conditional will be triggered (https://github.com/code-423n4/2023-09-maia/blob/f5ba4de628836b2a29f9b5fff59499690008c463/src/BranchBridgeAgent.sol#L616)BranchBridgeAgent
calls the functionexecuteWithSettlement
inBranchBridgeAgentExecutor
. (https://github.com/code-423n4/2023-09-maia/blob/f5ba4de628836b2a29f9b5fff59499690008c463/src/BranchBridgeAgentExecutor.sol#L66).The settlement object will be parsed and it's values will be supplied to the function "clearToken" in "BranchBridgeAgent"
clearToken
inBranchBridgeAgent
will be executed (https://github.com/code-423n4/2023-09-maia/blob/f5ba4de628836b2a29f9b5fff59499690008c463/src/BranchBridgeAgent.sol#L485C14-L485C24) and It will call the internal function_clearToken
.Internal function
_clearToken
will be executed and only the second conditional will be triggered. (https://github.com/code-423n4/2023-09-maia/blob/f5ba4de628836b2a29f9b5fff59499690008c463/src/BranchBridgeAgent.sol#L913)The function "withdraw" in
BranchPort
will be executed (https://github.com/code-423n4/2023-09-maia/blob/f5ba4de628836b2a29f9b5fff59499690008c463/src/BranchPort.sol#L226)And finally, it will transfer the locked 1,000 underlying "USDC" tokens to the "recipient" which is the attacker
PoC Code
In "RootForkTest.t.sol" please import "forge-std/console.sol" and then add the following function. and run the following command: forge test --match-contract RootForkTest --match-test test_exploit_1 -vvv
Tools Used
VSCode, Foundry
Recommended Mitigation Steps
There needs to be a way to know how much global hTokens belong to each user on root level, for example a mapping. And when executing a settlement, the system should check if in fact the requester owns the amount of underlying/global tokens he did request to get back.
Assessed type
Token-Transfer