Aller au contenu

Collision de stockage du proxy : comment une mise à niveau corrompt l'état

Le proxy conserve l'état tandis que le code d'implémentation change. Découvrez comment un layout incompatible écrase soldes, propriétaires et contrôles, et comment valider une mise à niveau.

Mis à jour

À des fins éducatives uniquement ; ceci ne constitue pas un conseil en investissement. Investir peut entraîner des pertes.

Réponse directe

Une collision de stockage de proxy se produit lorsque le code d’implémentation lit ou écrit un slot du proxy avec un sens différent de celui défini par le layout ayant créé l’état existant. Une mise à niveau peut alors faire passer un solde pour une adresse, effacer le propriétaire, corrompre le slot de base d’un mapping ou écraser les données de contrôle de mise à niveau.

Avec delegatecall, le bytecode d’implémentation s’exécute dans le contexte du proxy : le stockage, le solde et address(this) appartiennent au proxy. Les noms de variables ne sont pas enregistrés on-chain ; l’EVM suit seulement le slot et l’offset en octets calculés par le nouveau code.

Une mise à niveau doit donc préserver le layout déployé, et pas seulement compiler ou exposer les mêmes fonctions. Avant de l’autoriser, comparez les layouts produits par le compilateur à la version exacte déployée.

Fonctionnement

Solidity place normalement les variables d’état à partir du slot 0, dans l’ordre de déclaration après linéarisation C3 de l’héritage. Les valeurs de moins de 32 octets peuvent partager un slot ; les structures et tableaux suivent d’autres règles, tandis que mappings et tableaux dynamiques dérivent l’emplacement de leurs données d’un slot de base. Si celui-ci se déplace, les données dérivées se déplacent aussi.

Quatre frontières de collision distinctes doivent être auditées :

  • Proxy contre implémentation : les champs propres au proxy, comme l’implémentation ou l’administrateur, ne doivent pas occuper les slots de l’état applicatif. ERC-1967 affecte des slots normalisés, évités par le compilateur, aux données d’implémentation, de beacon et d’administrateur.
  • Ancienne contre nouvelle implémentation : les variables existantes doivent conserver des slots, offsets et types compatibles. Ajouter une variable à la fin peut être sûr ; l’insérer, la réordonner, la supprimer ou changer son type peut réinterpréter des mots existants.
  • Héritage : ajouter un état à un contrat de base ou changer l’ordre d’héritage peut déplacer le stockage des descendants, même si leur source ne change pas.
  • Stockage réservé ou à espaces de noms : un storage gap correctement consommé réserve de la place à un contrat de base et les espaces de noms de type ERC-7201 isolent les layouts. Aucun ne permet des changements arbitraires dans un layout existant.

Exemple

Supposons que la version 1 ait le layout suivant :

uint256 totalAssets; // slot 0
address owner;       // slot 1

La version 2 insère à tort une variable au début :

bool paused;         // slot 0, offset 0
uint256 totalAssets; // slot 1
address owner;       // slot 2

Après la mise à niveau, paused lit l’octet faible de l’ancien totalAssets, le nouveau totalAssets lit le mot de l’ancien owner comme entier, et owner lit ce qui se trouvait déjà dans le slot 2, souvent zéro. Les mots d’origine restent stockés, mais le nouveau code leur donne d’autres sens. Une transaction peut réussir tout en appliquant autorisation ou comptabilité à des interprétations corrompues.

Risques et contrôles de mise à niveau

  • Générez le layout des deux implémentations et comparez slot, offset, type et héritage au contrat de référence réellement déployé.
  • Pour un layout linéaire classique, ajoutez les variables uniquement à la fin. Ne réordonnez pas les champs, ne changez pas leur type, ne les supprimez pas pour les réutiliser et ne modifiez pas les contrats de base sans prouver la compatibilité.
  • En consommant un storage gap, réduisez-le du nombre exact de slots réservés utilisés. Avec les espaces de noms, gardez des identifiants uniques et validez les changements dans chaque espace existant.
  • Ne considérez pas ERC-1967 comme une protection complète. Il sépare les métadonnées du proxy des slots applicatifs attribués par le compilateur, mais ne rend pas compatibles deux layouts d’implémentation.
  • Testez la mise à niveau et tout réinitialiseur sur un fork ou un instantané d’état. Vérifiez avant et après propriétaires, rôles, soldes, allowances, entrées de mappings, pause, slots d’implémentation et contrôles de retour ou d’urgence.

Si une mise à niveau a déjà pu provoquer une collision, suspendez les autres mises à niveau et appels modifiant l’état lorsque la gouvernance le permet. Conservez le numéro de bloc antérieur et le bytecode, comparez le stockage brut des slots touchés et faites concevoir puis réviser indépendamment une migration par des spécialistes. Répéter des mises à niveau sans carte de stockage vérifiée peut détruire davantage d’état récupérable.

Idées reçues

  • « Les noms de variables sont identiques, donc le layout est sûr. » Les noms ne déterminent pas les positions. Les types, l’ordre, le packing, l’héritage et les espaces de noms les déterminent.
  • « Supprimer une variable libère son slot. » Le stockage du proxy persiste. Réutiliser le slot donne un nouveau sens à l’ancien mot, sauf si une migration auditée l’efface ou le transforme.
  • « Une transaction de test réussie prouve la compatibilité. » Elle peut ne toucher que quelques slots. La validation et les tests de différence d’état doivent couvrir champs privilégiés, valeurs compactées, mappings, tableaux et stockage hérité.

Sujets connexes

Sources

Navigation

Rechercher dans le wiki...