Skip to content

QBridge 漏洞技術分析

這是根據鏈上證據、合約原始碼及攻擊發生時所擷取的截圖證據,所進行的完整技術拆解。原文為受害者社群撰寫的中文分析;此處翻譯並擴充,附上所有已提取的數據。


角色地址
攻擊者0xd01ae1a708614948b2b5e0b7ab5be6afa01325c7
QBridge 合約(Ethereum)0x20e5e35ba29dc3b540a1aee781d0814d5c77bce6
QBridge Handler(BSC)0x4d8ae68fcae98bf93299548545933c0d273ba23a
ETH resourceID0x0000000000000000000000002f422fe9ea622049d6f73f81a906b9b8cff03b7f01

當調查人員首次在 BSC 上查看攻擊者的地址時,立即發現異常:

BSCScan 顯示 QubitFin Exploiter 的借款交易——沒有準備階段

攻擊者直接呼叫了 borrow()——沒有準備、沒有閃電貸、沒有合約部署。這意味著 BSC 並非攻擊的起點。攻擊者在觸及借貸協議之前就已經持有 qXETH 代幣。

追溯這些 qXETH 代幣,發現它們是由跨鏈橋中繼器鑄造的——這代表攻擊源頭在 Ethereum


步驟 2 — BSC 上的 voteProposal 交易

Section titled “步驟 2 — BSC 上的 voteProposal 交易”

其中一筆 qXETH 鑄造交易:

交易: 0x8c5877d1b618f29f6a3622cb610ace08ca96e04d8218f587072a3f91e8545bdc

BSCScan voteProposal 交易——顯示 xETH 從空地址鑄造至攻擊者

此交易的關鍵觀察:

  • 在 QBridge BSC 合約上呼叫了 voteProposal(uint8 chainID, uint64 depositNonce, bytes32 resourceID, bytes data)
  • 發生了 3 筆代幣轉移:
    • 空地址 → 0xa6b1bb... — 59,900 Cross-Chain xETH(鑄造)
    • 0xa6b1bb...0xfd7a55... — 59,900 xETH(跨鏈轉移)
    • 空地址 → QubitFin Exploiter — 59,900 qXETH(作為抵押品鑄造)
  • 價值:0 BNB — 沒有真正的 ETH 被存入任何地方

voteProposal 函數僅能由中繼器呼叫。中繼器呼叫它是因為在 Ethereum 上偵測到了 Deposit 事件。那個事件是偽造的。


步驟 3 — 攻擊者在 Ethereum 上的存款

Section titled “步驟 3 — 攻擊者在 Ethereum 上的存款”

Etherscan 顯示攻擊者對 QBridge 發起多筆零值 Deposit 呼叫

攻擊者對 Ethereum QBridge 合約(0x20e5e35ba29dc3b540a1aee781d0814d5c77bce6)發起了多次 deposit() 呼叫:

交易雜湊區塊價值
0x3dfa33b5...140902340 ETH
0x58020654...140902300 ETH
0x501f8541...140902300 ETH
0xa6282e60...140902230 ETH
0x94031569...140902160 ETH
0xbdedb13d...140902100 ETH
0xeb9f622e...140902010 ETH

每一筆交易的 ETH 價值都是 0。方法:deposit。每一筆都觸發了一個 Deposit 事件,中繼器忠實地處理了這些事件。

其中一筆交易的詳情:

Etherscan 其中一筆 deposit() 呼叫的詳情——0 Ether,使用 ETH resourceID 呼叫 deposit()

  • 區塊: 14090216
  • 時間戳: 2022 年 1 月 27 日 21:45:32 UTC
  • 發送方: 0xd01ae1a708614948b2b5e0b7ab5be6afa01325c7(攻擊者)
  • 接收方: 0x20e5e35ba29dc3b540a1aee781d0814d5c77bce6(QBridge Ethereum)
  • 價值: 0 Ether($0.00)
  • 呼叫函數: deposit(uint8 destinationDomainID, bytes32 resourceID, bytes data)
  • 攻擊時 ETH 價格: $2,425.83

步驟 4 — 兩個函數,同一個事件

Section titled “步驟 4 — 兩個函數,同一個事件”

QBridge 合約有兩個存款函數:

QBridge 合約程式碼顯示 deposit() 和 depositETH() 都發出相同的 Deposit 事件

  • depositETH() — ETH 的正確路徑,要求 msg.value > 0
  • deposit() — 設計用於 ERC-20 代幣,不需要 ETH

