Writeup for Good Samaritan
- Hello h4ck3r, welcome to the world of smart contract hacking. Solving the challenges from Ethernaut will help you understand Solidity better. Each challenge involves deploying a contract and exploiting its vulnerabilities. If you’re new to Solidity and haven’t deployed a smart contract before, you can learn how to do so using Remix here.
Challenge Description
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
Key Concepts To Learn
In order to solve this challenge you need to learn how errors works in solidity. I will be explaining two types of errors in solidity.
While writing contracts we will write some checks for function calls. If the checks are not satisfied we will revert. But reverting an be done in two ways. Check the below examples.
1// SPDX-License-Identifier: MIT
2pragma solidity ^0.8.0;
3
4contract FortaA{
5 error NotEnoughBalance();
6
7 function hello()external pure {
8 revert("NotEnoughBalance()");
9 }
10}
When we revert in this way during the revert the function will return the “NotEnoughBalance()” as string in hex format. Check the below example observe that.
1// SPDX-License-Identifier: MIT
2pragma solidity ^0.8.0;
3
4contract FortaA{
5
6 function hello()external pure {
7 revert("NotEnoughBalance()");
8 }
9}
10
11
12contract Forta {
13 error NotEnoughBalance();
14 bytes public mainerr;
15 FortaA forta=FortaA(//__FortaA_ADDRESS);
16
17
18 function notify() external {
19 try forta.hello() {
20 } catch (bytes memory arr) {
21 mainerr=(arr);
22 }
23 }
24
25}
Deploy the FortaA contract first and then Deploy Forta contract. Once you deploy call notify() function in Forta. It will return bunch of bytes data. When you convert that bytes data from hex to string you will get the reverted string. Check the below image.
Now i will explain custom errors. Check the below example.
1
2// SPDX-License-Identifier: MIT
3pragma solidity ^0.8.0;
4
5contract FortaA{
6 error NotEnoughBalance();
7
8 function hello()external pure {
9 revert NotEnoughBalance();
10 }
11}
12
13
14contract Forta {
15 error NotEnoughBalance();
16 bytes public mainerr;
17 FortaA forta=FortaA(//__FortaA_ADDRESS);
18
19
20 function notify() external {
21 try forta.hello() {
22 } catch (bytes memory arr) {
23 mainerr=(arr);
24 }
25 }
26
27}
Deploy the FortaA contract first and then Deploy Forta contract. Once you deploy call notify() function in Forta. It will return function selector of "NotEnoughBalance()"
.
Contract Explanation
If you understand the contract, you can move on to the exploit part. If you’re a beginner, please read the Contract Explanation to gain a better understanding of Solidity.
1// SPDX-License-Identifier: MIT
2pragma solidity >=0.8.0 <0.9.0;
3
4import "openzeppelin-contracts-08/utils/Address.sol";
5
6contract GoodSamaritan {
7 Wallet public wallet;
8 Coin public coin;
9
10 constructor() {
11 wallet = new Wallet();
12 coin = new Coin(address(wallet));
13
14 wallet.setCoin(coin);
15 }
16
17 function requestDonation() external returns (bool enoughBalance) {
18 // donate 10 coins to requester
19 try wallet.donate10(msg.sender) {
20 return true;
21 } catch (bytes memory err) {
22 if (keccak256(abi.encodeWithSignature("NotEnoughBalance()")) == keccak256(err)) {
23 // send the coins left
24 wallet.transferRemainder(msg.sender);
25 return false;
26 }
27 }
28 }
29}
First i will explain GoodSamaritan contract.
GoodSamaritan contract has two state variables wallet (address) and coin (address) . wallet is of type Wallet and coin is of type Coin. Wallet and Coin are two contracts.
1
2constructor() {
3 wallet = new Wallet();
4 coin = new Coin(address(wallet));
5
6 wallet.setCoin(coin);
7}
The constructor creates a Wallet
contract and Coin
contract. Then it will call the setCoin()
function in the Wallet
contract.
1function requestDonation() external returns (bool enoughBalance) {
2 // donate 10 coins to requester
3 try wallet.donate10(msg.sender) {
4 return true;
5 } catch (bytes memory err) {
6 if (keccak256(abi.encodeWithSignature("NotEnoughBalance()")) == keccak256(err)) {
7 // send the coins left
8 wallet.transferRemainder(msg.sender);
9 return false;
10 }
11 }
12 }
The function requestDonation()
is an external function, which means it can only be called by other contracts or EOAs. The function will execute the code in the try
block and call the donate10()
function in the Wallet
contract. If the function call is successful, it will return true
. If there is any error while calling the donate10()
function, then the code in the catch
block will execute instead of reverting. The catch
block will check if the error is NotEnoughBalance()
or not. If it is NotEnoughBalance()
, then it will call the transferRemainder()
function in the Wallet
contract and then return false
.
Now i will explain the Coin contract.
1
2interface INotifyable {
3 function notify(uint256 amount) external;
4}
5
6contract Coin {
7 using Address for address;
8
9 mapping(address => uint256) public balances;
10
11 error InsufficientBalance(uint256 current, uint256 required);
12
13 constructor(address wallet_) {
14 // one million coins for Good Samaritan initially
15 balances[wallet_] = 10 ** 6;
16 }
17
18 function transfer(address dest_, uint256 amount_) external {
19 uint256 currentBalance = balances[msg.sender];
20
21 // transfer only occurs if balance is enough
22 if (amount_ <= currentBalance) {
23 balances[msg.sender] -= amount_;
24 balances[dest_] += amount_;
25
26 if (dest_.isContract()) {
27 // notify contract
28 INotifyable(dest_).notify(amount_);
29 }
30 } else {
31 revert InsufficientBalance(currentBalance, amount_);
32 }
33 }
34}
The Coin
contract has a single state variable balances
. The balances
is a mapping of address to uint256
. It basically stores the balance of each address that holds this coin.
The contract is using the Address
library for address variables, which means all the functions in the library can be used on addresses.
The contract has an error InsufficientBalance()
which takes two arguments of type uint256
as inputs.
1constructor(address wallet_) {
2 // one million coins for Good Samaritan initially
3 balances[wallet_] = 10 ** 6;
4}
In the constructor, it takes the address of the Wallet
contract as input and sets the balance of the Wallet contract
to 10^6. The GoodSamaritan
contract passes the address of Wallet contract to the constructor of Coin contract.
1function transfer(address dest_, uint256 amount_) external {
2 uint256 currentBalance = balances[msg.sender];
3 // transfer only occurs if balance is enough
4 if (amount_ <= currentBalance) {
5 balances[msg.sender] -= amount_;
6 balances[dest_] += amount_;
7
8 if (dest_.isContract()) {
9 // notify contract
10 INotifyable(dest_).notify(amount_);
11 }
12 } else {
13 revert InsufficientBalance(currentBalance, amount_);
14 }
15}
The transfer()
function takes two arguments of type address (dest*) and uint256 (amount*) as input. The function will make some checks, and if all checks pass, it will send the specified amount to the destination address. If the destination address is a contract, it will call the notify()
function in the destination contract.
Now let’s go through Wallet
contract.
1contract Wallet {
2 // The owner of the wallet instance
3 address public owner;
4
5 Coin public coin;
6
7 error OnlyOwner();
8 error NotEnoughBalance();
9
10 modifier onlyOwner() {
11 if (msg.sender != owner) {
12 revert OnlyOwner();
13 }
14 _;
15 }
16
17 constructor() {
18 owner = msg.sender;
19 }
20
21 function donate10(address dest_) external onlyOwner {
22 // check balance left
23 if (coin.balances(address(this)) < 10) {
24 revert NotEnoughBalance();
25 } else {
26 // donate 10 coins
27 coin.transfer(dest_, 10);
28 }
29 }
30
31 function transferRemainder(address dest_) external onlyOwner {
32 // transfer balance left
33 coin.transfer(dest_, coin.balances(address(this)));
34 }
35
36 function setCoin(Coin coin_) external onlyOwner {
37 coin = coin_;
38 }
39}
The Wallet
contract has a state variable of type Coin
(address).
The contract has two errors defined: OnlyOwner()
and NotEnoughBalance()
.
The onlyOwner()
modifier will check if the msg.sender
(caller) is the owner or not. If the caller is not the owner, it will revert with the OnlyOwner()
error.
The constructor
sets the owner to msg.sender
(deployer of Wallet contract).
1function donate10(address dest_) external onlyOwner {
2 // check balance left
3 if (coin.balances(address(this)) < 10) {
4 revert NotEnoughBalance();
5 } else {
6 // donate 10 coins
7 coin.transfer(dest_, 10);
8 }
9}
The donate10()
function will take an argument of type address as input and execute the onlyOwner
modifier. If the modifier passes, it will check if the Wallet
contract has enough balance. If the Wallet
contract balance is less than 10, it will revert with a NotEnoughBalance()
error. If it has a balance of 10 or more, it will call the transfer()
function in the Coin
contract.
1function transferRemainder(address dest_) external onlyOwner {
2 // transfer balance left
3 coin.transfer(dest_, coin.balances(address(this)));
4}
The transferRemainder()
function will take an argument of type address (dest_) as input and execute the onlyOwner
modifier. If the modifier passes, it will call the transfer
function by passing the destination address and the balance of the Wallet
contract as arguments.
1function setCoin(Coin coin_) external onlyOwner {
2 coin = coin_;
3}
The setCoin()
function will take an argument of type Coin
(address) as input and execute the onlyOwner
modifier. If the modifier passes, it will set the coin
to the new Coin
passed.
Exploit
Our goal is to drain the balance of the Wallet contract.
We was given the instance of GoodSamaritan contract. In this contract the only function with which we can interact is requestDonation().
When we call requestDonation
, it will call the donate10()
function in the Wallet
contract. The donate10()
function will check whether the Wallet
contract has a balance less than 10 or not. If it has a balance less than 10, it will revert with the NotEnoughBalance()
error. If it has a balance of 10 or more, it will call the transfer()
function in the Coin
contract. The transfer()
function will transfer 10 coins to the address passed by donate10()
. If the receiver is a contract, the transfer()
function will call the notify()
function in the receiver.
But if the donate10()
function reverts with the NotEnoughBalance()
error, then the requestDonation()
function in GoodSamaritan
will call the transferRemainder()
function in the Wallet
contract. The transferRemainder()
function will transfer the entire balance of the Wallet
contract to the address passed by donate10()
.
According to the contract logic, the NotEnoughBalance()
error will be thrown only if the balance of the Wallet
contract is less than 10. So when the balance is less than 10, it transfers the entire balance to the latest person who called the requestDonation()
function in the GoodSamaritan
contract. After this call, no one will be able to call requestDonation()
and get donations.
If we check the constructor of the Coin
contract, it sets the balance of the Wallet
to 10^6. So, in order to drain the balance of the Wallet
contract, we need to call the requestDonation()
function 10^5 times, which will be a time-consuming task. Therefore, we need a shorter way to get the entire coins.
If we somehow make the donate10()
function revert with the message NotEnoughBalance()
, then it will transfer the entire tokens.
Suppose there are three contracts, and a function in one of the contracts calls functions in the other two contracts. If the functions in the other two contracts return the same error upon failure of some conditions, then the calling function won’t be able to determine which contract the error originated from. We can take this as advantage and we can exploit the GoodSamaritan contract.
Now when we call the requestDonation
in the GoodSamaritan
contract, it will call donate10()
in the Wallet
contract. Then donate10()
will call transfer()
in the Coin
contract, and the transfer()
function will transfer 10 coins to the receiver. If the receiver is a contract, it will call the notify()
function in the receiver contract, passing the amount of the transfer as an argument to notify()
.
So in our Exploit
contract, when the notify()
function is called, we need to check if the transfer amount is 10 or not. If it is 10, then we need to revert with a NotEnoughBalance()
error. Then the transfer()
function will be reverted, and subsequently, the donate10()
function will be reverted. Both calls will be reverted with data as the function selector of the NotEnoughBalance()
error. Now the try
block in requestDonation()
will fail, and it will execute the catch
block by passing the same error. Since the error returns the function selector of NotEnoughBalance()
, the if
condition will pass, and it will transfer the entire coins to the requestDonation()
caller.
Below is the Exploit contract code.
1
2// SPDX-License-Identifier: MIT
3pragma solidity ^0.8.0;
4
5
6interface IGoodSamaritan{
7 function requestDonation()external returns (bool);
8}
9
10contract ExploitGoodSamaritan{
11 ////////////////////////
12 //////Errors////////////
13 ////////////////////////
14
15 error NotEnoughBalance();
16
17 ///////////////////////
18 ///State Variables/////
19 ///////////////////////
20
21 IGoodSamaritan samaritan;
22
23 constructor(address _addr){
24 samaritan=IGoodSamaritan(_addr);
25 }
26
27
28 function Exploit()public{
29 samaritan.requestDonation();
30 }
31
32 function notify(uint256 _amount)public payable{
33 if(_amount==10){
34 revert NotEnoughBalance();
35 }
36
37 }
38
39}
Deploy this contract and call the Exploit() function. Once the call is done successfully the challenge will be solved.
Hope you enjoyed this challenge.
Key takeaways
We should not write our contract logic based on errors unless we are sure that the errors can only be thrown by our contract itself or in the way we intended. If our contract logic depends on errors and makes calls to other contracts, then malicious actors may revert with the same error and exploit our contract logic.
***Hope you enjoyed this write-up. Keep on hacking and learning!***
Comments