FCSC 2026 – Challenge “Tortoise Say”

FCSC 2026 – Challenge “Tortoise Say”

. lecture : 8 minutes

Énoncé

Tortoise Say 369 points    Après Shrimp Say, voici Tortoise Say !  Tortoise Say est un système embarqué révolutionnaire utilisant comme base la technologie e-Ink. Une tortue dessinée s'empresse de vous afficher un secret.  Nous vous donnons une capture effectuée à l'analyseur logique de la communication avec l'écran lorsque la tortue était en train d'afficher le flag. L'écran utilisé est un Waveshare 2.9inch e-paper V2.  Les broches mesurées sur l'analyseur logique sont :  D0 : DIN D1 : CLK D2 : CS D3 : DC D4 : RST D5 : BUSY Arriverez-vous à retrouver le secret de la tortue ? fichier `tortoise-say.vcd.tar.xz`

Premiers pas

Je commence par ouvrir le fichier fourni, un document texte de 20 Mo imbitable :

$version FCSC2026 $end
$date Sun, 01 Feb 2026 01:00:00 GMT $end
$timescale 1ns $end
$scope module logic $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
$upscope $end
$enddefinitions $end
#0
0!
#0
0"
#0
0#
#0
0$
#0
0%
#0
0&
#220168
1#
#333968
1&
#333968
0&
(…)

2,7 millions de lignes comme ça

Je découvre qu’il s’agit du format VCD pour “Value Change Dump” (défini dans le standard IEEE 1364-2005 🤓). Heureusement pour moi il existe des bibliothèques Python pour lire ce truc ; je choisis vcdvcd (pypi.org/project/vcdvcd) qui est fourni avec –je cite– “the nifty vcdcat VCD command line viewer”.

Je m’intéresse ensuite à l’écran utilisé, le “Waveshare 2.9inch e-paper V2”.
Comme tout composant électronique, ses spécifications sont disponibles en ligne :
waveshare.com/wiki/2.9inch_e-Paper_Module_Manual#Overview

schéma montrant le mode de communication du composant
Communication Method
CSB (CS): Slave chip select signal, active at low level. When it is at low level, the chip is enabled.
SCL (SCK/SCLK): Serial clock signal.
D/C (DC): Data/command control signal, write command (Command) when the level is low; write data (Data/parameter) when the level is high.
SDA (DIN): Serial data signal.
Timing: CPHL=0, CPOL=0, i.e. SPI mode 0.
Note: For specific information about SPI communication, you can search for information online on your own.

D’expérience, pour avoir bidouillé un peu un Arduino, je sais qu’un composant (ici l’écran) ne lit ses entrées que quand le signal “chip select” (CS) est actif et à chaque fois que le signal d’horloge (CLK pour clock) passe à 1.

D’après la datasheet, je vois que BUSY, le seul signal capturé venant de la puce, est envoyé pour indiquer que des traitements sont en cours.
DIN représente les données envoyées au composant, et je ne sais pas ce que fait RST mais je suppose que c’est suffisant pour commencer l’analyse.

représentation schématique d’un Arduino relié à un écran LCD
Mon expérience en électronique : brancher un Arduino à un LCD alphanumérique. Image tirée de starthardware.org/…

Analyse du .VCD

L’utilitaire vcdcat est en effet “nifty”, il n’y a même pas besoin de créer un script Python : la commande $ vcdcat tortoise-say.vcd réorganise le contenu du .VCD sous forme de table de vérité :

0 time
1 logic.DIN
2 logic.CLK
3 logic.CS
4 logic.DC
5 logic.RST
6 logic.BUSY

0 1 2 3 4 5 6 
=============
0 x x x x 0 x
0 x 0 x x 0 x
0 x 0 0 x 0 x
0 x 0 0 0 0 x
0 x 0 0 0 0 0
0 0 0 0 0 0 0
220168 0 0 1 0 0 0
333968 1 0 1 0 0 0
333968 0 0 1 0 0 0
333968 0 0 1 0 0 1
10334296 0 0 1 0 0 0
12334320 1 0 1 0 0 0
12334320 0 0 1 0 0 0
12334320 0 0 1 0 0 1
172334464 0 0 0 0 0 1
172334848 0 1 0 0 0 1
(…)

