Crosslink《CTF搶旗賽》解題全攻略(二)- getStorageAt() / delegatecall
從實作及比賽中,一探智能合約可能存在的種種漏洞
目前 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
起始狀態: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(…) 一個一個查的麻煩。
關卡 5 — Fake Fibonacci
初始狀態:合約地址存有 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 合約。
DELEGATECALL
和CALL
都是 Solidity 內建提供的函數,用來實現合約間的相互調用。兩者的差別在於,CALL
會將執行環境搬移到被呼叫者合約,而DELEGATECALL
則是在呼叫者合約的環境下運行(更簡單的解釋:CALL
是把資料丟給下個合約讓他執行;DELEGATECALL
是把被呼叫者的程式碼抓回來插進自己合約運行)。使用DELEGATECALL
時,msg.sender
和msg.value
的值不會改變。
看完代碼後會發現,兩個合約都有相同名稱的狀態變數: start
和 calculatedFibNumber
。有些人可能會想,相同名稱不就是為了讓兩個合約在互操作時用來對應的嗎?
錯!在前一題解析中有提到, 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 的程式碼為所欲為!
實際攻擊的方法有很多種,這邊列出其中三種:
- 假 lib 中只有 fallback function ,在 fallback function 中直接把錢轉走。
- 假 lib 中也有個相同名稱的
setFibonacci(uint256)
,並在這個 function 中正確設定calculatedFibNumber
的值,使我們再次呼叫主合約的withdraw()
時可以成功取款。 - 假 lib 中有任意名稱的 function 負責轉帳。在呼叫主合約的 fallback function 時帶上對應名稱的 function selector 即可轉帳。
我們將用最簡單的第一種來做說明,其它方式大家可以自己部署玩玩看。首先,部署假 lib 合約:
接著,要將主合約中的 lib 地址修改為假 lib 的地址:
- 計算
setStart(uint256)
的 function selector:
使用web3.sha3(“setStart(uint256)”)
並取出前4個 bytesf6a03ebf
。 - 複製假 lib 的地址並包裝成符合 ABI 規格。
(以 address 型態來說即為在前方補 0 直到總長度為 32 bytes,000000000000000000000000<address_without_0x>
,嫌麻煩也可以使用這個線上工具) - 將 1. 和 2. 的結果串接起來,並在最前面加上
0x
(0xf6a03ebf000000000000000000000000<address_without_0x>
) - 將這段 data 傳送到主合約,完成修改
此時可以呼叫主合約的 fakeFibonacciLibrary()
確認變數已經確實被改為假 lib 地址。
再來,我們只要再傳送一筆不帶 data (或任意 data,只要不會觸發主合約的其他 function)的交易就能觸發主合約的 fallback function ,並轉而呼叫到假 lib 的 fallback function 完成攻擊,過關!
如果您有其他有趣的解法,歡迎在下方一起留言討論!