【Ethernaut x Foundry】Level 02-Fallout 答案解說

tanner.dev
10 min readMar 6, 2024

--

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

Solidity:別總是看我獨自升級,你的語法也要升級啊!

看完這篇你會學到:

  1. Constructor 的版本差異
  2. Constructor 如何初始化合約狀態

題目

此次目標是想辦法取得合約的所有權。

Figure 2: Fallout 題目

合約程式碼如下:

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

import 'openzeppelin-contracts-06/math/SafeMath.sol';

contract Fallout {

using SafeMath for uint256;
mapping (address => uint) allocations;
address payable public owner;


/* constructor */
function Fal1out() public payable {
owner = msg.sender;
allocations[owner] = msg.value;
}

modifier onlyOwner {
require(
msg.sender == owner,
"caller is not the owner"
);
_;
}

function allocate() public payable {
allocations[msg.sender] = allocations[msg.sender].add(msg.value);
}

function sendAllocation(address payable allocator) public {
require(allocations[allocator] > 0);
allocator.transfer(allocations[allocator]);
}

function collectAllocations() public onlyOwner {
msg.sender.transfer(address(this).balance);
}

function allocatorBalance(address allocator) public view returns (uint) {
return allocations[allocator];
}
}

解說

想要取得合約所有權,通常離不開 owner = msg.sender 之類的函式,果然在關卡合約 Constructor 中有看到 owner = msg.sender這行程式碼。但…仔細一看,會發現 Constructor 竟然直接 typo 成一個 public function XD

/* constructor */
function Fal1out() public payable {
owner = msg.sender;
allocations[owner] = msg.value;
}

既然如此,我們事情好辦了,我們只要利用 EOA 呼叫 Fallout()這個 public function 就能順利成為合約 owner 啦!一行程式碼解決:

// script/solutions/02-Fallout.s.sol

contract FalloutSolution is Script, EthernautHelper {
...

function run() public {
...

// YOUR SOLUTION HERE
Fallout(payable(challengeInstance)).Fal1out();

...
}
}

我個人覺得這關其實比第一關更簡單,甚至直接在註解中給提示,想當然也不需要寫攻擊合約了。

值得補充的是,這個 typo 其實來自 Solidity 0.4.22 版本之前,那時的建構函式是通過定義一個與合約名稱完全相同的函式來實現的。但在 Solidity 0.4.22 版本之後,則新規定了讓 constructor 這個關鍵字成為合約的建構函式,取代早些版本的做法,這也使得語法更加清晰、直觀。

延伸閱讀

可能有人和我一樣好奇 Constructor 到底是如何初始化合約狀態?接下來的內容會涉及 EVM 的一些 Opcode,如果遇到我沒補充但又不熟悉的指令記得 google 或問 ChatGPT 找答案~

根據 OpenZeppelin 聖經,當我們想將合約部署到以太坊區塊鏈上時,會先發起一筆交易 (transaction),與普通交易不同的是,這筆交易中 to 接收地址欄位為 0,且 data欄位正是合約的原始碼。然而,EVM 是無法理解 Solidity 這個高階語言,故需要先 compile 成只有機器讀得懂的 bytescode 才會放進 data欄位。這些經過編譯後漏漏長的 bytescode 進而被區分成 Creation CodeRuntime Code 兩類。

  • Creation Code 是在部署合約時被執行一次的程式碼,不會儲存在合約內
  • Runtime Code 是儲存在鏈上合約內的程式碼

以下圖來說明,左方代表 Solidity 合約,中間則是經編譯後的 bytescode,右方則是將 bytescode 分為 Creation CodeRuntime Code

Figure 3: OpenZeppelin Deconstruction Diagram

Creation CodeRuntime Code 兩者最大的差別在於 Creation Code只在最一開始被執行一次用以創建合約,而 Runtime Code則是實際存在區塊鏈上且可以不斷被呼叫重複執行的合約程式碼。

讀到這裡不難猜測,Constructor 就是存在於 Creation Code的一部分程式碼,在部署過程中僅會被執行一次,以初始化合約的狀態。

至於 Creation Code實際上如何初始化合約的呢?

