【Ethernaut x Foundry】Level 06-Delegation 答案解說

tanner.dev
15 min readMar 10, 2024

--

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

原來合約也能請代理人幫忙!一招教你如何事半功倍

看完這篇你會學到:

  1. 什麼是 delegatecall
  2. 什麼是 function selector
  3. 什麼是 abi.encode 和 abi.encodeWithSignature

題目

此次目標是獲得合約的所有權即可通關。

Figure 2: Delegation 題目

合約程式碼如下:

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

contract Delegate {

address public owner;

constructor(address _owner) {
owner = _owner;
}

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

contract Delegation {

address public owner;
Delegate delegate;

constructor(address _delegateAddress) {
delegate = Delegate(_delegateAddress);
owner = msg.sender;
}

fallback() external {
(bool result,) = address(delegate).delegatecall(msg.data);
if (result) {
this;
}
}
}

解說

這是 Ethernaut 第一個和 delegatecall 和 function selector 相關的題目,他們是在合約中非常重要的概念 ,尤其是用以實現 Proxy Pattern (之後會提到),同時也是解題所需的先備知識,所以必須來認識一下他們。

什麼是 delegatecall?

Delegatecall 是本地合約委託調用其它合約的一種 Low-level 方法。當 A 合約使用 delegatecall 呼叫 B 合約的某 function,所有執行時的資料、狀態改變都發生在 A 合約 storage 內而非 B 合約。換一種表達方式,你可以想像成複製了一份 API 合約 (B 合約) 中的所有函式到本地合約 (A 合約) 執行,想當然是使用和修改到本地合約的資料。

那為什麼要有 delegatecall?其實這個問題就好比在問為什麼要使用 API?當然是為了省時省力省錢,如果已經有一個人開發出實用的工具,只需花費一次部署的成本,大家就可以直接拿他的 API 來使用多棒呀 (免費仔是我XD)。雖然只是比喻,但實際上確實是能大幅提高程式重用性 (省時省力)、降低合約部署成本 (省錢),以及最重要的是,讓 “合約變得可以升級”!不過現在還不會使用到升級概念,所以還是先用程式碼來舉個比較好理解的例子:

首先創建一個 Called 合約,包含一個變數 uint numbersetNumber()函式

contract Called {

uint public number;

function setNumber(uint _number) public {
number = _number;
}
}

接著創建 Caller 合約,包含兩個變數 uint numberaddress calledcallSetNumber()函式。這裡的 called 是 Called 合約的部署地址,而 callSetNumber() 是對 Called 合約執行 delegatecall。

contract Caller {

uint public number;
address public called = 0xd9145CCE52D386f254917e481eB44e9943F39138;

function callSetNumber(uint _number) public {
called.delegatecall(abi.encodeWithSignature("setNumber(uint256)",_number));
}
}

再來,我們呼叫 Caller 合約的 callSetNumber() ,讓他對 Called 合約執行 delegatecall 去調用 setNumber(),此時會把 Caller 合約中 number 的狀態修改成新設定的 _number 的狀態,並且不會更動到 Called 合約中原先 number的狀態。這正是 delegatecall 的特性,修改原始合約的狀態,而非被呼叫合約的狀態

但是,最重要的就是這個但是,如果我們不小心把 Caller 合約的兩個變數 numbercalled的位置順序調換過來,就會發生 Storage Collision!如下:

contract Caller {

// storage layout changed
address public called = 0xd9145CCE52D386f254917e481eB44e9943F39138;
uint public number;

function callSetNumber(uint _number) public {
called.delegatecall(abi.encodeWithSignature("setNumber(uint256)",_number));
}
}

關於 Storage Collision 的介紹在後面 Proxy Pattern 主題會補充,現在只要知道,如果兩個變數位置順序調換過來,會因合約 storage layout 不一致的關係,讓欲設定的 _number 數值塞到 called 這個變數裡而非原先希望的 number 變數裡,導致預期外的錯誤發生!因此,在使用 delegatecall 時要特別注意兩個合約之間的狀態變數數量、型態、順序是否皆相同,否則會成為攻擊漏洞。

什麼是 function selector

在介紹什麼是函式選擇器 (Function Selector) 之前,需要先知道什麼是函式簽章 (Function Signature),它是由函式名稱參數類型組合而成,我繼續接著以上方程式碼為例:

function callSetNumber(uint _number) public {
called.delegatecall(abi.encodeWithSignature("setNumber(uint256)",_number));
}

callSetNumber() 這個函式名稱 callSetNumber 和參數類型 uint256 (uint 是 uint256 的縮寫) 留下後,它的 function signature 即為:

callSetNumber(uint256)

在知道它的 function signature 後,對其做 keccak-256 hash 運算,並取前四個 bytes 出來,即為 function selector。

function_selector = bytes4(keccak256(bytes(function_signature)))

順便分享一個可以幫助我們快速取得 function selector 的好用工具,透過這個 keccak-256 線上計算工具,給他 function signature 作為 input 後可以取得 keccak-256 雜湊的結果,接著取雜湊結果的前四個 bytes,前綴再加上 “0x”,即可得 function selector。

// function signature 雜湊後的全部 bytes
keccak256(bytes(function_signature)) = 11c04ef446a5aadda1296e06b8aa584e0d31e76231669826c560249a59aa6778

// 只需要取前四個 bytes
bytes4(keccak256(bytes(function_signature))) = 11c04ef4

// 前綴加上 0x 即為 function selector
0x11c04ef4

若用一句話來解釋:對 function signature 做 hash (keccak-256) 後取前四個 bytes 就是 function selector。

那麼 function selector 為何是非常重要的概念?因為它讓 EVM 在執行合約時可以通過 function selector 找到並執行相應的函式

當在 EVM 使用 address(contract_A).call(...) 呼叫 A 合約函式其實是執行 transaction 中的 msg.data,並且會依 msg.data 的內容決定呼叫合約的哪個函式、參數數據。msg.data 是一個完整的 calldata,其編碼規則是 4 bytes (function selector) + 32 bytes x n (n 個參數),沒錯,前四個 bytes 就是辨識呼叫哪個函式用的 function selector,而函式所代參數則以每 32 個 bytes 對應一個參數,不過這邊講的參數並非常見的 value types (uint256, address, bool …),而是要把參數編碼成 bytes 的長相,這時就會使用到 Solidity 中的一個函式叫 abi.encode。

abi.encode

ABI 編碼 (abi.encode) 可以將 input 參數按照一定的規則轉換成 bytes 格式,經 ABI 編碼後的程式才能在合約之間進行互動。ABI 編碼會將每個參數填充成 32 bytes 長度的數據,同樣以 callSetNumber(uint256) 這個函式為例子,由於它只接收一個 uint256參數,且屬於 value type ,故編碼相對單純。假設我們要設定的參數是 99,則經過編碼後的 99 長相為:

// abi.encode
abi.encode(99)

// result
0x0000000000000000000000000000000000000000000000000000000000000063

encode 後的總長度是 64 個數字,因每個數字都是 hex (十六進位制) 表示法,故最後兩位數字是 63,換算成十進位制是 99。可以看到編碼後帶有非常多個 0,這是因為 1 個 hex 對應 4 個 bits要 2 個 hex 才能代表 1 個 byte (8 bits),所以將總長 64 個 hex 除以 2 剛好就是 32 bytes 的長度,正好符合前述所說的 calldata 參數部分的編碼規則。

順帶補充,bytes、string、<type>[] 這些 reference type 變數的編碼方式比較特殊,因為他們都屬於動態大小而非固定大小,所以在編碼時會生成三個元素:offset 、length、data

Figure 3: Types

offset 代表位移量,從現在位置往後數 offset 個 bytes 就會指到 length 所在的位置,提醒要將 hex 表示法轉成 decimal 才是正確位移量;length 代表陣列長度或字數長度;data 則是實際的數值。以 string 的編碼來舉例:

// abi.encode
abi.encode("hello world")

// result
0x
0000000000000000000000000000000000000000000000000000000000000020 => offset, 往後數 32 個 bytes (20 hex = 32 decimal)
000000000000000000000000000000000000000000000000000000000000000b => length, string 長度
68656c6c6f20776f726c64000000000000000000000000000000000000000000 => data, hello world 字串的 hex 表示法

透過上面兩種變數類型的範例,我們了解了 value type 和 reference type 編碼方式以及他們的相異處,想知道更多型態的編碼規則可以參考 abi-spec

最後,我們的 calldata 還差了一小部分, 前四個 bytes 的 function selector 部分還沒有解決,不過 Solidity 已經貼心地提供了內建函式讓我們使用,接下來有請 abi.encodeWithSignature 登場 (真的是最後一 part 了)。

abi.encodeWithSignature

abi.encodeWithSignature 與 abi.encode 功能非常類似,就只是多了一個參數,我們在第一個參數位置需要帶入 function signature,這樣便能取得完整 calldata 去調用其他合約函式。

還有另外一個長得很像的兄弟叫 abi.encodeWithSelector,看字面上的意思應該很好理解,沒錯!就是在第一個參數位置帶入 function selector。

這兩個方式編碼出來的 calldata 會長得一模一樣,也就是前 4 個 bytes 的 11c04ef4 代表要執行的函式選擇器,後面的 32 bytes 則為函式所代參數,忽略 0 的部分,把十六進位制的 63 換算成十進位制即可得出 99,範例如下:

// abi.encodeWithSignature
abi.encodeWithSignature("callSetNumber(uint256)", 99);

// result
0x11c04ef40000000000000000000000000000000000000000000000000000000000000063


// abi.encodeWithSelector
abi.encodeWithSelector(bytes4(keccak256(bytes("callSetNumber(uint256)"))), 99);

// result
0x11c04ef40000000000000000000000000000000000000000000000000000000000000063

這兩種編碼方式目的是一樣的,都是為了將函式及其參數編碼成完整的 calldata,僅僅差在第一個代入的參數不同而已。之所以分成這兩個方法,個人猜想可能的原因是有時候你無法得知 function signature,像是在 Etherscan 上沒有被驗證過 (verified) 的合約,當你要呼叫某個特定的函式,就只好從 function selector 下手。

Figure 4: Etherscan 上沒有被驗證過 (verified) 的合約,紅框內正是 function selector

在了解 delegatecall、function selector、abi.encode 與 abi.encodeWithSignature 之後 (先備知識還真不少…),我們回到本次題目,有兩個合約分別是 Delegation 和 Delegate,在 Delegation 合約有一個 fallback function,裡面會對 Delegate 合約使用 delagatecall。

很幸運的,由於本題 Delegate 和 Delegation 合約中第一個參數皆為address owner,所以不用擔心 Storage Collision 發生。

現在我們只需要在腳本中直接 call challengeInstance (其實就是 Delegation 合約),並給予正確的 calldata abi.encodeWithSignature("pwn()"),就會因為在 Delegation 合約找不到對應的 function selector 而進入 fallback function,進而使用 delagatecall 呼叫 Delegate 合約的 pwn() 函式。基於 delagatecall 的特性,任何狀態的修改會發生在呼叫者的合約內,如此就能順利把 Delegation 合約的 owner 改成 player 你自己了!

簡化攻擊流程如下:

Step 1. 準備好 calldata abi.encodeWithSignature("pwn()")

Step 2. 用 Low-level call 呼叫 challengeInstance,並帶上第一步準備的 calldata 作為 msg.data。

// script/solutions/06-Delegation.s.sol

contract DelegationSolution is Script, EthernautHelper {
..

function run() public {
...

// YOUR SOLUTION HERE
bytes memory data = abi.encodeWithSignature("pwn()");
challengeInstance.call(data);

...
}
}

這關也不需要寫攻擊合約,只用這兩行程式碼即可通關~

--

--

tanner.dev

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