Skip to content

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.


RoleAddress
Attacker0xd01ae1a708614948b2b5e0b7ab5be6afa01325c7
QBridge Contract (Ethereum)0x20e5e35ba29dc3b540a1aee781d0814d5c77bce6
QBridge Handler (BSC)0x4d8ae68fcae98bf93299548545933c0d273ba23a
ETH resourceID0x0000000000000000000000002f422fe9ea622049d6f73f81a906b9b8cff03b7f01

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:

BSCScan showing QubitFin Exploiter borrow transactions — no preparation phase

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

BSCScan voteProposal transaction — showing xETH minted from null address to attacker

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)
  • 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”

Etherscan showing multiple zero-value Deposit calls from attacker to QBridge

The attacker made multiple calls to deposit() on the Ethereum QBridge contract (0x20e5e35ba29dc3b540a1aee781d0814d5c77bce6):

Transaction HashBlockValue
0x3dfa33b5...140902340 ETH
0x58020654...140902300 ETH
0x501f8541...140902300 ETH
0xa6282e60...140902230 ETH
0x94031569...140902160 ETH
0xbdedb13d...140902100 ETH
0xeb9f622e...140902010 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:

Etherscan detail of one deposit() call — 0 Ether, calling deposit() with ETH resourceID

  • 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

The QBridge contract had two deposit functions:

QBridge contract code showing deposit() and depositETH() both emitting the same Deposit event

  • depositETH() — correct path for ETH, required msg.value > 0
  • deposit() — 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”

QBridgeHandler code showing deposit() function with whitelist check at line 128 and safeTransferFrom at line 135

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”

Contract query showing zero address is in 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.

Contract showing zero address mapped to ETH resourceID

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.

0x Protocol EOA trick documented in 2019

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”

Qore borrow() function — Compound-style, correct borrowAllowed check

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 correctborrowAllowed() 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.

Ethereum transaction showing WETH being deposited via deposit() function on Dec 1, 2021 — before resourceID was changed

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 same resourceID

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.

Code confirming the resourceID parameter was changed by an owner function

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.


FailureImpact
deposit() and depositETH() emit the same eventRelayer cannot distinguish real from fake deposits
EOA silent-success not accounted forsafeTransferFrom on zero address passes with no transfer
Zero address whitelistedThe EOA call gets past the whitelist check
Unaudited production deploymentNo external reviewer caught any of the above
No timelock on owner functionsresourceID remapped silently, instantly, invisibly
resourceID remapped before attackThe change that made exploitation possible
Blind relayer trusts eventsOff-chain service mints tokens without verifying on-chain value


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.

640_7.jpg — setResource() owner call on Dec 13, 2021 remapping ETH resourceID to zero address

Transaction: 0xe3da555d506638bd7b697c0bdf7920be8defc9a175cd35bf72fb10bc77167b66

FieldValue
Status✅ Success
Block13797391
TimestampDec-13-2021 02:31:21 PM UTC
From0xbee3971293374d0b4db7bf1654936951e5bdfe5a6
To0x20e5e35ba29dc3b540a1aee781d0814d5c77bce6 (QBridge, Ethereum)
Value0 ETH
FunctionsetResource(address handlerAddress, bytes32 resourceID, address tokenAddress)

Input arguments decoded:

#ParameterValue
0handlerAddress0x17B7163cf1Dbb6286262ddc68b553D899B893f526
1resourceID0x0000000000000000000000002f422fe9ea622049d6e73f81a906b9b8cff03b7f01
2tokenAddress0x0000000000000000000000000000000000000000

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:

640_1.jpg — voteProposal function: onlyRelayers modifier highlighted

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:

640_7.jpg — setResource() onlyOwner function

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)”

640_8.jpg — QubitFin Exploiter receiving qXETH minted from null address across multiple rounds

The attacker received multiple rounds of qXETH minted directly from the null address:

Transaction HashAmountTokenSource
0xd8bba15555...999qXETHNull Address → Exploiter
0xf6008ab482...499qXETHNull Address → Exploiter
0xcfa4379af6...140ETH (BEP-20)0xb4b778… → Exploiter
0x61ca8bc28f...190qXETHNull Address → Exploiter
0x881a68c9c9...0.1qXETHNull Address → Exploiter
0x8c5877d1b6...0.1qXETHNull Address → Exploiter

Each “Null Address → Exploiter” qXETH mint corresponds to a fake deposit() call on Ethereum.