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.
Endereços Principais
Seção intitulada “Endereços Principais”| Função | Endereço |
|---|---|
| Atacante | 0xd01ae1a708614948b2b5e0b7ab5be6afa01325c7 |
| Contrato QBridge (Ethereum) | 0x20e5e35ba29dc3b540a1aee781d0814d5c77bce6 |
| Handler do QBridge (BSC) | 0x4d8ae68fcae98bf93299548545933c0d273ba23a |
| resourceID do ETH | 0x0000000000000000000000002f422fe9ea622049d6f73f81a906b9b8cff03b7f01 |
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:

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.
Etapa 2 — A Transação voteProposal na BSC
Seção intitulada “Etapa 2 — A Transação voteProposal na BSC”Uma das transações de cunhagem de qXETH:
Transação: 0x8c5877d1b618f29f6a3622cb610ace08ca96e04d8218f587072a3f91e8545bdc

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)
- Endereço nulo →
- 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.
Etapa 3 — Os Depósitos do Atacante no Ethereum
Seção intitulada “Etapa 3 — Os Depósitos do Atacante no Ethereum”
O atacante fez múltiplas chamadas a deposit() no contrato QBridge do Ethereum (0x20e5e35ba29dc3b540a1aee781d0814d5c77bce6):
| Hash da Transação | Bloco | Valor |
|---|---|---|
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 |
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:

- 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
Etapa 4 — Duas Funções, Um Evento
Seção intitulada “Etapa 4 — Duas Funções, Um Evento”O contrato QBridge tinha duas funções de depósito:

depositETH()— caminho correto para ETH, exigiamsg.value > 0deposit()— 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.
Etapa 5 — O Código do Handler e a Whitelist
Seção intitulada “Etapa 5 — O Código do Handler e a Whitelist”
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).
Etapa 6 — O Endereço Zero Estava na Whitelist
Seção intitulada “Etapa 6 — O Endereço Zero Estava 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.

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.

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”
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 correto — borrowAllowed() 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.

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 mesmoresourceID
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.

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.
Resumo: Sete Falhas Simultâneas
Seção intitulada “Resumo: Sete Falhas Simultâneas”| Falha | Impacto |
|---|---|
deposit() e depositETH() emitem o mesmo evento | O relayer não consegue distinguir depósitos reais de falsos |
| Sucesso silencioso de EOA não foi considerado | safeTransferFrom no endereço zero passa sem transferência |
| Endereço zero na whitelist | A chamada EOA passa na verificação da whitelist |
| Deploy em produção sem auditoria | Nenhum revisor externo detectou qualquer dos problemas acima |
| Sem timelock nas funções do proprietário | resourceID remapeado silenciosamente, instantaneamente, invisivelmente |
resourceID remapeado antes do ataque | A alteração que tornou a exploração possível |
| Relayer cego confia nos eventos | Serviço off-chain cunha tokens sem verificar valor on-chain |
Referências Externas
Seção intitulada “Referências Externas”- Transação do ataque (BSC):
0x8c5877d1... - Atacante no Etherscan:
0xd01ae1... - QBridge (Ethereum):
0x20e5e3... - Truque EOA de endereço zero (0x Protocol, 2019): blog.0xproject.com
- Análise original em chinês: qbt.wiki
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.

Transação: 0xe3da555d506638bd7b697c0bdf7920be8defc9a175cd35bf72fb10bc77167b66
| Campo | Valor |
|---|---|
| Status | ✅ Sucesso |
| Bloco | 13797391 |
| Timestamp | 13-Dez-2021 02:31:21 PM UTC |
| De | 0xbee3971293374d0b4db7bf1654936951e5bdfe5a6 |
| Para | 0x20e5e35ba29dc3b540a1aee781d0814d5c77bce6 (QBridge, Ethereum) |
| Valor | 0 ETH |
| Função | setResource(address handlerAddress, bytes32 resourceID, address tokenAddress) |
Argumentos de entrada decodificados:
| # | Parâmetro | Valor |
|---|---|---|
| 0 | handlerAddress | 0x17B7163cf1Dbb6286262ddc68b553D899B893f526 |
| 1 | resourceID | 0x0000000000000000000000002f422fe9ea622049d6e73f81a906b9b8cff03b7f01 |
| 2 | tokenAddress | 0x0000000000000000000000000000000000000000 |
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:

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:

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)”
O atacante recebeu múltiplas rodadas de qXETH cunhados diretamente do endereço nulo:
| Hash da Transação | Quantidade | Token | Origem |
|---|---|---|---|
0xd8bba15555... | 999 | qXETH | Endereço Nulo → Exploiter |
0xf6008ab482... | 499 | qXETH | Endereço Nulo → Exploiter |
0xcfa4379af6... | 140 | ETH (BEP-20) | 0xb4b778… → Exploiter |
0x61ca8bc28f... | 190 | qXETH | Endereço Nulo → Exploiter |
0x881a68c9c9... | 0,1 | qXETH | Endereço Nulo → Exploiter |
0x8c5877d1b6... | 0,1 | qXETH | Endereço Nulo → Exploiter |
Cada cunhagem de qXETH “Endereço Nulo → Exploiter” corresponde a uma chamada falsa de deposit() no Ethereum.