1,3 millions de lignes comme ça

J’ai perdu un peu de temps à essayer d’exploiter ça, en l’ouvrant dans Calc (message d’erreur « dépassement du nombre maximum de lignes par feuille » 😅), en filtrant les lignes où CLK est à 1… Bref, je faisais fausse route.

Analyse des deltas

vcdvcd peut exporter uniquement les changements (« delta ») entre 0 et 1, ce qui s’avère être beaucoup plus utile ici. $ vcdcat --deltas tortoise-say.vcd :

(…)
222384936 1 logic.CS
222385064 0 logic.DC
222385208 0 logic.CS
222385592 0 logic.DIN
222385592 1 logic.CLK
222385856 0 logic.CLK
222386104 1 logic.DIN
222386104 1 logic.CLK
222386360 0 logic.CLK
222386608 0 logic.DIN
222386608 1 logic.CLK
(…)

autant de lignes que dans vcdcat.txt

J’ai créé un script python pour analyser ce nouveau fichier et interpréter les signaux comme le ferait la puce :

  • j’ignore les changements de signaux RST, BUSY, et CLK à 0
  • quand DIN change, je stocke la nouvelle valeur
  • quand DC change, j’enregistre le passage en mode « donnée » ou « commande »
  • quand CLK passe à 1, j’enregistre la valeur actuelle de DIN
  • quand CS est désactivé j’affiche les valeurs de DIN enregistrées, en indiquant s’il s’agit de données ou d’une commande (et je donne le nom de la commande appelée, tant qu’à faire 😎 (ce qui est probablement une perte de temps, mais bon on ne se refait pas))
#!/usr/bin/env python

# valeurs internes
chip_selected = False
mode_data = False
i_command = ''
i_DIN = ''
i_data = ''
i_DC = ''

with open('vcdcat-deltas.txt', 'r') as file:
	for line in file:
		s_time, s_value, s_pin = line.split()

		match s_pin:
			case 'logic.BUSY' | 'logic.RST':
				# ignore
				pass

			case 'logic.CS':
				chip_selected = True if s_value=='0' else False

				# commande 
				if not mode_data:
					match i_data:
						case '00000001':
							i_command = 'Driver Output control [+3 data]'
						# Booster Soft start Control
						case '00010000':
							i_command = 'Deep Sleep Mode'
						case '00010001':
							i_command = 'Data Entry mode setting [+1 data]'
						case '00010010':
							i_command = 'SWRESET'
						case '00100000':
							i_command = 'Master Activation'
						case '00100001':
							i_command = 'Display Update Control 1 [+1 data]'
						case '00100010':
							i_command = 'Display Update Control 2 [+1 data]'
						case '00100100':
							i_command = 'Write RAM'
						case '00110010':
							i_command = 'Write LUT register [+30 max. data]'
						case '01000100':
							i_command = 'Set RAM X [+2 data]'
						case '01000101':
							i_command = 'Set RAM Y [+4 data]'
						case '01001110':
							i_command = 'Set RAM X address counter [+1 data]'
						case '01001111':
							i_command = 'Set RAM Y address counter [+2 data]'
						case _:
							i_command = '???'

				if not chip_selected:
					print(f'{i_data}  ', end='')
					print('data') if mode_data else print(f'command  {i_command}')
						
					i_data = ''

			case 'logic.DC':
				# “When the pin is pulled HIGH, the data will be interpreted as data. When the pin is pulled LOW, the data will be interpreted as command”
				mode_data = True if s_value=='1' else False

			case 'logic.DIN':
				i_DIN = s_value

			case 'logic.CLK':
				if s_value == '1':
					i_data += i_DIN

Ce script transforme la liste des deltas produits par vcdcat en une suite d’octets, tels qu’ils ont été « compris » par le composant :

