Reverse engineering d'un firmware Zephyr pour retrouver une commande cachée obfusquée par XOR, dans le cadre du CTF Ph0wn
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.
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 MHzLe 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.
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.

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.

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 :
| Offset | Champ | Description |
|---|---|---|
| +0 | char * | Pointeur vers la string de commande (*piVar5) |
| +8 | code * | Pointeur de fonction du handler (&DAT_0800a480 = base 0x0800a478 + 8, appelé via (*(code *)(&DAT_0800a480)[iVar3 * 4])()) |
| +12 | byte | Flag 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.
Définir cette struct dans Ghidra rend immédiatement lisibles la vue données et le code décompilé :

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

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.
En naviguant vers DAT_0800abc3 dans la vue données, on trouve les 8 octets de la clé XOR :

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éeLe 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.
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 :

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 :

Le schéma de décodage est plus simple que le précédent : une clé d’un seul octet, sans rotation.
En naviguant vers DAT_0800abcb, on accède aux 38 octets bruts encodés :

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}
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.