Technical Analysis of the QBridge Exploit
This is a full technical breakdown of the exploit based on on-chain evidence, contract source code, and screenshot evidence captured at the time of the attack. Original Chinese-language analysis by the victims community; translated and expanded here with all extracted data.
Key Addresses
Section titled “Key Addresses”| Role | Address |
|---|---|
| Attacker | 0xd01ae1a708614948b2b5e0b7ab5be6afa01325c7 |
| QBridge Contract (Ethereum) | 0x20e5e35ba29dc3b540a1aee781d0814d5c77bce6 |
| QBridge Handler (BSC) | 0x4d8ae68fcae98bf93299548545933c0d273ba23a |
| ETH resourceID | 0x0000000000000000000000002f422fe9ea622049d6f73f81a906b9b8cff03b7f01 |
Step 1 — BSC Was Not the First Crime Scene
Section titled “Step 1 — BSC Was Not the First Crime Scene”When investigators first looked at the attacker’s address on BSC, something was immediately wrong:

The attacker went straight to borrow() — no preparation, no flash loan, no contract deployment. This meant BSC was not where the attack started. The attacker already had qXETH tokens before they touched the lending protocol.
Tracing those qXETH tokens back revealed they were minted by the bridge relayer — which meant the origin was Ethereum.
Step 2 — The voteProposal Transaction on BSC
Section titled “Step 2 — The voteProposal Transaction on BSC”One of the qXETH minting transactions:
Transaction: 0x8c5877d1b618f29f6a3622cb610ace08ca96e04d8218f587072a3f91e8545bdc

Key observations from this transaction:
- Called
voteProposal(uint8 chainID, uint64 depositNonce, bytes32 resourceID, bytes data)on the QBridge BSC contract - 3 token transfers occurred:
- Null address →
0xa6b1bb...— 59,900 Cross-Chain xETH (minted) 0xa6b1bb...→0xfd7a55...— 59,900 xETH (bridged)- Null address → QubitFin Exploiter — 59,900 qXETH (minted for collateral)
- Null address →
- Value: 0 BNB — no real ETH was deposited anywhere
The voteProposal function was only callable by the relayer. The relayer called it because it saw a Deposit event on Ethereum. That event was fake.
Step 3 — The Attacker’s Deposits on Ethereum
Section titled “Step 3 — The Attacker’s Deposits on Ethereum”
The attacker made multiple calls to deposit() on the Ethereum QBridge contract (0x20e5e35ba29dc3b540a1aee781d0814d5c77bce6):
| Transaction Hash | Block | Value |
|---|---|---|
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 |
Every single transaction had 0 ETH value. Method: deposit. Each triggered a Deposit event that the relayer faithfully processed.
One of these transactions in detail:

- Block: 14090216
- Timestamp: Jan-27-2022 09:45:32 PM UTC
- From:
0xd01ae1a708614948b2b5e0b7ab5be6afa01325c7(attacker) - To:
0x20e5e35ba29dc3b540a1aee781d0814d5c77bce6(QBridge Ethereum) - Value: 0 Ether ($0.00)
- Function called:
deposit(uint8 destinationDomainID, bytes32 resourceID, bytes data) - ETH price at attack time: $2,425.83
Step 4 — The Two Functions, One Event
Section titled “Step 4 — The Two Functions, One Event”The QBridge contract had two deposit functions:

depositETH()— correct path for ETH, requiredmsg.value > 0deposit()— designed for ERC-20 tokens, no ETH required
Both emitted the exact same Deposit event type. The relayer listened for Deposit events and could not distinguish which function had triggered them.
Step 5 — The Handler Code and the Whitelist
Section titled “Step 5 — The Handler Code and the Whitelist”
The QBridgeHandler.deposit() function:
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}For ETH’s resourceID, the contract returned tokenAddress = 0x0000000000000000000000000000000000000000 (the zero address).
Step 6 — Zero Address Was On the Whitelist
Section titled “Step 6 — Zero Address Was On the Whitelist”
Investigators queried the contract: Was the zero address whitelisted?
Yes. It was.
This was necessary because depositETH() also used the same whitelist mechanism with the zero address as ETH’s placeholder. But it created a fatal side effect: the zero address passed the whitelist check in deposit() too.

The ETH resourceID was mapped to 0x0000000000000000000000000000000000000000 — the zero address — as confirmed by querying resourceIDToTokenContractAddress.
Step 7 — Calling an EOA Silently Succeeds
Section titled “Step 7 — Calling an EOA Silently Succeeds”With tokenAddress = 0x0000...0000, the handler executed:
ERC20Interface(tokenAddress).safeTransferFrom(depositer, address(this), amount);The zero address is an EOA — an Externally Owned Account with no contract code.
In the EVM: calling any function on an address with no contract code silently succeeds — no revert, no error, no actual execution.
The safeTransferFrom call “succeeded.” Nothing moved. No ETH, no tokens. But the code continued as if everything was fine — and emitted a legitimate Deposit event.