兩者發出的 Deposit 事件類型完全相同。 中繼器監聽 Deposit 事件,無法區分是哪個函數觸發的。


步驟 5 — Handler 程式碼與白名單

Section titled “步驟 5 — Handler 程式碼與白名單”

QBridgeHandler 程式碼顯示 deposit() 函數,第 128 行有白名單檢查,第 135 行有 safeTransferFrom

QBridgeHandler.deposit() 函數:

function deposit(
bytes32 resourceID,
address depositer,
bytes calldata data
) external override {
address tokenAddress = _resourceIDToTokenContractAddress[resourceID]; // get token address
require(_contractWhitelist[tokenAddress], "not whitelisted"); // line 128: whitelist check
// ...
ERC20Interface(tokenAddress).safeTransferFrom(depositer, address(this), amount); // line 135
}

對於 ETH 的 resourceID,合約回傳 tokenAddress = 0x0000000000000000000000000000000000000000(零地址)。


合約查詢顯示零地址在白名單中

調查人員查詢合約:零地址是否在白名單中?

是的。 確實在。

這是必要的,因為 depositETH() 也使用相同的白名單機制,以零地址作為 ETH 的佔位符。但這造成了一個致命的副作用:零地址在 deposit() 中也通過了白名單檢查。

合約顯示零地址映射到 ETH resourceID

ETH resourceID 被映射到 0x0000000000000000000000000000000000000000——零地址——這是透過查詢 resourceIDToTokenContractAddress 確認的。


tokenAddress = 0x0000...0000 時,handler 執行了:

ERC20Interface(tokenAddress).safeTransferFrom(depositer, address(this), amount);

零地址是一個 EOA——外部擁有帳戶,沒有合約程式碼。

在 EVM 中:對一個沒有合約程式碼的地址呼叫任何函數,都會靜默成功——不會回滾、不會報錯、不會實際執行。

safeTransferFrom 呼叫「成功了」。什麼都沒有移動。沒有 ETH,沒有代幣。但程式碼繼續執行,彷彿一切正常——並發出了一個合法的 Deposit 事件。

0x Protocol 於 2019 年記錄的 EOA 技巧

這個技巧早在 0x Protocol 2019 年的安全更新中就已公開記錄。Mound Inc. 在 2022 年部署其跨鏈橋時並未考慮到這一點。


步驟 8 — 借貸函數本身是正確的,但為時已晚

Section titled “步驟 8 — 借貸函數本身是正確的,但為時已晚”

Qore borrow() 函數——Compound 風格,正確的 borrowAllowed 檢查

function borrow(address qToken, uint amount) external override onlyListedMarket(qToken) nonReentrant {
_enterMarket(qToken, msg.sender);
require(IQValidator(qValidator).borrowAllowed(qToken, msg.sender, amount), "Qore: cannot borrow");
IQToken(payable(qToken)).borrow(msg.sender, amount);
qDistributor.notifyBorrowUpdated(qToken, msg.sender);
}

借貸合約本身是正確的——borrowAllowed() 正確地檢查了抵押品價值。但 qXETH 代幣在鏈上看起來是合法的。詐騙已經在跨鏈橋層面發生。當 borrow() 執行時,攻擊者已持有看似真實但實際上毫無支撐的抵押品。


步驟 9 — 關鍵證據:攻擊前的參數變更

Section titled “步驟 9 — 關鍵證據:攻擊前的參數變更”

這是從未被解釋的細節。

Ethereum 交易顯示在 2021 年 12 月 1 日透過 deposit() 函數存入 WETH——在 resourceID 被更改之前

在攻擊發生之前,有人調查了 deposit() 函數的歷史。他們發現:

2021 年 12 月 1 日 — 一筆合法交易(0xc85df3fbfee7a8541e9fd354e98df78dca21ac6be40b929ff5da52ea8eab80de):

  • 區塊:13719888
  • 發送方:0xbee3971293374d0b4db7bf1654936951e5bdfe5a6
  • 接收方:QBridge 合約 0x20e5e35ba29dc3b540a1aee781d0814d5c77bce6
  • 轉移的代幣:0.1 WETH(當時價值 $241.81)
  • 函數:使用相同 resourceIDdeposit()

在 2021 年 12 月,使用該 resourceIDdeposit() 轉移了真正的 WETH——因為 resourceID 映射到了真正的 WETH 合約地址。

在 2021 年 12 月到 2022 年 1 月 28 日之間——有人呼叫了一個僅限擁有者的函數,將 resourceID 的映射從 WETH 合約地址重新指向了零地址。

