All audits
FailedBSCIndependent

Qubit Bridge Deposit (2022)

Smart contract security audit by TierZero · 2023-07-15

Independent research. This is an unsolicited security analysis published by TierZero — not a commissioned audit, and no certificate is issued. It reflects our own review of public code and on-chain events.

Findings summary

1
Critical
2
High
2
Medium
1
Low
1
Informational

Independent research — not a commissioned audit. This report analyzes a publicly documented incident using information available from post-incident disclosures and on-chain data. TierZero was not involved with Qubit Finance and did not have access to non-public code or internal records.

What the protocol did

Qubit Finance ran a lending market on BNB Smart Chain and operated QBridge, a bridge that let users lock ETH on Ethereum and receive a wrapped representation — xETH (also referenced as qXETH) — on BSC, which could then be supplied as collateral to borrow other assets from Qubit's money market. Like most lock-and-mint bridges of that era, the trust model reduced to one question: does the contract that emits "deposit confirmed" events actually hold the funds it claims to hold? In late January 2022, an attacker found that the answer was no, and walked away with a sum widely reported at roughly $80 million in assets drained from Qubit's lending pools — one of the larger bridge-related losses of that cycle.

The vulnerability

QBridge's Ethereum-side contract had a legitimate depositETH() entry point for native ETH, which correctly required msg.value to match the recorded deposit amount. It also had a more general deposit(token, amount) entry point intended for ERC-20 tokens. The bug sat in how that general function treated the zero address. Address 0x0 was used elsewhere in the codebase as a sentinel meaning "this is native ETH, not a token" — a common pattern in bridges and DEX routers. The generic deposit function was supposed to reject that sentinel and force callers through depositETH() instead, but the validation was missing or misapplied, so calling deposit() with token = address(0) was accepted as valid, skipped the ERC-20 transferFrom (because there is no ERC-20 contract at the zero address), and still emitted the same Deposit event that the off-chain relayer used as its sole signal to mint xETH on BSC.

The flawed pattern, simplified:

function deposit(address token, uint256 amount, string memory to) public payable {
    require(supportedTokens[token], "unsupported token");
    if (token != address(0)) {
        IERC20(token).safeTransferFrom(msg.sender, address(this), amount);
    }
    // BUG: token == address(0) is meant to be routed to depositETH(),
    // never reached here — but validation to enforce that is missing,
    // and no msg.value check exists in this branch.
    emit Deposit(msg.sender, token, amount, to, block.timestamp);
}

supportedTokens[address(0)] had been set to true for internal accounting reasons, which is exactly the kind of state that looks harmless in isolation and becomes a live bypass once combined with a missing branch check. The relayer had no independent way to distinguish a real ETH deposit from this fabricated one — it trusted the event, not the balance.

How the exploit worked

  1. The attacker called deposit() on the Ethereum-side QBridge contract with token = address(0) and a large amount, sending no ETH (msg.value = 0).
  2. The contract accepted the call, skipped any transfer since token wasn't a real ERC-20 contract, and emitted a Deposit event as if a genuine ETH deposit had occurred.
  3. Qubit's relayer/validator infrastructure watched for that event and, on BSC, minted the corresponding amount of xETH to the attacker's address — with no on-chain proof of the underlying ETH ever existing.
  4. The attacker repeated the call several times, then supplied the freshly minted xETH as collateral inside Qubit's BSC lending market and borrowed out other blue-chip assets (BNB, USDC, and others) up to the market's available liquidity.
  5. Borrowed assets were bridged out and swapped, and Qubit was left holding uncollateralized debt against xETH that had never been backed by real ETH.

How an audit catches this

This class of bug lives at the boundary between two systems that don't share atomic state — an L1 lock contract and an L2/sidechain mint contract, connected by a relayer that trusts events rather than verifying balances. Our review process for bridge and cross-chain minting code specifically tests:

  • Every code path that can emit a "funds received" event is enumerated and each is checked for a hard, non-bypassable requirement that value actually moved (msg.value for native assets, a successful transferFrom return for tokens, and a pre/post balance diff — not just a boolean return value).
  • Sentinel values (address(0), type(uint256).max, empty strings) used as markers are grepped across the whole codebase and traced through every function that branches on them, not just the ones the developer remembers.
  • Fuzzing and differential testing between the "intended" deposit function and any other function that can reach the same event-emission code, to catch exactly this kind of unreachable-in-theory, reachable-in-practice branch.
  • An explicit invariant: total minted wrapped supply on the destination chain must never exceed verified locked collateral on the source chain, checked both statically and via a monitoring rule the client can run in production.

This is the kind of finding that a focused smart contract audit is built to catch before deployment, and it's also why we push clients toward a second, independent code review pass specifically on bridge and relayer trust boundaries — these are exactly the seams where a single missing require turns into a nine-figure loss. The same "don't trust the event, verify the state" discipline applies whenever one program's logic depends on another program's account state without direct validation, a failure mode discussed in more depth in our piece on cross-program invocation risk in Solana DeFi, and the broader question of how approval and transfer semantics get silently bypassed is covered in our analysis of Permit2 gasless approvals and EVM sniping bots.

Remediation

The durable fix for this class of bug is to eliminate the sentinel-value branching entirely: give native-asset deposits their own contract or storage slot with no shared code path to ERC-20 deposits, so there's no branch to misconfigure. Where a shared path is unavoidable, enforce msg.value == amount and token == address(0) as a single atomic check with no early-return before it, and add a contract-level invariant test that asserts the deposit function cannot emit a Deposit event without a corresponding balance increase in the same transaction. On the relayer side, never mint on a single event — require a minimum number of independent confirmations plus a balance-based sanity check against the source-chain contract before authorizing a mint, and cap per-transaction and per-epoch mint volume so a bypass is bounded in size rather than open-ended. Qubit paused the bridge and offered a bounty after the fact; the cheaper version of that response is catching the missing check in review, before the contract is holding user funds.

If your protocol has a bridge, a wrapped-asset minter, or any function where an event triggers value creation on another chain, that's the highest-leverage place to spend audit budget — talk to us about a smart contract audit.

Need your contracts audited?

Manual review + tooling across TON, Solana and EVM — certificate and full report included.

Get an audit