Beeper 1 : reverse engineering d'une commande cachée

16 mars 2026 dans Security par Gerboise6 minutes

Reverse engineering d'un firmware Zephyr pour retrouver une commande cachée obfusquée par XOR, dans le cadre du CTF Ph0wn

Introduction

J’ai créé ce challenge pour le Ph0wn 2026, le CTF dédié aux objets connectés et à la sécurité IoT qui se tient chaque année à Sophia Antipolis. L’idée derrière “Beeper 1” était de proposer un challenge de reverse engineering firmware accessible : à partir d’un firmware R&D (build non-release, non strippé) et d’un document PDF confidentiel “volé” décrivant partiellement le protocole de communication, les participants devaient reverser le firmware pour trouver une commande cachée non documentée.

C’est un challenge de reverse engineering purement statique. Tout le solve peut se faire avec Ghidra. Dans cet article, je détaille la solution étape par étape.

Matériel du challenge

Les participants reçoivent deux fichiers :

  • firmware.elf : un build R&D du firmware (ARM Cortex-M33, Zephyr RTOS)
  • doc.pdf : un document R&D “volé” qui décrit le Pirate Beeper, un dispositif de communication tactique construit autour de la carte STM32H573I-DK avec un module radio CC1101 à 433,92 MHz

Le PDF documente trois commandes connues (pico_attack, pico_boom, pico_home) ainsi que les spécifications matérielles et les caractéristiques radio (modulation OOK, encodage PWM). Une section “Secret Feature” est commodément REDACTED, ce qui laisse entendre qu’il y a autre chose à trouver dans le firmware.

Trouver la commande cachée

Étape 1 : énumération des strings

En chargeant le firmware dans Ghidra/IDA, l’énumération des strings révèle les commandes connues (pico_attack, pico_boom, pico_home) ainsi qu’un élément suspect : hide_cmd.

img1

Étape 2 : analyse du command dispatcher

En suivant la XREF sur hide_cmd, on tombe sur le command dispatcher. La format string [RX] Unknown: %s, cohérente avec la description du protocole série dans le PDF, confirme le rôle de cette fonction.

img2

Voici le code décompilé produit par Ghidra :

void FUN_08001684(void)
{
    // ...
    if (uVar4 != 0) {
        piVar5 = &DAT_0800a478;
        FUN_08009a5e(auStack_b0, &DAT_20001bd8, uVar4);
        iVar3 = 0;
        auStack_b0[uVar4] = 0;
        do {
            pbVar6 = (byte *)*piVar5;
            if ((char)piVar5[3] != '\0') {           // flag d'obfuscation à +12
                iVar1 = FUN_08001258(pbVar6);
                FUN_08009a5e(local_6c, pbVar6, iVar1 + 1);
                pbVar6 = local_6c;
                for (uVar4 = 0; (int)uVar4 < iVar1; uVar4 = uVar4 + 1) {
                    *pbVar6 = *pbVar6 ^ (&DAT_0800abc3)[uVar4 & 7]; // clé XOR
                    pbVar6 = pbVar6 + 1;
                }
                pbVar6 = local_6c;
            }
            iVar1 = FUN_08001244(auStack_b0, pbVar6);
            if (iVar1 == 0) {                         // match de commande
                FUN_08007f40("\n>>> %s\n", pbVar6);
                if ((code *)(&DAT_0800a480)[iVar3 * 4] != (code *)0x0) {
                    (*(code *)(&DAT_0800a480)[iVar3 * 4])();  // appel du handler
                }
                goto LAB_08001716;
            }
            iVar3 = iVar3 + 1;
            piVar5 = piVar5 + 4;                      // stride : entrée suivante
        } while (iVar3 != 4);                         // 4 commandes au total
        FUN_08007f40("\n[RX] Unknown: %s\n", auStack_b0);
    }
    // ...
}

En lisant le code décompilé, on identifie une table de 4 entrées (la boucle se termine quand iVar3 != 4). Chaque entrée est une struct avec le layout suivant :

OffsetChampDescription
+0char *Pointeur vers la string de commande (*piVar5)
+8code *Pointeur de fonction du handler (&DAT_0800a480 = base 0x0800a478 + 8, appelé via (*(code *)(&DAT_0800a480)[iVar3 * 4])())
+12byteFlag d’obfuscation (piVar5[3], sachant que piVar5 est un int*, donc 3x4 = 12)

