Crosslink《CTF搶旗賽》解題全攻略(二)- getStorageAt() / delegatecall

從實作及比賽中,一探智能合約可能存在的種種漏洞

Robin Pan 潘宣任
CryptoCow
10 min readNov 29, 2019

--

目前 CTF 網站已轉移至:

https://cypherpunks-core.github.io/cypherpunks-ctf/

本文假設讀者對智能合約的操作、語法有一定程度的瞭解,若對 Metamask 不熟悉或是不了解如何與 CTF 網站互動,建議先參考這篇引導,內容包括了安裝 Metamask 、連結至 CTF 網站,到一步步與合約互動,最終解開關卡 0 。

系列文目錄

 (一)關卡 1、2、3 - Underflow / XOR / Reentrancy
▶(二)關卡 4、5   - getStorageAt() / delegatecall
(三)關卡 6、7、8 - randomIndex / EXTCODESIZE / Unexpected Ether
(四)關卡 9
(五)關卡 10
(六)關卡 11
(七)關卡 12

本文目錄

關卡4 - University
關卡5 - Fake Fibonacci

關卡 4 — University

關卡 4 — University.sol

起始狀態:combined = false

目標:combined = true

-

合約解析

過關指令發生在 combine(…) 函數中:

if (stringToBytes32(_name) == name[2]) {             
combined = true;
}

當傳入的字串參數 _name 轉為 bytes32 型態後,若與陣列變數 name 中第三個位置的值相等時,將更新 combined = true 。

name 是一個使用合約 storage 存儲的 bytes32 Array,其值會在 constructor 中被設定。與其他變數不同的是,name 的可視性被設為 private ,因此我們無法直接透過 ABI 呼叫來取得 name 的值。

解題關鍵

getStorageAt()

然而,宣告 private 關鍵字僅僅是預防其他合約對 name 變數的存取及修改,但狀態的改變都會被紀錄在鏈上,鏈上的一切都是公開透明的!對於鏈外的我們,仍有其他方法能讀取 name 所在位置的資料,那就是 web3 的 getStorageAt(…)

web3.eth.getStorageAt(address, position [, defaultBlock] [, callback])
  • address: 被查詢的地址
  • position: 欲查詢的 storage index

現在我們知道可以透過 getStorageAt(…) 來讀到 private 變數的值了,但到底 name 在哪個 position 我們仍不清楚,因此還必須瞭解一下 EVM 是如何編排 storage 空間的:

每個 storage position 稱為一個 slot ,每個 slot 的大小為 32 bytes。

靜態大小的變數(也就是除了 mapping 和動態長度的 array 型態以外)將會從 position 0 開始依序相鄰的編排。如果多個相鄰的項目的佔用空間皆少於 32 bytes 的話,將可能被打包進單一個 slot ,並遵循以下規則:

1. 第一個項目將從 slot 的最低位開始擺放(lower-order aligned)

2. 基礎型態只使用其必須佔用的大小

3. 若一個基礎型態無法塞進前一個 slot 的剩餘空間,將移到下一個 slot

4. struct 和 array 總是會從一個新的 slot 開始,並佔用整個 slot 。(但 struct 和 array 內的項目將依上述規則進行打包)

接著,我們將這份合約每個變數所佔用的空間列出來:

根據上述規則, EVM 會將 owner 安置在 slot[0] 的最低位,佔據了 20 bytes ,接著 EVM 發現 slot[0] 的剩餘空間還能塞得下,於是把 combined 繼續往較高位插入。然而,第三個變數 now 需要 32 btyes,無法再塞進 slot[0] ,因此將 slot[0] 的剩餘位數補 0 後,移往下一個 slot。

slot[0] = 0x0000000000000000000000 + ${combined} + ${owner}

以這樣的方式,我們可以很快算出 name 將會從 slot[3] 開始擺放,並推算出 name[2] 將位於 slot[5]

slot[5] =  0x4e6174696f6e616c2059616e67204d696e6720556e6976657273697479000000

到這邊差不多得到答案了,但由於合約 combine(string _name) 的參數是 string 型態, 我們還要將 slot[5] 的值轉回 ASCII 的字串型態才能傳入,可以使用 web3 的 toAscii() 並得到結果:

National Yang Ming University

過關!

p.s. 現在 Etherscan 的 Transaction Details 有個功能叫 State Changes,可以看到這筆交易對鏈上狀態做了哪些改變。由於這關的 name 都在 constructor 中被設定,直接查這個合約的 contract creation 交易就可以看到每個 slot 的變化,免去了用 getStorageAt(…) 一個一個查的麻煩。

https://ropsten.etherscan.io/tx/0x5bb9bfaf742721c3c24395e2b182831001fc5105197838bedecd2a26141f0278#statechange

關卡 5 — Fake Fibonacci

關卡 5 — FakeFibonacciBank.sol

初始狀態:合約地址存有 1 ether

目標:將合約地址掏空

-

合約解析

