Près de 90 % des protocoles DeFi où vous déposez vos stables planquent un panneau de contrôle backdoor tout à fait légal. C'est la magie de l'Upgradeability. Sur le papier, c'est vendu pour fix des bugs ou optimiser le gas. En réalité ? Ça permet de swap le code d'un contrat en prod par un vieux scam en un clic, et de siphonner la liquidité jusqu'au dernier centime.
Hier, j'ai checké un fork tout frais sur Base jusqu'à 3h du mat. Je pensais avoir les yeux explosés par la fatigue, mais non : il y avait bien une timebomb classique dans le proxy. Le pire, c'est qu'ils affichent un audit tout propre. Un beau PDF d'un cabinet top tier. Décryptage.
L'arnaque architecturale : Le fonctionnement des proxys
Pour le commun des mortels, un smart contract est un bloc immuable. On déploie et on oublie. Mais pour rendre le bouzin évolutif, on sépare l'architecture en deux morceaux : le Proxy et la Logique (Implementation). L'utilisateur interagit uniquement avec le Proxy. Ce contrat proxy est complètement stupide, il n'embarque aucune logique métier. Son seul taf est de push les appels via un delegatecall.
C'est exactement là que le piège se referme. Le delegatecall est l'opcode le plus dangereux de Solidity. Il exécute le code du contrat cible (l'implémentation), mais dans le contexte de stockage (storage) du Proxy. En gros, les variables restent dans le Proxy, mais le code vient de l'extérieur. L'admin change l'adresse de l'implémentation dans le proxy, et bam : le protocole est mis à jour. Ou la backdoor est activée.
Les patterns standards vendus comme des gages de sécurité :
- UUPS (UUPSUpgradeable) : Le slot contenant l'adresse de la logique est stocké directement dans le contrat de logique. Si l'admin pousse une implémentation foireuse qui n'hérite pas d'UUPS, le contrat est brické définitivement. Les fonds sont bloqués à jamais. Ironique, non ?
- Transparent Proxy Pattern (TPP) : Ici, la logique d'upgrade est gérée par un contrat dédié, le
ProxyAdmin. Les rôles sont bien cloisonnés : les utilisateurs appellent la logique métier, l'admin gère uniquement les upgrades. C'est plus propre visuellement, mais ça bouffe un gas de dingo à cause des checks systématiques dumsg.senderau niveau du fallback. - Beacon Proxy : Un unique contrat balise (Beacon) stocke l'adresse de la logique pour des centaines de proxys identiques. Ultra pratique pour des collections NFT ou des usines à pools. On met à jour l'adresse dans le beacon, et mille contrats sont upgrade d'un coup. Pratique pour les devs, mais encore mieux pour un hacker : un seul exploit et c'est tout le réseau de pools qui saute.
Anatomie d'un rug pull : Comment vos fonds s'évaporent
Vous pensez qu'il faut un exploit ultra complexe pour vider les caisses ? Absolument pas. L'admin a juste besoin de modifier une seule ligne dans sa nouvelle implémentation.
Regardons du code concret. J'ai codé rapidement un exemple classique de contrat "legit" qui se transforme en pur scam en un claquement de doigts.
Étape 1 : Le contrat clean (Implementation_V1.sol)
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
import "@openzeppelin/contracts-upgradeable/proxy/utils/Initializable.sol";
// Pool standard, les investisseurs déposent et attendent leur rendement
contract VaultV1 is Initializable {
address public admin;
mapping(address => uint256) public balances;
uint256 public totalFunds;
// On setup un initializer à la place du constructor. Si on oublie de l'appeler, le contrat est à poil, premier arrivé, premier servi.
function initialize() public initializer {
admin = msg.sender; // On stocke le deployer ou le multisig (on espère)
}
function deposit() external payable {
require(msg.value > 0, "Zero funds");
balances[msg.sender] += msg.value;
totalFunds += msg.value;
}
// Retrait honnête, pas de frais cachés. Pour l'instant.
function withdraw(uint256 _amount) external {
require(balances[msg.sender] >= _amount, "Low balance");
balances[msg.sender] -= _amount;
totalFunds -= _amount;
payable(msg.sender).transfer(_amount);
}
}Rien à signaler, le code est propre. Les auditeurs valident. Le protocole se lance, la TVL grimpe et ça s'enflamme sur Telegram.
Étape 2 : L'upgrade de minuit (Implementation_V2.sol)
Un mois passe. 5 000 ETH dorment dans le pool. L'admin (ou le hacker qui a cassé la clé privée) déploie la V2. Il trigger la fonction upgradeTo() du proxy et lui injecte la nouvelle adresse.
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
import "@openzeppelin/contracts-upgradeable/proxy/utils/Initializable.sol";
contract VaultV2 is Initializable {
// CRUCIAL : Le storage layout doit correspondre au pixel près à la V1.
// Une variable décalée, et c'est tout le mapping qui est cassé. Chaos total.
address public admin;
mapping(address => uint256) public balances;
uint256 public totalFunds;
address public constant shadowWallet = 0x9965507B1a05951961A0175f653429f1c08afde6; // Le fameux wallet aux Cayman
// Lock de l'initializer pour éviter un double init
function initialize() public initializer {}
function deposit() external payable {
balances[msg.sender] += msg.value;
totalFunds += msg.value;
}
// Et voilà la backdoor. Indétectable pour le user lambda via l'UI.
function withdraw(uint256 _amount) external {
// C'est clean en apparence, mais...
require(balances[msg.sender] >= _amount, "Low balance");
// Taxe fantôme : on détourne discrètement 99 % vers le shadowWallet.
// Pourquoi pas 100 % ? Pour éviter que la tx fail direct. On fait croire à un lag de l'interface.
uint256 tax = (_amount * 99) / 100;
uint256 userShare = _amount - tax;
balances[msg.sender] -= _amount;
totalFunds -= _amount;
payable(shadowWallet).transfer(tax);
payable(msg.sender).transfer(userShare);
}
// L'option classique : le bouton "quitter le navire" de l'admin
function emergencyDrain() external {
require(msg.sender == admin, "Not an admin");
// On rase tout. Au revoir la liquidité.
payable(shadowWallet).transfer(address(this).balance);
}
}Le user va sur la dApp, clique sur Withdraw et signe la tx. Le loader de l'UI tourne en boucle. Le wallet ne reçoit que 1 % du dépôt, le reste s'est volatilisé. Vous allez call-out l'équipe sur Telegram, mais les modérateurs suppriment déjà les messages et vous ban. Classique.
Collision de slots mémoire : La planque ultime pour camoufler une backdoor
Parfois, une équipe malveillante n'a même pas besoin de push une fonction suspecte comme emergencyDrain(). Il existe un trick bien plus vicieux : la collision volontaire de slots mémoire (Storage Slot Collision).
L'EVM n'a aucune notion du nom des variables. Elle ne gère que des slots (de 0 à 2256-1) où les données sont écrites séquentiellement. Si la nouvelle implémentation modifie volontairement l'ordre de déclaration des variables, on peut s'arranger pour qu'une écriture dans une bête variable userLimit écrase en fait l'adresse de l'admin.
Je suis tombé un jour sur un contrat où, lors d'un upgrade, un bête bool avait été calé juste avant la variable owner. Résultat : tout le layout s'est décalé. N'importe quel user mettant à jour ses configurations devenait instantanément propriétaire du contrat et pouvait vider le vault. Les devs ont hurlé à l'erreur d'inattention. Évidemment. Les fonds ont été transférés direct sur un wallet qui venait de ship des fonds depuis Tornado Cash deux jours avant. Sûrement un coup de chance.
La check-list du parano : Comment éviter de finir en exit liquidity
Si vous pensez que le badge vert "Verified" sur Etherscan vous protège, vous vous foutez le doigt dans l'œil. Ça indique juste que le code du proxy est clean. Il faut creuser le storage.
Voici un tableau récapitulatif des points à check et des red flags à repérer avant de poser sur un protocole autre chose que l'argent de votre café.
| Paramètre du contrat | État idéal (Safe) | Alerte maximale (Red Flag) | Comment vérifier sur l'explorer |
|---|---|---|---|
| Type de contrat | Immuable (Immutable) | Proxy (UUPS / Transparent) | Onglet Contract -> Présence des boutons Read as Proxy / Write as Proxy. |
| Gouvernance (Admin) | Multisig (Gnosis Safe 3/5 minimum) + Timelock | EOAs (adresse standard d'un seul admin) | On check le slot admin ou owner. On inspecte cette adresse : s'il n'y a aucun code (simple wallet et non un contrat), le projet est centralisé sur une seule clé. Un leak de clé privée et c'est la faillite. |
| Timelock (Délai d'exécution) | Entre 48 heures et 7 jours | Aucun délai ou setup à 0 | On vérifie si l'appel d'upgrade passe par un contrat de timelock. Si l'admin peut call upgradeTo instantanément en direct : barrez-vous. |
| Slot d'implémentation | Haché selon l'EIP-1967 | Slot custom et masqué | Vérification de la migration du storage. Suivre l'onglet State sur Etherscan au moment des upgrades. |
Les timelocks ne sont qu’une illusion de sécurité pour rassurer les exit liquidity (les holders rits-mous). Les devs adorent flex sur Discord : « Tranquille, on a collé un timelock de 48 heures ! Zéro upgrade surprise ! ».
Sur le papier, ça gère. L'admin a deux jours de délai pour push sa tx de mise à jour. De votre côté, ça laisse 48 heures pour capter l’embrouille, FUD le chat et pull out vos bags. En réalité ? Tout le monde s'en fout.
Qui check la mempool ou scrute les events du timelock H24 ? Personne. Ça dort, ça bosse ou ça va boire des bières. Un hacker ou un dev malveillant va setup sa tx d'upgrade un vendredi soir. Le timelock release le dimanche d'après en pleine nuit, le code est swap, et le lundi matin c'est le réveil difficile avec un wallet shorté à 0. Ce délai sert uniquement si vous tournez avec des alertes auto (via Defender Sentinel ou Tenderly) couplées à des bots de panic withdraw. Pas de bot ? Vous allez juste regarder vos fonds se faire siphonner en direct depuis la file d'attente.
Passons maintenant aux dingueries bien planquées dont les rapports d'audit standards ne parlent jamais.
Mines architecturales : Les initializers fantômes
Au déploiement d’un contrat classique, le constructor s'exécute. Il run une seule fois pour setup le storage, puis il dégage. Problème : avec l'architecture proxy, le constructeur de l'implementation ne peut pas write dans le storage du proxy. C'est pour ça qu'on utilise un pattern avec une fonction logique dédiée, le fameux initialize vu plus haut.
C'est ici que le reverse engineering de backdoor devient un art. Qu'est-ce qui empêche un dev de laisser traîner une deuxième fonction d'initialisation ? Ou un endpoint de re-initialization camouflé ?
OpenZeppelin fournit bien le modifier reinitializer(uint8 version), pensé pour injecter les nouvelles variables lors d'une bascule en V2. Mais si le dev s'amuse à rewrite sa propre logique ou « omet » de lock cette fonction de reconfiguration, le premier venu peut venir overwrite les slots critiques.
Exemple de code de migration vulnérable (ou backdooré à dessein) :
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
import "@openzeppelin/contracts-upgradeable/proxy/utils/Initializable.sol";
contract VaultV3 is Initializable {
address public admin;
mapping(address => uint256) public balances;
uint256 public totalFunds;
// Nouvelles variables de la V3
bool public isPaused;
address public trustedRecoveryAddress;
// Reinitializer censé gérer l'upgrade.
// Regardez bien cette fonction. Vous captez le bug ?
function upgradeConfig(address _recovery) external {
// Le require(msg.sender == admin, "Not admin") est passé à la trappe
// Ou alors une sous-méthode reset discrètement le flag d'initialisation
trustedRecoveryAddress = _recovery;
// Le tip pour la maison :
admin = msg.sender; // Et bim. N'importe qui call la fonction et call le flag admin.
}
}Vous allez me dire : « C’est grossier, ça saute aux yeux ». Absolument pas. Ce genre de flaw est noyé sous des formules mathématiques chelous ou déporté dans des dépendances externes non vérifiées au moment du deploy. Au scope, la fonction ressemble à un bête calcul de yield, alors qu'en scred, elle fait un overwrite complet du slot de l'admin.
Vérification on-chain : Inspecter les storage slots à poil
Si l'équipe décide de rug, elle ne va pas s'embêter à verify le code de sa backdoor sur Etherscan. Ils vont juste pousser l'implémentation en mode unverified bytecode. Sur l'explorer, vous vous retrouvez face à un wall de hex. C'est flippant, et c'est le but.
Pour savoir vers où pointe un proxy à un instant T, il faut inspecter directement la racine : les slots de la mémoire de l'EVM. Selon l'EIP-1967, l'adresse de la logique doit être hardcodée dans un slot ultra-spécifique pour éviter les collisions de storage.
Adresse du slot d'implémentation (EIP-1967) :bytes32(uint256(keccak256('eip1967.proxy.implementation')) - 1)
Ce qui génère le pointeur : 0x360894a13ba1a3210667c828492db98dca3e2076cc3735a920a3ca505d382bbc
En maîtrisant la méthode RPC eth_getStorageAt, pas besoin que le contrat soit vérifié. Vous ciblez l’adresse du proxy, vous interrogez ce slot, et vous récupérez l'adresse hex brute du contrat d’implémentation actif. Si cette adresse a swap en douce : shortez le protocole et sortez vos billes.
# Appel RPC via curl pour dump la valeur réelle du slot
curl https://mainnet.infura.io/v3/YOUR_KEY \
-X POST \
-H "Content-Type: application/json" \
--data '{"jsonrpc":"2.0","method":"eth_getStorageAt","params":["0xProxyContractAddress", "0x360894a13ba1a3210667c828492db98dca3e2076cc3735a920a3ca505d382bbc", "latest"],"id":1}'La reponse renvoie 32 bytes. Les 20 derniers bytes correspondent à l'adresse réelle du contrat qui pilote vos jetons actuellement. Oubliez l'adresse affichée sur la belle UI du front-end, c'est celle-ci qui va encaisser le delegatecall.
TL;DR
Gardez en tête que l'upgradeability reste un arbitrage permanent entre la flexibilité des devs et la sécurité des utilisateurs. Un protocole peut afficher des milliards de TVL (Total Value Locked), s'il tourne sur un proxy adossé à un multisig 2-sur-3 sans timelock, ces milliards ne vous appartiennent pas. Ils appartiennent aux trois gars qui détiennent les clés, ou au premier hacker capable de les phisher.
Dès qu'un contrat est modifiable, vous ne faites plus confiance au code, vous faites confiance à des humains. Et l'histoire du web3 a prouvé maintes fois que les humains finissent toujours par call un bad trade, céder à un coup de pression, ou vriller dès qu'ils voient trop de zéros sur l'écran.