這就要跳進 EVM 去觀察它怎麼執行 bytescode 了。當 EVM 執行指令時,一定是從 stack 由上而下開始執行,故準確來說,EVM 執行指令不會去區分誰是 Creation CodeRuntime Code,就是由上而下無情地執行 bytescode。唯一能改變執行順序的方法只有 JUMPJUMPI這兩個 opcode (下方補充他們在幹嘛,也可以跳過此 part)。

  • JUMP無條件跳轉到指定的目標位置,它會從 stack 取出最上面的值並跳轉至該值對應的目標位置,但目標位置必須包含 JUMPDEST ,否則 revert。(JUMPDEST 唯一目的就是標記跳轉的目標位置)
  • JUMPI則是執行條件跳轉,它會從 stack 取出最上面的兩個值,一個是目標位置,另一個是條件。如果條件為 True,將跳轉到指定的JUMPDEST,條件為 False 或等於 0,則繼續執行下一個指令。

由於 constructor 內的參數是放在 stack 的最後 (最下方),這時就需要利用 JUMPJUMPI 跳轉到最後的位置去讀取參數值。

再來是最核心的部分,方才執行位置已跳轉到 constructor 的參數位置,這時透過 CODECOPY把 constructor 的參數數值寫入 memory,這是由於 constructor 參數是以 bytescode 形式存在 Creation Code 裡面。假設一參數 uint256 x = 1,那麼在 Creation Code 的長相就是 00 00 00 … 00 00 01,共由 32 個 instructions 連續組成,這樣拆開的指令不具任何意義,但如果將它轉成 hex 表示法 (0x0000…0001),是不是覺得有點熟悉了!

接著,再用 MLOAD把 hex 表示法的參數從 memory 讀進 stack,這是因為 Solidity 在產生的 EVM bytescode 中常見的模式,在執行函式前,會盡可能把即將用到的函式參數載入到 stack 中,以利接下來的程式碼方便執行,而即將執行的函式其實就是 constructor()。

MLOAD 完所有參數後,就會開始執行 constructor 內定義的指令,假設一合約有一個 uint256 totalSupply 變數要初始化成 _initialSupply 的值,還記得剛剛我們已經把會用到的參數讀進 stack 了嗎?這時就需要使用 SSTORE將stack 的參數存入正確的 storage slot,因為合約只有一個狀態變數,所以是從 slot 0 的位置開始儲存 (未來會介紹到什麼是 storage slot),如此就順利初始化合約狀態啦!

// Example
contract Test {
uint256 totalSupply;

constructor(uint256 _initialSupply) {
totalSupply = _initialSupply;
}
}

// sstore(0x00, 0x01)
// | |
// | What to store. (_initialSupply)
// Where to store. (Slot 0)
// (in storage)

Construct 的初始化完成後,那 Runtime Code 要怎麼辦?

把它 RETURN就結束了。

還有印象在最開始有提到部署合約的交易中 to 欄位為 0x00 嗎,所以這裡的 RETURN 指的是將剛剛存在 memory 中的 Runtime Code 回傳至新部署的合約地址,所以最後留在新合約內的就剩下 Runtime Code 而已。

恭喜,總算是走完一遍流程了!如果把以上整個創建合約的流程簡化後:

  1. jump 到最後讀出 constructor 參數 (CODECOPYMLOAD)
  2. 執行 constructor body (像是把參數寫進 storage)
  3. 複製 Runtime Code 到 memory
  4. 回傳 Runtime Code 到新合約地址

這樣看起來是不是就乾淨、簡單多了!

再額外補充一下,創建合約時第三、四步通常 CODECOPY 會搭配 RETURN 指令一起使用,CODECOPY 用於將合約的 Runtime Code 複製到 memory 中的指定位置,RETURN 則會將剛剛存在 memory 中的 Runtime Code 回傳並儲存在部署的合約地址。

以上就是 Constructor 與 Creation Code關係的解說,雖然內容似乎有點超過預期範圍,但多了解 EVM 底層運作也是有益健康(?)。最後最後,上面提到的 CODECOPYMLOADSSTORE 三個 opcode 的操作或是 free memory pointer 的機制沒有提及太多,有興趣了解細節的讀者很推薦花點時間看看 OpenZeppelin 的經典文章,內容真的超級詳細又紮實!

--

--

tanner.dev

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