- La propriété essentielle de temps constant (constant-time) dans le code cryptographique peut être rompue par les seules optimisations du compilateur ; une expérience consiste donc à ajouter un patch d’avertissement dans LLVM pour repérer les motifs à risque
- Les « optimisations » du compilateur peuvent accélérer certains benchmarks, mais les chemins critiques réels s’appuient souvent sur des intrinsics et de l’assembleur, tandis que le coût des bugs introduits par l’optimisation s’accumule séparément
- En juin 2024, Antoon Purnal a confirmé que le code de référence Kyber pouvait, avec certaines options d’optimisation de Clang 15 et ultérieur, être transformé en branchements conditionnels dépendant de valeurs secrètes, ouvrant potentiellement la voie à des attaques temporelles
- TIMECOP 2 vérifie, dans SUPERCOP, les résultats de compilation déclarés en temps constant, mais il est limité par les instructions prises en charge par Valgrind et par les flux de données effectivement observés pendant les tests
- Les réponses pratiques consistent à empêcher le compilateur de voir un résultat sur 1 bit comme un
bool, via des fonctions comme crypto_{int,uint}{8,16,32,64}.h, ou à migrer vers de l’assembleur vérifié, des langages orientés sécurité ou des compilateurs dédiés
Le vide de responsabilité créé par les « optimisations » du compilateur
- Les journaux de changements récents de LLVM et GCC mentionnent sans cesse des « optimisations », des tests d’« optimisation », des corrections de tests et des corrections de bugs d’« optimisation »
- Quand du code qui fonctionnait correctement avant compilation change de comportement après une modification du compilateur, la responsabilité est souvent renvoyée au programmeur qui aurait déclenché un « undefined behavior »
- Ces « language standards » sont rédigés par les auteurs de compilateurs ; en pratique, cela crée une structure où le code de millions de programmeurs porte plus de responsabilité que les changements d’un petit groupe d’auteurs de compilateurs
- Dans le code cryptographique, par exemple, sur plusieurs benchmarks CPU, l’implémentation
avx2 de kyber768 est environ 4 fois plus rapide que du code portable compilé avec un compilateur « optimisant »
Les limites de la mesure des performances d’optimisation
- En 2000, Todd A. Proebsting a formulé dans Proebsting's Law que « les progrès des compilateurs doublent la puissance de calcul tous les 18 ans », concluant que l’apport des optimisations de compilateurs est marginal
- En 2022, Arseny Kapoulkine a résumé dans un benchmark que LLVM 11 prend deux fois plus de temps que LLVM 2.7 pour compiler avec optimisation, et que le code exécuté est généralement 10 à 20 % plus rapide
- Les deux discussions passent à côté de la performance réellement perçue par les utilisateurs
- Les hotspots, où se concentre la performance, contiennent beaucoup d’intrinsics et d’assembleur
- FFmpeg contient 160 000 lignes d’assembleur si l’on compte les fichiers
.asm et .S
- Plus les ordinateurs et les réseaux traitent de données, plus le temps CPU réel se concentre sur ces hotspots
- Les coûts de sécurité augmentent eux aussi séparément dans les discussions sur l’optimisation
- Deloitte a indiqué qu’en 2023, les budgets de sécurité IT représentaient 0,5 % du chiffre d’affaires des entreprises
- Mis en regard du chiffre selon lequel le chiffre d’affaires total des entreprises dans le monde dépassait 48 000 milliards de dollars en 2022, l’ordre de grandeur global pourrait atteindre des centaines de milliards de dollars
- Il faut toutefois noter que les 0,5 % de Deloitte pourraient être une simple moyenne par entreprise, et que toutes les entreprises n’ont pas répondu à l’enquête
Fuites temporelles et cas Kyber
- Les problèmes de sécurité créés par les compilateurs « optimisants » ne se limitent pas aux bugs traditionnels : ils incluent aussi des fuites temporelles, où des informations secrètes transparaissent dans le temps d’exécution
- L’article EuroS&P 2018 de Laurent Simon, David Chisnall et Ross Anderson avertit qu’une mise à niveau du compilateur peut ouvrir sans prévenir un canal temporel dans du code auparavant sûr
- L’exemple mis en avant dans l’article de 2018 portait sur du code qui choisissait entre deux valeurs avec un
bool, ce bool poussant le compilateur à générer un saut conditionnel
- Dans les implémentations cryptographiques, la pratique consiste à éviter cela en supprimant
bool du code critique et en créant des fonctions de comparaison en temps constant séparées
- OpenSSL est cité comme déclarant 37 fonctions à cette fin
- Le cas de 2015 concernant
curve25519-donna et MSVC 2015 est présenté dans l’article comme un malentendu
- En réalité, lors de la compilation pour x86 32 bits, les opérations
int64 étaient transformées en appels à llmul.asm, la bibliothèque int64 32 bits de Microsoft
- La fuite temporelle provenait de branchements dépendant des données dans
llmul.asm, et cette bibliothèque devrait elle aussi être incluse dans une conception raisonnable du code source
- En juin 2024, Antoon Purnal a confirmé que le code de référence Kyber pouvait permettre des attaques temporelles avec certaines options d’optimisation de Clang 15 et ultérieur
- La forme problématique était
(-((x>>j)&1))&y, un calcul qui produit y si le bit j de x est défini, et 0 sinon
- Clang transforme ce bit en
bool via une instruction de test de bit, puis génère un branchement conditionnel fondé sur ce bool
- Dans LLVM, cette « optimisation » est gérée par
combineShiftAnd1ToBitTest dans lib/CodeGen/SelectionDAG/DAGCombiner.cpp
- Cette fonction a été ajoutée par Sanjay Patel en septembre 2019, puis modifiée par plusieurs personnes
- GCC présente aussi des cas similaires de franchissement de limite
- Un patch GCC d’ARM, datant de novembre 2021, transforme
(-x)>>31 en -(x>0)
- Un avertissement à ce sujet est apparu en avril 2024
TIMECOP et vérification du temps constant
- TIMECOP 2 est intégré au framework de tests cryptographiques SUPERCOP et vérifie automatiquement les branchements conditionnels dérivés de valeurs secrètes dans le code compilé déclaré en temps constant
- La vérification ne couvre pas seulement les branchements conditionnels, mais aussi les index de tableaux dérivés de valeurs secrètes
- L’article KyberSlash décrit aussi un patch vérifiant les divisions dérivées de valeurs secrètes
- TIMECOP 1 était un outil créé par Moritz Neikes en modifiant SUPERCOP, automatisant l’approche ctgrind d’Adam Langley
- TIMECOP 2 étend l’approche existante sur plusieurs points
- Il marque automatiquement la sortie du RNG comme valeur secrète
- Il prend en charge la « declassification »
- Il prend en charge la déclaration d’« entrées publiques »
- Il s’exécute sur plusieurs cœurs
- TIMECOP a des limites claires
- Il ne peut traiter que les instructions prises en charge par Valgrind, et s’arrête donc par exemple sur les instructions AMD XOP
- Il ne vérifie que les flux de données observés pendant l’exécution effective des tests
- Les travaux sur les outils de vérification du comportement en temps constant se poursuivent ; une liste d’outils liés est disponible sur ct-tools
- Une vérification équivalente à TIMECOP a été intégrée à la suite de tests de libmceliece et pourrait se diffuser à d’autres bibliothèques
Méthodes de réécriture en temps constant
- Après avoir trouvé des fragments de code à temps variable, il faut une méthode pour les réécrire en temps constant sans introduire de bugs
- Une présentation de juillet 2024 a montré certaines fonctions en temps constant fournies par libmceliece et SUPERCOP
- Les fichiers s’appellent
crypto_{int,uint}{8,16,32,64}.h
- Ces fichiers peuvent être copiés dans d’autres projets
- La fonction d’exemple
crypto_uint32_bitmod_mask(x,j) a le même effet que -((x>>(j&31))&1), mais empêche le compilateur de voir le résultat sur 1 bit
- Un exemple plus complexe est
crypto_uint32_max(x,y)
- L’article de 2018 traite d’un tweak ajoutant à Clang/LLVM une fonction en temps constant
__builtin_ct_choose(bool cond, x, y)
- L’article suggère à tort que cette seule fonction suffirait
- Cette fonction pourrait un jour entrer dans le compilateur, mais il pourrait falloir longtemps avant que les projets puissent en dépendre
- Son mode d’implémentation est jugé plus fragile que
crypto_{int,uint}{8,16,32,64}.h
Éviter le problème en amont
- Si les tests avant distribution d’une bibliothèque compilée détectent une fuite temporelle introduite par le compilateur, la distribution peut utiliser l’ancienne version du compilateur pendant la réécriture du code
- Cette approche est une réponse temporaire qui continue de protéger les utilisateurs
- Une solution consiste à distribuer la bibliothèque en assembleur
- La présentation RWC 2024 Adoption of high-assurance and highly performant cryptographic algorithms at AWS présente un logiciel X25519 rapide dont il est prouvé qu’il calcule correctement X25519 pour toutes les entrées
- L’implémentation est écrite en assembleur, en deux versions pour CPU Intel/AMD 64 bits et deux versions pour CPU ARM 64 bits
- La proposition de correction porte sur le code machine réellement exécuté par l’utilisateur, et la preuve est vérifiée par le prouveur de théorèmes HOL Light
- Cependant, pour les logiciels cryptographiques qui n’atteignent pas ce niveau, la difficulté d’auditer l’assembleur demeure
- Pour le code écrit en C, C++ et autres langages similaires, des méthodes permettant d’ajouter rapidement un « vaccin » contre les fuites temporelles sont également explorées
Expérience de patch clang-vs-clang
x&1 et x>>31 ont en commun de ne produire que deux résultats possibles
x&1 vaut 0 ou 1
- Pour un
uint32, x>>31 vaut 0 ou 1
- Pour un
int32, x>>31 vaut 0 ou -1
- Ces formes sont faciles à envoyer vers un
bool pour les auteurs d’« optimisations » de compilateurs
- Il est recommandé de toujours compiler avec
-fwrapv afin que GCC et Clang supposent une arithmétique en complément à deux
- Un simple scan des sources à la recherche de
&1, 1&, >>31, etc. remonte déjà de nombreux exemples, mais ici le scan est effectué autrement, en patchant directement l’« optimizer » de LLVM
- Le patch part du commit LLVM
68df06a0b2998765cb0a41353fcf0919bbf57ddb, cherche &1 et >>31, puis émet l’avertissement suivant
please take this away before clang does something bad
- Un exemple de commande de compilation est
clang -Rpass-analysis=clang-vs-clang -O -c x.c
- La fonction de test est la suivante
int sra31(int x)
{
x >>= 31;
return x;
}
- Il n’est pas surprenant que le même avertissement soit répété
- Le compilateur continue d’essayer d’appliquer des « optimisations » jusqu’à ce qu’elles ne progressent plus
- La sortie de
clang-vs-clang distingue signed et unsigned dans les shifts
- Cette différence est importante pour une réécriture manuelle ou automatique fondée sur
crypto_{int,uint}{8,16,32,64}.h
clang-tidy fait partie des méthodes possibles pour automatiser la transformation des sources
- Le code exclu par
#ifdef ou éliminé avant cette phase d’« optimisation » ne produit pas d’avertissement clang-vs-clang
Résultats d’exécution SUPERCOP et cas trouvés
- SUPERCOP 20240716 a été exécuté sur un double EPYC 7742 avec
./data-do-biglittle
- L’overclocking était désactivé
- La liste des compilateurs SUPERCOP a été ajustée pour utiliser
clang-vs-clang en ajoutant -Rpass-analysis=clang-vs-clang aux lignes clang de okcompilers/{c,cpp}
- Les résultats étaient prêts après 3 heures
- La sortie de Clang comptait au total 675 752 lignes
- La taille d’origine était de 210 786 494 octets
- Le résultat compressé est
20240803-fromclang.txt.gz, de 3 595 199 octets
- La sortie contient beaucoup de bruit issu de branchements source fondés sur des données publiques, qui créent des
&1 en interne dans Clang
- Un exemple clairement pertinent pour une modification préventive est le suivant
a0 += (a0>>15)&106;
- Un exemple qui demanderait un effort de parsing C pour être trouvé par un simple scan des sources est le suivant
- La macro
ONE8 est définie comme ((uint8_t)1)
*pk2^=(((* pk_cp)>>ir)&ONE8)<<jr;
- Un exemple encore plus difficile à trouver vient de macros basées sur des intrinsics AVX2
signmask_x16(x) est défini comme _mm256_srai_epi16((x),15)
- Cela décale à droite de 15 bits chaque fragment signé de 16 bits dans un vecteur de 256 bits
mask = signmask_x16(sub_x16(x,const_x16((q+1)/2)));
- Ce cas AVX2 n’est pas prioritaire
- Pour qu’une opération vectorielle devienne un branchement conditionnel, il faudrait compiler en AVX-512 et que le compilateur prenne la décision étrange de transformer un
bool vectorisé en branchement conditionnel bool sériel
- TIMECOP utilise Valgrind, et Valgrind ne prend pas en charge AVX-512
- Pour l’instant, la compilation AVX-512 n’est pas recommandée
int128 et réponses plus larges
- La découverte la plus intéressante est un cas où un shift à droite de 64 bits sur
int128 a déclenché un avertissement >>
- Une implémentation
int128 peut utiliser en interne un shift à droite de 63 bits pour déterminer le signe du mot haut de 64 bits
- Si Clang ajoute, comme GCC, une prise en charge transformant un shift à droite de 63 bits en
bool, puis en branchement conditionnel, beaucoup de code int128 pourrait soudain devenir à temps variable
- Dans ce cas, la situation ressemblerait à ce qu’affirmait le titre de l’article de 2015, mais cette fois elle se produirait réellement même sans
bool dans le source
- La protection la plus simple au niveau source est d’éviter l’implémentation
int128 existante du compilateur et d’utiliser des fonctions crypto_int128
- Contrairement au
int128 de GCC et Clang, crypto_int128 peut aussi fonctionner sur de petites plateformes 32 bits
- L’ajout de types de données secrets à GCC et Clang semble une bonne idée, mais il est difficile de voir comment la rendre robuste dans l’architecture de ces deux compilateurs
- Les compilateurs conçus dès le départ pour la sécurité inspirent davantage confiance
- Parmi les compilateurs orientés sécurité qui exigent un nouveau langage d’entrée figurent FaCT et Jasmin, activement développé
- Le temps nécessaire à la réécriture du code suscite des inquiétudes, mais au vu de la façon dont les compilateurs actuels traitent le code existant, une action est nécessaire sous une forme ou une autre
1 commentaires
Avis sur Hacker News
Il n’est pas correct d’appeler cela un bug du compilateur quand du code ayant un comportement indéfini ne se comporte pas comme souhaité.
C’est un peu comme lancer
ddavec de mauvais arguments, perdre ses données, puis dire quedda un bug.Il est difficile de dire que le code source ou le compilateur est bogué ; il est plus juste de considérer que la norme C est trop peu spécifiée selon les critères de l’auteur, ce qui crée des bugs de sécurité sur certaines cibles.
Au bout du compte, les auteurs de la norme C ne peuvent pas définir jusqu’au comportement du matériel ; ils ne peuvent définir que la sémantique du langage. Le domaine de la cryptographie est donc condamné à souffrir de bugs imputables au matériel.
L’un des avantages de Rust est de limiter les comportements potentiellement indéfinis aux blocs
unsafe. Même si Rust définit beaucoup de choses qui sont indéfinies en C, dès qu’on entre dans du codeunsafe, il est très facile de tomber par erreur sur un comportement indéfini subtil.Le troisième modèle, qui consiste à échouer silencieusement en générant du code imprévisible, n’est utile qu’aux auteurs de compilateurs. Se réfugier derrière la spécification n’apporte rien aux vrais utilisateurs.
Ces optimisations cassent du code qui fonctionnait bien auparavant. Les auteurs de compilateurs pourraient privilégier la compatibilité descendante, mais ils ne le font pas.
De plus, comme ces optimisations n’améliorent pas vraiment les performances du code réel de manière significative, il faudrait réfuter l’idée que ce compromis, qui casse du code, n’en vaut pas la peine.
J’aime bien Bernstein, mais il lui arrive de se tromper de cible et de devenir excessif ; cet article en est un bon exemple. Il le reconnaît d’ailleurs à moitié lui-même à la fin.
Une grande partie de l’article porte sur le point secondaire de savoir à quel point les gains d’optimisation sont intéressants, et même avec des données, c’est un jugement qui dépend des cas d’usage.
Le grief principal est que les compilateurs C ne prennent pas en compte une sémantique impossible à exprimer dans le langage, ce qui n’a rien d’étonnant.
À la fin, il dit « utilisez un langage capable d’exprimer la sémantique dont vous avez besoin » ; tout l’article aurait pu être remplacé par cette seule phrase.
Pour une bonne partie d’entre eux, la justification est douteuse, et cela rend l’écriture de programmes corrects plus difficile.
C et C++ ne sont pas adaptés à l’écriture d’algorithmes offrant une garantie de temps constant.
La norme ne contient presque aucune notion de temps réel, et les compilateurs ne fournissent pas non plus de garanties supplémentaires via des extensions.
Mais rejeter la faute sur les développeurs de compilateurs n’est pas la bonne direction.
Sur les CPU Intel, ni
clangni aucun autre compilateur ne peut générer du code correct en mode utilisateur, parce qu’un code correct n’existe tout simplement pas.https://www.intel.com/content/www/us/en/developer/articles/t...
En regardant
DOITMdans le document, il est tout simplement impossible pour une bibliothèque de chiffrement en espace utilisateur de définir le bit nécessaire.Une fois activé, cela fonctionne aussi en espace utilisateur ; on pourrait donc par exemple en faire un indicateur par processus activé via l’appel système
prctl, puis ajuster leMSRlors des changements de tâche par l’ordonnanceur.Rien que la phrase « chaque fois qu’ils le peuvent, les auteurs de compilateurs refusent d’assumer la responsabilité des bugs qu’ils ont créés » suffit à voir rarement l’expertise d’un billet de blog s’effondrer aussi vite.
Si l’on suit même le lien, il ne s’agit que d’un point très élémentaire de C : un comportement indéfini ne signifie pas qu’il produit une « valeur arbitraire ».
Même en présence d’un comportement indéfini, le code source peut être bogué alors que le programme généré reste souvent correct. Plus tard, si un auteur de compilateur ajoute une nouvelle optimisation et génère, sur la base de ce comportement indéfini, un programme bogué, la bataille sur les responsabilités commence.
Ce qu’on rechigne à admettre, c’est que la responsabilité envers l’utilisateur est répartie entre toutes les parties. Si la batterie a pris feu simplement parce qu’une application CRUD a déréférencé
NULL, une personne raisonnable ne se contenterait pas de reprocher à l’auteur de l’application d’avoir oublié un test deNULL.Les compilateurs, les systèmes d’exploitation et les fabricants de matériel doivent aussi répondre de produits conçus de manière irresponsable ; la mention « comportement indéfini » dans la norme ISO ne suffit pas à clore le sujet. Tous les acteurs de la chaîne d’approvisionnement partagent la responsabilité d’anticiper les mauvais usages possibles de leurs produits et de les gérer raisonnablement.
Le comportement indéfini existe pour apporter de la valeur. On peut concevoir un langage sans cela ; s’il existe malgré tout, c’est pour la portabilité et pour la flexibilité qu’il donne aux auteurs de compilateurs.
Le cœur du texte est de savoir si cette flexibilité vaut la difficulté d’écrire des programmes sans comportement indéfini.
L’auteur estime que l’argent perdu à cause des bugs semble supérieur à celui économisé grâce à un bytecode plus rapide, et que, comme les auteurs de compilateurs ont une forte influence sur ce qui entre dans la norme du langage, la volonté de corriger cela est faible.
À noter que
clangdispose de l’attributclang::optnone, qui désactive toutes les optimisations fonction par fonction, et que GCC possède l’excellent attributgnu::optimize, qui permet d’ajouter ou de retirer des optimisations par leur nom, ou de fixer un niveau d’optimisation indépendamment des flags du compilateur.gnu::optimize(0)ressemble à ce flag declang.clangpropose aussiclang::no_builtins, qui désactive en particulier les optimisations dememcpyetmemset.optimizene devrait être utilisé qu’à des fins de débogage et n’est pas adapté au code de production. »https://gcc.gnu.org/onlinedocs/gcc/Common-Function-Attribute...
Je comprends dans une certaine mesure les objectifs recherchés par les gens de la cryptographie, par exemple l’évaluation en temps constant et la dissimulation des valeurs secrètes.
Mais un compilateur généraliste ne pense pas à ce genre de choses la plupart du temps, donc cela semble difficile d’aller au-delà de hacks qui fonctionnent à peu près.
Pour faire les choses sérieusement, il faudra sans doute un compilateur spécialisé maison, ou continuer à passer par l’assembleur.
Un jour, on considérera probablement notre époque comme le mauvais vieux temps, et on aura quitté C pour des langages avec beaucoup moins de comportements indéfinis
En C, il est beaucoup trop facile d’écrire des expressions qui compilent, mais dont le compilateur ne peut absolument pas deviner l’intention
Par exemple, en Python, on peut écrire du code comme
result = [something(value) for value in set_object]. Comme un objetsetn’a pas d’ordre, il est clair que l’ordre de traitement des éléments et l’ordre du résultat n’ont pas d’importance, ce qui ouvre la voie, au niveau du langage, à de nombreuses optimisations sans que le compilateur ait à deviner l’intention de l’auteurUn code similaire dans un autre langage avec des données immuables va encore plus loin : comme
something(value1)ne peut pas affectersomething(value2), il peut être exécuté en parallèle, que ce soit avec des threads ou des processusUne grande partie de l’optimisation des compilateurs C consiste à observer des motifs de code et à trouver comment faire plus vite ce que l’auteur avait probablement l’intention de faire. Par rapport aux langages modernes, C exprime mal l’intention, ce qui laisse une certaine liberté d’interprétation, mais il faut effectuer ce type d’inférence pour obtenir des performances correctes
Cela dit, c’est peut-être une bénédiction déguisée, un peu comme lorsque le télescope Hubble a eu besoin de lunettes. Pour dépasser ces limites, on a inventé d’excellentes techniques, et une fois le problème corrigé, ces techniques ont produit des performances bien supérieures à ce qui était initialement attendu. Appliquer les optimisations des compilateurs C à des langages non-C pourrait presque fonctionner comme un super-pouvoir
C’est fondamentalement proche d’un comportement indéfini, sauf que cela ne se manifeste pas immédiatement comme un problème de sûreté, mais comme un résultat incorrect. Bien sûr, un résultat incorrect peut ensuite mener à un problème de sûreté
Contrairement au comportement indéfini, il est pratiquement impossible de créer un « sanitizer » qui vérifierait que le code fonctionne pour tous les ordres possibles d’un
setgccetclangdisposent de nombreux indices bas niveau que l’on ne trouve pas souvent dans d’autres langages. Il y a__builtin_expect/__builtin_unpredictable,__builtin_unreachable/__builtin_assume,#pragma clang loop vectorize(assume_safety)/#pragma GCC ivdep, ainsi que des pragma pour désactiver le déroulage de boucle ou la vectorisation, ou pour choisir certaines valeursLe plus grand manque, à mon avis, est une barrière d’optimisation explicite empêchant le compilateur de faire des inférences à partir de l’origine des valeurs.
__asm__le permet dans une certaine mesure, mais avec des effets secondaires indésirables et la nécessité de noms de types de registres propres à chaque plateformeLe potentiel des optimisations de haut niveau fondées sur l’intention est lui aussi évident. On peut imaginer réserver l’espace d’une liste-tableau avant de faire
npushdans une boucle, fusionner des accès à une hashmap du typecontains→get→putavec la même clé, ou encore inférer localement le comportement d’allocation global pour éliminer des objets et des allocationsC est suffisamment proche du matériel réel pour que le programmeur puisse simplement dire quoi faire, sans que le compilateur ait besoin de deviner son intention
Les langages qui implémentent ce type d’optimisations mémoire sont pour la plupart de la famille Java, et comme ils comportent dès le départ une pessimisation agressive en amont, cela crée une motivation pour ces optimisations. Mais même ces optimisations ne compensent pas les pertes
L’essentiel, c’est que C n’est pas terrible, mais que l’autre option est pire
Si la sémantique de C ne vous plaît pas, ne vous énervez pas contre les ingénieurs compilateurs : utilisez un autre langage de programmation
qhasm. Même Zig. Cette critique venant de lui n’est donc pas très surprenanteUn article rafraîchissant qui expose un point de vue qu’on n’entendait pas souvent. À lire aussi : https://gavinhoward.com/2023/08/the-scourge-of-00ub/