
Vulnérabilité critique dans le compilateur Solidity : suppression accidentelle de l'affectation des variables d'état
TechFlow SélectionTechFlow Sélection

Vulnérabilité critique dans le compilateur Solidity : suppression accidentelle de l'affectation des variables d'état
Cet article explique en détail, au niveau du code source, le principe d'une vulnérabilité moyenne à élevée présente dans le compilateur Solidity (0.8.13 ≤ solidity < 0.8.17), laquelle provoque la suppression erronée d'opérations d'affectation de variables d'état pendant la compilation, en raison d'un défaut dans le mécanisme d'optimisation Yul, ainsi que les mesures préventives correspondantes.
Cet article explique en détail, au niveau du code source, le principe d'une vulnérabilité moyenne à élevée dans le compilateur Solidity (0.8.13 <= solidity < 0.8.17), provoquée par un défaut du mécanisme d'optimisation Yul, entraînant la suppression incorrecte d'opérations d'affectation de variables d'état, ainsi que les mesures préventives correspondantes.
Il vise à aider les développeurs de contrats intelligents à renforcer leur vigilance en matière de sécurité lors du développement, afin d'éviter efficacement ou d'atténuer l'impact de la vulnérabilité SOL-2022-7 sur la sécurité du code des contrats.
Détails de la vulnérabilité
L'optimisation Yul est une option disponible dans la compilation Solidity permettant de réduire certaines instructions redondantes dans le contrat, abaissant ainsi les frais de gaz liés au déploiement et à l'exécution. Pour plus d'informations, veuillez consulter la documentation officielle.
Durant l’étape d’optimisation « UnusedStoreEliminator », le compilateur supprime les écritures « redondantes » dans le Storage. Toutefois, en raison d’un défaut dans l’identification de cette « redondance », lorsque certains blocs Yul appellent une fonction définie par l’utilisateur (dont un branchement interne n’affecte pas le flux d’exécution du bloc appelant) et qu’il existe des opérations d’écriture successives sur une même variable d’état avant et après l’appel de cette fonction Yul, l’optimisation Yul supprime de manière permanente, au moment de la compilation, toutes les écritures dans le Storage précédant l'appel de la fonction définie par l'utilisateur.
Considérons le code suivant :
contract Eocene {
uint public x;
function attack() public {
x = 1;
x = 2;
}
}
Lors de l’optimisation « UnusedStoreEliminator », l’instruction x = 1 est clairement redondante pour l’exécution complète de la fonction attack(). Par conséquent, le code Yul optimisé supprimera naturellement x = 1 afin de réduire la consommation de gaz.
Examinons maintenant ce qui se passe en insérant un appel à une fonction personnalisée au milieu :
contract Eocene {
uint public x;
function attack(uint i) public {
x = 1;
y(i);
x = 2;
}
function y(uint i) internal{
if (i > 0){
return;
}
assembly { return( 0, 0) }
}
}
Évidemment, en raison de l'appel à y(), nous devons évaluer si y() peut affecter l'exécution de attack(). Si l'appel à y() peut provoquer l'arrêt complet du flux d'exécution (notez bien : pas un rollback ; dans le code Yul, l'instruction return() peut effectivement terminer l'appel), alors x = 1 ne doit surtout pas être supprimé. Dans ce cas précis, puisque y() contient assembly {return(0, 0)}, pouvant interrompre complètement l'appel du message, x = 1 ne doit donc pas être supprimé.
Cependant, en raison d'un défaut logique dans le compilateur Solidity, x = 1 est incorrectement supprimé lors de la compilation, modifiant irrémédiablement la logique du code.
Résultat pratique du test de compilation :
Stupéfiant ! Le code Yul correspondant à x = 1, qui ne devait pas être optimisé, a disparu ! Pour connaître la suite, poursuivez votre lecture.

Dans le module UnusedStoreEliminator du compilateur Solidity, on utilise le suivi des variables SSA et le suivi du flux de contrôle pour déterminer si une écriture dans le Storage est redondante. Lorsqu’on entre dans une fonction personnalisée, UnusedStoreEliminator effectue les actions suivantes :
-
Opération d'écriture en memory ou storage : stocke cette opération dans la variable m_store, avec un état initial défini comme Undecided (indécis) ;
-
Appel de fonction : récupère les positions de lecture/écriture en memory ou storage de la fonction appelée, puis compare ces opérations avec celles stockées dans m_store ayant un statut Undecided ;
-
Si l'écriture couvre une opération déjà présente dans m_store, l'état de celle-ci est changé en Unused (inutilisée) ;
-
Si la fonction lit une opération présente dans m_store, l'état de cette opération est mis à Used (utilisée) ;
-
Si la fonction n'a aucun branchement permettant de continuer l'appel du message, toutes les écritures en mémoire sont marquées comme Unused ;
-
Sous les conditions ci-dessus, si la fonction peut terminer le flux d'exécution, toutes les opérations d'écriture en storage dans m_store ayant un statut Undecided sont marquées comme Used ; sinon, elles sont marquées Unused ;
-
Fin de la fonction : toutes les opérations marquées Unused sont supprimées.
Le code d'initialisation des opérations d'écriture en memory ou storage est le suivant :
On observe que les opérations d'écriture en memory et storage rencontrées sont stockées dans m_store.

