HDWSec
Ctf CTFHardwareHackingIAIoTSécu · 7 min de lecture

Tortoise Say, FCSC 2026 Writeup

Pour ce writeup FCSC 2026, on s’attaque à Tortoise Say : un challenge hardware où le flag est caché dans la communication SPI entre un contrôleur et un écran e-Ink. L’occasion de revenir sur l’analyse d’une capture VCD, mais aussi sur ce que les IA savent faire (ou non) lorsqu’il faut comprendre du matériel plutôt que du code.

FT
Félix TERRIEN Ingénieur cybersécurité
Tortoise Say, FCSC 2026 Writeup

Introduction

Dans la continuité du challenge de PWN que l’on a résolu pour le FCSC 2026, nous avons voulu profiter de l’occasion pour revenir sur un terrain que l’on croise ponctuellement chez HDW Sec : la recherche de vulnérabilités IoT et le hacking hardware. Tortoise Say faisait justement parti des challenges hardware.

Le scénario du challenge est simple en apparence : une tortue dessinée utilise un écran e-Ink pour afficher un secret, et on nous donne la capture logique de la communication avec l’écran au moment où le secret apparaît. On est donc limité à seulement un fichier VCD et quelques broches nommées D0 à D5.

J’ai essayé de me faire aider par des modèles comme Claude (Sonnet 4.6) et ChatGPT (5.4) pendant l’analyse. Ils étaient utiles pour rassembler la documentation, rappeler le principe général du SPI ou proposer une structure de script. En revanche, ils n’ont pas réussi à traiter correctement le cœur du challenge, notamment le traitement du VCD : même avec la documentation, l’IA finissait vite dans une boucle de réflexion qui ne menait nulle part.

Mais dans un challenge hardware, cette étape est justement la plus importante. Il faut vérifier chaque signal, et beaucoup bidouiller, en comptant les octets, testant des hypothèses sur la résolution ou le sens de l’affichage. C’est une analyse très expérimentale, où on va avancer à tâtons pour réussir à obtenir une image. L’IA peut accélérer l’exploration de la documentation, mais elle ne remplace pas encore la réflexion humaine sur ce type de matériel hardware.

Présentation du challenge

L’énoncé nous donne une capture d’analyseur logique au format VCD ainsi que le modèle de l’écran utilisé. Les broches observées sont les suivantes :