En C, cette struct s’écrit :

struct pico_command {
    const char *cmd;        // +0
    uint32_t    unknown;    // +4  (non accédé dans cette fonction)
    void      (*handler)(void); // +8
    uint8_t     xored;      // +12
    uint8_t     pad[3];     // +13 (alignement)
};

Le flag d’obfuscation est l’élément clé : quand il est non nul, le champ cmd (le nom de la commande) est décodé par XOR avec la clé située à DAT_0800abc3 avant d’être comparé à l’entrée utilisateur. Les trois commandes connues ont un flag à zéro et leur champ cmd est stocké en clair. Seule hide_cmd a son champ cmd encodé par XOR.

Étape 3 : définir la struct dans Ghidra

Définir cette struct dans Ghidra rend immédiatement lisibles la vue données et le code décompilé :

img3

En appliquant le type sur DAT_0800a478, la structure devient directement visible dans la vue données du firmware :

img4

On confirme ici que seule l’entrée hide_cmd a son flag xored activé, ce qui signifie que seul son champ cmd est encodé par XOR. On s’attend donc à un décodage XOR avec la clé à DAT_0800abc3, qui fait 8 octets.

Étape 4 : extraire la clé XOR

En naviguant vers DAT_0800abc3 dans la vue données, on trouve les 8 octets de la clé XOR :

img5

key = [ 0x18, 0x00, 0x07, 0x0A, 0x00, 0x11, 0x18, 0x09 ]

Puisque hide_cmd est la forme encodée par XOR de la vraie commande stockée dans le binaire, on retrouve la commande réelle en la décodant avec la clé :

key     = [0x18, 0x00, 0x07, 0x0A, 0x00, 0x11, 0x18, 0x09]
encoded = b"hide_cmd"
decoded = bytes(b ^ key[i % 8] for i, b in enumerate(encoded))
print(decoded)  # la commande cachée

Le résultat est pico_rum. Ce n’est pas un flag en soi, mais ça confirme l’existence d’une commande secrète non documentée acceptée par le device.

Récupérer le flag

Étape 6 : trouver le handler de la commande cachée

Maintenant que la struct est appliquée, l’entrée pico_rum est entièrement visible dans la vue données. Son flag xored est à 0x01 et son champ handler pointe vers FUN_0800164c, la fonction exécutée quand la commande est déclenchée :

img7

Étape 7 : analyser le handler

La décompilation de FUN_0800164c révèle une boucle de décodage simple : elle lit 38 octets (0x26 itérations) depuis DAT_0800abcb, XOR chaque octet avec la constante 0x21, et affiche le résultat sous forme de string :

img8

Le schéma de décodage est plus simple que le précédent : une clé d’un seul octet, sans rotation.

Étape 8 : décoder le flag

En naviguant vers DAT_0800abcb, on accède aux 38 octets bruts encodés :

img9

En appliquant la même logique :

encoded = bytes([
    0x51, 0x49, 0x11, 0x56,
    0x4F, 0x5A, 0x55, 0x49,
    0x44, 0x7E, 0x53, 0x44,
    0x40, 0x4D, 0x7E, 0x49,
    0x48, 0x45, 0x45, 0x44,
    0x4F, 0x7E, 0x42, 0x4D,
    0x45, 0x7E, 0x48, 0x52,
    0x7E, 0x79, 0x79, 0x79,
    0x79, 0x79, 0x79, 0x79,
    0x79, 0x5C
])
decoded = bytes(b ^ 0x21 for b in encoded)
print(decoded.decode())

La string décodée est ph0wn{the_real_hidden_cmd_is_XXXXXXXX}. Les 8 X sont un placeholder dont la valeur a été retrouvée à l’étape précédente : le flag est ph0wn{the_real_hidden_cmd_is_pico_rum}

Conclusion

Ce challenge était noté easy/medium au Ph0wn. Le firmware étant un build de debug (non strippé), l’énumération initiale des strings est triviale, et l’obfuscation XOR est simple une fois identifiée. La difficulté principale réside dans la compréhension du layout de la struct du command dispatcher : reconnaître le stride de la boucle, mapper les offsets vers les champs (pointeur de string de commande, handler, flag d’obfuscation), et définir correctement la struct dans Ghidra pour rendre la vue données lisible. Ce type de manipulation de struct est une compétence fondamentale en reverse engineering firmware, et Beeper 1 a été conçu comme une introduction progressive à cette technique.