Crosslink《CTF搶旗賽》解題全攻略(一)- Underflow / XOR / Reentrancy

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

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

--

目前 CTF 網站已轉移至:

https://cypherpunks-core.github.io/cypherpunks-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

關卡 1 — Bank.sol

起始狀態:玩家擁有 29 個 Token。

目標:讓自己的 Token 數量大於 29。

-

合約解析

Bank 是一個代幣合約,在部署時可以設定總供給量,並帶有三個 function ,分別為轉帳函數 transfer(...) 、餘額查詢函數 balanceOf(...) ,以及一個自動生成的總供給量查詢函數 totalSupply() ,其中有幾個重點需要先瞭解:

  1. 第五行 mapping(address => uint) balances; 表示 balances 變數是一個 mapping 型態,用來以地址對應其所擁有的 Token 數量。數量單位為 uint (uint 為 uint256 的簡寫)。
  2. 第九行 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 發生在超過變數型態所能表示的最大值時,會從頭重新循環:將 2551 會變為 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

關卡 2 — Encrypt.sol

起始狀態: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 = 1
AND 運算子,符號為「&0 & 0 = 0
0 & 1 = 0
1 & 0 = 0
1 & 1 = 1
XOR 運算子,符號為「^0 ^ 0 = 0
0 ^ 1 = 1
1 ^ 0 = 1
1 ^ 1 = 0

解題關鍵

XOR 和 AND 運算子

第八行的等式右邊可以看成是三個部分做 AND 運算,這三個部分為:

  1. (entropy1 ^ entropy2) &
  2. keccak256(abi.encodePacked(block.number)) &
  3. sault

第一部分是這關的重點。在前面我們已經發現,entropy1entropy2 帶有完全相同的值,而對兩個完全相同的值做 XOR 運算,結果會得到 0 。又因為三個部分是以 AND 連接,在 AND 的特性下,只要其中一方是 0 ,結果就是 0 。因此,第二和第三部分完全不需要計算,就能得到結果 target = 0

接著,最後一行會將輸入的唯一參數轉換成 bytes32 型態,並與上面算出的 target 比對是否相同,再把比對結果指派回 complete 變數。

因此,我們只要在呼叫 guess(…) 函數時將傳入參數值設為 0 ,即可把 complete 更新為 true ,過關!

關卡3 — Freeshop

關卡 3 — Freeshop.sol

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

目標:將合約地址掏空

-

合約解析

這個合約是一個具有時間鎖的錢包。它提供了存款、提領及餘額查詢的功能,並在提款時加上了金額及時間的限制。這類錢包合約通常被用於實行強制鎖倉,使用者將資金存入合約,等待預先定義的時間結束後才能解鎖,以避免在這段時間受到價格波動或其他因素影響而動用。

合約中有兩個預備知識: payable 和 fallback function。

  1. 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(…)

前三行程式碼照順序分別檢查了

  1. 提款人在合約中的資金量 ≥ 提領金額
  2. 提領金額 ≤ 單次提領限制 0.1 ether
  3. 距離上次提領時間已間隔超過一週以上

做完檢查後,合約開始進行資金轉移

4. 對 msg.sender 轉移提領金額

5. 更新 msg.sender 的擁有數量紀錄

6. 更新 msg.sender 的最後提領時間

乍看之下沒什麼問題,該做的檢查都做了,資金轉移完也有記得更新狀態。然而,魔鬼卻隱藏在細節之中…

解題關鍵

順序

withdrawFunds(…) 函數中先進行了資金轉移,接著才更新狀態。如果我們能在更新狀態前再次呼叫 withdrawFunds(…),是不是就能避免因狀態更新而受到的提領限制,並且再次轉移資金呢?

沒錯!我們的方法就是在狀態被更新前,透過遞迴重複呼叫 withdrawFunds(…) 直到把合約資金掏空,才讓它更新狀態。

withdrawFunds(…) 已經有既定的程式碼了,要怎麼在中途改變它的執行路線?

關鍵在於 withdrawFunds(…) 中第四行的資金轉移:如果是轉移到一般的非合約地址,轉移完成後將直接繼續執行第五行程式碼。然而,若轉移的目的地是一個合約地址,前文有提到,將會呼叫這個合約的 fallback function!

因此,我們撰寫另外一個合約 AttackFreeshop.sol ,並在其 fallback function 中呼叫 Freeshop.sol 合約的 withdrawFunds(…)

AttackFreeshop.sol

在這份合約的 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),請調整金額並重新部署後再執行攻擊。

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

--

--