Saltearse al contenido

Análisis Técnico del Exploit de QBridge

Este es un desglose técnico completo del exploit basado en evidencia on-chain, código fuente de contratos y capturas de pantalla capturadas en el momento del ataque. Análisis original en chino por la comunidad de víctimas; traducido y ampliado aquí con todos los datos extraídos.


RolDirección
Atacante0xd01ae1a708614948b2b5e0b7ab5be6afa01325c7
Contrato QBridge (Ethereum)0x20e5e35ba29dc3b540a1aee781d0814d5c77bce6
Handler de QBridge (BSC)0x4d8ae68fcae98bf93299548545933c0d273ba23a
resourceID de ETH0x0000000000000000000000002f422fe9ea622049d6f73f81a906b9b8cff03b7f01

Paso 1 — BSC No Fue la Primera Escena del Crimen

Sección titulada «Paso 1 — BSC No Fue la Primera Escena del Crimen»

Cuando los investigadores revisaron por primera vez la dirección del atacante en BSC, algo estaba claramente mal:

BSCScan mostrando transacciones de préstamo del Explotador de QubitFin — sin fase de preparación

El atacante fue directamente a borrow() — sin preparación, sin flash loan, sin despliegue de contratos. Esto significaba que BSC no fue donde comenzó el ataque. El atacante ya tenía tokens qXETH antes de tocar el protocolo de préstamos.

Rastreando esos tokens qXETH hacia atrás se reveló que fueron acuñados por el relayer del puente — lo que significaba que el origen era Ethereum.


Paso 2 — La Transacción voteProposal en BSC

Sección titulada «Paso 2 — La Transacción voteProposal en BSC»

Una de las transacciones de acuñación de qXETH:

Transacción: 0x8c5877d1b618f29f6a3622cb610ace08ca96e04d8218f587072a3f91e8545bdc

Transacción voteProposal en BSCScan — mostrando xETH acuñado desde dirección nula hacia el atacante

Observaciones clave de esta transacción:

  • Se llamó a voteProposal(uint8 chainID, uint64 depositNonce, bytes32 resourceID, bytes data) en el contrato QBridge de BSC
  • Ocurrieron 3 transferencias de tokens:
    • Dirección nula → 0xa6b1bb... — 59,900 Cross-Chain xETH (acuñados)
    • 0xa6b1bb...0xfd7a55... — 59,900 xETH (transferidos por el puente)
    • Dirección nula → Explotador de QubitFin — 59,900 qXETH (acuñados como colateral)
  • Valor: 0 BNB — no se depositó ETH real en ningún lugar

La función voteProposal solo podía ser llamada por el relayer. El relayer la llamó porque vio un evento Deposit en Ethereum. Ese evento era falso.


Paso 3 — Los Depósitos del Atacante en Ethereum

Sección titulada «Paso 3 — Los Depósitos del Atacante en Ethereum»

Etherscan mostrando múltiples llamadas Deposit con valor cero del atacante a QBridge

El atacante realizó múltiples llamadas a deposit() en el contrato QBridge de Ethereum (0x20e5e35ba29dc3b540a1aee781d0814d5c77bce6):

Hash de TransacciónBloqueValor
0x3dfa33b5...140902340 ETH
0x58020654...140902300 ETH
0x501f8541...140902300 ETH
0xa6282e60...140902230 ETH
0x94031569...140902160 ETH
0xbdedb13d...140902100 ETH
0xeb9f622e...140902010 ETH

Cada transacción tenía un valor de 0 ETH. Método: deposit. Cada una activó un evento Deposit que el relayer procesó fielmente.

Una de estas transacciones en detalle:

Detalle en Etherscan de una llamada deposit() — 0 Ether, llamando deposit() con resourceID de ETH

  • Bloque: 14090216
  • Marca temporal: Ene-27-2022 09:45:32 PM UTC
  • De: 0xd01ae1a708614948b2b5e0b7ab5be6afa01325c7 (atacante)
  • A: 0x20e5e35ba29dc3b540a1aee781d0814d5c77bce6 (QBridge Ethereum)
  • Valor: 0 Ether ($0.00)
  • Función llamada: deposit(uint8 destinationDomainID, bytes32 resourceID, bytes data)
  • Precio de ETH en el momento del ataque: $2,425.83

