Pular para o conteúdo

Análise Técnica do Exploit do QBridge

Esta é uma análise técnica completa do exploit baseada em evidências on-chain, código-fonte dos contratos e capturas de tela obtidas no momento do ataque. Análise original em chinês pela comunidade de vítimas; traduzida e expandida aqui com todos os dados extraídos.


FunçãoEndereço
Atacante0xd01ae1a708614948b2b5e0b7ab5be6afa01325c7
Contrato QBridge (Ethereum)0x20e5e35ba29dc3b540a1aee781d0814d5c77bce6
Handler do QBridge (BSC)0x4d8ae68fcae98bf93299548545933c0d273ba23a
resourceID do ETH0x0000000000000000000000002f422fe9ea622049d6f73f81a906b9b8cff03b7f01

Etapa 1 — A BSC Não Foi a Primeira Cena do Crime

Seção intitulada “Etapa 1 — A BSC Não Foi a Primeira Cena do Crime”

Quando os investigadores analisaram pela primeira vez o endereço do atacante na BSC, algo estava imediatamente errado:

BSCScan mostrando transações de empréstimo do QubitFin Exploiter — sem fase de preparação

O atacante foi direto ao borrow() — sem preparação, sem flash loan, sem deploy de contrato. Isso significava que a BSC não foi onde o ataque começou. O atacante já possuía tokens qXETH antes de tocar no protocolo de empréstimos.

Rastreando esses tokens qXETH de volta, descobriu-se que foram cunhados pelo relayer da bridge — o que significava que a origem era o Ethereum.


Uma das transações de cunhagem de qXETH:

Transação: 0x8c5877d1b618f29f6a3622cb610ace08ca96e04d8218f587072a3f91e8545bdc

Transação voteProposal no BSCScan — mostrando xETH cunhado do endereço nulo para o atacante

Observações principais desta transação:

  • Chamou voteProposal(uint8 chainID, uint64 depositNonce, bytes32 resourceID, bytes data) no contrato QBridge da BSC
  • 3 transferências de tokens ocorreram:
    • Endereço nulo → 0xa6b1bb... — 59.900 Cross-Chain xETH (cunhados)
    • 0xa6b1bb...0xfd7a55... — 59.900 xETH (transferidos via bridge)
    • Endereço nulo → QubitFin Exploiter — 59.900 qXETH (cunhados como colateral)
  • Valor: 0 BNB — nenhum ETH real foi depositado em lugar algum

A função voteProposal só podia ser chamada pelo relayer. O relayer a chamou porque detectou um evento Deposit no Ethereum. Esse evento era falso.


Etherscan mostrando múltiplas chamadas Deposit de valor zero do atacante para o QBridge

O atacante fez múltiplas chamadas a deposit() no contrato QBridge do Ethereum (0x20e5e35ba29dc3b540a1aee781d0814d5c77bce6):

Hash da TransaçãoBlocoValor
0x3dfa33b5...140902340 ETH
0x58020654...140902300 ETH
0x501f8541...140902300 ETH
0xa6282e60...140902230 ETH
0x94031569...140902160 ETH
0xbdedb13d...140902100 ETH
0xeb9f622e...140902010 ETH

Todas as transações tinham valor de 0 ETH. Método: deposit. Cada uma disparou um evento Deposit que o relayer processou fielmente.

Uma dessas transações em detalhe:

Detalhe no Etherscan de uma chamada deposit() — 0 Ether, chamando deposit() com resourceID de ETH

  • Bloco: 14090216
  • Timestamp: 27-Jan-2022 09:45:32 PM UTC
  • De: 0xd01ae1a708614948b2b5e0b7ab5be6afa01325c7 (atacante)
  • Para: 0x20e5e35ba29dc3b540a1aee781d0814d5c77bce6 (QBridge Ethereum)
  • Valor: 0 Ether ($0,00)
  • Função chamada: deposit(uint8 destinationDomainID, bytes32 resourceID, bytes data)
  • Preço do ETH no momento do ataque: $2.425,83

O contrato QBridge tinha duas funções de depósito:

Código do contrato QBridge mostrando deposit() e depositETH() ambas emitindo o mesmo evento Deposit

  • depositETH() — caminho correto para ETH, exigia msg.value > 0
  • deposit() — projetada para tokens ERC-20, sem necessidade de ETH

