在區塊鏈短短的歷史上發生過跟智能合約相關的攻擊事件中,重入攻擊無疑是最廣為人知的一種類型,在以太坊新創時期,2016 年 7 月的 TheDAO 事件甚至直接造成了以太坊硬分叉為以太經典 (ETC) 及現在大部分人熟知的以太坊 (ETH)。這次將由 Amber Group 區塊鏈安全團隊負責人 Chiachih Wu 博士,步步深入三種重入攻擊的基本概念、背後細節與手法。
(前情提要:乾貨 | Amber 安全專家吳博士:剖析 BSC 的閃電貸攻擊手法,如何再引發 3 個分叉項目連環爆?)
本文為數位資產投資集團 Amber Group 的投稿,作者為該集團區塊鏈安全專家吳家志博士(Chiachih Wu),同時共同創辦了區塊鏈知名安全公司派頓(Peckshield)。
在 TheDAO 事件給以太坊重擊之後,開發者也多了一些方法來防範重入攻擊,例如,Checks-Effects-Interactions [1] 以及 Reentrancy Guard [2]。
然而,許多重入攻擊仍然持續發生,攻擊的形式也從通過 fallback 函數重入同函數轉變成通過不同的外部函數進入智能合約,造成合約狀態混亂以達成有效攻擊。
本文將介紹及重現發生於 2020 年 4 月 UniswapV1 的重入攻擊,2021 年 7 月發生在 BSC 上 DeFiPIE 項目的重入攻擊,以及近期發生於 C.R.E.A.M. 項目的 AMP 代幣重入攻擊 [8]。
延伸閱讀:DeFi | 拆解Cream.Finance「ERC-777重入攻擊」,駭客獲利1,880萬美元
延伸閱讀:勿輕信熱心網友!Board Ape投資者 78 萬鎂的NFT全被盜走:MetaMask也很有問題
經典案例分析
在進入案例分析之前,我們先介紹下重入攻擊的基本概念。
下面是 Solidity 網站上面介紹重入攻擊給的簡單案例 [3],事實上這個 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() 也不會造成什麼影響。
延伸閱讀:被誤解的閃電貸:我只是一個工具….5月DeFi安全事件25起、BSC占15起
延伸閱讀:你的「紙錢包」可能不安全!私鑰盜竊問題叢生,資安新創 CYBAVO 詳列危險清單
Uniswap 遭重入攻擊
接下來,我們將介紹一個類似的案例,只是漏洞利用方式稍微複雜一點。2020 年 4 月 18 日下午,Twitter 上開始出現了關於 Uniswap imBTC pool 被攻擊的消息 [4]:
(編輯註:以下 Uniswap 攻擊模擬測試由 Amber Group「STAR-X實習計劃」 中來自卡內基梅隆大學的學生所做。目前新一期的 STAR-X 計畫已啟動。)
Uniswap 的創辦人 Hayden Adams 提到了 UniswapV1 不支持 ERC-777 並且附上了一個 ConsenSys Diligence blog 的連結[5]。事實上,這次攻擊符合 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 重現這次攻擊的程式碼:
首先是透過 ERC-1820 合約註冊 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。
DeFiPIE 重入攻擊
上述 UniswapV1 + ERC-777 的例子其實跟 TheDAO 的案例類似,都屬於同一個函數的重入,下面介紹一個多函數參與的案例,是近期發生在 BSC 上的 DeFiPIE 攻擊事件。
在第一眼看到 DeFiPIE 原始碼時,有一種熟悉感,與老牌 DeFi 項目 Compound 有 87% 的相似度,直覺聯想起了 2020-4-19 的 Lendf.Me $25M Better future 事件 [6]。仔細分析之後發現,問題的根源確實如出一轍,都是透過重入攻擊造成內部記帳錯誤,達成獲利。
從上面 DeFiPIE 的 PToken 合約程式碼片段中可以看到,borrowFresh() 函數會在把資產發給 borrower 之後才將因為這次借款造成的狀態改變寫入合約的 storage,所以又是一個 effects after interactions 的案例。
由於借款的上限取決於抵押資產的價值,正常情況下,某一次借款把額度用完之後,在歸還借款之前應該就借不出任何資產了。
但由於上述情況數據沒有及時更新,重入後的第二次借款仍然可以使用跟第一次借款發生前一樣的額度,因此理論上是可以無限嵌套,多次利用有限額度,最終攻擊者可透過清算自己以較低成本創造的負債獲利。
在 Lendf.Me 事件中,攻擊者是透過 imBTC 的 ERC-777 內建機制攔截 transferFrom() 完成重入攻擊。在 DeFiPIE ,對於 token 本身並沒有任何限制,可以隨意創建 token 合約納入借貸體系。
如上圖所示,任何人都可以創建一個惡意的 EvilToken 並且人工製造一個攔截 transfer() 的機制以達成重入攻擊,下面介紹我們如何 reproduce 針對 DeFiPIE 的攻擊,由於這個攻擊比較複雜,我們會依序從各個模組介紹,最後介紹如何組裝使用。
先從惡意 token contract 開始,現在要寫一個 ERC-20 合約基本上只要繼承 OpenZeppelin 的 template[7] 自行修改 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。雖然數額不大,但這個案例涉及到代幣合約,清算合約等較複雜的漏洞利用過程,值得研究分享。
CREAM 遭同樣攻擊
2021 年 8 月 30 日下午,就在這篇文章完稿之際,C.R.E.A.M. 項目傳出了遭遇攻擊損失 $18M [9]。筆者短暫分析攻擊交易後發現這次攻擊與上述 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。
若以『幣圈一天,人間一年』給區塊鏈世界計時,重入攻擊算是上古時期的物種了,開發者還需多從歷史上發生過的案例中吸取經驗,形成肌肉記憶,避免受到傷害。
.Reference.
[1] https://docs.soliditylang.org/en/v0.4.21/security-considerations.html#use-the-checks-effects-interactions-pattern
[2] https://docs.openzeppelin.com/contracts/4.x/api/security#ReentrancyGuard
[3] https://docs.soliditylang.org/en/v0.4.21/security-considerations.html#re-entrancy
[4] https://twitter.com/_prestwich/status/1251382098188877824
[5] https://medium.com/consensys-diligence/uniswap-audit-b90335ac007
[6] https://peckshield.medium.com/uniswap-lendf-me-hacks-root-cause-and-loss-analysis-50f3263dcc09
[7] https://github.com/OpenZeppelin/openzeppelin-contracts/blob/master/contracts/token/ERC20/ERC20.sol
[8] https://twitter.com/CreamdotFinance/status/1432249771750686721
[9] https://twitter.com/ICO_Analytics/status/1432234014878879744
📍相關報導📍
乾貨 | Amber 安全專家吳博士:剖析 BSC 的閃電貸攻擊手法,如何再引發 3 個分叉項目連環爆?
台灣Defi | Cream Finance再遭閃電貸駭客攻擊,損失1,800萬美元的 ETH, AMP
BSC首現閃電貸攻擊/技術解析 Spartan Protocol 遭駭手法,造成 3 千萬美元損失
讓動區 Telegram 新聞頻道再次強大!!立即加入獲得第一手區塊鏈、加密貨幣新聞報導。
LINE 與 Messenger 不定期為大家服務