00010010  command  SWRESET
00000001  command  Driver Output control [+3 data]
00100111  data
00000001  data
00000000  data
00010001  command  Data Entry mode setting [+1 data]
00000011  data
01000100  command  Set RAM X [+2 data]
00000000  data
00001111  data
01000101  command  Set RAM Y [+4 data]
00000000  data
00000000  data
00100111  data
00000001  data
(…)
00000000  data
00000000  data
00000000  data
00111111  command  ???
00100010  data
00000011  command  ???
00010111  data
00000100  command  ???
01000001  data
00000000  data
00110010  data
00101100  command  ???
00110110  data
00100100  command  Write RAM
11111111  data
11111111  data
11111111  data
11111111  data
(…)

76 005 lignes comme ça

Je n’ai pas pu identifier certaines commandes soit à cause d’un bug dans mon script, soit parce que j’ai mal lu la documentation, mais ce que j’obtiens est cohérent : les commandes qui doivent être suivies d’un certain nombre d’octets “data” le sont bien.

Je remarque aussi que les signaux envoyés semblent respecter le mode de fonctionnement de l’écran : on commence par transmettre des instructions de configuration de l’affichage (Data Entry mode setting, Set RAM X, Set RAM Y, etc.) avant de balancer énormément d’octets « donnée ».

Je vois qu’on envoie la commande Data Entry mode setting suivie de l’octet 00000011 qui correspond, toujours d’après la documentation, à l’option “Y decrement, X decrement”. Je vois aussi que les données sont entrecoupés de commandes Write RAM, ce qui correspond à une demande d’affichage :

Write RAM: “After this command, data entries will be written into the RAM until another command is written. Address pointers will advance accordingly.”

Extraction de l’image

Confiant d’avoir trouvé les données d’affichage, je commence un nouveau script Python capable d’en faire une image.

J’ai eu un moment de doute ne sachant pas si l’affichage était configuré en mode « noir et blanc » ou en « niveaux de gris », j’ai décidé d’essayer en N&B (où 1 est noir et 0 et blanc) dans un premier temps, en me disant que le ou la créatrice de ce challenge ne serait pas aussi méchante🙂.

(je ne l’avais pas remarqué sur le coup, mais entre chaque octet « commande » j’avais exactement 4736 octets « données », avec 8 pixels par octets on trouve bien : 4 736 × 8 = 37 888 = 128×296, exactement la résolution de l’écran.

Je sais que l’écran à une résolution de 128(H)×296(V) et qu’on va envoyer les bits en partant de (128,296) vers (0,0), il ne me reste plus qu’à faire un script python qui lit les octets de données et qui ajoute un pixel soit blanc soit noir à une image 128×296, grâce à la bibliothèque Pillow :

#!/usr/bin/env python
from PIL import Image

# Image parameters
width, height = 128, 296
image = Image.new("RGB", (width, height), 'purple')
x = width-1
y = height-1

with open('write_RAM.txt', 'r') as file:
	for line in file:
		s_bin, _ = line.split()

		for chiffre in s_bin:
			couleur = (0, 0, 0, 0) if chiffre == '0' else (255, 255, 255, 255)
			image.putpixel((x,y), couleur)

			if x>0:
				x-=1
			else:
				# ligne suivante
				x = width-1
				y = height-1 if y==0 else y-1

image.save("Tortoise_say_extraction.bmp")

Mon code a fonctionné du premier coup (j’en suis le premier étonné, croyez bien) et a produit l’image suivante (tournée de 90°) :

…qui est l’image de l’énoncé. 😐

Après un moment de panique où je me suis demandé si les données pouvaient représenter une image en « noir et blanc » ET une autre en « niveaux de gris », ou si le flag était caché dans les différences de pixels entre mon image et celle de l’énoncé, je me suis souvenu que les données envoyées représentaient beaucoup plus qu’une image, et que le flag était peut être affiché puis recouvert par le “good luck!”

Après une petite modification de mon script pour qu’il enregistre toutes les images intermédiaires : BINGO j’avais trouvé le flag ! 🥳

j’en ai fait un gif animé, parce que pourquoi pas.

🚩 FCSC{49a3efe9bbf4f610b05a133ad6156ba7080c35} validé, pour 369 points
(j’étais la 55e personne à résoudre ce problème) 😎