Cover photo

DeFi Hacked Analysis: Xcarnival Logic Flaws

在 2022Q2,因緣際會開始拜師學習 Solidity,意外的發現自己對於 DeFi Security 蠻有熱誠,也向大佬學習了很多技術和心態。

在 2023 年,我想完成的其中一件事是把 ventral.digital 的這篇 Ethereum Smart Contract Auditor's 2022 Rewind 所提到的攻擊事件進行完整的分析,也順便當作自己的筆記。

隨便數了一下,內文大概提到了超過 80 個攻擊事件吧,如果每一個攻擊事件都進行分析、寫 Reproduce,大概會耗掉兩個月時間來練功,希望可以堅持住,哈哈。

我還沒仔細看每一個攻擊事件,打算直接一邊分析一邊當作鐵人賽寫文章了,太類似的攻擊事件或屬於無法寫 Reproduce 的漏洞(像 BNB Chain hacked 那樣?),應該會直接跳過。文章撰寫上沒有順序,對什麼攻擊事件感興趣就寫什麼,不過每個大類別至少都會分析一個吧。

Intro

XCarnival 是一個在以太坊上的去中心化 NFT 市集,用戶可以在該平台上買賣 NFT、將 NFT 進行超額抵押貸款等操作。

XCarnival 官方網站頁面
XCarnival 官方網站頁面

在 2022 年 06 月 26 日,該項目遭到駭客攻擊,駭客利用了該項目智能合約所存在的邏輯漏洞,該漏洞使駭客將超額抵押的 NFT 解除抵押後,仍能進行借款,這可對系統造成壞帳,最終 XCarnival 項目方損失了 3087 顆 ETH,當時市價相當於 3.87 Million US$。

攻擊者地址:0xb7cbb4d43f1e08327a90b32a8417688c9d0b800a

主要攻擊合約:0xf70f691d30ce23786cfb3a1522cfd76d159aca8d

被攻擊的 XNFT.sol 合約:0x39360ac1239a0b98cb8076d4135d0f72b7fd9909

Details

那駭客是如何利用這個漏洞盜取 3087 ETH 呢?

我做了一張圖來分解駭客的攻擊流程,就讓我們搭配這張圖一步步食用吧:

攻擊流程
攻擊流程

1. Deploy

攻擊者在區塊高度 15028839 部署了主要攻擊合約,合約代碼當然沒有開源,不過我們可以從 Reproduce 中大致理解攻擊合約的代碼邏輯。

TxID: 0xe4f99b2fb86a317eb16f7f288fda74ab07f0ffcbf645fb3b1a6490ca23206d09

2. Transfer BAYC#5110 to Main Attack Contract

攻擊者將它從區塊高度 15028718 買來的 BAYC#5110 轉給 Main Attack Contract,準備作為 XCarnival 超額抵押貸款的抵押品。

順帶一提,攻擊者的成本是 91.65 ETH (114K US$),有錢人才玩得起的漏洞

  • 賣家收到 87.0675 ETH (95%)

  • 藝術家版稅 2.29125 ETH (2.5%)

  • OpenSea服務費 2.29125 ETH (2.5%)

買 BAYC#5110 的 Tx Details
買 BAYC#5110 的 Tx Details

買在進到下一步之前,稍稍讓我們整理一下目前為止的步驟:

  1. 區塊高度 15028718:買 BAYC#5110

  2. 區塊高度 15028839:部署 Main Attack Contract

  3. 區塊高度 15028847:把 BAYC#5110 轉給 Main Attack Contract

對應 txs 最底下 3 條交易紀錄:

攻擊者的 txs 紀錄
攻擊者的 txs 紀錄

3~8. Deploy Payload Contracts

從上圖 攻擊者的 txs 紀錄 你可以觀察到,攻擊者在轉給主攻擊合約 BAYC#5110 後,一直對主攻擊合約0xf70f691d30ce23786cfb3a1522cfd76d159aca8d 調用 0xadf6a75d 這個 Function。

那這到底是在幹嘛?我們可以透過 Blocksec Phalcon 進行 Transaction View:

第一個交易, TxId: 0x422e7b0a449deba30bfe922b5c34282efbdbf860205ff04b14fd8129c5b91433
第一個交易, TxId: 0x422e7b0a449deba30bfe922b5c34282efbdbf860205ff04b14fd8129c5b91433

將調用層級設為 Expand: 1,可以觀察到基本上主攻擊合約做的就是幾件事:

  1. 部署暫時的 Payload Contract

  2. 把 BAYC#5110 從主攻擊合約,轉移到 Payload Contract

  3. 呼叫 Payload Contract 的 0x97c1edd3 函數

0x97c1edd3 函數在做什麼呢?將它展開至第 2 層:

函數 0x97c1edd3 的調用流程
函數 0x97c1edd3 的調用流程

STATICCALL 都是向主攻擊合約讀取狀態變數,所以基本上我們可以先略過,專注看 CALL 與 XCarnival 互動的部分。

  1. setApprovalForAll() 給 XCarnival 合約權限,讓 XCarnival 可以把 BAYC NFT 轉走

  2. pledgeAndBorrow() 把 BAYC#5110 作為抵押品向 XCarnival 借錢

  3. counter() 取得剛剛抵押借款的 orderId,這邊是取到 orderId: 11

  4. withdrawNFT() 把剛剛抵押的 BAYC#5110 解除抵押,將 NFT 退還。

  5. transferFrom() 把 BAYC#5110 再轉回給主攻擊合約

    這個 Transaction 重複 1 ~ 5 四次,然後用相同的手法重複了 14 個 Transactions,建立了 orderId: 11orderId: 66

還是覺得很頭痛嗎?沒關係,這邊有 Reproduce Code 幫助你梳理攻擊者的邏輯:

