Blinder le code : tests, traces et fuzzing
Comprendre le protocole, c'est la moitié du travail. L'autre moitié, c'est de s'assurer que le programme ne fait jamais n'importe quoi. Ces chiffres finissent dans le suivi d'un traitement à l'insuline : une mesure perdue, inventée ou décalée, ce n'est pas n'importe quoi
Ce chapitre raconte tout ce que j'ai mis en place pour être certain de ne pas faire de bêtises
Règle n°1 : ne jamais lire au-delà du paquet
Un paquet USB, c'est une suite d'octets qui vient de l'extérieur. Il annonce lui-même sa longueur, le nombre d'attributs, le nombre de mesures... Si on fait confiance à ces nombres sans vérifier, un paquet abîmé (ou un lecteur un peu exotique) peut faire lire le programme en dehors de sa mémoire. Au mieux il plante, au pire il renvoie des valeurs prises n'importe où.
Tous les décodeurs passent donc par une petite structure, Reader, définie dans protocol.h. Elle connaît la taille réelle de ce qu'on a reçu et refuse d'aller plus loin :
uint16_t Reader::u16() {
if(!has(2)) {
ok = false; // on retient que la lecture a débordé
return 0;
}
return be16r(data, offset);
}
Dès qu'une lecture déborde, ok passe à false et toutes les lectures suivantes renvoient 0. Le décodeur vérifie ok au bon moment et s'arrête avec un message précis, par exemple :
accuchek: data segment announces 200 entries but holds 2
La méthode sub(n) crée un "sous-lecteur" limité aux n octets d'un attribut. Même si l'attribut ment sur sa propre longueur, on ne peut pas déborder sur le suivant.
Règle n°2 : tout ou rien
Imaginez : le câble se débranche au milieu du transfert, après 300 mesures sur 638. Si le programme écrivait au fur et à mesure, on se retrouverait avec un fichier à moitié rempli, qui ressemble à un fichier normal. Et une application qui l'importe penserait que le lecteur ne contient que 300 mesures.
accuchek garde donc tout en mémoire et n'écrit le JSON sur la sortie standard que si toute la session a réussi. En cas d'échec, la sortie standard reste vide et la sortie d'erreur contient une seule ligne :
accuchek: failed to receive message data segment: Operation timed out (300 samples received before the error, none written)
Et le code de sortie dit de quelle famille de problème il s'agit :
| Code | Signification |
|---|---|
| 0 | succès, y compris un lecteur vide ("readings": []) |
| 1 | mauvaise utilisation : option inconnue, fichier de config ou trace illisible |
| 2 | aucun lecteur connu sur le bus USB |
| 3 | lecteur trouvé mais accès refusé : règle udev absente ou pas chargée |
| 4 | transfert USB échoué : délai dépassé, lecteur débranché |
| 5 | protocole : le lecteur a abandonné ou a répondu quelque chose d'inattendu |
Ces codes font partie du "contrat" du programme : Glucofi s'en sert directement pour choisir le message à afficher.
Pouvoir travailler sans lecteur
Le gros problème du reverse engineering d'un appareil, c'est qu'on ne peut tester qu'avec l'appareil sous la main. Et je n'allais pas emprunter le lecteur de mon proche à chaque modification du code, ni lui demander de se piquer le doigt pour avoir des mesures de test !
C'est là que le découpage en trois opérations du chapitre 2 (controlStatus, bulkOut, bulkIn) prend tout son sens. Elles forment une interface abstraite, Transport, et la session ne parle qu'à cette interface. Il en existe trois versions :
LibusbTransport: le vrai lecteur, via libusb ;RecordingTransport: il se place devant le vrai lecteur et enregistre chaque transfert dans un fichier texte (option--capture) ;ReplayTransport: il rejoue un fichier enregistré comme si le lecteur était branché (option--replay).
Le format d'une trace est volontairement tout simple, lisible à l'œil nu :
# accuchek trace v1
c 0000
< E2000032800000000001002A...
> E300002C0003507900268000...
< !-7
Une ligne par transfert, avec son sens et ses octets en hexadécimal. Une erreur USB s'écrit avec un ! suivi du code d'erreur libusb (ici -7, un délai dépassé). Au rejeu, le programme vérifie aussi que ce qu'il envoie correspond exactement à ce qui avait été envoyé. Si je change par erreur un octet d'un message sortant, le rejeu échoue en montrant les deux versions côte à côte.
La trace d'un téléchargement raté est gardée elle aussi : c'est justement là qu'elle est la plus utile pour comprendre ce qui s'est passé.
Un lecteur simulé
Pour les tests, j'ai écrit un simulateur (tests/sim.h) qui fabrique les paquets qu'enverrait un lecteur, avec la même disposition que ceux du pilote Tidepool et que les réponses capturées sur un vrai Accu-Chek Guide. On peut lui demander n'importe quel scénario : un lecteur vide, plusieurs paquets de données, des marqueurs repas, un lecteur qui refuse la mise à l'heure, un lecteur qui ne se décrit pas, un abandon en plein milieu...
À côté du simulateur, deux fichiers contiennent de vraies réponses d'un Accu-Chek Guide 925 (guide925_mds_answer.hex et guide925_segment_info.hex), avec le numéro de série, l'identifiant système et les dates remplacés. Ce sont eux qui garantissent que le décodage marche sur le vrai matériel, pas seulement sur ce que j'imagine qu'il envoie.
Les tests
Tout ça est vérifié par une suite de tests qui se lance avec une seule commande :
$ make test
accuchek: 105 tests, 0 failed checks
On y trouve trois familles :
- les décodeurs (
test_protocol.cpp) : dates BCD, valeurs HI et LO, heure d'été, format JSON, carte d'identité du lecteur ; - les sessions complètes (
test_session.cpp) : chaque scénario du simulateur rejoué de bout en bout, plus des tests du programme lui-même en ligne de commande (codes de sortie, rien sur la sortie standard en cas d'échec, logs qui ne polluent jamais la sortie JSON, règle udev cohérente avec la liste des lecteurs connus, fonctionnement sans root) ; - les limites (
test_bounds.cpp) : des paquets coupés à chaque longueur possible, des compteurs qui mentent, des tailles absurdes.
Quand elles sont installées, les tests sont compilés avec AddressSanitizer et UndefinedBehaviorSanitizer, deux outils du compilateur qui font planter le programme à la moindre lecture hors mémoire ou au moindre comportement indéfini, même s'il n'aurait rien fait de visible en temps normal.
Il y a aussi make schema-check, qui rejoue toutes les traces de test et valide chaque sortie JSON contre un schéma formel (schema/output.schema.json). Si un jour la forme de la sortie change par accident, ce test le voit.
Le fuzzing, ou l'art de tout casser exprès
Les tests vérifient les cas auxquels j'ai pensé. Le fuzzing sert à trouver ceux auxquels je n'ai pas pensé. Le principe : on prend des paquets valides et on les abîme au hasard, des centaines de milliers de fois. Couper un paquet, inverser un bit, remplacer un champ de longueur par 0xFFFF, ajouter des octets au hasard, ou carrément envoyer du bruit. Puis on regarde si le programme tient le coup.
Le fuzzer d'accuchek a une petite astuce que j'aime bien : chaque paquet abîmé est placé juste avant une page mémoire protégée. Avec mmap, on réserve de la mémoire, et avec mprotect, on interdit tout accès à la page qui suit le paquet :
map = (uint8_t *)mmap(0, mapSize, PROT_READ | PROT_WRITE, MAP_PRIVATE | MAP_ANONYMOUS, -1, 0);
mprotect(map + dataPages * page, page, PROT_NONE); // la page d'après est interdite
auto start = map + dataPages * page - bytes.size(); // le paquet finit pile au bord
memcpy(start, bytes.data(), bytes.size());
Résultat : lire ne serait-ce qu'un seul octet de trop fait planter le programme immédiatement, même sans AddressSanitizer. Pas de place pour un débordement discret.
$ make fuzz
fuzz seed=20261001: 200000 iterations, ..., invariant failures 0, crashes 0
Le fuzzer ne cherche pas que les plantages, il vérifie aussi des "invariants" : un paquet accepté ne doit jamais contenir plus d'entrées qu'il ne peut physiquement en tenir, et une date ou une horloge invalide ne doit jamais être acceptée comme valide. Le hasard est déterministe (un générateur xorshift avec une "graine"), donc en cas de plantage, le fuzzer affiche la commande exacte pour rejouer cette itération précise :
FUZZ_ARGS="--seed S --from N --count 1" make fuzz
L'intégration continue
Enfin, tout ça tourne automatiquement sur GitHub Actions à chaque modification : compilation, tests avec les sanitizers, validation du schéma JSON, 20 000 itérations de fuzzing, et vérification que make install place bien le programme et la règle udev. Une fois par semaine, le fuzzing monte à 200 000 itérations avec une graine différente à chaque fois.
Au final, il n'y a plus beaucoup de place pour la surprise. Si le lecteur répond quelque chose d'inattendu, accuchek s'arrête proprement, dit pourquoi, et n'écrit rien. Si tout se passe bien, on a la totalité des mesures, ni plus, ni moins.
Aucun commentaire à afficher
Aucun commentaire à afficher