Aller au contenu

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.


RôleAdresse
Attaquant0xd01ae1a708614948b2b5e0b7ab5be6afa01325c7
Contrat QBridge (Ethereum)0x20e5e35ba29dc3b540a1aee781d0814d5c77bce6
Handler QBridge (BSC)0x4d8ae68fcae98bf93299548545933c0d273ba23a
resourceID ETH0x0000000000000000000000002f422fe9ea622049d6f73f81a906b9b8cff03b7f01

É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 :

BSCScan montrant les transactions d'emprunt de QubitFin Exploiter — aucune phase de préparation

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.


L’une des transactions d’émission de qXETH :

Transaction : 0x8c5877d1b618f29f6a3622cb610ace08ca96e04d8218f587072a3f91e8545bdc

Transaction voteProposal sur BSCScan — montrant xETH émis depuis l'adresse nulle vers l'attaquant

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)
  • 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 »

Etherscan montrant plusieurs appels Deposit de valeur zéro de l'attaquant vers QBridge

L’attaquant a effectué plusieurs appels à deposit() sur le contrat QBridge Ethereum (0x20e5e35ba29dc3b540a1aee781d0814d5c77bce6) :

Hachage de transactionBlocValeur
0x3dfa33b5...140902340 ETH
0x58020654...140902300 ETH
0x501f8541...140902300 ETH
0xa6282e60...140902230 ETH
0x94031569...140902160 ETH
0xbdedb13d...140902100 ETH
0xeb9f622e...140902010 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 :

Détail Etherscan d'un appel deposit() — 0 Ether, appel de deposit() avec le resourceID ETH

  • 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 $

Le contrat QBridge avait deux fonctions de dépôt :

Code du contrat QBridge montrant deposit() et depositETH() émettant tous deux le même événement Deposit

  • depositETH() — chemin correct pour l’ETH, exigeait msg.value > 0
  • deposit() — 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 »

Code de QBridgeHandler montrant la fonction deposit() avec la vérification de la liste blanche à la ligne 128 et safeTransferFrom à la ligne 135

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 »

Requête sur le contrat montrant que l'adresse zéro est dans 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().

Contrat montrant l'adresse zéro associée au resourceID ETH

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.

Astuce EOA du protocole 0x documentée en 2019

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 »

Fonction borrow() de Qore — style Compound, vérification borrowAllowed correcte

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 correctborrowAllowed() 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é.

Transaction Ethereum montrant un dépôt de WETH via la fonction deposit() le 1er décembre 2021 — avant que le resourceID ne soit modifié

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ême resourceID

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.

Code confirmant que le paramètre resourceID a été modifié par une fonction propriétaire

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.


DéfaillanceImpact
deposit() et depositETH() émettent le même événementLe relayeur ne peut pas distinguer les vrais dépôts des faux
Le succès silencieux des EOA non pris en comptesafeTransferFrom sur l’adresse zéro réussit sans aucun transfert
Adresse zéro sur la liste blancheL’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étaireresourceID réassigné silencieusement, instantanément, invisiblement
resourceID réassigné avant l’attaqueLe changement qui a rendu l’exploitation possible
Relayeur aveugle qui fait confiance aux événementsService off-chain qui émet des tokens sans vérifier la valeur on-chain


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.

640_7.jpg — Appel propriétaire de setResource() le 13 décembre 2021 réassignant le resourceID ETH à l'adresse zéro

Transaction : 0xe3da555d506638bd7b697c0bdf7920be8defc9a175cd35bf72fb10bc77167b66

ChampValeur
Statut✅ Succès
Bloc13797391
Horodatage13 déc. 2021, 14:31:21 UTC
De0xbee3971293374d0b4db7bf1654936951e5bdfe5a6
Vers0x20e5e35ba29dc3b540a1aee781d0814d5c77bce6 (QBridge, Ethereum)
Valeur0 ETH
FonctionsetResource(address handlerAddress, bytes32 resourceID, address tokenAddress)

Arguments d’entrée décodés :

#ParamètreValeur
0handlerAddress0x17B7163cf1Dbb6286262ddc68b553D899B893f526
1resourceID0x0000000000000000000000002f422fe9ea622049d6e73f81a906b9b8cff03b7f01
2tokenAddress0x0000000000000000000000000000000000000000

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 :

640_1.jpg — Fonction voteProposal : modificateur onlyRelayers mis en évidence

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é :

640_7.jpg — Fonction onlyOwner setResource()

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) »

640_8.jpg — QubitFin Exploiter recevant des qXETH émis depuis l'adresse nulle sur plusieurs tours

L’attaquant a reçu plusieurs séries de qXETH émis directement depuis l’adresse nulle :

Hachage de transactionMontantTokenSource
0xd8bba15555...999qXETHAdresse nulle → Exploiteur
0xf6008ab482...499qXETHAdresse nulle → Exploiteur
0xcfa4379af6...140ETH (BEP-20)0xb4b778… → Exploiteur
0x61ca8bc28f...190qXETHAdresse nulle → Exploiteur
0x881a68c9c9...0,1qXETHAdresse nulle → Exploiteur
0x8c5877d1b6...0,1qXETHAdresse nulle → Exploiteur

Chaque émission de qXETH « Adresse nulle → Exploiteur » correspond à un faux appel deposit() sur Ethereum.