Ambas emitiam exatamente o mesmo tipo de evento Deposit. O relayer escutava eventos Deposit e não conseguia distinguir qual função os havia disparado.


Código do QBridgeHandler mostrando função deposit() com verificação de whitelist na linha 128 e safeTransferFrom na linha 135

A função 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
}

Para o resourceID do ETH, o contrato retornava tokenAddress = 0x0000000000000000000000000000000000000000 (o endereço zero).


Consulta ao contrato mostrando que o endereço zero está na whitelist

Os investigadores consultaram o contrato: O endereço zero estava na whitelist?

Sim. Estava.

Isso era necessário porque depositETH() também usava o mesmo mecanismo de whitelist com o endereço zero como placeholder para ETH. Mas isso criou um efeito colateral fatal: o endereço zero passava na verificação de whitelist em deposit() também.

Contrato mostrando endereço zero mapeado para o resourceID de ETH

O resourceID do ETH estava mapeado para 0x0000000000000000000000000000000000000000 — o endereço zero — conforme confirmado pela consulta a resourceIDToTokenContractAddress.


Etapa 7 — Chamar um EOA Silenciosamente Tem Sucesso

Seção intitulada “Etapa 7 — Chamar um EOA Silenciosamente Tem Sucesso”

Com tokenAddress = 0x0000...0000, o handler executou:

ERC20Interface(tokenAddress).safeTransferFrom(depositer, address(this), amount);

O endereço zero é um EOA — uma Conta de Propriedade Externa sem código de contrato.

No EVM: chamar qualquer função em um endereço sem código de contrato silenciosamente tem sucesso — sem revert, sem erro, sem execução real.

A chamada safeTransferFrom “teve sucesso.” Nada foi movido. Nenhum ETH, nenhum token. Mas o código continuou como se tudo estivesse bem — e emitiu um evento Deposit legítimo.

Truque de EOA do 0x Protocol documentado em 2019

Esse truque exato foi documentado publicamente em uma atualização de segurança do 0x Protocol em 2019. A Mound Inc. implantou sua bridge em 2022 sem levar isso em consideração.


Etapa 8 — A Função Borrow Estava Correta, Mas Era Tarde Demais

Seção intitulada “Etapa 8 — A Função Borrow Estava Correta, Mas Era Tarde Demais”

Função borrow() do Qore — estilo Compound, verificação borrowAllowed correta

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);
}

O contrato de empréstimos estava corretoborrowAllowed() verificava corretamente o valor do colateral. Mas os tokens qXETH pareciam legítimos on-chain. A fraude já havia acontecido na bridge. Quando borrow() foi executado, o atacante possuía colateral de aparência real lastreado em nada.


Etapa 9 — A Prova Decisiva: Alteração de Parâmetro Pré-Ataque

Seção intitulada “Etapa 9 — A Prova Decisiva: Alteração de Parâmetro Pré-Ataque”

Este é o detalhe que nunca foi explicado.

Transação no Ethereum mostrando WETH sendo depositado via função deposit() em 1 de dezembro de 2021 — antes do resourceID ser alterado

Antes do ataque, alguém investigou o histórico da função deposit(). Encontraram:

1 de dezembro de 2021 — uma transação legítima (0xc85df3fbfee7a8541e9fd354e98df78dca21ac6be40b929ff5da52ea8eab80de):

  • Bloco: 13719888
  • De: 0xbee3971293374d0b4db7bf1654936951e5bdfe5a6
  • Para: Contrato QBridge 0x20e5e35ba29dc3b540a1aee781d0814d5c77bce6
  • Tokens Transferidos: 0,1 WETH ($241,81 na época)
  • Função: deposit() com o mesmo resourceID

Em dezembro de 2021, deposit() com aquele resourceID transferia WETH real — porque o resourceID estava mapeado para o endereço real do contrato WETH.

Em algum momento entre dezembro de 2021 e 28 de janeiro de 2022 — uma função restrita ao proprietário foi chamada para remapear o resourceID do endereço do contrato WETH para o endereço zero.

Código confirmando que o parâmetro resourceID foi alterado por uma função do proprietário

