作者:Dr. Wu Chiachih(Amber Group 區塊鏈安全專家)
在區塊鏈短短的歷史上發生過跟智能合約相關的攻擊事件中,重入攻擊無疑是最廣為人知的一種類型,在以太坊草創時期,2016 年 7 月的 TheDAO 事件甚至直接造成了以太坊硬分叉為以太經典 (ETC) 及現在大部分人熟知的以太坊 (ETH) 。在 TheDAO 事件給以太坊重擊之後,開發者也多了一些方法來防範重入攻擊,例如,Checks-Effects-Interactions 以及 Reentrancy Guard 。然而,許多重入攻擊仍然持續發生,攻擊的形式也從通過 fallback 函數重入同函數轉變成通過不同的外部函數進入智能合約,造成合約狀態混亂以達成有效攻擊。本文將介紹及復現發生於 2020 年 4 月 UniswapV1 的重入攻擊,2021 年 7 月發生在 BSC 上 DeFiPIE 項目的重入攻擊,以及近期發生於 C.R.E.A.M. 項目的 AMP 代幣重入攻擊。
在進入案例分析之前,我們先介紹下重入攻擊的基本概念。下面是 Solidity 網站上面介紹重入攻擊給的簡單案例,事實上這個 Fund 合約,就是簡化過的 TheDAO 合約,在 withdraw() 函數裡我們可以看到 shares[msg.sender] 數量的 ETH 會通過 msg.sender.send() 發給 msg.sender,也就是 Fund.withdraw() 的 caller 。其中 shares[] 裡頭存的是每個 user 存入合約的 ETH 額度,因此在 user 成功取出 ETH 之後,shares[msg.sender] 會被清零,這個程式邏輯看起來沒有任何問題。
然而,上述的 caller (msg.sender) 可以是個惡意合約地址,如果惡意合約裡寫了 fallback function ,則 Fund.withdraw() 裡的 msg.sender.send() call 就可以被 hijack,在這個 fallback function 裡如果再次調用了 Fund.withdraw() 則 shares[msg.sender] 數量的 ETH 就會在被清零之前被多次發送給 msg.sender,下面是一個示意圖:
攻擊者部署一個 Evil 合約,在 Evil.receive()(即 fallback function)檢查 Fund 的 ETH 足夠的情況下連續調用 Fund.withdraw() ,即可將 Fund 合約的 ETH 抽光,直到最後一次調用,shares[msg.sender] 才會被真正的清零。
這個簡單的重入攻擊案例有一個關鍵點:「清零」發生在「轉帳」之後。雖然這樣的寫法比較符合人類的邏輯,即「確認轉帳成功了,再把紀錄清掉」,但是在 EVM 的世界裡有點不同。其實先清零再轉帳也沒有什麼問題的,如果轉帳失敗了,清零的操作會自動回滾(revert)。而且把轉帳放到清零之後,反而可以避免重入攻擊,也就是 Checks-Effects-Interactions pattern 。 shares[msg.sender] 是 effects,msg.sender.send() 是 interactions,只要所有的 interactions 都在 effects 之後,即使重入了 Fund.withdraw() 也不會造成什麼影響。
接下來,我們將介紹一個類似的案例,只是漏洞利用方式稍微複雜一點。 2020 年 4 月 18 日下午,Twitter 上開始出現了關於 Uniswap imBTC pool 被攻擊的消息:
Uniswap 的創始人 Hayden Adams 提到了 UniswapV1 不支持 ERC-777 並且附上了一個 ConsenSys Diligence blog 的鏈接。事實上,這次攻擊符合 ConsenSys Diligence blog 裡的描述,而且這篇 blog 是差不多剛好一年之前寫的 (2019-4-20) 。
關鍵點在 UniswapV1 的 tokenToEthInput() 函數與 ERC-777 token 的兼容性問題,從下面程式碼片段可以看到,tokenToEthInput() 函數基本上是符合 Checks-Effects-Interactions 的寫法,第 208 行合約給用戶發送 ETH,第 209 行用戶給合約發送 token,都在函數的最後面執行,如果從 UniswapV1 本身來看是沒有任何問題的。然而,DeFi 世界就像是一個金融樂高遊樂場,209 行發送的 token 本身也是一個智能合約,在這個合約肚子裡存在一個 effects after interactions 的場景。
下面是某個 ERC-777 token contract 的 transferFrom() 函數底層實現,第 866 行有一個 callback interface 可以用來通知 holder ,只要 holder 是一個合約地址,並且按照 ERC-1820 註冊了 tokensToSend() 函數。而第 868 行的 _move() 才是真正更新 token balances 的地方。因此,如果攻擊者在 _callTokensToSend() 時重入了 UniswapV1 的 tokenToEthInput(),可以造成 UniswapV1 pool 本身 token balance 增加之前,多次兌換成 ETH 。即第 204 行的 token_reserve 永遠不變。
簡單的說,在重入攻擊發生的情況下,第 204 取出的 token_reserve 可能跟上一層調用是一樣的,在 Uniswap xy=k 的設定下,如果可以用同樣的 token_reserve 多次交易,等於是可以持續用較高的價格賣出 token 把流通性提供方 (LP) 的代幣消耗殆盡。
下面是我們利用 eth-brownie 回到案發之前的 2020-2-15 區塊高度 9488451 復現這次攻擊的程式碼:
首先是通過 ERC1820 合約註冊 tokensToSend() callback function,註冊完成之後所有兼容 ERC-777 的 token transfer 發生時,如果目標地址是攻擊合約本身,則合約的 tokensToSend() external function 會被調用。接下來介紹攻擊發起函數 trigger():
上面這短短 10 行代碼只做了四件事,第 38 行將 ETH 換成 token,第 39 行將上一步換出來的 token 又換回 ETH,第 40 行將一部分 ETH 換成 token,第 43-44 行將所有的 ETH 及 token 轉給 owner,也就是攻擊者錢包地址。其中,第 39 行有一個比較特別的點,只有 1/32 的 token 被換回 ETH,按照這樣的寫法肯定是會虧錢的。其實另外的 31/32 置換是在上述的 callback function 裡頭完成,程式碼如下:
從上面的程式碼可以看到 entry 會計算現在是第幾次進入 tokensToSend() 然後在第 57 行完成另外 31 次兌換,每次也是 1/32 的 token balance 。通過這 31 次重入,攻擊者可以用較好的價錢賣出 token 並且破壞 UniswapV1 pool 裡的平衡狀態,即 xy=k 的 k 值改變,因此最終 pool 裡的 ETH 會變得很少 token 很多,ETH 相對於 token 的價值極高,所以上面 trigger() 函數的第 40 行,攻擊者可以用很少的 ETH 把大部分 pool 裡的 token 買回來。下面是攻擊代碼執行的結果:
原本 pool 裡頭有 718 ETH + 19.59 imBTC,攻擊完成之後只剩下 0.013 ETH + 0.019 imBTC,幾乎是掏空了 pool 。
上述 UniswapV1 + ERC-777 的例子其實跟 TheDAO 的案例類似,都屬於同一個函數的重入,下面介紹一個多函數參與的案例,是近期發生在 BSC 上的 DeFiPIE 攻擊事件。在第一眼看到 DeFiPIE 代碼時,有一種熟悉感,與老牌 DeFi 項目 Compound 有 87% 的相似度,直覺聯想起了 2020-4-19 的 Lendf.Me $25M Better future 事件 。仔細分析之後發現,問題的根源確實如出一轍,都是通過重入攻擊造成內部記帳錯誤,達成獲利。
從上面 DeFiPIE 的 PToken 合約程式碼片段中可以看到,borrowFresh() 函數會在把資產發給 borrower 之後才將因為這次借款造成的狀態改變寫入合約的 storage,所以又是一個 effects after interactions 的案例。由於借款的上限取決於抵押資產的價值,正常情況下,某一次借款把額度用完之後,在歸還借款之前應該就借不出任何資產了。但由於上述情況數據沒有及時更新,重入後的第二次借款仍然可以使用跟第一次借款發生前一樣的額度,因此理論上是可以無限嵌套,多次利用有限額度,最終攻擊者通過清算自己以較低成本創造的負債獲利。
在 Lendf.Me 事件中,攻擊者是通過 imBTC 的 ERC-777 內建機制攔截 transferFrom() 完成重入攻擊。在 DeFiPIE ,對於 token 本身並沒有任何限制,可以隨意創建 token 合約納入借貸體系。如上圖所示,任何人都可以創建一個惡意的 EvilToken 並且人工製造一個攔截 transfer() 的機制以達成重入攻擊,下面介紹我們如何 reproduce 針對 DeFiPIE 的攻擊,由於這個攻擊比較複雜,我們會依序從各個模塊介紹,最後介紹如何組裝使用。
先從惡意 token contract 開始,現在要寫一個 ERC20 合約基本上只要繼承 OpenZeppelin 的 template 自行修改 token name 以及 symbol 就行。在上面的 X token contract 裡可以看到,第 233 行的 transfer() 我們加入了一個開關 optIn,在開關打開的情況下 (optIn == true),Lib.shellcode() 會被調用執行重入攻擊任務,這就是上面說到的人工創建攔截 transfer() 的機制。其他如 mint(), setup(), start() 就是一些方便使用的外部函數。
第二個模塊是 Lib.shellcode() 函數,也就是上述 transfer() 被攔截後發起重入攻擊的地方,在這次模擬中,我們嵌套了三層,依序調用了自行創建的 PToken (pX[1], pX[2]) 並且在第三層從 pBUSD 真正的借出了 21,000 BUSD,在這過程中實現了「三個罈子一個蓋」。
第三個模塊是獲利的關鍵,清算者 (Liquidator) 。在上面的 Liquidator.trigger() 函數可以看到,清算者使用 x 代幣調用 pX 合約的 liquidateBorrow() 獲取質押品 colleteral(即 pCAKE),隨後在第 66-67 行將 pCAKE 換成 CAKE 並轉給 owner(即 Lib 合約)。 mint() 函數的作用是提供足夠的 x 給 pX 合約,讓上述 Lib 合約能夠調用 pX.borrow() 借出資產。
接下來就是組裝上面三個模塊搭配閃電貸取得獲利,首先是創建三個 X tokens 及 Lib 合約。 Lib 合約的 constructor 創建了 Liquidator 合約。第 272-278 行鑄造了 X tokens 給 Liquidator 及 Lib,第 280-284 行將 X tokens 與 Lib 互相關聯上。第 285 行觸發 Lib 合約啟動後續流程,最後在第 288 行將獲利的 WBNB 轉給 owner(即攻擊者錢包地址)。
Lib.trigger() 實際上就做了一個兩層的 PancakeSwap 閃電貸,第 116 行可以看到 154.5 WBNB 被借出,在回調函數 pancakeCall() 裡又借了 2,900 CAKE 。主要的攻擊流程在 pancakeCall() 的後半段。
在進入第二層 pancakeCall() 時,就是真正攻擊流程的開始,首先是使用 x[0], x[1], x[2] 這三個 X tokens 創建三個 pToken (pX[0], pX[1], pX[2]) 。要創建 pToken 需要預先在 Uniswap 創建交易對並且注入流通性(第 136-142 行),pX[i] 創建完畢後,即可取出流通性(第 149 行)以方便重複使用前面借出的 WBNB,最後觸發 Liquidator 存入足夠的 x[i] 讓 pX[i] 能夠被 borrow()(第 152 行)。
第二步是觸發 pX.borrow() 前的準備工作,第 156-162 行調用了 Controller.enterMarkets() 將 pX[0], pX[1], pX[2], pCAKE 等 pToken 納入 DeFiPIE 體系,以便後續操作。第 166 行將前面閃電貸借出的 2,900 CAKE 全數注入 pCAKE 合約充當後續借貸的抵押品。
第三步打開 x[0], x[1], x[2] 的 transfer() 攔截機制(第 170-172 行),並且觸發 pX[0].borrow(),由於上述 Lib.shellcode() 的作用下,最終會拿到 21,000 BUSD,並且創造了不良資產。
第四步觸發 Liquidator 清算不良資產,獲得 CAKE 。
清償完閃電貸後,在測試環境中最終獲利 66 WBNB 。雖然數額不大,但這個案例涉及到代幣合約,清算合約等較複雜的漏洞利用過程,值得研究分享。
2021 年 8 月 30 日下午,就在這篇文章完稿之際,C.R.E.A.M. 項目傳出了遭遇攻擊損失 1,800 萬美元 。筆者短暫分析攻擊交易後發現這次攻擊與上述 DeFiPIE 遭遇的攻擊手法極其類似,決定復現此案例並加入本文。
漏洞的原理其實不需要贅述,跟 DeFiPIE 基本是一樣的,攻擊者通過 AMP 代幣自身的回調機制實現了「兩個罈子一個蓋」,用同一筆 ETH 質押品借出了 AMP 及 ETH,最終通過另一個合約清算自己的不量債務獲利。下面直接介紹攻擊合約的各個模塊以及最後的組裝使用:
首先是註冊 callback function,跟前面 UniswapV1 的情況類似,攻擊者通過 ERC-1820 合約註冊一個 tokensReceived() 函數,當有人往攻擊合約發送 AMP tokens 時,callback function 會被觸發。
而 callback function 本身就是一個針對 crETH 合約的 borrow() 調用,攻擊者的預期是在 crAMP.borrow() 的調用過程中利用同樣的抵押品再借一筆 ETH 。
第三個模塊是 Liquidator 合約,與上述 DeFiPIE 的 Liquidator 類似,在上圖 Liquidator.trigger() 函數裡,攻擊者用 AMP 清算了自身創造的不良資產獲得 crETH 抵押品(第 60 行),隨後將 crETH 換成 ETH(第 61 行),並發回給 owner,即攻擊合約。
最後就是組裝執行攻擊了,上圖是 Exp.trigger() 函數,在第 94 行先是一個 UniswapV2 的閃電貸,借出了 500 WETH,後面的 uniswapV2Call() 函數才是真正的流程。
首先是一些準備工作,由於閃電貸借的是 WETH 而 crETH 需要使用 ETH 才能鑄造,因此在第 105 行,先將 WETH 換成 ETH,接下來將換出的 ETH 全數發給 crETH 合約鑄造出 crETH cTokens 。與前面 DeFiPIE 攻擊一樣,需要調用一次 Comptroller.enterMarkets() 將 crETH 激活以便後續的操作。
第二步就是利用上面存入的 500 ETH,借出 AMP tokens,在 crAMP.borrow() 的過程中 crAMP 合約把 AMP 轉給攻擊合約,由於前面 ERC-1820 的機制,這次轉帳會被攔截並另外借出 355 ETH 。
第三步通過 Liquidator 合約清算債務,將部分質押品取回。從上圖可以看到攻擊者將前面借出的一半 AMP 發給 Liquidator,換回足夠支付閃電貸的 ETH,保留剩下的 AMP 。
最終將 ETH 都換成 WETH 支付閃電貸後,帶走 41 WETH + 9.74M AMP 。
若以「幣圈一天,人間一年」給區塊鏈世界計時,重入攻擊算是上古時期的物種了,開發者還需多從歷史上發生過的案例中吸取經驗,形成肌肉記憶,避免受到傷害。
作者簡介:Amber Group 區塊鏈安全專家吳家志博士(Dr. Wu Chiachih),目前主導集團的區塊鏈安全研究和鏈上風險管理平台開發。吳博士曾於英特爾和中國網絡公司奇虎 360 擔任高級安全研究員,隨後共同創立了知名區塊鏈安全公司 Peckshield 。
領先全球的加密金融服務提供商 Amber Group ,在全球 24/7 全天候運營,業務遍及香港,台北,首爾和溫哥華。 成立於 2017 年的 Amber Group 為 1,000 多家機構客戶提供服務,在 100 多個電子交易所中累計交易超過 1 兆美元,管理資產超過 40 億美元。作為一家綜合的加密金融服務提供商,幫助客戶獲得流動性,賺取收益並管理各種加密資產的風險。 目標通過提供投資靈活性和最大化回報來優化長期價值。
【了解更多 Amber Group】
Amber Group 官網
Whalefin 官網
Facebook
YouTube
Twitter
Medium
Telegram
Line