Vous lancez un programme. Il doit vous dire à quelle catégorie appartient un client. Il affiche :
2864743186439
Code de sortie : 0. Aucune erreur, aucun avertissement, aucun plantage. Le programme est content de lui.
La bonne réponse était premium.
Et le plus troublant, c’est que ce nombre n’est pas du bruit. Ce n’est pas de la mémoire corrompue, ce n’est pas une valeur au hasard. C’est la bonne réponse — simplement lue de travers. On va le prouver en deux lignes.
Décodons le nombre
Passons-le en hexadécimal :
2864743186439 (décimal)
= 0x29B00000007 (hexadécimal)
Maintenant coupons-le en deux moitiés :
0x29B 00000007
└─┬─┘ └───┬──┘
│ │
0x29B = 7
= 667 longueur
« le texte commence à l'octet 667, et fait 7 octets de long »
Et si on va lire le fichier source à l’octet 667, sur 7 octets, on trouve exactement :
premium
La réponse était là. Complète, correcte, à la bonne adresse. Le programme la tenait dans la main — et l’a imprimée comme un nombre entier.
Deux nombres qui veulent dire « du texte »
Ce format, on l’a déjà croisé la semaine dernière, en regardant le serveur HTTP émis en 943 octets. Le parseur y extrayait la méthode et le chemin de la requête, et j’insistais sur un point : on ne copie rien. On note juste où ça commence et combien ça fait.
C’est ça, un span compacté : deux nombres rangés dans un seul entier.
Un entier 64 bits, coupé en deux :
┌────────────────────────┬────────────────────────┐
│ début (l'adresse) │ longueur (les octets) │
└────────────────────────┴────────────────────────┘
667 7
Pour l'ordinateur : le nombre 2864743186439.
Pour le langage : le texte "premium".
C’est très économique. Aucune copie, aucune allocation, un seul registre transporte une chaîne de n’importe quelle taille. C’est exactement pour ça que le serveur de la semaine dernière tient en 943 octets.
Mais il y a une contrepartie, et c’est le sujet de tout cet article : rien, dans le nombre lui-même, ne dit que c’est du texte. L’information « ces 64 bits sont un span, pas une quantité » n’existe que dans la tête du compilateur. Le jour où il l’oublie, il n’y a pas de plantage — il y a juste un nombre imprimé à la place d’un mot.
Analogie : c’est une adresse postale. « 12 rue des Lilas » désigne une maison. Si vous oubliez que c’est une adresse, vous pouvez toujours faire l’addition 12 + 4 et obtenir 16. Le calcul est valide. Il ne veut simplement plus rien dire.
Quatre façons d’oublier, en une semaine
Neuf défauts ont été fermés sur verbose entre le 3 et le 8 août. Quatre d’entre eux sont la même erreur, à quatre endroits différents — et tous les quatre produisent une réponse fausse avec un code de sortie 0.
Ce qu'on demande Ce qu'on obtenait Ce qu'il fallait
─────────────────────────────────────────────────────────────────────────
la catégorie du client 2864743186439 premium
une fiche client 1 {"name":"Ada",
"bonus":5000}
une phrase composée Hello 2287825359413968899, Hello Ada,
age 36 age 36
« ce texte vaut-il "ab" ? » (comparaison d'adresses) (comparaison
d'octets)
Les trois premiers sont des oublis à l’impression : le compilateur savait produire la valeur, mais pas l’afficher comme du texte. Le deuxième est particulièrement parlant — il n’imprimait même pas un span, mais l’indice interne de l’emplacement mémoire où la fiche était rangée. Un 1. Bien rangé, complètement muet.
Le quatrième est d’un autre genre, et c’est le plus intéressant. Il concerne la comparaison. Le code généré pour == était :
pop rcx ; le second span
pop rax ; le premier span
cmp rax, rcx ; on compare... les deux nombres
sete al
Ça compare deux adresses, pas deux chaînes. Deux textes identiques rangés à deux endroits différents de la mémoire sont donc déclarés différents. Et deux textes différents qui commenceraient par hasard au même endroit seraient déclarés identiques.
Le désassemblage le montre sans ambiguïté — la constante "ab" est un span littéral compilé en dur :
movabs rax, 0x13700000002 ; "ab" : début 0x137, longueur 2
Ce défaut-là mérite une mention spéciale, parce qu’il est arrivé après qu’on ait annoncé la famille fermée. Trois mauvaises réponses silencieuses corrigées, catégorie déclarée vide… et le quatrième mécanisme apparaît dans la journée. D’où la première leçon consignée dans le dépôt :
Préférer nommer le mécanisme qu’on a fermé plutôt que déclarer une catégorie vide.
C’est une règle d’honnêteté, pas de modestie. « J’ai corrigé l’impression des Result » est vérifiable. « Il n’y a plus de mauvaises réponses silencieuses » ne l’est pas — et se révèle faux en quelques heures.
Le même oubli, en version dangereuse
Un cinquième défaut vient de la même racine, mais il ne se contente pas de mal répondre.
En verbose, on peut déclarer une borne sur un champ texte : name : text [..N] — au plus N octets. L’émetteur natif exploite cette déclaration pour dimensionner le tampon à la compilation. Logique : vous avez promis au plus N, il réserve N.
Sauf que la passe de remplissage, elle, écrivait les octets réels, mesurés à l’exécution. Rien ne vérifiait la promesse.
Le tampon est dimensionné depuis la DÉCLARATION (au plus N)
Le tampon est rempli depuis la RÉALITÉ (ce qui arrive)
─────────────────
et personne ne compare les deux
Un débordement de tampon sur la pile, contrôlé par l’entrée. Et — c’est là que ça pique — atteignable à distance, via les champs method et path d’une requête HTTP. Sur echo_path.verbose, un chemin de 3001 octets faisait écrire environ 2740 octets choisis par l’attaquant au-delà du tampon.
echo_path.verbose, c’est précisément l’exemple que j’ai montré la semaine dernière pour illustrer le routage. Autant le dire franchement : le petit serveur que je vous ai présenté comme une réussite avait, à ce moment-là, un débordement exploitable à distance. Il est corrigé depuis (bornes vérifiées à l’exécution, côté natif puis côté auto-hébergé) — mais l’ordre des choses est celui-là, et le cacher n’apprendrait rien à personne.
Pourquoi ce sont les pires bugs
Un plantage est une bonne nouvelle. Il est bruyant, daté, reproductible ; il vous emmène droit à la ligne fautive. Personne ne construit sur un plantage.
La mauvaise réponse silencieuse, elle, est adoptée. Elle sort avec un code 0, elle a la bonne forme, elle passe les tests qui vérifient « ça n’a pas planté ». Elle entre dans un fichier, dans une facture, dans une décision. Et quand elle est enfin repérée, il faut remonter tout ce qui a été construit dessus.
C’est aussi pour ça qu’on ne les trouve pas en relisant le code : il n’y a rien à voir. Le code compile, tourne, sort en 0. Il faut un mécanisme qui compare deux exécutions — la version de référence et la version auto-hébergée, sur le même programme, et qui exige le même octet en sortie. C’est le troisième pilier de la qualité de l’auto-hébergement, après la reproduction à l’octet près (le point fixe) et la vérification à l’émission.
Et ce harnais a lui aussi appris quelque chose cette semaine, consigné comme deuxième leçon :
Un balayage différentiel avec des entrées de remplissage peut manquer un défaut qui est correct sur la moitié de son espace d’entrée.
Le bug de comparaison de textes est passé au travers exactement comme ça : avec des entrées bidon, les deux branches ne sont pas exercées. Comparer des adresses donne parfois la bonne réponse — assez souvent pour avoir l’air vivant. Le balayage matérialise désormais de vraies entrées, et exerce les deux branches.
Ce qu’il faut en retenir
un texte = deux nombres (début, longueur) dans un seul entier
│
├── oublié à l'impression → un nombre à la place d'un mot (3 défauts)
├── oublié à la comparaison → on compare des adresses (1 défaut)
└── oublié au dimensionnement → débordement de tampon (1 faille)
Une seule représentation, cinq façons d’oublier ce qu’elle veut dire. C’est le prix de l’économie : ne rien copier, c’est rapide, mais ça déplace la responsabilité du type vers le compilateur — et un compilateur qui oublie ne prévient pas.
Épilogue : et l’instrument de mesure, lui ?
Ce texte était écrit quand la semaine suivante a fourni sa propre chute.
Tout ce qui précède repose sur un chiffre : « le compilateur auto-hébergé accepte 100 fichiers du corpus ». C’est la métrique du projet, celle qui dit où on en est.
Elle était fausse.
Le binaire émis démarrait sur la règle n°0 — non pas parce qu’on l’avait déclarée, mais parce que l’émetteur dispose les procédures dans l’ordre du fichier et fait pointer l’entrée sur la première. Autrement dit : le programme exécuté était celui écrit en premier. Sur les 100 fichiers, dans 37 cas, cette première règle était une fonction auxiliaire appelée par la vraie.
La démonstration tient en une commande :
$ gen0 0 < examples/aes_gcm.verbose > aes_gcm && ./aes_gcm 0 1 16 255
99 124 202 22 # la table S-box de FIPS-197, exacte à l'octet.
# Pas la moindre trace de GCM là-dedans.
Le fichier s’appelle aes_gcm. On croyait mesurer AES-GCM. On mesurait sa table de substitution. Huit fichiers sha256_* entraient en réalité dans rotr32 ; six fichiers aes_* dans aes_sbox ; et le parseur du langage lui-même entrait dans skip_spaces.
Rien ne plantait. Le compilateur acceptait le fichier, produisait un binaire, ce binaire tournait et sortait en 0 avec des octets parfaitement corrects — ceux d’un autre programme. C’est très exactement la mauvaise réponse silencieuse décrite plus haut, remontée d’un étage : plus dans le produit, mais dans l’appareil qui sert à juger le produit.
Et il n’était pas seul. La même semaine, on a découvert que le compilateur n’était pas reproductible depuis des semaines : un parcours de table de hachage laissait fuiter dans la disposition des octets émis l’ordre aléatoire propre à chaque exécution. Même source, même taille, octets différents. Là encore : aucune erreur, aucun symptôme.
Il n’y a pas de morale rassurante à en tirer, et c’est bien le sujet. Un instrument de mesure ne s’auto-vérifie pas ; il faut décider d’aller le regarder, en sachant qu’on va probablement s’y trouver moins avancé qu’annoncé. Le chiffre a été corrigé, pas maquillé.
Et la limite, nommée comme les fois précédentes : la v0.10.0 n’est toujours pas taguée, main a maintenant 88 commits d’avance sur la v0.9.0. Sixième semaine que le dossier se renforce. Ce n’est plus du code qui manque.
La prochaine fois que votre programme affiche un grand nombre bizarre, essayez : passez-le en hexadécimal, coupez-le en deux. Il y a une chance non nulle qu’il vous dise, très exactement, où se trouve votre réponse.