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.
Direcciones Clave
Sección titulada «Direcciones Clave»| Rol | Dirección |
|---|---|
| Atacante | 0xd01ae1a708614948b2b5e0b7ab5be6afa01325c7 |
| Contrato QBridge (Ethereum) | 0x20e5e35ba29dc3b540a1aee781d0814d5c77bce6 |
| Handler de QBridge (BSC) | 0x4d8ae68fcae98bf93299548545933c0d273ba23a |
| resourceID de ETH | 0x0000000000000000000000002f422fe9ea622049d6f73f81a906b9b8cff03b7f01 |
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:

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

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)
- Dirección nula →
- 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»
El atacante realizó múltiples llamadas a deposit() en el contrato QBridge de Ethereum (0x20e5e35ba29dc3b540a1aee781d0814d5c77bce6):
| Hash de Transacción | Bloque | 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 |
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:

- 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
Paso 4 — Dos Funciones, Un Solo Evento
Sección titulada «Paso 4 — Dos Funciones, Un Solo Evento»El contrato QBridge tenía dos funciones de depósito:

depositETH()— ruta correcta para ETH, requeríamsg.value > 0deposit()— 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»
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»
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.

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.

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»
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 correcto — borrowAllowed() 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.

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

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.
Resumen: Siete Fallos Simultáneos
Sección titulada «Resumen: Siete Fallos Simultáneos»| Fallo | Impacto |
|---|---|
deposit() y depositETH() emiten el mismo evento | El relayer no puede distinguir depósitos reales de falsos |
| No se tuvo en cuenta el éxito silencioso de EOA | safeTransferFrom en la dirección cero pasa sin transferencia |
| Dirección cero en la lista blanca | La llamada al EOA pasa la verificación de lista blanca |
| Despliegue en producción sin auditar | Ningún revisor externo detectó nada de lo anterior |
| Sin timelock en funciones del propietario | resourceID remapeado de forma silenciosa, instantánea e invisible |
resourceID remapeado antes del ataque | El cambio que hizo posible la explotación |
| Relayer ciego que confía en eventos | El servicio off-chain acuña tokens sin verificar el valor on-chain |
Referencias Externas
Sección titulada «Referencias Externas»- Transacción del ataque (BSC):
0x8c5877d1... - Atacante en Etherscan:
0xd01ae1... - QBridge (Ethereum):
0x20e5e3... - Truco de dirección cero EOA (0x Protocol, 2019): blog.0xproject.com
- Análisis original en chino: qbt.wiki
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.

Transacción: 0xe3da555d506638bd7b697c0bdf7920be8defc9a175cd35bf72fb10bc77167b66
| Campo | Valor |
|---|---|
| Estado | ✅ Éxito |
| Bloque | 13797391 |
| Marca temporal | Dic-13-2021 02:31:21 PM UTC |
| De | 0xbee3971293374d0b4db7bf1654936951e5bdfe5a6 |
| A | 0x20e5e35ba29dc3b540a1aee781d0814d5c77bce6 (QBridge, Ethereum) |
| Valor | 0 ETH |
| Función | setResource(address handlerAddress, bytes32 resourceID, address tokenAddress) |
Argumentos de entrada decodificados:
| # | Parámetro | Valor |
|---|---|---|
| 0 | handlerAddress | 0x17B7163cf1Dbb6286262ddc68b553D899B893f526 |
| 1 | resourceID | 0x0000000000000000000000002f422fe9ea622049d6e73f81a906b9b8cff03b7f01 |
| 2 | tokenAddress | 0x0000000000000000000000000000000000000000 |
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:

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:

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)»
El atacante recibió múltiples rondas de qXETH acuñados directamente desde la dirección nula:
| Hash de Transacción | Cantidad | Token | Origen |
|---|---|---|---|
0xd8bba15555... | 999 | qXETH | Dirección Nula → Explotador |
0xf6008ab482... | 499 | qXETH | Dirección Nula → Explotador |
0xcfa4379af6... | 140 | ETH (BEP-20) | 0xb4b778… → Explotador |
0x61ca8bc28f... | 190 | qXETH | Dirección Nula → Explotador |
0x881a68c9c9... | 0.1 | qXETH | Dirección Nula → Explotador |
0x8c5877d1b6... | 0.1 | qXETH | Dirección Nula → Explotador |
Cada acuñación de qXETH “Dirección Nula → Explotador” corresponde a una llamada falsa a deposit() en Ethereum.