Skip to content

QBridge 漏洞利用技术分析

这是基于链上证据、合约源代码以及攻击发生时截取的截图证据所进行的完整技术拆解。原始中文分析由受害者社区撰写;此处翻译并扩展,包含所有提取的数据。


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

当调查人员首次查看攻击者在 BSC 上的地址时,发现了明显的异常:

BSCScan 显示 QubitFin Exploiter 的借贷交易——没有准备阶段

攻击者直接调用了 borrow()——没有准备工作、没有闪电贷、没有部署合约。这意味着攻击并非从 BSC 开始。攻击者在接触借贷协议之前就已经持有 qXETH 代币。

追溯这些 qXETH 代币的来源,发现它们是由跨链桥中继器铸造的——这意味着攻击起源于 Ethereum


第二步——BSC 上的 voteProposal 交易

Section titled “第二步——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 事件。而该事件是伪造的。


第三步——攻击者在 Ethereum 上的存款操作

Section titled “第三步——攻击者在 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日 UTC 21:45:32
  • 发送方: 0xd01ae1a708614948b2b5e0b7ab5be6afa01325c7(攻击者)
  • 接收方: 0x20e5e35ba29dc3b540a1aee781d0814d5c77bce6(QBridge Ethereum)
  • 金额: 0 Ether($0.00)
  • 调用函数: deposit(uint8 destinationDomainID, bytes32 resourceID, bytes data)
  • 攻击时 ETH 价格: $2,425.83

第四步——两个函数,一个事件

Section titled “第四步——两个函数,一个事件”

QBridge 合约有两个存款函数:

QBridge 合约代码显示 deposit() 和 depositETH() 都发出相同的 Deposit 事件

  • depositETH() — ETH 的正确路径,要求 msg.value > 0
  • deposit() — 为 ERC-20 代币设计,不需要 ETH

两者发出的 Deposit 事件类型完全相同。 中继器监听 Deposit 事件,无法区分是哪个函数触发的。


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年部署跨链桥时并未考虑到这一点。


第八步——借贷函数本身是正确的,但为时已晚

Section titled “第八步——借贷函数本身是正确的,但为时已晚”

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() 运行时,攻击者持有的抵押品看起来真实,实际上毫无支撑。


第九步——关键证据:攻击前的参数变更

Section titled “第九步——关键证据:攻击前的参数变更”

这是从未被解释的关键细节。

Ethereum 交易显示2021年12月1日通过 deposit() 函数存入 WETH——在 resourceID 被更改之前

在攻击发生前,有人调查了 deposit() 函数的历史记录。他们发现:

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

  • 区块:13719888
  • 发送方:0xbee3971293374d0b4db7bf1654936951e5bdfe5a6
  • 接收方:QBridge 合约 0x20e5e35ba29dc3b540a1aee781d0814d5c77bce6
  • 代币转账:0.1 WETH(当时价值 $241.81)
  • 函数:使用相同 resourceID 调用 deposit()

在2021年12月,使用该 resourceID 调用 deposit() 转移了真实的 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日 UTC 14:31:21
发送方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() 调用。