D0 -> DIN (données envoyées à l'écran)
D1 -> CLK (horloge)
D2 -> CS (chip select)
D3 -> DC (data / command)
D4 -> RST (reset)
D5 -> BUSY (état de l'écran)

Le composant est un écran e-Ink Waveshare 2.9”. Sa documentation indique une interface SPI, une résolution de 296×128, et un fonctionnement monochrome où un bit suffit à représenter un pixel : 1 pour blanc, 0 pour noir. À ce stade, le but n’est pas encore de lire une image mais plutôt de reconstituer la « conversation » entre le contrôleur et l’écran pour ensuite recréer l’image.

Premiers pas : un VCD peu lisible

Le fichier fourni est un VCD, pour Value Change Dump. Ce format ne stocke pas une ligne par instant régulier, mais une ligne à chaque changement de valeur. C’est très pratique pour un outil de simulation ou d’analyse logique, beaucoup moins lorsqu’on doit le lire directement dans un éditeur texte qu’on se retrouve avec une très grande suite de transitions de bits :

$version FCSC2026 $end
$timescale 1ns $end
$var wire 1 ! DIN $end
$var wire 1 " CLK $end
$var wire 1 # CS $end
$var wire 1 $ DC $end
$var wire 1 % RST $end
$var wire 1 & BUSY $end
...
#222385856
0"

Évidemment, la tâche de lire le fichier à la main est absurde, on va donc chercher à voir comment interpréter ces signaux pour ensuite automatiser leur lecture.

Comprendre le SPI

Le bus SPI est synchrone. Ce que cela veut dire, c’est qu’une ligne transporte les données (DIN) et qu’une autre ligne donne le rythme (CLK). Le composant lit la valeur de DIN sur les fronts montants de l’horloge.

Pour vulgariser :

  • CS dit si l’écran doit écouter ou non. Ici, CS est actif à l’état bas : quand CS = 0, l’écran écoute.
  • CLK donne le rythme. Lorsque CLK passe à 1, on lit un bit.
  • DIN contient le bit envoyé, la donnée intéressante.
  • DC dit si ce qu’envoie DIN est une commande ou une donnée. DC = 0 pour une commande, DC = 1 pour de la donnée.

Le mode SPI indiqué par la documentation est le mode 0, c’est-à-dire que l’horloge est au repos à 0 et les données sont lues lorsqu’elle passe à 1. Il suffit donc de parcourir les changements du VCD puis de capturer DIN à chaque passage de CLK de 0 à 1 quand CS vaut 0.

Le code utilisé pour trier les octets est donc plutôt simple :

bits = []
octets = []

# état courant des broches
DIN = 0
CLK = 0
CS = 1
DC = 0

for event in vcd_events:
    old_clk = CLK
    update_pin_state(event)

    rising = old_clk == 0 and CLK == 1

    if CS == 0 and rising:
        bits.append(DIN)

        if len(bits) == 8:
            value = int("".join(map(str, bits)), 2)
            octets.append((DC, value))
            bits.clear()

Une fois cette étape passée, le fichier cesse d’être incompréhensible et devient une liste de commande et de données associées. Il ne nous reste plus qu’à traduire !

Identifier les commandes intéressantes

Toutes les commandes ne sont pas nécessaires pour retrouver le flag. Plusieurs ne servent qu’à initialiser le contrôleur. Pour comprendre où sont les pixels, quelques commandes nous suffisent :

CommandeRôleIntérêt pour le challenge
0x44Set RAM X AddressFenêtre horizontale de la RAM graphique
0x45Set RAM Y AddressFenêtre verticale de la RAM graphique
0x4E / 0x4FSet RAM Address CounterPosition de départ de l’écriture
0x24Write RAMÉcriture des pixels noir/blanc
0x22 / 0x20Display UpdateDéclenchement du rafraîchissement

La commande la plus importante est 0x24, Write RAM. Après l’envoi de cette commande, les octets sont directement écrits dans la mémoire d’affichage noir/blanc, jusqu’à ce qu’une nouvelle commande soit envoyée. Autrement dit, si le flag est affiché à l’écran, il passe nécessairement dans les données qui suivent 0x24.

En regroupant les données par exécution de 0x24, on va observer dans chaque bloc 4 736 octets. Un nombre qui pour les plus matheux n’est pas une coïncidence :

128 * 296 = 37 888 pixels
37 888 / 8 = 4 736 octets

Le nombre d’octets dans chaque exécution nous confirme l’existence d’une image complète de 128 x 296 pixels, avec un bit par pixel.

Transformer les données en image

Une fois les blocs 0x24 isolés, il ne reste plus qu’à les convertir en pixels. Pour chaque octet, on lit les bits du plus fort au plus faible, puis on écrit un pixel noir ou blanc. La documentation indique que, pour le mode noir et blanc, 0 correspond au noir et 1 au blanc.

Le seul piège restant concerne l’orientation. Selon la configuration du contrôleur, les pixels peuvent être écrits de droite à gauche ou de bas en haut. Il va donc nous falloir essayer plusieurs orientations avant d’avoir la bonne image :

from PIL import Image

WIDTH = 128
HEIGHT = 296


def render_frame(data, filename):
    img = Image.new("1", (WIDTH, HEIGHT), 1)

    x = 0
    y = 0

    for byte in data:
        for bit_index in range(7, -1, -1):
            bit = (byte >> bit_index) & 1
            img.putpixel((x, y), bit)

            x += 1
            if x == WIDTH:
                x = 0
                y += 1

    img.save(filename)

Le premier rendu ne donne pas tout de suite le flag, ce serait trop simple. Il nous confirme au moins que notre méthode est bonne, avec l’apparition de cette image :

Image1.png

Qu’une seule image ?

On pourrait s’arrêter sur cette image et se demander où se trouve le flag. Mais ce serait mal me connaître, en se renseignant un peu plus sur l’écran e-Ink, on voit qu’il n’est pas forcément mis à jour une seule fois. Dans cette capture comme je l’avais dit plus haut la commande 0x24 apparaît plusieurs fois, et chaque apparition contient une image complète de 4 736 octets.

En extrayant toutes les écritures comme fait précédemment, on obtient 8 frames. La première correspond à une image blanche ou à une phase d’initialisation. Les 7 suivantes contiennent des images faisant apparaître le texte morceau par morceau. C’était donc la dernière étape de ce challenge, la compréhension du rafraîchissement afin d’obtenir le message global.

Résultat

En remettant les différentes images dans l’ordre, le message affiché par la tortue est :

Image2.png

Image3.png

Image4.png

Image6.png

Image5.png

Image7.png

Image1.png

Hello! I’m a tortoise!

FCSC{49a3efe9bbf4f610b05a133ad6156ba7080c35}

Good luck!

Le flag final est donc : FCSC{49a3efe9bbf4f610b05a133ad6156ba7080c35}

Conclusion

Ce challenge est intéressant parce qu’il a l’air plutôt simple une fois terminé. « Il suffisait de décoder le SPI et de rendre une image à chaque rafraichissement”, ce que plusieurs personnes pourraient dire, mais tout aussi simple que cela soit, c’est là que les LLM trébuchent.

Sur du code source classique, l’IA a juste besoin de faire du pattern recognition et de la compréhension de code et le tour est joué. Ici comme on a vu, on a affaire à des niveaux d’abstraction qui nécessitent de l’expérimentation :

Broches -> bits SPI -> octets -> commandes écran -> RAM -> pixels -> image

C’est ce qui rend le hardware encore assez résistant à l’automatisation des LLM. Le solve consiste à reconstruire mentalement le montage pour ensuite le vérifier à chaque étape. L’IA peut accélérer la recherche sur la technique, mais elle ne remplace pas encore ce raisonnement d’ingénierie.

FT
Écrit par Félix TERRIEN Ingénieur cybersécurité

Prêt à tester votre sécurité ?

Nos experts réalisent des tests d'intrusion adaptés à votre périmètre et vos enjeux, avec un rapport clair et des recommandations actionnables.