This exact trick was publicly documented in a 0x Protocol security update in 2019. Mound Inc. deployed their bridge in 2022 without accounting for it.
Step 8 — The Borrow Function Was Correct, But Too Late
Section titled “Step 8 — The Borrow Function Was Correct, But Too Late”
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);}The lending contract was correct — borrowAllowed() properly checked collateral value. But qXETH tokens appeared legitimate on-chain. The fraud had already happened at the bridge. By the time borrow() ran, the attacker held real-looking collateral backed by nothing.
Step 9 — The Smoking Gun: Pre-Attack Parameter Change
Section titled “Step 9 — The Smoking Gun: Pre-Attack Parameter Change”This is the detail that has never been explained.

Before the attack, someone investigated the history of the deposit() function. They found:
December 1, 2021 — a legitimate transaction (0xc85df3fbfee7a8541e9fd354e98df78dca21ac6be40b929ff5da52ea8eab80de):
- Block: 13719888
- From:
0xbee3971293374d0b4db7bf1654936951e5bdfe5a6 - To: QBridge Contract
0x20e5e35ba29dc3b540a1aee781d0814d5c77bce6 - Tokens Transferred: 0.1 WETH ($241.81 at that time)
- Function:
deposit()with the sameresourceID
In December 2021, deposit() with that resourceID transferred real WETH — because resourceID was mapped to the real WETH contract address.
Sometime between December 2021 and January 28, 2022 — an owner-only function was called to reassign the resourceID mapping from WETH’s contract address to the zero address.

Only the contract owner could make this change. It required no timelock. There was no announcement. This single invisible parameter change is what turned a working bridge into an exploitable one.
Summary: Seven Simultaneous Failures
Section titled “Summary: Seven Simultaneous Failures”| Failure | Impact |
|---|---|
deposit() and depositETH() emit the same event | Relayer cannot distinguish real from fake deposits |
| EOA silent-success not accounted for | safeTransferFrom on zero address passes with no transfer |
| Zero address whitelisted | The EOA call gets past the whitelist check |
| Unaudited production deployment | No external reviewer caught any of the above |
| No timelock on owner functions | resourceID remapped silently, instantly, invisibly |
resourceID remapped before attack | The change that made exploitation possible |
| Blind relayer trusts events | Off-chain service mints tokens without verifying on-chain value |
External References
Section titled “External References”- Attack transaction (BSC):
0x8c5877d1... - Attacker on Etherscan:
0xd01ae1... - QBridge (Ethereum):
0x20e5e3... - EOA zero-address trick (0x Protocol, 2019): blog.0xproject.com
- Original Chinese analysis: qbt.wiki
The Smoking Gun Transaction — setResource() Called December 13, 2021
Section titled “The Smoking Gun Transaction — setResource() Called December 13, 2021”This is the most critical piece of evidence.

Transaction: 0xe3da555d506638bd7b697c0bdf7920be8defc9a175cd35bf72fb10bc77167b66
| Field | Value |
|---|---|
| Status | ✅ Success |
| Block | 13797391 |
| Timestamp | Dec-13-2021 02:31:21 PM UTC |
| From | 0xbee3971293374d0b4db7bf1654936951e5bdfe5a6 |
| To | 0x20e5e35ba29dc3b540a1aee781d0814d5c77bce6 (QBridge, Ethereum) |
| Value | 0 ETH |
| Function | setResource(address handlerAddress, bytes32 resourceID, address tokenAddress) |
Input arguments decoded:
| # | Parameter | Value |
|---|---|---|
| 0 | handlerAddress | 0x17B7163cf1Dbb6286262ddc68b553D899B893f526 |
| 1 | resourceID | 0x0000000000000000000000002f422fe9ea622049d6e73f81a906b9b8cff03b7f01 |
| 2 | tokenAddress | 0x0000000000000000000000000000000000000000 |
setResource() is an onlyOwner function. Only the Mound Inc. team could call it.
On December 13, 2021 — 45 days before the hack — the contract owner deliberately changed the tokenAddress mapping for the ETH resourceID to the zero address. Before this call, that same resourceID pointed to the WETH token contract. After this call, it pointed to nothing — enabling the EOA silent-success exploit.
The prior voteProposal code showing only relayers can call it:

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) });And the setResource() code that changed everything:

function setResource(address handlerAddress, bytes32 resourceID, address tokenAddress) external onlyOwner { resourceIDToHandlerAddress[resourceID] = handlerAddress; IQBridgeHandler(handlerAddress).setResource(resourceID, tokenAddress);}The qXETH Minting Sequence (BSC Token Transfers)
Section titled “The qXETH Minting Sequence (BSC Token Transfers)”
The attacker received multiple rounds of qXETH minted directly from the null address:
| Transaction Hash | Amount | Token | Source |
|---|---|---|---|
0xd8bba15555... | 999 | qXETH | Null Address → Exploiter |
0xf6008ab482... | 499 | qXETH | Null Address → Exploiter |
0xcfa4379af6... | 140 | ETH (BEP-20) | 0xb4b778… → Exploiter |
0x61ca8bc28f... | 190 | qXETH | Null Address → Exploiter |
0x881a68c9c9... | 0.1 | qXETH | Null Address → Exploiter |
0x8c5877d1b6... | 0.1 | qXETH | Null Address → Exploiter |
Each “Null Address → Exploiter” qXETH mint corresponds to a fake deposit() call on Ethereum.