Crosslink《CTF搶旗賽》解題全攻略(一)- Underflow / XOR / Reentrancy
從實作及比賽中,一探智能合約可能存在的種種漏洞
目前 CTF 網站已轉移至:
Crosslink 是由「台北以太坊」社群舉辦的區塊鏈年度技術盛會,聚集了來自世界各地的技術大佬及開發者一同交流分享。今年的會議雖已結束,但議程中都有志工全程錄影及文字紀錄,若您對會議內容有興趣,可以到台北以太坊 Medium搜尋。
前言
Crosslink 2019 舉辦了智能合約 CTF (Capture The Flag,搶旗賽),讓大家在享受競賽樂趣的同時,也能對智能合約安全有更深一層的認識。即使我解開了所有關卡,許多題目牽涉的漏洞及工具也是第一次接觸,希望能透過寫文章讓自己重新檢視並真正理解每一題,也希望藉著這篇記錄,能把主辦單位本次精心策劃 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
本文目錄
關卡1 - Bank
關卡2 - Encrypt
關卡3 - Freeshop
接下來,就讓我們開始向 Crosslink Ship 前進吧!
關卡 1 — Bank
起始狀態:玩家擁有 29 個 Token。
目標:讓自己的 Token 數量大於 29。
-
合約解析
Bank 是一個代幣合約,在部署時可以設定總供給量,並帶有三個 function ,分別為轉帳函數 transfer(...) 、餘額查詢函數 balanceOf(...) ,以及一個自動生成的總供給量查詢函數 totalSupply() ,其中有幾個重點需要先瞭解:
- 第五行
mapping(address => uint) balances;
表示 balances 變數是一個 mapping 型態,用來以地址對應其所擁有的 Token 數量。數量單位為uint
(uint 為 uint256 的簡寫)。 - 第九行
balances[msg.sender] = totalSupply = _initialSupply;
表示 Bank 合約在被部署時會將 totalSupply 設為 _initialSupply 的值,並指定為 msg.sender 所擁有。在這關合約被部署的當下, msg.sender 即為玩家本人,而 _initialSupply 參數則被設定為 29 。
解題關鍵
Arithmetic Underflow 算術下溢
Bank 合約的解題關鍵在於 transfer(…)
函數中。
transfer(…)
函數接收兩個參數 — 第一個參數 _to
為目的錢包地址,第二個 _value
為轉送數量。函數中的第一行要求 msg.sender 擁有的 Token 數量扣除 _value
必須大於等於零,然而卻沒先驗證「轉送數量是否小於等於擁有數量」( _value <= balances[msg.sender]
),這讓我們可以使用 underflow 的方式來攻擊合約。
Underflow 意思為在超過變數型態所能表示的最小值時,將會從尾重新開始循環。舉例來說,一個
uint8
所能表示的值為 [0, 255],若我們將0
減掉1
則會得到255
,即稱為 underflow 。反之,overflow 發生在超過變數型態所能表示的最大值時,會從頭重新循環:將255
加1
會變為0
。
由於遊戲開始時,玩家的地址被賦予的 Token 數量為 29 (意即 balances[msg.sender] = 29
),為了造成 underflow ,我們將轉送數量 _value
設為大於 29 的值(假設為 30),使得
balances[msg.sender] - _value
等於
29 - 30
而在 uint
(= uint256
)的型態下 ,將會得到
29 - 30 = 2²⁵⁶ - 1 //也就是uint所能表示的最大值
如此一來,就通過了 function 中第一行的限制,並在第二行 balances[msg.sender] -= _value;
時將我們的擁有數量更新為 2²⁵⁶-1 。
然而,還有一點要注意: 第一個參數 _to
必須設定為自己以外的地址。在 function 第二和第三行:
balances[msg.sender] -= _value;
balances[_to] += _value;
若將 _to
設定為自己,在一減一加中,雖然會先造成 underflow ,但接著會再發生 overflow ,並使得玩家最終擁有數量仍為初始值,無法過關!
關卡 2 — Encrypt
起始狀態:complete = false
目標:complete = true
-
合約解析
這關的過關條件是將 complete
變數更新為 true,稍微瀏覽一下整個合約,可以發現 complete
只有在一個地方會被更動,就是 guess(…)
函數的最後一行。因此,我們直接來細看一下函數內容:
二三行會去讀取當下區塊的時間戳(block.timestamp
),轉換成 bytes32 型態,計算其 hash 值,並放進 entropy1
變數。
五六行也是做一模一樣的事,只是最後將值放進 entropy2
。
(p.s. 若想瞭解 abi.encode 與 abi.encodePacked 的差別,這篇文章有很清楚的介紹)
在講解第八行之前,幫大家複習一下位元運算 OR、AND、XOR:
OR 運算子,符號為「|」0 | 0 = 0
0 | 1 = 1
1 | 0 = 1
1 | 1 = 1AND 運算子,符號為「&」0 & 0 = 0
0 & 1 = 0
1 & 0 = 0
1 & 1 = 1XOR 運算子,符號為「^」0 ^ 0 = 0
0 ^ 1 = 1
1 ^ 0 = 1
1 ^ 1 = 0
解題關鍵
XOR 和 AND 運算子
第八行的等式右邊可以看成是三個部分做 AND 運算,這三個部分為:
(entropy1 ^ entropy2)
&
keccak256(abi.encodePacked(block.number))
&
sault
第一部分是這關的重點。在前面我們已經發現,entropy1
和 entropy2
帶有完全相同的值,而對兩個完全相同的值做 XOR 運算,結果會得到 0 。又因為三個部分是以 AND 連接,在 AND 的特性下,只要其中一方是 0 ,結果就是 0 。因此,第二和第三部分完全不需要計算,就能得到結果 target = 0
。
接著,最後一行會將輸入的唯一參數轉換成 bytes32 型態,並與上面算出的 target
比對是否相同,再把比對結果指派回 complete
變數。
因此,我們只要在呼叫 guess(…)
函數時將傳入參數值設為 0 ,即可把 complete
更新為 true ,過關!
關卡3 — Freeshop
初始狀態:合約地址存有 1 ether
目標:將合約地址掏空
-
合約解析
這個合約是一個具有時間鎖的錢包。它提供了存款、提領及餘額查詢的功能,並在提款時加上了金額及時間的限制。這類錢包合約通常被用於實行強制鎖倉,使用者將資金存入合約,等待預先定義的時間結束後才能解鎖,以避免在這段時間受到價格波動或其他因素影響而動用。
合約中有兩個預備知識: payable
和 fallback function。
payable
payable
是 function 的修飾符,代表呼叫此 function 的交易允許帶有 ether。反之若 function 在宣告時沒有寫上 payable
,呼叫時的交易附有 ether 將會引發 error 。
以這關的合約為例子, depositFunds()
可以接收 ether,而 withdrawFunds(…)
則不能。
2. fallback function
fallback function 是一個沒有名字、不能接收參數且不能回傳值的函數。一個合約僅能擁有一個 fallback function。其意義在於「當交易發送至合約地址卻無法找到對應名稱的 function 時,將 fallback 到這個 function 執行」。另外,當合約收到單純的 ether 交易時(不帶有 data),也會被導引至 fallback function。所以,如果希望合約能夠接收一般的 ether 傳送,必須在 fallback function 後面加上 payable
。
接著,我們來看一下提領函數 withdrawFunds(…)
:
前三行程式碼照順序分別檢查了
- 提款人在合約中的資金量 ≥ 提領金額
- 提領金額 ≤ 單次提領限制 0.1 ether
- 距離上次提領時間已間隔超過一週以上
做完檢查後,合約開始進行資金轉移
4. 對 msg.sender 轉移提領金額
5. 更新 msg.sender 的擁有數量紀錄
6. 更新 msg.sender 的最後提領時間
乍看之下沒什麼問題,該做的檢查都做了,資金轉移完也有記得更新狀態。然而,魔鬼卻隱藏在細節之中…
解題關鍵
順序
withdrawFunds(…)
函數中先進行了資金轉移,接著才更新狀態。如果我們能在更新狀態前再次呼叫 withdrawFunds(…)
,是不是就能避免因狀態更新而受到的提領限制,並且再次轉移資金呢?
沒錯!我們的方法就是在狀態被更新前,透過遞迴重複呼叫 withdrawFunds(…)
直到把合約資金掏空,才讓它更新狀態。
但 withdrawFunds(…)
已經有既定的程式碼了,要怎麼在中途改變它的執行路線?
關鍵在於 withdrawFunds(…)
中第四行的資金轉移:如果是轉移到一般的非合約地址,轉移完成後將直接繼續執行第五行程式碼。然而,若轉移的目的地是一個合約地址,前文有提到,將會呼叫這個合約的 fallback function!
因此,我們撰寫另外一個合約 AttackFreeshop.sol ,並在其 fallback function 中呼叫 Freeshop.sol 合約的 withdrawFunds(…)
。
在這份合約的 attack()
中,會先呼叫 freeshop.depositFunds()
存入 0.1 ether 以讓我們在接下來的步驟能通過帳戶額度擁有的限制,接著立即呼叫 freeshop.withdrawFunds(…)
展開攻擊。
攻擊開始後,當 Freeshop 合約判斷 AttackFreeshop 地址都符合條件,會向其轉帳並執行 AttackFreeshop 的 fallback function 。由於狀態尚未被更新, Freeshop 此時仍以為 AttackFreeshop 尚未領取,因此再次呼叫 freeshop.withdrawFunds(…)
就能再次取款,並再次觸發 fallback function。直到 Freeshop 合約餘額低於 AttackFreeshop 當初的存入額(0.1 ether),才會終止遞迴並完成交易。正常情況下, 此時 Freeshop 餘額應該為 0 ,恭喜你過關!
若 Freeshop 餘額不為零,可能是因為你曾經自己向 Freeshop 轉過帳,導致總餘額變為不是 0.1 ether 的倍數(AttackFreeshop 中設定提領金額為 0.1),請調整金額並重新部署後再執行攻擊。
如果您有其他有趣的解法,歡迎在下方一起留言討論!