程式碼確認 resourceID 參數由擁有者函數所更改

只有合約擁有者才能進行此更改。不需要時間鎖。沒有公告。這一個不可見的參數變更,就是將一座正常運作的跨鏈橋變成可被利用的關鍵。


缺陷影響
deposit()depositETH() 發出相同的事件中繼器無法區分真實與偽造的存款
未考慮 EOA 靜默成功的特性對零地址的 safeTransferFrom 在無轉移的情況下通過
零地址被列入白名單EOA 呼叫通過了白名單檢查
未經審計的生產環境部署沒有外部審查員發現上述任何問題
擁有者函數無時間鎖resourceID 被靜默地、即時地、不可見地重新映射
攻擊前 resourceID 被重新映射使漏洞利用成為可能的關鍵變更
盲目信任事件的中繼器鏈下服務在未驗證鏈上價值的情況下鑄造代幣


關鍵證據交易 — 2021 年 12 月 13 日呼叫的 setResource()

Section titled “關鍵證據交易 — 2021 年 12 月 13 日呼叫的 setResource()”

這是最關鍵的證據。

640_7.jpg — 2021 年 12 月 13 日 setResource() 擁有者呼叫,將 ETH resourceID 重新映射至零地址

交易: 0xe3da555d506638bd7b697c0bdf7920be8defc9a175cd35bf72fb10bc77167b66

欄位
狀態✅ 成功
區塊13797391
時間戳2021 年 12 月 13 日 14:31:21 UTC
發送方0xbee3971293374d0b4db7bf1654936951e5bdfe5a6
接收方0x20e5e35ba29dc3b540a1aee781d0814d5c77bce6(QBridge,Ethereum)
價值0 ETH
函數setResource(address handlerAddress, bytes32 resourceID, address tokenAddress)

解碼後的輸入參數:

#參數
0handlerAddress0x17B7163cf1Dbb6286262ddc68b553D899B893f526
1resourceID0x0000000000000000000000002f422fe9ea622049d6e73f81a906b9b8cff03b7f01
2tokenAddress0x0000000000000000000000000000000000000000

setResource() 是一個 onlyOwner 函數。 只有 Mound Inc. 團隊才能呼叫。

在 2021 年 12 月 13 日——駭客事件發生前 45 天——合約擁有者刻意將 ETH resourceIDtokenAddress 映射更改為零地址。在此呼叫之前,同一 resourceID 指向 WETH 代幣合約。在此呼叫之後,它指向了虛無——從而啟用了 EOA 靜默成功的漏洞利用。

先前的 voteProposal 程式碼顯示只有中繼器才能呼叫:

640_1.jpg — voteProposal 函數:onlyRelayers 修飾器被標示

function voteProposal(uint8 originDomainID, uint64 depositNonce, bytes32 resourceID, bytes calldata data)
external onlyRelayers notPaused {
address handlerAddress = resourceIDToHandlerAddress[resourceID];
require(handlerAddress != address(0), "QBridge: invalid handler");
// ...
if (proposal._status == ProposalStatus.Passed) {
executeProposal(originDomainID, depositNonce, resourceID, data, true);
return;
}
// ...
if (proposal._status == ProposalStatus.Inactive) {
proposal = Proposal({
status: ProposalStatus.Active,
_yesVotes: 0,
_yesVotesTotal: 0,
_proposedBlock: uint40(block.number)
});

以及改變一切的 setResource() 程式碼:

640_7.jpg — setResource() onlyOwner 函數

function setResource(address handlerAddress, bytes32 resourceID, address tokenAddress) external onlyOwner {
resourceIDToHandlerAddress[resourceID] = handlerAddress;
IQBridgeHandler(handlerAddress).setResource(resourceID, tokenAddress);
}

640_8.jpg — QubitFin Exploiter 接收從空地址跨多輪鑄造的 qXETH

攻擊者接收了多輪直接從空地址鑄造的 qXETH:

交易雜湊數量代幣來源
0xd8bba15555...999qXETH空地址 → 攻擊者
0xf6008ab482...499qXETH空地址 → 攻擊者
0xcfa4379af6...140ETH (BEP-20)0xb4b778… → 攻擊者
0x61ca8bc28f...190qXETH空地址 → 攻擊者
0x881a68c9c9...0.1qXETH空地址 → 攻擊者
0x8c5877d1b6...0.1qXETH空地址 → 攻擊者

每一筆「空地址 → 攻擊者」的 qXETH 鑄造,都對應著 Ethereum 上的一筆偽造 deposit() 呼叫。