Reproduce Code of 0x97c1edd3 Function
Reproduce Code of 0x97c1edd3 Function

0xb14b-TransparentUpgradeableProxy地址,其實是有漏洞的 XNFT.sol 的透明代理地址,有學過 Proxy Pattern 的你,知道 XNFT.sol 的狀態變數都是儲存在 0xb14b-TransparentUpgradeableProxy 的,所以使用者其實是在和 0xb14b-TransparentUpgradeableProxy互動。

對 DeFi Landing 稍微暸解過的你,可能會覺得很疑惑:為什麼攻擊者可以 pledgeAndBorrow() 之後,不還款就直接調用 withdrawNFT() 呢?

這其實是因為攻擊者 pledgeAndBorrow() 借出 0 元,所以只是”單純的”建立了這筆抵押紀錄 orderId

並且合約代碼也沒有不允許債務等於 0 的訂單取消抵押 NFT:

L260: 允許債務為 0 的 order 提取 NFT
L260: 允許債務為 0 的 order 提取 NFT

當順利通過 withdrawNFT() 的所有檢查,也順利取回 BAYC#5110 了,就會設定狀態變數_order.isWithdraw = true

所以攻擊者在一個 Transaction,就完成了 3 ~ 8 的步驟,分別是:

  • Deploy Payload Contract

  • Transfer BAYC#5110 from Main to Payload

  • Call: XNFT.pledgeAndBorrow() 將 BAYC#5110 作為抵押品,借 0 元

    • Transfer BAYC#5110 from Payload to XNFT

  • Call: XNFT.withdrawNFT() 取回 BAYC#5110

    • Transfer BAYC#5110 from XNFT to Payload

9~13. Exploit The Vulnerability

好的,目前我們已經知道攻擊者把 BAYC#5110 作為抵押品送到 XCarnival 裡,借款 0 元同時產生 order,然後將 BAYC#5110 解除抵押從 XCarnival 退回。

接下來我們來分析攻擊者究竟是怎麼把 ETH 盜領出來的。

第一個 start() 交易, TxId: 0xabfcfaf3620bbb2d41a3ffea6e31e93b9b5f61c061b9cfc5a53c74ebe890294d
第一個 start() 交易, TxId: 0xabfcfaf3620bbb2d41a3ffea6e31e93b9b5f61c061b9cfc5a53c74ebe890294d

可以看到攻擊者先調用了主攻擊合約的 start() 函數。

主攻擊合約再呼叫每一個 Payload Contract 的 0x2a3e7cec 函數。

Payload Contract 再向 XCarnival 的 XToken.sol 互動,調用 borrow() 函數將 36 ETH 借出來,漏洞利用結束。

由於在先前步驟,攻擊者已經透過調用 withdrawNFT() 解除抵押了 BAYC#5110,所以 BAYC#5110 實際上是在攻擊者手上,所以此時 borrow() 出來的 36 顆 ETH 沒有實質資產儲備,造成 XCarnival 壞帳,最終攻擊者反覆操作,盜走 3087 顆 ETH。

漏洞根本原因分析 - 為什麼在 BAYC#5110 解除抵押的狀況下,XCarnival 可以讓攻擊者憑空借出 36 顆以太幣?

讓我們看一下 XToken.sol 的代碼:

XToken.sol - borrow() 函數
XToken.sol - borrow() 函數

如果你對 DeFi 不陌生的話,其實可以發現 XToken.sol 似乎是 Fork Compound 的代碼。

讓我們跟進 controller.borrowAllowed() 函數,看看風控模組在確定借款之前,做了什麼校驗:

controller.borrowAllowed()
controller.borrowAllowed()

從相關的 require 語句可以觀察到,controller.borrowAllowed() 主要是檢查 BAYC 系列是否為 XCarnival 支持超額抵押貸款的 NFT 系列。

上圖可以看到,又出現一個 orderAllowed(orderId, borrower),所以我們繼續往下追:

controller.orderAllowed()
controller.orderAllowed()

在 L49,controller 向 xNFT 合約索取有關 orderId 對應的 NFT 系列地址與抵押者,require 的要求是借款人調用 borrow(orderId) 的 orderId 是合法的、借款人就是抵押人、抵押品不能是已經被清算的狀態。

getOrderDetail() 也只是簡單的返回狀態變數
getOrderDetail() 也只是簡單的返回狀態變數

神奇的地方在於 orderAllowed() 函數並沒有檢查 orderId.isWithdraw 是否等於 false,這就讓攻擊者可以拿已經 withdraw 的 orderId 來向 XCarnival 借款!

攻擊者的思路就是利用這個漏洞,透過多個 Payload Contract 來產生已經 withdraw 過的 orderId,然後反覆向 XCarnival 借款,最後就造成損失 3087 ETH 的悲劇了。

我們可以如何修復漏洞?

現在,我們已經知道漏洞成因、攻擊者的利用思路,現在我們試著寫 Patch:

文字版: https://pastebin.com/t5E5fAaF
文字版: https://pastebin.com/t5E5fAaF

Summary

Compound 是專注在 ERC20 操作的協議,項目方方 fork 代碼做成 ERC-721 通用的協議還是要仔細檢查兩種協議之間商業邏輯的差異;ERC-721 確實還蠻多坑的,比方說不怎麼 safe 的 safeMint()

在此攻擊事件發生後,我未找到 XNFT.sol 和 XToken.sol 所開源出來的 Code Repo,也沒有找到相關的審計報告,這表明項目方在 Production 上線前似乎未經過第三方代碼安全公司審計,也再次凸顯了審計的重要性。

在 2022 年 7 月 25 日,XCarnival 給 PeckShield 再次審計過,審計報告連結