El contrato QBridge tenía dos funciones de depósito:

Código del contrato QBridge mostrando que deposit() y depositETH() emiten el mismo evento Deposit

  • depositETH() — ruta correcta para ETH, requería msg.value > 0
  • deposit() — diseñada para tokens ERC-20, no requería ETH

Ambas emitían exactamente el mismo tipo de evento Deposit. El relayer escuchaba eventos Deposit y no podía distinguir qué función los había activado.


Paso 5 — El Código del Handler y la Lista Blanca

Sección titulada «Paso 5 — El Código del Handler y la Lista Blanca»

Código de QBridgeHandler mostrando la función deposit() con verificación de lista blanca en línea 128 y safeTransferFrom en línea 135

La función 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 el resourceID de ETH, el contrato retornaba tokenAddress = 0x0000000000000000000000000000000000000000 (la dirección cero).


Paso 6 — La Dirección Cero Estaba en la Lista Blanca

Sección titulada «Paso 6 — La Dirección Cero Estaba en la Lista Blanca»

Consulta al contrato mostrando que la dirección cero está en la lista blanca

Los investigadores consultaron el contrato: ¿Estaba la dirección cero en la lista blanca?

Sí. Lo estaba.

Esto era necesario porque depositETH() también usaba el mismo mecanismo de lista blanca con la dirección cero como marcador de posición para ETH. Pero creó un efecto secundario fatal: la dirección cero pasaba la verificación de lista blanca en deposit() también.

Contrato mostrando la dirección cero mapeada al resourceID de ETH

El resourceID de ETH estaba mapeado a 0x0000000000000000000000000000000000000000 — la dirección cero — como se confirmó al consultar resourceIDToTokenContractAddress.


Paso 7 — Llamar a un EOA Tiene Éxito Silencioso

Sección titulada «Paso 7 — Llamar a un EOA Tiene Éxito Silencioso»

Con tokenAddress = 0x0000...0000, el handler ejecutó:

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

La dirección cero es un EOA — una Cuenta de Propiedad Externa sin código de contrato.

En el EVM: llamar a cualquier función en una dirección sin código de contrato tiene éxito silencioso — sin reversión, sin error, sin ejecución real.

La llamada a safeTransferFrom “tuvo éxito.” Nada se movió. Ni ETH, ni tokens. Pero el código continuó como si todo estuviera bien — y emitió un evento Deposit legítimo.

Truco de EOA documentado por 0x Protocol en 2019

Este truco exacto fue documentado públicamente en una actualización de seguridad de 0x Protocol en 2019. Mound Inc. desplegó su puente en 2022 sin tenerlo en cuenta.


Paso 8 — La Función Borrow Era Correcta, Pero Ya Era Demasiado Tarde

Sección titulada «Paso 8 — La Función Borrow Era Correcta, Pero Ya Era Demasiado Tarde»

Función borrow() de Qore — estilo Compound, verificación borrowAllowed correcta

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

El contrato de préstamos era correctoborrowAllowed() verificaba correctamente el valor del colateral. Pero los tokens qXETH parecían legítimos on-chain. El fraude ya había ocurrido en el puente. Para cuando borrow() se ejecutó, el atacante tenía colateral de apariencia real respaldado por nada.


Paso 9 — La Prueba Irrefutable: Cambio de Parámetro Pre-Ataque

Sección titulada «Paso 9 — La Prueba Irrefutable: Cambio de Parámetro Pre-Ataque»

Este es el detalle que nunca ha sido explicado.

Transacción en Ethereum mostrando WETH siendo depositado mediante la función deposit() el 1 de diciembre de 2021 — antes de que se cambiara el resourceID

Antes del ataque, alguien investigó el historial de la función deposit(). Encontraron:

1 de diciembre de 2021 — una transacción legítima (0xc85df3fbfee7a8541e9fd354e98df78dca21ac6be40b929ff5da52ea8eab80de):

  • Bloque: 13719888
  • De: 0xbee3971293374d0b4db7bf1654936951e5bdfe5a6
  • A: Contrato QBridge 0x20e5e35ba29dc3b540a1aee781d0814d5c77bce6
  • Tokens Transferidos: 0.1 WETH ($241.81 en ese momento)
  • Función: deposit() con el mismo resourceID

