Post

ECOWAS CTF 2026 — GrIOT [Forensics/100pts]

CTF : ECOWAS CTF 2026 · Catégorie : Forensics · Difficulté : ⭐⭐ (Medium) · Points : 100
← Retour à l’index du CTF


Fichiers du challenge

⚠️ Note : Les fichiers sont hébergés sur la plateforme ECOWAS CTF. Les liens de téléchargement peuvent expirer après la fin de la compétition. Si un lien ne fonctionne plus ou consultez les archives de la plateforme.

Fichier Télécharger
griot-gw-v1.3.bin ⬇ Télécharger


Étape 1 — Analyse de l’en-tête binaire

La première chose à faire avec un fichier binaire inconnu : analyser son en-tête.

import struct data = open('griot-gw-v1.3.bin', 'rb').read() # Lire les premiers champs de l'en-tête magic = data[0:4] # "ECFW" version = struct.unpack_from('<H', data, 4)[0] device_id = data[8:22].rstrip(b'\x00').decode() payload_offset = struct.unpack_from('<I', data, 32)[0] # offset dans le fichier payload_size = struct.unpack_from('<I', data, 36)[0] # taille xor_key_byte = struct.unpack_from('<I', data, 28)[0] # clé XOR print(f"Magic: {magic}") # b'ECFW' print(f"Device ID: {device_id}") # 'GRIOT-IOT-7200' print(f"Payload offset: 0x{payload_offset:x}") # 0x1040 = 4160 print(f"Payload size: 0x{payload_size:x}") # 0xBAD = 2989 print(f"XOR key: 0x{xor_key_byte:x}") # 0xa7

Sortie :

Magic: b'ECFW' Device ID: GRIOT-IOT-7200 Payload offset: 0x1040 Payload size: 0xbad XOR key: 0xa7

Observation clé : Le champ à l’offset 0x1c contient 0xa7 — c’est la clé XOR.


Étape 2 — Extraction et déchiffrement XOR du payload

# Extraire le payload depuis le bon offset payload = data[0x1040 : 0x1040 + 0xbad] print(f"Payload premiers octets: {payload[:8].hex()}") # b8 2c af a7 fa 04 74 ce ... → octets "chiffrés" # Appliquer XOR avec la clé 0xa7 xor_key = 0xa7 decrypted = bytes(b ^ xor_key for b in payload) print(f"Après XOR 0xa7: {decrypted[:8].hex()}") # 1f 8b ... → signature GZIP !

Les deux premiers octets après XOR sont 1f 8b — c’est la signature magique GZIP !

Comment trouver la clé XOR ?

Méthode 1 — Lire l’en-tête (comme ci-dessus, le champ 0x1c = 0xa7).

Méthode 2 — Brute force : si on sait que le payload est GZIP, le premier octet déchiffré doit être 0x1f, donc key = paylaod[0] XOR 0x1f :

key = payload[0] ^ 0x1f # b8 XOR 1f = a7 ✓

Étape 3 — Décompression GZIP → Système de fichiers TAR

import zlib # zlib.MAX_WBITS | 16 → mode GZIP (pas juste zlib raw) rootfs = zlib.decompress(decrypted, zlib.MAX_WBITS | 16) print(f"Taille décompressée: {len(rootfs)} octets") # → 102400 octets (100 KB)

On a obtenu 100 KB de filesystem décompressé.


Étape 4 — Lire le système de fichiers TAR

import io, tarfile # Créer un objet fichier en mémoire à partir des données décompressées tar = tarfile.open(fileobj=io.BytesIO(rootfs)) # Lister tous les fichiers for member in tar.getmembers(): print(f"[{'DIR ' if member.isdir() else 'FILE'}] {member.name} ({member.size} B)")

Sortie (extrait) :

