FCSC 2026 Si proche et pourtant si loin

Apr 16, 2026 • 15:42 / 6 min read

1 - Reconnaissance

3 fichiers nous sont donnés pour ce challenge :

  • un PCAP avec des trames d’annonce BLE
  • un fichier de logs ADB
  • et l’image suivante

Logs ADB

Dans l’énoncé, on nous indique que le téléphone a vibré en touchant le device. On cherche donc dans les logs des références au NFC. On trouve cette ligne :

text ///
03-21 12:56:17.149  4583  4583 D NfcDispatcher: dispatch tag: TAG: Tech [android.nfc.tech.IsoDep, android.nfc.tech.NfcA, android.nfc.tech.Ndef] message: NdefMessage [NdefRecord tnf=1 type=54 payload=02696449443A2046323A46383A37363A32313A43463A36453A42413A3232, NdefRecord tnf=1 type=54 payload=02616446573A206861636B726F706F6C652E66722F3238363763663235623036632E686578, NdefRecord tnf=1 type=54 payload=02737746573A20527575766920465720363934373636612B66637363, NdefRecord tnf=1 type=54 payload=026474081D1B3A0BE703A171E7745F35B44312EEFBD61E96AC0CA3]

On la décompose et on décode les 4 secteurs, et on trouve alors :

text ///
secteur 0 | idID: F2:F8:76:21:CF:6E:BA:22
secteur 1 | adFW: hackropole.fr/2867cf25b06c.hex
secteur 2 | swFW: Ruuvi FW 694766a+fcsc
secteur 3 | 6474 081D1B3A0BE703A171E7745F35B44312EEFBD61E96AC0CA3

On va regarder le site hackropole.fr/2867cf25b06c.hex et on récupère un fichier .hex.

PCAP

