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: 128/175
Findings: 2
Award: $11.58
🌟 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
VirtualAccounts are created for user specific and it holds user's financial assets. VirtualAccount contract has call
and payableCall
functions purpose is make this function like a EOA. So this is important that there should be important put access control for this call functions. If there is not, any malicious user can use this call function to steal asset's of virtualAccount's user.
As can be seen call
function has access control which can be seen as requiresApprovedCaller
modifier.
function call(Call[] calldata calls) external override requiresApprovedCaller returns (bytes[] memory returnData) {
modifier requiresApprovedCaller() { if (!IRootPort(localPortAddress).isRouterApproved(this, msg.sender)) { if (msg.sender != userAddress) { revert UnauthorizedCaller(); } } _; }
However there is no such that modifier in payableCall
or any other access control so anybody can call 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; } } // Finally, make sure the msg.value = SUM(call[0...i].value) if (msg.value != valAccumulator) revert CallFailed(); }
It can be seen that there is no access control for msg.sender
. Due to this lack,for example, a malicious user can give parameter as target USDT and calldata as transfer(addressOfMalUser,BalanceOfVirtualAccount). So by this way malicious user can steal users assets without revert.
Invalid Validation
#0 - c4-pre-sort
2023-10-08T14:06:50Z
0xA5DF marked the issue as duplicate of #888
#1 - c4-pre-sort
2023-10-08T14:38:50Z
0xA5DF marked the issue as sufficient quality report
#2 - c4-judge
2023-10-26T11:29:44Z
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-
11.4657 USDC - $11.47
https://github.com/code-423n4/2023-09-maia/blob/f5ba4de628836b2a29f9b5fff59499690008c463/src/RootBridgeAgent.sol#L490-L510 https://github.com/code-423n4/2023-09-maia/blob/f5ba4de628836b2a29f9b5fff59499690008c463/src/RootBridgeAgentExecutor.sol#L82-L106 https://github.com/code-423n4/2023-09-maia/blob/f5ba4de628836b2a29f9b5fff59499690008c463/src/RootBridgeAgent.sol#L351-L384 https://github.com/code-423n4/2023-09-maia/blob/f5ba4de628836b2a29f9b5fff59499690008c463/src/RootPort.sol#L277-L291
If user call callOutAndBridge
function, _params
parameter encoded with deposit parameter in payload and it is used in RootBridgeAgentExecutor.executeWithDeposit
function.
function callOutAndBridge( address payable _refundee, bytes calldata _params, 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 will send message to layerzero and after that RootBridgeAgent
called with lzReceive
. lzReceive
call RootBridgeAgentExecutor.executeWithDeposit
.
function executeWithDeposit(address _router, bytes calldata _payload, uint16 _srcChainId) external payable onlyOwner { // Read Deposit Params DepositParams memory dParams = DepositParams({ depositNonce: uint32(bytes4(_payload[PARAMS_START:PARAMS_TKN_START])), hToken: address(uint160(bytes20(_payload[PARAMS_TKN_START:PARAMS_TKN_START_SIGNED]))), token: address(uint160(bytes20(_payload[PARAMS_TKN_START_SIGNED:45]))), amount: uint256(bytes32(_payload[45:77])), deposit: uint256(bytes32(_payload[77:PARAMS_TKN_SET_SIZE])) }); // Bridge In Assets _bridgeIn(_router, dParams, _srcChainId); // 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 ); } }
It can be seen that in payload, after PARAMS_TKN_SET_SIZE bytes correspond to _params
in callOutAndBridge
.And if _params
is empty this function will not call router.executeDepositSingle
. So executeWithDeposit
function just run _bridgeIn
function.
function _bridgeIn(address _recipient, DepositParams memory _dParams, uint16 _srcChainId) internal { //Request assets for decoded request. RootBridgeAgent(payable(msg.sender)).bridgeIn(_recipient, _dParams, _srcChainId); }
This function call RootBridgeAgent.bridgeIn.
function bridgeIn(address _recipient, DepositParams memory _dParams, uint256 _srcChainId) public override requiresAgentExecutor { // Deposit can't be greater than amount. if (_dParams.amount < _dParams.deposit) revert InvalidInputParams(); // Cache local port address address _localPortAddress = localPortAddress; // Check local exists. if (_dParams.amount > 0) { if (!IPort(_localPortAddress).isLocalToken(_dParams.hToken, _srcChainId)) { revert InvalidInputParams(); } } // Check underlying exists. if (_dParams.deposit > 0) { if (IPort(_localPortAddress).getLocalTokenFromUnderlying(_dParams.token, _srcChainId) != _dParams.hToken) { revert InvalidInputParams(); } } // Move hTokens from Branch to Root + Mint Sufficient hTokens to match new port deposit IPort(_localPortAddress).bridgeToRoot( _recipient, IPort(_localPortAddress).getGlobalTokenFromLocal(_dParams.hToken, _srcChainId), _dParams.amount, _dParams.deposit, _srcChainId ); }
And this will call rootPort.bridgeToRoot to mint globalHtoken to recepient.,
function bridgeToRoot(address _recipient, address _hToken, uint256 _amount, uint256 _deposit, uint256 _srcChainId) external override requiresBridgeAgent { if (!isGlobalAddress[_hToken]) revert UnrecognizedToken(); if (_amount - _deposit > 0) { unchecked { _hToken.safeTransfer(_recipient, _amount - _deposit); } } if (_deposit > 0) if (!ERC20hTokenRoot(_hToken).mint(_recipient, _deposit, _srcChainId)) revert UnableToMint(); }
The recipient parameter come from executeWithDeposit function and it can be seen that it indicate router.So RootRouter take globalHTokens.In short callOutAndBridge take asset from user in branch token and mint globalhToken in root chain rooRouter and transaction finish without revert.
This globalHTokens cannot be taken by user after that and this tokens cannot be tranfered out of the router. Moreover due to transaction finish successfully user cannot take his underlying token with retriveDeposit
because retriveDeposit
can be work only if transaction in the root chain reverted.
In short user lose his underlyingtoken and his tokens will stuck in RootRouter
permanently.
Provide direct links to all referenced code in GitHub. Add screenshots, logs, or any other relevant proof that illustrates the concept.
Invalid Validation
#0 - c4-pre-sort
2023-10-14T12:43:43Z
0xA5DF marked the issue as duplicate of #898
#1 - c4-pre-sort
2023-10-14T12:43:48Z
0xA5DF marked the issue as sufficient quality report
#2 - c4-judge
2023-10-25T12:41:52Z
alcueca marked the issue as duplicate of #685
#3 - c4-judge
2023-10-25T13:12:56Z
alcueca changed the severity to QA (Quality Assurance)
#4 - alcueca
2023-10-25T13:31:06Z
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.
#5 - c4-judge
2023-10-27T10:15:57Z
alcueca marked the issue as grade-b