Crosslink《CTF搶旗賽》解題全攻略(三)- randomIndex / EXTCODESIZE / Unexpected Ether
從實作及比賽中,一探智能合約可能存在的種種漏洞
目前 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
本文目錄
關卡6 - PaperScissorsStone
關卡7 - Fomo4D
關卡8 - MorganStark
關卡 6 — PaperScissorsStone
初始狀態:winTimes = 0
目標:winTimes = 3
-
合約解析
這是一個猜拳的合約,如果能連續猜贏三次,就算過關。
合約中的隨機數是由以太坊鏈上某個區塊的哈希值計算出來的,雖然看似隨機,但其實每個人在發送交易前都能算得出來。
解題關鍵
randomIndex
guess(uint8)
中其實就是判斷傳入的 _guess
是不是等於 rps[randomIndex]
+1 再 mod 3 ,若是,則算猜中一次。
其中一個作法是,預設要在某 A 區塊時發送交易,並先計算出以 A 區塊高度為基準的 randomIndex
及 _guess
,待 A 區塊到來時再發送交易。
然而,其實我們可以不用預先人工計算,只要再寫另一個 Attack 合約,透過這個合約再去呼叫 guess(uint8)
作攻擊,由於兩個 function 會在同一個區塊內執行,只要在 Attack 合約中複製一模一樣的程式,就能在攻擊前得到一樣的 randomIndex
並算出 _guess
。
透過這種方式,完全不用煩惱要在哪個時間點攻擊,也不需要知道 block.number 和 blockhash,只要呼叫 Attack 合約中的 attack()
,就能直接猜中答案。連續呼叫三次之後,即可過關!
關卡 7 — Fomo4D
起始狀態:owner = “BoYu Chen”
目標:owner = “Bill Hsu”
-
合約解析
這關的重點在於讓 changeOwner()
執行成功即可過關。
changeOwner()
的第一行即要求必須符合兩個條件,才能繼續執行下去:
msg.sender != tx.origin
isContract(msg.sender) == false
** msg.sender
為當前調用的發起者,而 tx.origin
則是整筆交易(整條調用鏈)的發起者。
若用戶是從 EOA(Externally Owned Account)直接調用 changeOwner()
,由於沒有經過合約轉手,msg.sender
和 tx.origin
會是相同的,不符合條件,交易會直接失敗。因此,為了通過這個限制,必須部署另一個合約,並在新合約中再回去調用 changeOwner()
,此時 msg.sender
會變成新合約的地址,而 tx.origin
則還是用戶的 EOA 地址。
完成第一個條件後,接著會調用 isContract(address)
檢查剛剛的 msg.sender
是不是一個合約,如果是的話會回傳 true ,這將導致條件不符,並使得交易再次失敗。
看到這邊,你可能會想:兩個條件相互矛盾,一個需要通過合約轉手調用,另一個又會驗證調用者必須不是合約,怎麼可能通過?
沒錯,兩個條件相互衝突就是為了讓你無法修改 owner
。然而,在放棄之前,我們應該先細看一下 isContract(address)
是如何判斷地址的合約與否…
解題關鍵
EXTCODESIZE
extcodesize(a)
是一段 EVM 的 opcode (operation code),用來取得位於地址 a 的程式碼大小。而這也就是 isContract(address)
判斷傳入地址是否為合約的標準 — 如果這個地址上的程式碼大小大於 0 ,那就是合約;反之,沒有程式碼,就是 EOA。
然而,使用 constructor 去調用時, extcodesize(a)
會回傳 0 !
這是由於合約執行 constructor 時程式碼尚未完成部署,區塊也尚未完成打包,對 extcodesize(a)
來說 a 地址還未包含程式碼,因此回傳 0 。
了解這些後,我們直接部署一個攻擊合約:
在合約的 constructor 中傳入被攻擊目標 Fomo4D 的地址,並呼叫其 changeOwner()
。在攻擊者合約部署完成後, Fomo4D 的 owner
也會跟著改變,過關!
關卡 8 — MorganStark
起始狀態: 合約餘額 = 0
目標:合約餘額 > 0
-
合約解析
這關的過關條件是讓合約地址的餘額大於 0 。
在解析第三關時有介紹過,合約中的 function 要接收 ether 必須註明 payable
,因此我們直接找有 payable
的 function 開始看。
- constructor:有標明
payable
,但在其第一行又要求msg.value == 0
,使得我們無法帶著 ether 創建這個合約。 collegeFund()
:這的確是個收款 function ,但唯有owner
可以呼叫。而owner
並不是我們,是幫我們部署這個合約 instance 的 Level 合約。
看完了合約中僅有的兩個 payable
function ,似乎都無法讓我們捐款成功。由於沒有 payable
fallback function,因此也無法透過一般交易把 ether 轉進合約。
然而,有兩種例外可以強制將 ether 轉進合約,不論目標合約是否有 payable
function ,且不會執行任何程式碼(包含 fallback function)。
解題關鍵
Unexpected Ether
這兩種例外分別為:
selfdestruct
- 預傳送 Ether
方法一
第一種方式是使用 selfdestruct
function 。selfdestruct(target)
會將程式碼從合約地址移除,並將合約剩餘的所有 ether 轉到 target
地址,無論 target
是 EOA 或 contract ,都必須無條件接收。
為了使用這個方法,我們需要部署另一個含有 selfdestruct
的合約,並使它擁有大於 0 的 ether:
只要帶著 ether 呼叫 kill
並將 MorganStark 的地址傳入,在 selfdestruct
後就會發現 ether 確實進了 MorganStark 合約,第一種方式過關!
方法二
第二種方式預傳送 ether ,是在合約被部署前預測出合約即將被部署到的地址,並將 ether 預先轉入,由於地址上尚未有程式碼, ether 的轉送不會受到阻擋。
但合約的地址要如何預知呢?
其實合約的地址是確定性的(deterministic),可以經由合約部署者的地址(sender
)及它在部署當下的 nonce
計算出來 :
new_address = keccak256(rlp([sender, nonce]))[12:]
對 sender 和 nonce 做 RLP 編碼後,計算其 keccak256 的值,再取最後 20 bytes。下方是以 node.js 實作的一段 script:
使用前,記得先將 nonce 和 sender 依需求修改後再執行。注意, nonce 的值是十六進制而非十進制。舉個例子:
- address:
0x6a250e76b574b5b26b8953a5229b623f5e68eab3
- nonce:
0x26
(十進制為 38) - result:
0x45bd566b3fa2e7b4268988d307cbb4a98ecce2d0
還要注意的一點是,在這個 CTF 中,關卡 instance 都是由 Level 合約部署的,並不是玩家本身的 EOA 。Level 合約的 nonce 可以透過 web3.eth.getTransactionCount
來取得。
計算出即將被部署的地址後,只要先將 ether 轉過去,再去獲取關卡 instance ,即可直接過關!
註:在以太坊 Constantinople 硬分叉時,加入了
CREATE2
opcode,與原本的CREATE
一樣是用來部署合約的,不同的地方在於,CREATE2
讓你更有彈性的控制合約產生時的地址。細節可以參考 EIP-1014 和 Constantinople硬分叉內容介紹,以及有趣的應用。
如果您有其他有趣的解法,歡迎在下方一起留言討論!