Dans le PCAP, on a des paquets de présentation qui permettent au device de s’indiquer comme disponible, mais pas de paquets de données classiques. Cependant, on a un identifiant d’entreprise de création. On est donc face à un RuuviTag (https://ruuvi.com/ruuvitag/), ce qui est confirmé par l’image. Grâce à cela, on peut trouver la documentation publique (vive l’open source https://docs.ruuvi.com/).

Avec toutes ces informations, on comprend que les advertising packets sont le véhicule classique des données pour ce type de device. On peut même identifier que l’on est sur une version chiffrée du protocole (encore de la crypto dans mon hardware (T-T) ).

Conclusion

On est face à un RuuviTag avec un firmware custom qui envoie des paquets chiffrés via les advertising du BLE.

2 - Reverse du firmware

On a récupéré un firmware en .hex. Au début, j’ai cru que c’était un format custom. Je l’ai analysé de manière approximative, mais je ne comprenais pas pourquoi c’était à ce point le bazar dedans lors du reverse. Puis j’ai appliqué les sains principes…

Donc, c’est le format Intel HEX (https://en.wikipedia.org/wiki/Intel_HEX). On l’extrait de manière normal, et on peut commencer le travail sérieux.

On connaît le modèle de la carte grâce à celui du device : on est sur un nRF52832. On ouvre donc le firmware avec Ghidra, ainsi que le script SVD Loader, qui va nous permettre de mapper précisément la mémoire et de retrouver facilement toutes les périphériques qui sont accessibles via des zones d’adressage mémoire réservées, correspondant à des registres mappés en mémoire. On passe en argument au script le SVD du nRF52832.

On avait vu dans les données NFC : Ruuvi FW 694766a+fcsc. On peut donc raisonnablement penser que le firmware est basé sur le firmware original. On va donc aller regarder les sources disponible sur github (https://github.com/ruuvi/ruuvi.firmware.c).

On trouve alors cette fonction :

c ///
encode_to_8 (uint8_t * const output,
             size_t * const output_length,
             const rd_sensor_data_t * const data)
{
    static uint16_t ep_8_measurement_count = 0;
    uint8_t final_key[RE_8_CIPHERTEXT_LENGTH] = { 0 };
    rd_status_t err_code = RD_SUCCESS;
    re_status_t enc_code = RE_SUCCESS;
    re_8_data_t ep_data = {0};
    ep_8_measurement_count++;
    ep_8_measurement_count %= (RE_8_SEQCTR_MAX + 1);
    ep_data.humidity_rh       = rd_sensor_data_parse (data, RD_SENSOR_HUMI_FIELD);
    ep_data.pressure_pa       = rd_sensor_data_parse (data, RD_SENSOR_PRES_FIELD);
    ep_data.temperature_c     = rd_sensor_data_parse (data, RD_SENSOR_TEMP_FIELD);
    ep_data.message_counter = ep_8_measurement_count;
    uint8_t mvtctr = (uint8_t) (app_sensor_event_count_get() % (RE_8_MVTCTR_MAX + 1));
    ep_data.movement_count    = mvtctr;
    err_code |= ep_8_key_generate (final_key);
    err_code |= ri_radio_address_get (&ep_data.address);
    err_code |= ri_adv_tx_power_get (&ep_data.tx_power);
    err_code |= rt_adc_vdd_get (&ep_data.battery_v);
    enc_code |= re_8_encode (output,
                             &ep_data,
                             &app_data_encrypt,
                             final_key,
                             RE_8_CIPHERTEXT_LENGTH);

    if (RE_SUCCESS != enc_code)
    {
        err_code |= RD_ERROR_INTERNAL;
    }

    *output_length = RE_5_DATA_LENGTH;
    return err_code;
}

On identifie une fonction de génération de clé, allons voir ça de plus près :

c ///
ep_8_key_generate (uint8_t * const key)
{
    rd_status_t err_code = RD_SUCCESS;
    memcpy (key, ep_8_key, RE_8_CIPHERTEXT_LENGTH);
    uint64_t device_id = 0;
    err_code |= ri_comm_id_get (&device_id);

    for (uint8_t ii = 0U; ii < 8; ii++)
    {
        key[ii] = key[ii] ^ ( (device_id >> (ii * 8U)) & 0xFFU);
    }

    return err_code;
}

La fonction ri_comm_id_get retourne l’identifiant unique du NFC. Cela nous donne un point d’entrée dans le firmware pour retrouver la fonction de génération de clé, si elle n’a pas été modifiée. On la trouve, puis on inspecte toutes les références à cette fonction. On en trouve une en particulier qui est intéressante.

c ///
uint FUN_00033fd8(undefined4 param_1,undefined4 *param_2,undefined4 param_3,int param_4)

{
  byte bVar1;
  byte bVar2;
  short *psVar3;
  byte bVar4;
  uint uVar5;
  uint uVar6;
  uint uVar7;
  uint uVar8;
  int iVar9;
  undefined4 local_50;
  undefined4 uStack_4c;
  undefined1 local_48 [2];
  byte bStack_46;
  byte bStack_45;
  undefined4 local_44;
  undefined4 local_40;
  undefined4 uStack_3c;
  undefined4 local_38;
  undefined4 local_34;
  undefined4 local_30;
  undefined1 auStack_2c [4];
  ushort local_28;
  short local_26;
  undefined1 auStack_24 [4];
  undefined1 auStack_20 [8];
  
  psVar3 = DAT_0003412c;
  if (param_4 == 4) {
    local_44 = 0;
    _local_48 = 0;
    local_40 = 0;
    uStack_3c = 0;
    FUN_00042a8c(&local_38,0,0x20);
    uVar6 = DAT_00034138;
    *psVar3 = *psVar3 + 1U +
              (short)(uint)((ulonglong)DAT_00034138 * (ulonglong)(ushort)(*psVar3 + 1U) >> 0x2f);
    local_38 = FUN_000327cc(param_3,0x80);
    local_34 = FUN_000327cc(param_3,0x10000);
    local_30 = FUN_000327cc(param_3,0x40000);
    local_26 = *psVar3;
    uVar5 = FUN_00034cb0();
    local_28 = (short)uVar5 + (short)(uint)((ulonglong)uVar6 * (ulonglong)uVar5 >> 0x2f) & 0xff;
    memcpy(local_48,DAT_00034130,0x10);
    local_50 = 0;
    uStack_4c = 0;
    uVar6 = get_device_id(&local_50);
    bVar1 = bStack_46 ^ (byte)((uint)local_50 >> 0x10);
    bVar2 = bStack_45 ^ (byte)((uint)local_50 >> 0x18);
    bVar4 = local_48[0];
    _local_48 = (uint3)(byte)(local_48[1] ^ (byte)((uint)local_50 >> 8)) << 8;
    _local_48 = CONCAT13(bVar2,_local_48);
    _local_48 = CONCAT31(stack0xffffffb9,bVar4 ^ (byte)local_50);
    local_44._0_1_ = (byte)local_44 ^ (byte)uStack_4c;
    local_44._1_1_ = local_44._1_1_ ^ (byte)((uint)uStack_4c >> 8);
    _local_48 = CONCAT12(bVar1,local_48);
    _local_48 = CONCAT13(bVar2,_local_48);
    local_44 = CONCAT22(CONCAT11(local_44._3_1_ ^ (byte)((uint)uStack_4c >> 0x18),
                                 local_44._2_1_ ^ (byte)((uint)uStack_4c >> 0x10)),
                        CONCAT11(local_44._1_1_,(byte)local_44));
    uVar5 = FUN_0002d52c(auStack_20);
    uVar7 = FUN_0002c3cc(auStack_24);
    uVar8 = FUN_0003190c(auStack_2c);
    uVar8 = uVar5 | uVar6 | uVar7 | uVar8;
    iVar9 = FUN_00032c60(param_1,&local_38,DAT_00034134,local_48,0x10);
    if (iVar9 != 0) {
      uVar8 = uVar8 | 1;
    }
    *param_2 = 0x18;
    return uVar8;
  }
  return 0x200000;
}

On regarde un peu la tête des fonctions qu’elle appelle et on trouve un memcpy. On est sur une structure qui rappelle vaguement la fonction de génération de clé, mais avec un petit twist qui nous intéresse : la clé a l’air d’être hardcodée.

On la dump :

text ///
83 bc 4c 12 6d 8b b6 ba 39 3c b6 dc b4 78 93 90

3 - Décodage de la communication

On ressort la documentation, et en particulier cette page qui détaille le fonctionnement de l’algorithme de chiffrement, un simple AES-ECB (https://docs.ruuvi.com/communication/bluetooth-advertisements/data-format-8-encrypted-environmental).

On a donc tout pour dériver la clé, donc :

python ///
id = bytes.fromhex("22 BA 6E CF 21 76 F8 F2")
k  = bytes.fromhex("83 bc 4c 12 6d 8b b6 ba 39 3c b6 dc b4 78 93 90")

def deriv(id, key):
    key2 = bytes([id[i] ^ key[i] for i in range(8)])
    key2 += key[8:]

    return key2

On ajoute un petit coup de Scapy et on peut retrouver le flag en modifiant le fait de retirer quelques répétitions :

python ///
from scapy.all import *
from Crypto.Cipher import AES

packets = rdpcap("btmon.pcap")

key = deriv(id, k)

for i in packets:
    cipher = i[HCI_LE_Meta_Extended_Advertising_Reports][Raw].load

    cipher2 = cipher[1:17]

    A = AES.new(key, AES.MODE_ECB)
    decrypted = A.decrypt(cipher2)

    print(decrypted[-4:])

Je tiens à remercier les challenge makers pour le temps et le soin qu’ils ont mis dans le développement de cette épreuve. Je l’ai trouvée extrêmement bien construite.