Solution de git__ pour A la recherche du Wapiti

hardware Wi-Fi

25 juin 2026

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 :

  1. des trames ESP-NOW ;
  2. des trames avec l’EtherType 0x88b4 ;
  3. des trames 802.11 protégées ;
  4. 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}