La logique de traitement lors d’un appel de fonction est la suivante :
Ici, operationFromFunctionCall() et applyOperation() implémentent respectivement les étapes 2.1 et 2.2 décrites ci-dessus. La structure conditionnelle If située en bas, basée sur canContinue et canTerminate, implémente la logique 2.3.
Notez bien : c’est précisément le défaut dans cette instruction conditionnelle qui cause la vulnérabilité !!!

operationFromFunctionCall() récupère toutes les opérations de lecture/écriture en memory ou storage de la fonction appelée. Il convient de noter que Yul inclut de nombreuses fonctions internes, telles que sstore(), return(). On observe ici que les fonctions intégrées et les fonctions définies par l’utilisateur sont traitées différemment.

La fonction applyOperation() compare ensuite toutes les opérations de lecture/écriture obtenues via operationFromFunctionCall() avec celles stockées dans m_store, afin de déterminer si elles ont été lues ou écrites durant cet appel, et met à jour l’état correspondant dans m_store.

Analysons maintenant comment UnusedStoreEliminator traite la fonction attack() du contrat Eocene :
L’opération x = 1 est stockée dans la variable m_store avec un état Undecided.
1. Appel de la fonction y() : récupération de toutes ses opérations de lecture/écriture
2. Parcours de m_store : aucune des opérations de lecture/écriture causées par y() n'est liée à x = 1 ; l'état de x = 1 reste Undecided
- Récupération du flux de contrôle de y() : comme y() possède un branchement pouvant retourner normalement, canContinue vaut True, donc la condition If n’est pas exécutée. L’état de x = 1 reste Undecided !!!
3. Opération d’écriture x = 2 :
-
Parcours de m_store : on trouve x = 1 en état Undecided. Comme x = 2 remplace x = 1, l’état de x = 1 est changé en Unused.
-
L’opération x = 2 est ajoutée à m_store avec un état initial Undecided.
4. Fin de la fonction :
-
Toutes les opérations en état Undecided dans m_store sont marquées Used.
-
Toutes les opérations en état Unused dans m_store sont supprimées.
Clairement, lors d’un appel de fonction, si la fonction appelée peut interrompre l’exécution du message, toutes les opérations d’écriture précédentes ayant un statut Undecided devraient être marquées Used, plutôt que de rester Undecided, ce qui conduit à la suppression erronée des écritures antérieures à l’appel.
En outre, il est important de noter que les indicateurs de flux de contrôle des fonctions utilisateur sont transmis. Ainsi, dans des scénarios d'appels récursifs multiples, même si la fonction la plus profonde satisfait la logique susmentionnée, x = 1 pourrait tout de même être supprimé.
Dans le billet Solidity, un exemple similaire montre un contrat non affecté par cette vulnérabilité. Cependant, ce contrat n’est pas impacté non pas parce que UnusedStoreEliminator fonctionne différemment, mais parce qu’une étape d’optimisation Yul antérieure, appelée FullInliner, intègre automatiquement les petites fonctions ou celles appelées une seule fois directement dans la fonction appelante, évitant ainsi la condition déclencheuse de la vulnérabilité (présence d'une fonction utilisateur).
contract Normal {
uint public x;
function f(bool a) public {
x = 1;
g(a);
x = 2;
}
function g(bool a) internal {
if (!a)
assembly { return( 0, 0) }
}
}
Résultat de compilation :
La fonction g(bool a) est intégrée directement dans f(), évitant ainsi la condition vulnérable liée aux fonctions utilisateur, et neutralisant la vulnérabilité.

Solutions
La solution fondamentale consiste à ne pas utiliser les versions de compilateur Solidity concernées. Si l'utilisation d'une version vulnérable est nécessaire, envisagez de désactiver l'étape d'optimisation UnusedStoreEliminator lors de la compilation.
Pour atténuer la vulnérabilité directement au niveau du code du contrat, étant donné la complexité des différentes étapes d’optimisation et des flux d’appels réels, il est fortement recommandé de faire appel à des experts en sécurité pour auditer le code afin d’identifier les problèmes de sécurité induits par cette faille.
Bienvenue dans la communauté officielle TechFlow
Groupe Telegram :https://t.me/TechFlowDaily
Compte Twitter officiel :https://x.com/TechFlowPost
Compte Twitter anglais :https://x.com/BlockFlow_News














