Projet du week-end : s’amuser avec des défis en C
(lcamtuf.substack.com)- C est simple et expressif, mais les seules règles de l’instruction switch et des labels permettent déjà d’écrire du code capable de dérouter même des développeurs expérimentés
- L’obfuscation à la manière de l’IOCCC cache le code avec des macros et de la mise en forme, mais la syntaxe propre à C permet aussi de produire du code lisible tout en étant déroutant
switch (...)n’exige pas d’accolades et, comme il saute vers le label case correspondant, il peut se comporter différemment du flux habituel d’initialisation d’un bloc- Les labels
casen’ont pas besoin de se trouver au niveau supérieur d’un blocswitch, si bien qu’un switch sans accolades combinantif (0) case...et une chaîneelse ifcompile aussi - L’extension GNU C des adresses de labels avec
&&permet même de créer son propre switch ou des boucles fondées sur des labels, mais certains exemples sont propres à GCC et peuvent ne pas être sûrs vis-à-vis des comportements indéfinis
Le code déroutant que permet la syntaxe de C
- C a de nombreux défauts, mais sa syntaxe simple et son expressivité en ont fait un langage utilisé pour écrire de grands logiciels comme des systèmes d’exploitation
- Sa syntaxe concise a aussi influencé la structure du code de nombreux langages grand public qui lui ont succédé, de Java à Go
- L’exemple emblématique de l’obfuscation de code est l’IOCCC
- Les programmes primés à l’IOCCC sont souvent composés de macros de préprocesseur, d’une mise en forme inhabituelle, de noms de variables peu utiles et d’expressions arithmétiques obscures
- Ce type de code peut susciter l’admiration, mais il faut le rétroconcevoir en code normal, ce qui peut le rendre moins amusant à étudier
Les recoins profonds de C révélés par switch, case et goto
switch (...)peut s’écrire sans accolades, commeif (...)oufor (...)switch (i) case 1: puts("i = 1");compile correctement- Sans accolades, une seule instruction est attachée au
switch, si bien que lecase 2:qui suit n’est plus à l’intérieur duswitchet provoque une erreur
switchest fondamentalement une structure proche de goto, qui se déplace vers le labelcasecorrespondant- Même si le bloc
switchcontientint a = 123;etputs(...), un saut versdefault:n’exécute pas le code d’initialisation qui le précède - Dans ce cas,
an’est pas initialisée à 123 et, techniquement, le comportement est indéfini - L’exemple est consultable sur Godbolt
- Même si le bloc
- Les labels
casen’ont pas besoin d’être uniquement au niveau supérieur du blocswitchauquel ils sont associés- Une forme comme
if (0) case 0: puts("i = 0");fonctionne aussi - Comme
switchsaute directement vers cecase, leif (0)qui le précède est ignoré - Les autres instructions d’affichage qui suivent restent bloquées par la condition
if (0), ce qui permet d’éviter le fallthrough même sansbreak - L’exemple est consultable sur Godbolt
- Une forme comme
- Une chaîne
if ... elsese comporte syntaxiquement comme une seule instruction de niveau supérieur ; on peut donc la combiner avecswitchpour créer un switch étrange sans accolades- L’exemple inclut des
casede plagecase 1 ... 10ainsi qu’undefault - L’exemple est consultable sur Godbolt
- L’exemple inclut des
- L’opérateur
&&, une extension GNU, permet d’obtenir l’adresse d’un label puis de faire ungotovers cette adresse- On peut l’utiliser pour implémenter un switch maison avec une approche comme
goto *(void*[]){ &&case_0, &&case_1, &&case_2 }[i]; - L’exemple est consultable sur Godbolt
- On peut l’utiliser pour implémenter un switch maison avec une approche comme
- La même extension GNU permet d’implémenter une boucle fondée sur des labels dans une déclaration de variable, même sans
for (...)- L’exemple construit avec des labels et
goto *&&_une boucle qui affiche dei = 0ài = 5 - L’exemple est consultable sur Godbolt
- Ce dernier extrait est propre à GCC et peut ne pas être sûr vis-à-vis des comportements indéfinis
- L’exemple construit avec des labels et
- En C, même sans obfusquer volontairement le code, on peut créer des formes totalement inhabituelles et déroutantes
1 commentaires
Commentaires sur Hacker News
L’exemple ci-dessus affiche la valeur de
a, tout en disant qu’elle n’est pas initialisée à 123, mais en C cela peut effectivement arriverUtiliser une variable non initialisée ne signifie pas lire « la valeur qui se trouvait par hasard encore dans cette mémoire » : c’est un comportement indéfini, donc le compilateur peut faire ce qu’il veut
Par exemple, il pourrait toujours initialiser cette mémoire à 123, ou considérer que tout le fragment relève d’un comportement indéfini et supprimer toutes les instructions, de sorte que rien ne soit affiché. Il peut même optimiser en supprimant le
returnqui suit, ou des instructions précédentes, ce qui peut donner l’impression qu’un comportement indéfini a des effets « à rebours dans le temps »Il peut affecter des instructions précédentes, mais les réordonnancements de code et les transformations complexes peuvent aussi se produire sans comportement indéfini
Les objets statiques sont toujours initialisés, donc cette situation ne se présente pas
Restent les objets dynamiques, par exemple un membre non initialisé d’une structure allouée avec
malloc; lire de la mémoire dynamique non initialisée n’est pas un comportement indéfini en C, et l’on obtient la valeur représentée par les bits non initialisés. Si le type concerné n’a pas de représentation piège, cela ne peut pas échouerCe genre de syntaxe est aussi possible
switch(k) { if (0) case 0: x = 1; if (0) case 1: x = 2; if (0) default: x = 3; }On peut en faire une sorte de
switchqui n’a pas besoin d’unbreakà la fin de chaque clause, et même un macro du type#define brkcase if (0) case. Le compilateur n’aimera probablement pas le flot de contrôle, mais il devrait en général bien l’éliminer#define brkcase break;casesemble fonctionner de manière similaire, mais l’objectif du macro disparaît alors un peugotoplutôt que d’abuser duswitchà la manière du Duff's devicecasetient sur une ligne ou est entouré d’accoladesPar le passé, j’ai utilisé cette structure pour signifier « ne sauter que la première ligne du label
casesuivant, puis continuer en fallthrough sur le reste »Tout devient cohérent si l’on considère les labels
casenon pas comme des séparateurs entre instructions, mais comme de simples labelsOn peut utiliser cette technique pour implémenter des coroutines en C : https://stackoverflow.com/questions/24202890/switch-based-co...
Je ne sais pas pourquoi j’ignorais que
case 1 ... 10:était du C valideJ’ai utilisé C pendant des années, et je me demande de quelle norme cela vient
Je n’ai pas trouvé l’historique de cette extension, et à ma connaissance elle n’existe pas dans le C standard. Pour
clang, je ne suis pas sûrIl y a quelque temps, pour m’amuser, j’ai écrit un bout de C bizarre qui compte à rebours de 10 à 1
Les versions C, Python et shell peuvent toutes tenir en une ligne en utilisant
&&et des appels récursifssys.stdout.write(f"{n}\n")peut être remplacé parprint(n)Le code actuel, si l’on retire les
f-string, ressemble à du style Python 2, oùprintétait une instruction. En Python 3,printest une fonction normale et renvoieNone, qui est évalué comme faux ; il faut donc aussi remplacer le premierandpar unorUne autre chose surprenante est que
4[arr]est identique àarr[4]*(array_label+offset), et ici cela devient*(offset+array_label)Autrement dit,
*(arr+4)et*(4+arr)sont identiquesarr[i][j]etj[i[arr]]sont exactement identiquesIl suffit de savoir que
a[x][y]est équivalent à(a[x])[y], et quea[x]est équivalent àx[a]arr[i][j]→(arr[i])[j]→(i[arr])[j]→j[i[arr]]Le dernier fragment de code obscurci de l’article montre encore une autre extension GCC : https://stackoverflow.com/questions/34559705/ternary-conditi...
J’avais d’abord vu ces techniques ludiques de l’auteur de ce blog sur Twitter
On peut aussi créer des boucles avec une instruction
switch: https://twitter.com/lcamtuf/status/1807129116980007037J’ai l’impression que ce genre de bidouille avec
switchest une partie importante du Duff's deviceswitchet une boucle, sur le choix de laisser lescasetomber en fallthrough si l’on n’écrit pas explicitementbreak, et sur le fait qu’en C une boucle peut sauter de nouveau à l’intérieur duswitchDuff cherchait à optimiser des entrées/sorties à mémoire mappée (MMIO), et on ne ferait probablement plus cela ainsi en C aujourd’hui. Les MMIO ne sont plus aussi rapides que les instructions CPU, et dès qu’il y a un peu de données, on peut utiliser le DMA
Un langage moderne ne traiterait sans doute pas non plus les MMIO comme de simples déréférencements de pointeurs ; en C, on a continué à ajouter au système de types des échappatoires pour préserver cela
À titre personnel, je considère que le mécanisme des iterate loops de WUFFS est l’héritier du « Device » de Tom Duff. Il permet de préciser comment dérouler partiellement N étapes d’une boucle, tout en promettant un résultat équivalent à N exécutions du corps principal de la boucle, mais potentiellement plus rapide. La vectorisation comprend plus facilement l’intention, et les cas limites pénibles comme
M % N != 0sont correctement gérés par l’outil, pas par l’humain