Analyse technique de l'exploit QBridge
Ceci est une analyse technique complète de l’exploit basée sur les preuves on-chain, le code source des contrats et les captures d’écran recueillies au moment de l’attaque. Analyse originale en chinois par la communauté des victimes ; traduite et enrichie ici avec toutes les données extraites.
Adresses clés
Section intitulée « Adresses clés »| Rôle | Adresse |
|---|---|
| Attaquant | 0xd01ae1a708614948b2b5e0b7ab5be6afa01325c7 |
| Contrat QBridge (Ethereum) | 0x20e5e35ba29dc3b540a1aee781d0814d5c77bce6 |
| Handler QBridge (BSC) | 0x4d8ae68fcae98bf93299548545933c0d273ba23a |
| resourceID ETH | 0x0000000000000000000000002f422fe9ea622049d6f73f81a906b9b8cff03b7f01 |
Étape 1 — BSC n’était pas la première scène de crime
Section intitulée « Étape 1 — BSC n’était pas la première scène de crime »Lorsque les enquêteurs ont examiné pour la première fois l’adresse de l’attaquant sur BSC, quelque chose n’allait manifestement pas :

L’attaquant est allé directement à borrow() — aucune préparation, aucun flash loan, aucun déploiement de contrat. Cela signifiait que BSC n’était pas le point de départ de l’attaque. L’attaquant possédait déjà des tokens qXETH avant d’interagir avec le protocole de prêt.
En remontant la trace de ces tokens qXETH, on a découvert qu’ils avaient été émis par le relayeur du pont — ce qui signifiait que l’origine était Ethereum.
Étape 2 — La transaction voteProposal sur BSC
Section intitulée « Étape 2 — La transaction voteProposal sur BSC »L’une des transactions d’émission de qXETH :
Transaction : 0x8c5877d1b618f29f6a3622cb610ace08ca96e04d8218f587072a3f91e8545bdc

Observations clés de cette transaction :
- Appel de
voteProposal(uint8 chainID, uint64 depositNonce, bytes32 resourceID, bytes data)sur le contrat QBridge BSC - 3 transferts de tokens ont eu lieu :
- Adresse nulle →
0xa6b1bb...— 59 900 Cross-Chain xETH (émis) 0xa6b1bb...→0xfd7a55...— 59 900 xETH (pontés)- Adresse nulle → QubitFin Exploiter — 59 900 qXETH (émis comme collatéral)
- Adresse nulle →
- Valeur : 0 BNB — aucun ETH réel n’a été déposé nulle part
La fonction voteProposal n’était appelable que par le relayeur. Le relayeur l’a appelée parce qu’il a vu un événement Deposit sur Ethereum. Cet événement était faux.
Étape 3 — Les dépôts de l’attaquant sur Ethereum
Section intitulée « Étape 3 — Les dépôts de l’attaquant sur Ethereum »
L’attaquant a effectué plusieurs appels à deposit() sur le contrat QBridge Ethereum (0x20e5e35ba29dc3b540a1aee781d0814d5c77bce6) :
| Hachage de transaction | Bloc | Valeur |
|---|---|---|
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 |
Chaque transaction avait une valeur de 0 ETH. Méthode : deposit. Chacune a déclenché un événement Deposit que le relayeur a fidèlement traité.
L’une de ces transactions en détail :

- Bloc : 14090216
- Horodatage : 27 jan. 2022, 21:45:32 UTC
- De :
0xd01ae1a708614948b2b5e0b7ab5be6afa01325c7(attaquant) - Vers :
0x20e5e35ba29dc3b540a1aee781d0814d5c77bce6(QBridge Ethereum) - Valeur : 0 Ether (0,00 $)
- Fonction appelée :
deposit(uint8 destinationDomainID, bytes32 resourceID, bytes data) - Prix de l’ETH au moment de l’attaque : 2 425,83 $
Étape 4 — Deux fonctions, un seul événement
Section intitulée « Étape 4 — Deux fonctions, un seul événement »Le contrat QBridge avait deux fonctions de dépôt :