[DIR ] etc [DIR ] etc/config [FILE] etc/passwd (175 B) [FILE] etc/shadow (179 B) [FILE] etc/hostname (14 B) [FILE] etc/config/network.conf (281 B) [FILE] etc/config/iot_cloud.conf (441 B) ← SUSPECT ! [FILE] etc/config/mqtt.conf (192 B) [FILE] usr/bin/griot-daemon (82144 B) [FILE] opt/app/.credentials (195 B) ← TRÈS SUSPECT ! [FILE] var/log/syslog (494 B)

Les fichiers iot_cloud.conf et .credentials méritent une attention particulière.


Étape 5 — Extraction et lecture des fichiers suspects

# Lire opt/app/.credentials f = tar.extractfile('opt/app/.credentials') print(f.read().decode())

Contenu de .credentials :

# Legacy credentials — DO NOT USE IN PRODUCTION # These were for the development MQTT broker MQTT_USER=dev_griot MQTT_PASS=D3v_P@ss_2024! # Note: production auth uses token from iot_cloud.conf
# Lire etc/config/iot_cloud.conf f = tar.extractfile('etc/config/iot_cloud.conf') print(f.read().decode())

Contenu de iot_cloud.conf :

# Griot Cloud Connector — production config [cloud] endpoint = https://api.griot-iot.ecowas.int/v2 region = wa-west-1 protocol = mqtts [auth] method = token api_token = RWNvd2FzQ1RGe2Yxcm13NHIzX3hvcl9mc19kdW1wX2dyMTB0fQ== device_id = GRT-7200-00AF-B1C3

Le champ api_token est du Base64 !


Étape 6 — Décoder le token Base64

import base64 api_token = "RWNvd2FzQ1RGe2Yxcm13NHIzX3hvcl9mc19kdW1wX2dyMTB0fQ==" flag = base64.b64decode(api_token).decode() print(flag) # → "EcowasCTF{f1rmw4r3_xor_fs_dump_gr10t}"

Script complet

import struct, zlib, io, tarfile, base64 # Étape 1 : lire le binaire data = open('griot-gw-v1.3.bin', 'rb').read() # Étape 2 : parser l'en-tête payload_offset = struct.unpack_from('<I', data, 32)[0] # 0x1040 payload_size = struct.unpack_from('<I', data, 36)[0] # 0xbad xor_key = struct.unpack_from('<I', data, 28)[0] & 0xff # 0xa7 # Étape 3 : extraire et déchiffrer le payload payload = data[payload_offset : payload_offset + payload_size] decrypted = bytes(b ^ xor_key for b in payload) # Étape 4 : décompresser GZIP rootfs = zlib.decompress(decrypted, zlib.MAX_WBITS | 16) # Étape 5 : ouvrir l'archive TAR tar = tarfile.open(fileobj=io.BytesIO(rootfs)) # Étape 6 : lire iot_cloud.conf et décoder le token f = tar.extractfile('etc/config/iot_cloud.conf') content = f.read().decode() for line in content.splitlines(): if 'api_token' in line: token = line.split('=')[1].strip() flag = base64.b64decode(token).decode() print("FLAG:", flag)

Sortie :

FLAG: EcowasCTF{f1rmw4r3_xor_fs_dump_gr10t}

Récapitulatif de la chaîne d’exploitation

griot-gw-v1.3.bin ↓ Parse l'en-tête ECFW → offset=0x1040, size=0xbad, xor_key=0xa7 ↓ Extraire le payload binaire ↓ XOR avec 0xa7 octet par octet ↓ Décompresser GZIP ↓ Lire l'archive TAR (système de fichiers Linux) ↓ Ouvrir etc/config/iot_cloud.conf ↓ Décoder le champ api_token en Base64 → EcowasCTF{f1rmw4r3_xor_fs_dump_gr10t}

Flag

EcowasCTF{f1rmw4r3_xor_fs_dump_gr10t}


← Retour à l’index du CTF

Cet article est sous licence CC BY 4.0 par t3chw1z4rd.