Introduction
L’épreuve fournit deux fichiers :
firmware.bin, le micrologiciel récupéré depuis un ESP32-C3 ;a-la-recherche-du-wapiti.pcapng, une capture 802.11 avec radiotap.
La description suggère des capteurs ESP32-C3 qui ne parlent pas un protocole Wi-Fi habituel. La première intuition peut être de chercher du WPA, mais la capture ne contient pas de poignée de main EAPOL exploitable. En revanche, elle contient plusieurs couches intéressantes :
- des trames ESP-NOW ;
- des trames avec l’EtherType
0x88b4; - des trames 802.11 protégées ;
- du trafic applicatif MQTT une fois les trames déchiffrées.
Reconnaissance de la capture
On commence par regarder les types de trames présents dans le fichier pcapng.
Un petit script Scapy suffit :
from scapy.all import rdpcap, Dot11, LLC, SNAP
pcap = rdpcap("a-la-recherche-du-wapiti.pcapng")
for p in pcap:
if not p.haslayer(Dot11):
continue
d = p[Dot11]
if d.type == 0 and d.subtype == 8:
# Beacon
print("beacon", d.addr2)
if d.type == 0 and d.subtype == 13:
# Action frame
raw = bytes(d.payload)
if b"\x18\xfe\x34" in raw:
print("ESP-NOW candidate", d.addr2, "->", d.addr1)
if p.haslayer(SNAP):
print("SNAP ethertype:", hex(p[SNAP].code))
La capture montre notamment :
- des trames Action contenant l’OUI Espressif
18:fe:34, ce qui pointe vers ESP-NOW ; - un point d’accès annonçant le SSID wapanda, avec l’adresse MAC
ce:e0:02:83:6d:12; - un capteur associé avec l’adresse MAC
dc:da:0c:d2:9c:d8; - des trames de protocole
0x88b4.
L’EtherType 0x88b4 correspond à WAPI. C’est le premier point important de l’épreuve : il ne faut pas chercher à casser du WPA, le protocole n’est pas WPA.
Le message ESP-NOW
Les trames ESP-NOW contiennent un message qui ressemble à de l’ASCII obfusqué. En testant un XOR simple sur les octets imprimables, la clé 0x42 donne une structure TLV lisible.
Exemple de décodage :
from scapy.all import rdpcap, Dot11
pcap = rdpcap("a-la-recherche-du-wapiti.pcapng")
for p in pcap:
if not p.haslayer(Dot11):
continue
d = p[Dot11]
# Action frame
if d.type != 0 or d.subtype != 13:
continue
raw = bytes(d.payload)
# OUI Espressif
pos = raw.find(bytes.fromhex("18fe34"))
if pos == -1:
continue
data = raw[pos:]
# On cherche une zone qui devient lisible avec XOR 0x42.
decoded = bytes(b ^ 0x42 for b in data)
if b"wapanda" in decoded:
print(decoded)
On récupère une structure de ce type :
01 07 "wapanda"
02 20 "de54c41a82afc200ac0835207edeaf98"
Le champ wapanda donne le nom du réseau. Le champ suivant ressemble fortement à un flag partiel ou à un leurre :
de54c41a82afc200ac0835207edeaf98
Ce n’est pas le flag. C’est une valeur utilisée plus loin comme secret de dérivation. Plus précisément, elle est utilisée comme chaîne ASCII de 32 octets.
Analyse du firmware
L’étape suivante consiste à confirmer ce que fait réellement le micrologiciel.
En cherchant dans firmware.bin, on trouve des constantes et chaînes caractéristiques :
strings firmware.bin | grep -i -E "preshared|pairwise|nonce|key|sms4|wapi"
On retrouve notamment les chaînes utilisées dans la dérivation de clés :
preshared key expansion for authentication and key negotiation
pairwise key expansion for unicast and additional keys and nonce
Le firmware contient aussi les constantes de SMS4/SM4 :
- la S-box ;
- les constantes
FK; - les constantes
CK.
Cela confirme que les trames 0x88b4 ne sont pas du WPA mais bien du WAPI, avec chiffrement SMS4/SM4.
Le secret récupéré dans ESP-NOW est donc la PSK WAPI :
PSK = b"de54c41a82afc200ac0835207edeaf98"
Il faut bien l’utiliser comme chaîne ASCII, pas comme valeur hexadécimale convertie en 16 octets.
Dérivation des clés WAPI
Les trames WAI non protégées fournissent les nonces nécessaires à la dérivation. On récupère :
- l’adresse MAC de l’AP ;
- l’adresse MAC du capteur ;
- le nonce côté AE ;
- le nonce côté ASUE.
Dans mon cas :
AP = bytes.fromhex("cee002836d12")
STA = bytes.fromhex("dcda0cd29cd8")
N_AE = bytes.fromhex(
"5f51bc9abcfe1b8bb788c3374298520c"
"ce5aca09d00409aedf00f3d10276133f"
)
N_ASUE = bytes.fromhex(
"9f0eed52771429fff705ce9cad159529"
"94c2e53fc5454b35a522f878412df6af"
)
La fonction de dérivation utilisée est basée sur HMAC-SHA256 en mode chaîné :
import hmac
import hashlib
def KD(key, text, n):
out = b""
prev = text
while len(out) < n:
prev = hmac.new(key, prev, hashlib.sha256).digest()
out += prev
return out[:n]
On dérive d’abord la clé de base, puis les clés unicast :
PSK = b"de54c41a82afc200ac0835207edeaf98"
PRE = b"preshared key expansion for authentication and key negotiation"
PAIR = b"pairwise key expansion for unicast and additional keys and nonce"
BK = hmac.new(PSK, PRE, hashlib.sha256).digest()
USK = KD(BK, AP + STA + N_AE + N_ASUE + PAIR, 96)
UEK = USK[0:16]
UCK = USK[16:32]
MAK = USK[32:48]
KEK = USK[48:64]
print("UEK =", UEK.hex())
print("UCK =", UCK.hex())
print("MAK =", MAK.hex())
print("KEK =", KEK.hex())
La clé utile pour déchiffrer les données est UEK.
On obtient :
UEK = 79e2bfee4f0750884bf48c0556aba7ee
Pour vérifier que la dérivation est correcte, on peut recalculer le MIC des messages de handshake. Le firmware utilise un MIC tronqué :
mic = hmac.new(MAK, message, hashlib.sha256).digest()[:20]
Le MIC recalculé correspond à celui des trames WAI, ce qui valide la dérivation.
Déchiffrement des trames protégées
Les trames de données protégées sont des trames WPI. Après l’en-tête 802.11, leur format utile est :
KeyIdx(1) | Reserved(1) | PN(16) | SMS4-OFB(payload || MIC(16))
Sur les trames QoS Data, il faut ignorer les 2 octets de QoS Control avant ce champ.
Le chiffrement utilisé est SM4 en mode OFB. Le vecteur d’initialisation est le PN inversé.
Le script suivant déchiffre les trames, extrait les paquets IP, puis collecte les segments TCP venant du broker MQTT :
import struct
from scapy.all import rdpcap, Dot11, IP, TCP
from gmssl.sm4 import CryptSM4, SM4_ENCRYPT
UEK = bytes.fromhex("79e2bfee4f0750884bf48c0556aba7ee")
SNAP_IP = bytes.fromhex("aaaa03000000")
def sm4_block(key, block):
c = CryptSM4()
c.set_key(key, SM4_ENCRYPT)
return c.crypt_ecb(block)
def sm4_ofb(key, iv, ct):
out = b""
x = iv
while len(out) < len(ct):
x = sm4_block(key, x)
out += x
return bytes(a ^ b for a, b in zip(ct, out))
pcap = rdpcap("a-la-recherche-du-wapiti.pcapng")
segments = {}
for p in pcap:
if not p.haslayer(Dot11):
continue
d = p[Dot11]
# Protected bit
if not (d.FCfield & 0x40):
continue
body = bytes(d.payload)
# QoS Data frames contain 2 extra bytes before the WPI header.
off = 2 if d.subtype == 8 else 0
if len(body) < off + 2 + 16 + 16:
continue
keyidx = body[off]
reserved = body[off + 1]
pn = body[off + 2:off + 18]
ct = body[off + 18:]
# WAPI uses the reversed PN as IV for SMS4-OFB.
iv = pn[::-1]
plain = sm4_ofb(UEK, iv, ct)
# Last 16 bytes are the encrypted MIC.
if len(plain) < 16:
continue
plain = plain[:-16]
# LLC/SNAP IPv4 header:
# aa aa 03 00 00 00 08 00
if not plain.startswith(SNAP_IP):
continue
ip = IP(plain[8:])
if not ip.haslayer(TCP):
continue
tcp = ip[TCP]
payload = bytes(tcp.payload)
if not payload:
continue
# MQTT broker -> client
if tcp.sport == 1883:
segments[tcp.seq] = payload
raw = b"".join(segments[seq] for seq in sorted(segments))
print(raw)
Après déchiffrement, on obtient du trafic TCP vers ou depuis le broker :
51.255.68.182:1883
Le port 1883 est le port MQTT non chiffré.
Reconstruction MQTT
Le broker republie le flag sur le topic flag, un caractère par message PUBLISH.
Pour extraire les messages MQTT, il suffit de parser les paquets PUBLISH. Ici, les longueurs restantes tiennent sur un octet, ce qui simplifie le parsing :
import struct
flag = b""
i = 0
while i < len(raw):
packet_type = raw[i] >> 4
# Remaining length.
# Dans cette capture, elle tient sur un seul octet.
remaining_length = raw[i + 1]
body = raw[i + 2:i + 2 + remaining_length]
if packet_type == 3: # PUBLISH
topic_len = struct.unpack(">H", body[:2])[0]
topic = body[2:2 + topic_len]
payload = body[2 + topic_len:]
if topic == b"flag":
flag += payload
i += 2 + remaining_length
print(flag.decode())
Le script affiche :
FCSC{W1F1_IS_M0RE_TH4N_WPA_c9ffdcad}
Script complet
Voici le script complet utilisé pour refaire l’extraction du flag à partir de la capture, une fois les nonces et la clé dérivée connus.
#!/usr/bin/env python3
import hmac
import hashlib
import struct
from scapy.all import rdpcap, Dot11, IP, TCP
from gmssl.sm4 import CryptSM4, SM4_ENCRYPT
PCAP = "a-la-recherche-du-wapiti.pcapng"
PSK = b"de54c41a82afc200ac0835207edeaf98"
AP = bytes.fromhex("cee002836d12")
STA = bytes.fromhex("dcda0cd29cd8")
N_AE = bytes.fromhex(
"5f51bc9abcfe1b8bb788c3374298520c"
"ce5aca09d00409aedf00f3d10276133f"
)
N_ASUE = bytes.fromhex(
"9f0eed52771429fff705ce9cad159529"
"94c2e53fc5454b35a522f878412df6af"
)
PRE = b"preshared key expansion for authentication and key negotiation"
PAIR = b"pairwise key expansion for unicast and additional keys and nonce"
SNAP_IP = bytes.fromhex("aaaa03000000")
def KD(key, text, n):
out = b""
prev = text
while len(out) < n:
prev = hmac.new(key, prev, hashlib.sha256).digest()
out += prev
return out[:n]
def derive_keys():
bk = hmac.new(PSK, PRE, hashlib.sha256).digest()
usk = KD(bk, AP + STA + N_AE + N_ASUE + PAIR, 96)
uek = usk[0:16]
uck = usk[16:32]
mak = usk[32:48]
kek = usk[48:64]
return uek, uck, mak, kek
def sm4_block(key, block):
c = CryptSM4()
c.set_key(key, SM4_ENCRYPT)
return c.crypt_ecb(block)
def sm4_ofb(key, iv, ct):
stream = b""
x = iv
while len(stream) < len(ct):
x = sm4_block(key, x)
stream += x
return bytes(a ^ b for a, b in zip(ct, stream))
def extract_mqtt_stream(pcap_path, uek):
pcap = rdpcap(pcap_path)
segments = {}
for p in pcap:
if not p.haslayer(Dot11):
continue
d = p[Dot11]
if not (d.FCfield & 0x40):
continue
body = bytes(d.payload)
# QoS Data frames have a 2-byte QoS Control field.
off = 2 if d.subtype == 8 else 0
if len(body) < off + 2 + 16 + 16:
continue
pn = body[off + 2:off + 18]
ct = body[off + 18:]
iv = pn[::-1]
plain = sm4_ofb(uek, iv, ct)
# Remove WPI MIC.
plain = plain[:-16]
if not plain.startswith(SNAP_IP):
continue
ip = IP(plain[8:])
if not ip.haslayer(TCP):
continue
tcp = ip[TCP]
payload = bytes(tcp.payload)
if payload and tcp.sport == 1883:
segments[tcp.seq] = payload
return b"".join(segments[seq] for seq in sorted(segments))
def extract_flag_from_mqtt(raw):
flag = b""
i = 0
while i + 2 <= len(raw):
packet_type = raw[i] >> 4
remaining_length = raw[i + 1]
body_start = i + 2
body_end = body_start + remaining_length
body = raw[body_start:body_end]
if len(body) != remaining_length:
break
if packet_type == 3: # PUBLISH
if len(body) < 2:
i = body_end
continue
topic_len = struct.unpack(">H", body[:2])[0]
topic = body[2:2 + topic_len]
payload = body[2 + topic_len:]
if topic == b"flag":
flag += payload
i = body_end
return flag
def main():
uek, uck, mak, kek = derive_keys()
print(f"UEK = {uek.hex()}")
print(f"UCK = {uck.hex()}")
print(f"MAK = {mak.hex()}")
print(f"KEK = {kek.hex()}")
raw = extract_mqtt_stream(PCAP, uek)
flag = extract_flag_from_mqtt(raw)
print(flag.decode())
if __name__ == "__main__":
main()
Exécution :
python3 solve.py
Résultat :
UEK = 79e2bfee4f0750884bf48c0556aba7ee
FCSC{W1F1_IS_M0RE_TH4N_WPA_c9ffdcad}