depositETH()— chemin correct pour l’ETH, exigeaitmsg.value > 0deposit()— conçu pour les tokens ERC-20, aucun ETH requis
Les deux émettaient exactement le même type d’événement Deposit. Le relayeur écoutait les événements Deposit et ne pouvait pas distinguer quelle fonction les avait déclenchés.
Étape 5 — Le code du Handler et la liste blanche
Section intitulée « Étape 5 — Le code du Handler et la liste blanche »
La fonction 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}Pour le resourceID de l’ETH, le contrat renvoyait tokenAddress = 0x0000000000000000000000000000000000000000 (l’adresse zéro).
Étape 6 — L’adresse zéro était sur la liste blanche
Section intitulée « Étape 6 — L’adresse zéro était sur la liste blanche »
Les enquêteurs ont interrogé le contrat : L’adresse zéro était-elle sur la liste blanche ?
Oui. Elle l’était.
C’était nécessaire parce que depositETH() utilisait également le même mécanisme de liste blanche avec l’adresse zéro comme substitut pour l’ETH. Mais cela a créé un effet secondaire fatal : l’adresse zéro passait également la vérification de la liste blanche dans deposit().

Le resourceID ETH était associé à 0x0000000000000000000000000000000000000000 — l’adresse zéro — comme confirmé en interrogeant resourceIDToTokenContractAddress.
Étape 7 — Appeler un EOA réussit silencieusement
Section intitulée « Étape 7 — Appeler un EOA réussit silencieusement »Avec tokenAddress = 0x0000...0000, le handler a exécuté :
ERC20Interface(tokenAddress).safeTransferFrom(depositer, address(this), amount);L’adresse zéro est un EOA — un compte détenu par une entité externe sans code de contrat.
Dans l’EVM : appeler n’importe quelle fonction sur une adresse sans code de contrat réussit silencieusement — pas de revert, pas d’erreur, pas d’exécution réelle.
L’appel safeTransferFrom a « réussi ». Rien n’a bougé. Pas d’ETH, pas de tokens. Mais le code a continué comme si tout allait bien — et a émis un événement Deposit légitime.

Cette astuce exacte a été publiquement documentée dans une mise à jour de sécurité du protocole 0x en 2019. Mound Inc. a déployé leur pont en 2022 sans en tenir compte.
Étape 8 — La fonction Borrow était correcte, mais trop tard
Section intitulée « Étape 8 — La fonction Borrow était correcte, mais trop tard »
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);}Le contrat de prêt était correct — borrowAllowed() vérifiait correctement la valeur du collatéral. Mais les tokens qXETH semblaient légitimes on-chain. La fraude avait déjà eu lieu au niveau du pont. Au moment où borrow() s’exécutait, l’attaquant détenait un collatéral d’apparence réelle adossé à rien.
Étape 9 — La preuve accablante : changement de paramètre avant l’attaque
Section intitulée « Étape 9 — La preuve accablante : changement de paramètre avant l’attaque »C’est le détail qui n’a jamais été expliqué.

Avant l’attaque, quelqu’un a enquêté sur l’historique de la fonction deposit(). Il a trouvé :
1er décembre 2021 — une transaction légitime (0xc85df3fbfee7a8541e9fd354e98df78dca21ac6be40b929ff5da52ea8eab80de) :
- Bloc : 13719888
- De :
0xbee3971293374d0b4db7bf1654936951e5bdfe5a6 - Vers : Contrat QBridge
0x20e5e35ba29dc3b540a1aee781d0814d5c77bce6 - Tokens transférés : 0,1 WETH (241,81 $ à ce moment-là)
- Fonction :
deposit()avec le mêmeresourceID
En décembre 2021, deposit() avec ce resourceID transférait du vrai WETH — parce que le resourceID était associé à l’adresse réelle du contrat WETH.
Entre décembre 2021 et le 28 janvier 2022 — une fonction réservée au propriétaire a été appelée pour réassigner l’association du resourceID de l’adresse du contrat WETH à l’adresse zéro.

