QBridge 漏洞利用技术分析
这是基于链上证据、合约源代码以及攻击发生时截取的截图证据所进行的完整技术拆解。原始中文分析由受害者社区撰写;此处翻译并扩展,包含所有提取的数据。
| 角色 | 地址 |
|---|---|
| 攻击者 | 0xd01ae1a708614948b2b5e0b7ab5be6afa01325c7 |
| QBridge 合约(Ethereum) | 0x20e5e35ba29dc3b540a1aee781d0814d5c77bce6 |
| QBridge Handler(BSC) | 0x4d8ae68fcae98bf93299548545933c0d273ba23a |
| ETH resourceID | 0x0000000000000000000000002f422fe9ea622049d6f73f81a906b9b8cff03b7f01 |
第一步——BSC 并非第一现场
Section titled “第一步——BSC 并非第一现场”当调查人员首次查看攻击者在 BSC 上的地址时,发现了明显的异常:

攻击者直接调用了 borrow()——没有准备工作、没有闪电贷、没有部署合约。这意味着攻击并非从 BSC 开始。攻击者在接触借贷协议之前就已经持有 qXETH 代币。
追溯这些 qXETH 代币的来源,发现它们是由跨链桥中继器铸造的——这意味着攻击起源于 Ethereum。
第二步——BSC 上的 voteProposal 交易
Section titled “第二步——BSC 上的 voteProposal 交易”其中一笔 qXETH 铸造交易:
交易: 0x8c5877d1b618f29f6a3622cb610ace08ca96e04d8218f587072a3f91e8545bdc

该交易的关键观察:
- 在 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 上的存款操作”
攻击者对 Ethereum QBridge 合约(0x20e5e35ba29dc3b540a1aee781d0814d5c77bce6)进行了多次 deposit() 调用:
| 交易哈希 | 区块 | 金额 |
|---|---|---|
0x3dfa33b5... | 14090234 | 0 ETH |
0x58020654... | 14090230 | 0 ETH |
0x501f8541... | 14090230 | 0 ETH |
0xa6282e60... | 14090223 | 0 ETH |
0x94031569... | 14090216 | 0 ETH |
0xbdedb13d... | 14090210 | 0 ETH |
0xeb9f622e... | 14090201 | 0 ETH |
每笔交易的 ETH 金额都为 0。调用方法:deposit。每笔交易都触发了一个 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 合约有两个存款函数:

depositETH()— ETH 的正确路径,要求msg.value > 0deposit()— 为 ERC-20 代币设计,不需要 ETH
两者发出的 Deposit 事件类型完全相同。 中继器监听 Deposit 事件,无法区分是哪个函数触发的。
第五步——Handler 代码与白名单
Section titled “第五步——Handler 代码与白名单”
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(零地址)。
第六步——零地址在白名单中
Section titled “第六步——零地址在白名单中”
调查人员查询了合约:零地址是否在白名单中?
是的。 在白名单中。
这是必要的,因为 depositETH() 同样使用了以零地址作为 ETH 占位符的白名单机制。但这产生了致命的副作用:零地址同样通过了 deposit() 中的白名单检查。

ETH resourceID 映射到了 0x0000000000000000000000000000000000000000——零地址——这通过查询 resourceIDToTokenContractAddress 得到了确认。
第七步——调用 EOA 会静默成功
Section titled “第七步——调用 EOA 会静默成功”由于 tokenAddress = 0x0000...0000,handler 执行了:
ERC20Interface(tokenAddress).safeTransferFrom(depositer, address(this), amount);零地址是一个 EOA——外部拥有账户,没有合约代码。
在 EVM 中:对没有合约代码的地址调用任何函数都会静默成功——不会回滚、不会报错、不会实际执行任何操作。
safeTransferFrom 调用”成功”了。什么都没有转移。没有 ETH,没有代币。但代码继续执行,仿佛一切正常——并发出了一个看似合法的 Deposit 事件。

这个技巧在 0x Protocol 2019年的安全更新中已被公开记录。Mound Inc. 在2022年部署跨链桥时并未考虑到这一点。
第八步——借贷函数本身是正确的,但为时已晚
Section titled “第八步——借贷函数本身是正确的,但为时已晚”
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 “第九步——关键证据:攻击前的参数变更”这是从未被解释的关键细节。