Apenas o proprietário do contrato podia fazer essa alteração. Não exigia timelock. Não houve anúncio. Essa única alteração invisível de parâmetro é o que transformou uma bridge funcional em uma explorável.


FalhaImpacto
deposit() e depositETH() emitem o mesmo eventoO relayer não consegue distinguir depósitos reais de falsos
Sucesso silencioso de EOA não foi consideradosafeTransferFrom no endereço zero passa sem transferência
Endereço zero na whitelistA chamada EOA passa na verificação da whitelist
Deploy em produção sem auditoriaNenhum revisor externo detectou qualquer dos problemas acima
Sem timelock nas funções do proprietárioresourceID remapeado silenciosamente, instantaneamente, invisivelmente
resourceID remapeado antes do ataqueA alteração que tornou a exploração possível
Relayer cego confia nos eventosServiço off-chain cunha tokens sem verificar valor on-chain


A Transação Decisiva — setResource() Chamada em 13 de Dezembro de 2021

Seção intitulada “A Transação Decisiva — setResource() Chamada em 13 de Dezembro de 2021”

Esta é a evidência mais crítica.

640_7.jpg — chamada setResource() pelo proprietário em 13 de dezembro de 2021 remapeando resourceID de ETH para endereço zero

Transação: 0xe3da555d506638bd7b697c0bdf7920be8defc9a175cd35bf72fb10bc77167b66

CampoValor
Status✅ Sucesso
Bloco13797391
Timestamp13-Dez-2021 02:31:21 PM UTC
De0xbee3971293374d0b4db7bf1654936951e5bdfe5a6
Para0x20e5e35ba29dc3b540a1aee781d0814d5c77bce6 (QBridge, Ethereum)
Valor0 ETH
FunçãosetResource(address handlerAddress, bytes32 resourceID, address tokenAddress)

Argumentos de entrada decodificados:

#ParâmetroValor
0handlerAddress0x17B7163cf1Dbb6286262ddc68b553D899B893f526
1resourceID0x0000000000000000000000002f422fe9ea622049d6e73f81a906b9b8cff03b7f01
2tokenAddress0x0000000000000000000000000000000000000000

setResource() é uma função onlyOwner. Apenas a equipe da Mound Inc. podia chamá-la.

Em 13 de dezembro de 2021 — 45 dias antes do hack — o proprietário do contrato deliberadamente alterou o mapeamento de tokenAddress para o resourceID de ETH para o endereço zero. Antes desta chamada, o mesmo resourceID apontava para o contrato do token WETH. Após esta chamada, apontava para nada — habilitando o exploit de sucesso silencioso de EOA.

O código anterior do voteProposal mostrando que apenas relayers podem chamá-lo:

640_1.jpg — função voteProposal: modificador onlyRelayers destacado

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)
});

E o código setResource() que mudou tudo:

640_7.jpg — função setResource() onlyOwner

function setResource(address handlerAddress, bytes32 resourceID, address tokenAddress) external onlyOwner {
resourceIDToHandlerAddress[resourceID] = handlerAddress;
IQBridgeHandler(handlerAddress).setResource(resourceID, tokenAddress);
}

A Sequência de Cunhagem de qXETH (Transferências de Tokens na BSC)

Seção intitulada “A Sequência de Cunhagem de qXETH (Transferências de Tokens na BSC)”

640_8.jpg — QubitFin Exploiter recebendo qXETH cunhados do endereço nulo em múltiplas rodadas

O atacante recebeu múltiplas rodadas de qXETH cunhados diretamente do endereço nulo:

Hash da TransaçãoQuantidadeTokenOrigem
0xd8bba15555...999qXETHEndereço Nulo → Exploiter
0xf6008ab482...499qXETHEndereço Nulo → Exploiter
0xcfa4379af6...140ETH (BEP-20)0xb4b778… → Exploiter
0x61ca8bc28f...190qXETHEndereço Nulo → Exploiter
0x881a68c9c9...0,1qXETHEndereço Nulo → Exploiter
0x8c5877d1b6...0,1qXETHEndereço Nulo → Exploiter

Cada cunhagem de qXETH “Endereço Nulo → Exploiter” corresponde a uma chamada falsa de deposit() no Ethereum.