Seul le propriétaire du contrat pouvait effectuer ce changement. Il ne nécessitait aucun timelock. Il n’y a eu aucune annonce. Ce simple changement de paramètre invisible est ce qui a transformé un pont fonctionnel en un pont exploitable.
Résumé : sept défaillances simultanées
Section intitulée « Résumé : sept défaillances simultanées »| Défaillance | Impact |
|---|---|
deposit() et depositETH() émettent le même événement | Le relayeur ne peut pas distinguer les vrais dépôts des faux |
| Le succès silencieux des EOA non pris en compte | safeTransferFrom sur l’adresse zéro réussit sans aucun transfert |
| Adresse zéro sur la liste blanche | L’appel EOA passe la vérification de la liste blanche |
| Déploiement en production non audité | Aucun auditeur externe n’a détecté aucun des problèmes ci-dessus |
| Pas de timelock sur les fonctions propriétaire | resourceID réassigné silencieusement, instantanément, invisiblement |
resourceID réassigné avant l’attaque | Le changement qui a rendu l’exploitation possible |
| Relayeur aveugle qui fait confiance aux événements | Service off-chain qui émet des tokens sans vérifier la valeur on-chain |
Références externes
Section intitulée « Références externes »- Transaction d’attaque (BSC) :
0x8c5877d1... - Attaquant sur Etherscan :
0xd01ae1... - QBridge (Ethereum) :
0x20e5e3... - Astuce de l’adresse zéro EOA (protocole 0x, 2019) : blog.0xproject.com
- Analyse originale en chinois : qbt.wiki
La preuve accablante — setResource() appelée le 13 décembre 2021
Section intitulée « La preuve accablante — setResource() appelée le 13 décembre 2021 »C’est la pièce à conviction la plus critique.

Transaction : 0xe3da555d506638bd7b697c0bdf7920be8defc9a175cd35bf72fb10bc77167b66
| Champ | Valeur |
|---|---|
| Statut | ✅ Succès |
| Bloc | 13797391 |
| Horodatage | 13 déc. 2021, 14:31:21 UTC |
| De | 0xbee3971293374d0b4db7bf1654936951e5bdfe5a6 |
| Vers | 0x20e5e35ba29dc3b540a1aee781d0814d5c77bce6 (QBridge, Ethereum) |
| Valeur | 0 ETH |
| Fonction | setResource(address handlerAddress, bytes32 resourceID, address tokenAddress) |
Arguments d’entrée décodés :
| # | Paramètre | Valeur |
|---|---|---|
| 0 | handlerAddress | 0x17B7163cf1Dbb6286262ddc68b553D899B893f526 |
| 1 | resourceID | 0x0000000000000000000000002f422fe9ea622049d6e73f81a906b9b8cff03b7f01 |
| 2 | tokenAddress | 0x0000000000000000000000000000000000000000 |
setResource() est une fonction onlyOwner. Seule l’équipe de Mound Inc. pouvait l’appeler.
Le 13 décembre 2021 — 45 jours avant le hack — le propriétaire du contrat a délibérément changé l’association tokenAddress pour le resourceID ETH vers l’adresse zéro. Avant cet appel, ce même resourceID pointait vers le contrat du token WETH. Après cet appel, il ne pointait vers rien — rendant possible l’exploit du succès silencieux des EOA.
Le code précédent de voteProposal montrant que seuls les relayeurs peuvent l’appeler :

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) });Et le code de setResource() qui a tout changé :

function setResource(address handlerAddress, bytes32 resourceID, address tokenAddress) external onlyOwner { resourceIDToHandlerAddress[resourceID] = handlerAddress; IQBridgeHandler(handlerAddress).setResource(resourceID, tokenAddress);}La séquence d’émission de qXETH (transferts de tokens BSC)
Section intitulée « La séquence d’émission de qXETH (transferts de tokens BSC) »
L’attaquant a reçu plusieurs séries de qXETH émis directement depuis l’adresse nulle :
| Hachage de transaction | Montant | Token | Source |
|---|---|---|---|
0xd8bba15555... | 999 | qXETH | Adresse nulle → Exploiteur |
0xf6008ab482... | 499 | qXETH | Adresse nulle → Exploiteur |
0xcfa4379af6... | 140 | ETH (BEP-20) | 0xb4b778… → Exploiteur |
0x61ca8bc28f... | 190 | qXETH | Adresse nulle → Exploiteur |
0x881a68c9c9... | 0,1 | qXETH | Adresse nulle → Exploiteur |
0x8c5877d1b6... | 0,1 | qXETH | Adresse nulle → Exploiteur |
Chaque émission de qXETH « Adresse nulle → Exploiteur » correspond à un faux appel deposit() sur Ethereum.