在攻击发生前,有人调查了 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 合约地址重新指向了零地址。

只有合约所有者才能进行此更改。不需要时间锁。没有公告。这个单一的、不可见的参数变更,将一座正常运作的跨链桥变成了可被利用的漏洞。
总结:七个同时发生的失败
Section titled “总结:七个同时发生的失败”| 失败点 | 影响 |
|---|---|
deposit() 和 depositETH() 发出相同的事件 | 中继器无法区分真实存款和伪造存款 |
| 未考虑 EOA 静默成功问题 | 对零地址的 safeTransferFrom 调用通过但未实际转账 |
| 零地址在白名单中 | EOA 调用通过了白名单检查 |
| 未经审计的生产环境部署 | 没有外部审计人员发现上述任何问题 |
| 所有者函数没有时间锁 | resourceID 被静默地、即时地、不可见地重新映射 |
攻击前 resourceID 被重新映射 | 使漏洞利用成为可能的关键变更 |
| 中继器盲目信任事件 | 链下服务在未验证链上价值的情况下铸造代币 |
- 攻击交易(BSC):
0x8c5877d1... - 攻击者 Etherscan 页面:
0xd01ae1... - QBridge(Ethereum):
0x20e5e3... - EOA 零地址技巧(0x Protocol,2019): blog.0xproject.com
- 原始中文分析: qbt.wiki
关键证据交易——2021年12月13日调用的 setResource()
Section titled “关键证据交易——2021年12月13日调用的 setResource()”这是最关键的证据。

交易: 0xe3da555d506638bd7b697c0bdf7920be8defc9a175cd35bf72fb10bc77167b66
| 字段 | 值 |
|---|---|
| 状态 | ✅ 成功 |
| 区块 | 13797391 |
| 时间戳 | 2021年12月13日 UTC 14:31:21 |
| 发送方 | 0xbee3971293374d0b4db7bf1654936951e5bdfe5a6 |
| 接收方 | 0x20e5e35ba29dc3b540a1aee781d0814d5c77bce6(QBridge,Ethereum) |
| 金额 | 0 ETH |
| 函数 | setResource(address handlerAddress, bytes32 resourceID, address tokenAddress) |
解码后的输入参数:
| # | 参数 | 值 |
|---|---|---|
| 0 | handlerAddress | 0x17B7163cf1Dbb6286262ddc68b553D899B893f526 |
| 1 | resourceID | 0x0000000000000000000000002f422fe9ea622049d6e73f81a906b9b8cff03b7f01 |
| 2 | tokenAddress | 0x0000000000000000000000000000000000000000 |
setResource() 是一个 onlyOwner 函数。 只有 Mound Inc. 团队才能调用它。
2021年12月13日——黑客攻击发生前45天——合约所有者刻意将 ETH resourceID 的 tokenAddress 映射更改为零地址。在此调用之前,该 resourceID 指向 WETH 代币合约。此调用之后,它指向空值——使得 EOA 静默成功漏洞利用成为可能。
此前的 voteProposal 代码显示只有中继器才能调用它:

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() 代码:

function setResource(address handlerAddress, bytes32 resourceID, address tokenAddress) external onlyOwner { resourceIDToHandlerAddress[resourceID] = handlerAddress; IQBridgeHandler(handlerAddress).setResource(resourceID, tokenAddress);}qXETH 铸造序列(BSC 代币转账)
Section titled “qXETH 铸造序列(BSC 代币转账)”
攻击者通过多轮接收了从空地址直接铸造的 qXETH:
| 交易哈希 | 数量 | 代币 | 来源 |
|---|---|---|---|
0xd8bba15555... | 999 | qXETH | 空地址 → 攻击者 |
0xf6008ab482... | 499 | qXETH | 空地址 → 攻击者 |
0xcfa4379af6... | 140 | ETH (BEP-20) | 0xb4b778… → 攻击者 |
0x61ca8bc28f... | 190 | qXETH | 空地址 → 攻击者 |
0x881a68c9c9... | 0.1 | qXETH | 空地址 → 攻击者 |
0x8c5877d1b6... | 0.1 | qXETH | 空地址 → 攻击者 |
每一笔”空地址 → 攻击者”的 qXETH 铸造都对应一笔在 Ethereum 上伪造的 deposit() 调用。