En diciembre de 2021, deposit() con ese resourceID transfería WETH real — porque el resourceID estaba mapeado a la dirección real del contrato WETH.

En algún momento entre diciembre de 2021 y el 28 de enero de 2022 — se llamó a una función exclusiva del propietario para reasignar el mapeo del resourceID de la dirección del contrato WETH a la dirección cero.

Código confirmando que el parámetro resourceID fue cambiado por una función del propietario

Solo el propietario del contrato podía hacer este cambio. No requería timelock. No hubo ningún anuncio. Este único cambio de parámetro invisible es lo que convirtió un puente funcional en uno explotable.


FalloImpacto
deposit() y depositETH() emiten el mismo eventoEl relayer no puede distinguir depósitos reales de falsos
No se tuvo en cuenta el éxito silencioso de EOAsafeTransferFrom en la dirección cero pasa sin transferencia
Dirección cero en la lista blancaLa llamada al EOA pasa la verificación de lista blanca
Despliegue en producción sin auditarNingún revisor externo detectó nada de lo anterior
Sin timelock en funciones del propietarioresourceID remapeado de forma silenciosa, instantánea e invisible
resourceID remapeado antes del ataqueEl cambio que hizo posible la explotación
Relayer ciego que confía en eventosEl servicio off-chain acuña tokens sin verificar el valor on-chain


La Prueba Irrefutable — setResource() Llamada el 13 de Diciembre de 2021

Sección titulada «La Prueba Irrefutable — setResource() Llamada el 13 de Diciembre de 2021»

Esta es la pieza de evidencia más crítica.

640_7.jpg — Llamada setResource() del propietario el 13 de dic de 2021 remapeando el resourceID de ETH a la dirección cero

Transacción: 0xe3da555d506638bd7b697c0bdf7920be8defc9a175cd35bf72fb10bc77167b66

CampoValor
Estado✅ Éxito
Bloque13797391
Marca temporalDic-13-2021 02:31:21 PM UTC
De0xbee3971293374d0b4db7bf1654936951e5bdfe5a6
A0x20e5e35ba29dc3b540a1aee781d0814d5c77bce6 (QBridge, Ethereum)
Valor0 ETH
FunciónsetResource(address handlerAddress, bytes32 resourceID, address tokenAddress)

Argumentos de entrada decodificados:

#ParámetroValor
0handlerAddress0x17B7163cf1Dbb6286262ddc68b553D899B893f526
1resourceID0x0000000000000000000000002f422fe9ea622049d6e73f81a906b9b8cff03b7f01
2tokenAddress0x0000000000000000000000000000000000000000

setResource() es una función onlyOwner. Solo el equipo de Mound Inc. podía llamarla.

El 13 de diciembre de 2021 — 45 días antes del hackeo — el propietario del contrato cambió deliberadamente el mapeo de tokenAddress para el resourceID de ETH a la dirección cero. Antes de esta llamada, ese mismo resourceID apuntaba al contrato del token WETH. Después de esta llamada, apuntaba a nada — habilitando el exploit de éxito silencioso del EOA.

El código previo de voteProposal mostrando que solo los relayers pueden llamarlo:

640_1.jpg — Función voteProposal: modificador onlyRelayers resaltado

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

Y el código de setResource() que lo cambió todo:

640_7.jpg — Función setResource() onlyOwner

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

La Secuencia de Acuñación de qXETH (Transferencias de Tokens en BSC)

Sección titulada «La Secuencia de Acuñación de qXETH (Transferencias de Tokens en BSC)»

640_8.jpg — Explotador de QubitFin recibiendo qXETH acuñados desde dirección nula en múltiples rondas

El atacante recibió múltiples rondas de qXETH acuñados directamente desde la dirección nula:

Hash de TransacciónCantidadTokenOrigen
0xd8bba15555...999qXETHDirección Nula → Explotador
0xf6008ab482...499qXETHDirección Nula → Explotador
0xcfa4379af6...140ETH (BEP-20)0xb4b778… → Explotador
0x61ca8bc28f...190qXETHDirección Nula → Explotador
0x881a68c9c9...0.1qXETHDirección Nula → Explotador
0x8c5877d1b6...0.1qXETHDirección Nula → Explotador

Cada acuñación de qXETH “Dirección Nula → Explotador” corresponde a una llamada falsa a deposit() en Ethereum.