這關的 source code 有兩個合約,在實際部署時會被分別部署到不同地址。

FakeFibonacciLib 是一個 library 合約,裡面定義了一些可重用的邏輯來讓其他合約引用。而 FakeFibonacciLib 是用來計算 Fibonacci 數的 lib ,提供用戶自行設定起始值(start),以及依起始值算出 Fibonacci 數。

FakeFibonacciBank 則是這關的主合約,主合約中會透過 DELEGATECALL 引用 library 的邏輯來簡化自己的程式碼:

  • 首先,在 constructor 中會設定要引用的 lib 合約地址。
  • 當被呼叫 withdraw() 時會去引用 lib 的 setFibonacci(uint256) 以設定 calculatedFibNumber ,再依計算的值轉出對應的 ether 。(fibsig 變數是一個 function selector,用來告訴 EVM 你要呼叫的 function 的長相)
  • fallback function 會將當筆交易附帶的所有 data 直接帶去引用 lib 合約。

DELEGATECALLCALL 都是 Solidity 內建提供的函數,用來實現合約間的相互調用。兩者的差別在於, CALL 會將執行環境搬移到被呼叫者合約,而 DELEGATECALL 則是在呼叫者合約的環境下運行(更簡單的解釋:CALL 是把資料丟給下個合約讓他執行;DELEGATECALL 是把被呼叫者的程式碼抓回來插進自己合約運行)。使用 DELEGATECALL 時,msg.sendermsg.value 的值不會改變。

看完代碼後會發現,兩個合約都有相同名稱的狀態變數: startcalculatedFibNumber 。有些人可能會想,相同名稱不就是為了讓兩個合約在互操作時用來對應的嗎?

錯!在前一題解析中有提到, EVM 儲存和取用狀態變數是認「位置(slot)」的,而不是名稱!

因為 calculatedFibNumber 在兩個合約中都處於 slot[1] 所以沒有影響,但 start 卻宣告在不同位置,這將導致錯誤的引用,進而使得整個合約後門大開…

解題關鍵

delegatecall

結合前面提到的兩個特點:

  • EVM 用 slot 而非名稱來存取變數
  • DELEGATECALL 會在呼叫者的環境下執行

lib 中的 setFibonacci(uint256) 會使用到位於 slot[0]start ,經過遞迴計算後再把結果指派給位於 slot[1]calculatedFibNumber。然而, DELEGATECALL 是在呼叫者環境下執行的,而位於主合約 slot[0] 的值是 lib 的合約地址 fakeFibonacciLibrary!!由於 address 型態的值通常非常大,以這個值當起始值計算出的 calculatedFibNumber 也會非常大,但主合約內並沒有那麼多錢,因此此時呼叫 withdraw() 的結果就是不斷被 revert 。

關鍵來了, lib 還有一個 function 叫 setStart(uint256) ,原本在 lib 中是用來設定起始值的,但在主合約執行時就變成是設定位於 slot[0]fakeFibonacciLibrary 地址!!

也就是我們可以部署另一個假的 lib 合約,通過在主合約呼叫原本 lib 的 setStart(uint256) 將 lib 地址修改為假 lib 地址,再來,我們就可以透過假 lib 的程式碼為所欲為!

實際攻擊的方法有很多種,這邊列出其中三種:

  1. 假 lib 中只有 fallback function ,在 fallback function 中直接把錢轉走。
  2. 假 lib 中也有個相同名稱的 setFibonacci(uint256) ,並在這個 function 中正確設定 calculatedFibNumber 的值,使我們再次呼叫主合約的 withdraw() 時可以成功取款。
  3. 假 lib 中有任意名稱的 function 負責轉帳。在呼叫主合約的 fallback function 時帶上對應名稱的 function selector 即可轉帳。

我們將用最簡單的第一種來做說明,其它方式大家可以自己部署玩玩看。首先,部署假 lib 合約:

接著,要將主合約中的 lib 地址修改為假 lib 的地址:

  1. 計算 setStart(uint256) 的 function selector:
    使用 web3.sha3(“setStart(uint256)”) 並取出前4個 bytes f6a03ebf
  2. 複製假 lib 的地址並包裝成符合 ABI 規格。
    (以 address 型態來說即為在前方補 0 直到總長度為 32 bytes, 000000000000000000000000<address_without_0x>,嫌麻煩也可以使用這個線上工具
  3. 將 1. 和 2. 的結果串接起來,並在最前面加上 0x0xf6a03ebf000000000000000000000000<address_without_0x>
  4. 將這段 data 傳送到主合約,完成修改

此時可以呼叫主合約的 fakeFibonacciLibrary() 確認變數已經確實被改為假 lib 地址。

再來,我們只要再傳送一筆不帶 data (或任意 data,只要不會觸發主合約的其他 function)的交易就能觸發主合約的 fallback function ,並轉而呼叫到假 lib 的 fallback function 完成攻擊,過關!

如果您有其他有趣的解法,歡迎在下方一起留言討論!

--

--