Si proche et pourtant si loin 🇫🇷

⭐⭐⭐
April 12, 2026 FCSC 2026 #hardware #bluetooth

Analyse de l’enoncé

On sait qu’on a une capture pcap des broadcast bluetooth d’un appareil dans un frigo.

Après avoir fait “remoBLE control”, en voyant la photo on sait que la connection ne peut pas se faire en utilisant un input utilisateur. Soit on est dans un cas de “Just Works”, soit en OOB.

Le fait que téléphone vibre quand on l’approche de l’appareil indique qu’on a probablement fait un appairage par NFC, et qu’on devrait trouver la clef de déchiffrement / une clef. C’est ce qu’on va chercher dans le log adb.

Deuxième chose qui m’intéressait était de savoir quel protocole serait employé. J’ai donc fait quelques recherches supplémentaires sur la différence entre BLE et Bluetooth “classique”. En gros Bluetooth classique a été inventé dans les années 2000 pour les échanges continus entre deux appareils (typiquement l’audio). Le BLE en revanche lui permet des échanges plus sporadiques, avec des temps d’appairage plus courts. Il permet aussi des connections 1 vers plusieurs.

Les appareils connectés utilisent donc du BLE, plus spécifiquement des “Payloads d’Advertising”.

Analyse des fichiers

En ouvrant le fichier pcap on tombe bien sur 54 packets dont le Info est “Rcvd LE Meta (LE Extended Advertising Report)”

Le log en revanche fait 8000 lignes, donc on va devoir l’analyser à coup de grep. On ne voit rien qui s’appelle ‘oob’, mais beaucoup d’évènements liés au NFC.

En se baladant dans les logs on voit beaucoup de libnfc_nci, et pas mal d’entre eux utilisent l’api de SecHAL.

SecHAL semble être le pont entre le “Secure Element” et le NFC. Dans la documentation Android la seule chose que je trouve est ce lien

Bon finalement je suis retourné aux Principes de bases de la communication NFC

On y trouve notamment ce paragraphe

Avant de commencer à écrire vos applications NFC, il est important de comprendre les différents types de tags NFC, comment le système d’envoi de tags analyse les tags NFC et le travail spécial que le système d’envoi de tags effectue lorsqu’il détecte un message NDEF. Les tags NFC sont disponibles dans une large gamme de technologies et peuvent également être utilisés pour écrire des données de différentes manières. Android est le système d’exploitation qui prend le mieux en charge la norme NDEF, définie par le NFC Forum.

Les données NDEF sont encapsulées dans un message (NdefMessage) contenant un ou plusieurs enregistrements (NdefRecord). […] Nous vous recommandons donc d’utiliser NDEF lorsque cela est possible pour faciliter le développement et assurer une compatibilité maximale avec les appareils fonctionnant sous Android.

On espère que Charlie n’est pas un power user de NFC, et on va effectuer un grep sur ces Ndef qui nous donne

❯ cat adb-logcat.txt | grep Ndef
03-21 12:55:36.941  4583  4583 I RegisteredComponentCache: ComponentInfo: ResolveInfo{2596964 com.android.apps.tag/.TagViewer m=0x108000}, techs: android.nfc.tech.Ndef,
03-21 12:55:39.638  4583  4850 I libnfc_nci: [INFO:NativeNfcTag.cpp(1815)] nativeNfcTag_registerNdefTypeHandler
03-21 12:55:39.643  4583  4850 D NdefPushServer: start, thread = null
03-21 12:55:39.643  4583  4850 D NdefPushServer: starting new server thread
03-21 12:55:39.643  4583  5287 D NdefPushServer: about create LLCP service socket
03-21 12:55:39.648  4583  5287 D NdefPushServer: created LLCP service socket
03-21 12:55:39.648  4583  5287 D NdefPushServer: about to accept
03-21 12:56:16.993  4583  4583 I libnfc_nci: [INFO:NativeNfcTag.cpp(1528)] nativeNfcTag_doIsIsoDepNdefFormatable
03-21 12:56:16.993  4583  4583 I libnfc_nci: [INFO:NativeNfcTag.cpp(1506)] nativeNfcTag_doIsNdefFormatable: is formattable=0
03-21 12:56:17.031  4583  4583 I libnfc_nci: [INFO:NativeNfcTag.cpp(1231)] nativeNfcTag_doCheckNdef: enter
03-21 12:56:17.031  4583  4583 I libnfc_nci: [INFO:NativeNfcTag.cpp(1259)] nativeNfcTag_doCheckNdef: try NFA_RwDetectNDef
03-21 12:56:17.072  4583  4854 I libnfc_nci: [INFO:NativeNfcTag.cpp(1172)] nativeNfcTag_doCheckNdefResult: flag formatted for ndef
03-21 12:56:17.072  4583  4854 I libnfc_nci: [INFO:NativeNfcTag.cpp(1175)] nativeNfcTag_doCheckNdefResult: flag ndef supported
03-21 12:56:17.072  4583  4583 I libnfc_nci: [INFO:NativeNfcTag.cpp(1328)] nativeNfcTag_doCheckNdef: exit; status=0x0
03-21 12:56:17.103  4583  4583 I libnfc_nci: [INFO:NativeNfcTag.cpp(1111)] nativeNfcTag_doGetNdefType: enter; libnfc type=4; java type=3
03-21 12:56:17.104  4583  4583 I libnfc_nci: [INFO:NativeNfcTag.cpp(1132)] nativeNfcTag_doGetNdefType: exit; ndef type=4
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]
03-21 12:56:17.155  4583  4583 D NfcDispatcher: tryHandover(): 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]
03-21 12:56:17.990  4583  4583 I libnfc_nci: [INFO:NativeNfcTag.cpp(1528)] nativeNfcTag_doIsIsoDepNdefFormatable
03-21 12:56:17.990  4583  4583 I libnfc_nci: [INFO:NativeNfcTag.cpp(1506)] nativeNfcTag_doIsNdefFormatable: is formattable=0
03-21 12:56:18.047  4583  4583 I libnfc_nci: [INFO:NativeNfcTag.cpp(1231)] nativeNfcTag_doCheckNdef: enter
03-21 12:56:18.047  4583  4583 I libnfc_nci: [INFO:NativeNfcTag.cpp(1259)] nativeNfcTag_doCheckNdef: try NFA_RwDetectNDef
03-21 12:56:18.183  4583  4854 I libnfc_nci: [INFO:NativeNfcTag.cpp(1172)] nativeNfcTag_doCheckNdefResult: flag formatted for ndef
03-21 12:56:18.184  4583  4854 I libnfc_nci: [INFO:NativeNfcTag.cpp(1175)] nativeNfcTag_doCheckNdefResult: flag ndef supported
03-21 12:56:18.185  4583  4583 I libnfc_nci: [INFO:NativeNfcTag.cpp(1328)] nativeNfcTag_doCheckNdef: exit; status=0x0

Il y a un message ici qui nous semble très intéressant: celui avec des payload

❯ cat adb-logcat.txt | grep Ndef | grep -P '(?<=payload=)\w+\b' | tr ',' '\n'
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]
03-21 12:56:17.155  4583  4583 D NfcDispatcher: tryHandover(): 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]

Le champ tnf=1 est celui de TNF_WELL_KNOWN pour des types tels que RTD_TEXT ou RTD_URI

On trouve la valeur de ces deux types avec une recherche dans la codebase du AOSP

RTD_TEXT = 0x54 et RTD_URI=0x55

On peut le décoder en

for i in $(cat adb-logcat.txt | grep Ndef | grep -Po '(?<=payload=)\w+\b') ; do echo $i | xxd -r -p ; echo ; done
idID: F2:F8:76:21:CF:6E:BA:22
adFW: hackropole.fr/2867cf25b06c.hex
swFW: Ruuvi FW 694766a+fcsc
dt
 ��q�t_5�C�����
idID: F2:F8:76:21:CF:6E:BA:22
adFW: hackropole.fr/2867cf25b06c.hex
swFW: Ruuvi FW 694766a+fcsc
dt
 ��q�t_5�C�����

Ruuvi

Quand on google Ruuvi FW On tombe sur une marque de capteurs. Ceci est super encourageant on a probablement trouvé le message envoyé par NFC du capteur. On obtient

  • son ID
  • une URI
  • un champ dont je ne sais que faire…
  • la version de son firmware: 694766a+fcsc le 6694766a est le dernier commit pour ruuvi, le +fcsc est probablement pour nous indiquer que les chall makers sont partis de ce commit et ont fait quelques modifications (notamment le chiffrage des payloads de l’advertising?) On a beau chercher dans les fork (je sais ce n’est pas un chall d’OSINT) mais je ne trouve pas leurs modifications.

En clonant ce repo et en cherchant les champs “FW” et “SW” on tombe sur test/test_app_comms.c.

D’ailleurs la doc nous dit aussi que lorsqu’on s’approche d’un tel appareil normalement en NFC il nous envoie:

When an existing sensor is scanned with NFC on the Dashboard or on the Sensor Card page (full image view), a popup showing sensor details is displayed. En gros on devrait avoir

  • name -> on l’a pas
  • MAC -> on l’a pas (mais on l’a par le pcap)
  • Unique ID -> on l’a
  • Firmware version -> on l’a

Ce qu’on a presque eu… visiblement l’ANSSI a fait quelques modifications. On n’est pas censé obtenir d’URI et on est censé obtenir son addresse mac, ce qu’on n’a pas obtenu.

C’est ici que la phrase de l’énoncé ci-dessous prend tout son sens

Votre voisine Charlie aime jouer aux CTF. Elle a caché un flag dans les données télétransmises par le galet. Elle vous met ainsi au défi de réussir à retrouver ce flag (format habituel FCSC{).

Quand on cherche dans la doc de ruuvi on voit que les informations envoyées ne sont pas les mêmes selon qu’on ait un “ruuvi tag” ou un “ruuvi air”… Avec la photo qui est donné dans le challenge on penche donc pour un “ruuvi tag”

Enfin quand on cherche en ligne que sont les formats des broadcast ruuvi on tombe sur cette doc.

Bon wireshark nous disait déjà que c’était du ruuvi, mais à l’époque je ne savais pas quoi en faire.

Ruuvi advertisement

Advertisement data

The advertisement begins with mandatory flags highlighted in blue. The the actual manufacturer specific data begins with a header highlighted in dark green which contains:

Length (not counting length byte): 0x1B = 27 bytes

Type: 0xFF = Manufacturer specific data

Manufacturer ID, least significant byte first: 0x0499 = Ruuvi Innovations Ltd

Payload data: 0x050F274035C454005000C8FC20A456F030E5C9445429E38D

C’est exactement ce qu’on a. Quand on regarde, on nous apprend que le premier octet de cette payload nous donne le format des données, nous c’est 08 qui correspond à Encrypted Environmental

La doc nous dit à son sujet

This is a proposed encrypted data format which is not yet implemented in Ruuvi devices outside of a few proof-of-concept projects

The encryption uses nRF52-builtin AES128 encryption in Elctronic Codebook (ECB) mode. Data to be encrypted is temprature, humidity, pressure, voltage, TX power, measurement count and movement counts. The measurement sequence counter protects against replay attacks.

Data format has an unencrypted header, 16 bytes of AES-128 encrypted data, 1 byte crc8 and 6 bytes long MAC address for iOS devices.

[…]

The encryption key is formed according to the application, usually a static key shared in application + unique key derived from 8-byte tag ID.

Bon le format exact correspond exactement à ça: On en apprend quand même la MAC: d6:1e:96:ac:0c:a3

Visiblement la version de l’ANSSI (qu’ils gardent jalousement pour eux (je l’ai pas trouvé sur gh)) fait partie des quelques rares à en avoir fait une implémentation.

Pour déchiffrer le pcap il nous faut donc

  • la clef AES de 128 bit, qui est dérivée de
    • la clef statique partagée entre les deux appareil
    • le tag ID

L’ID nous l’avons obtenu de l’échange NFC, il ne manque plus que la clef statique partagée entre les appareils…

Analyse de l’hexadécimal en ligne

Puisque Charlie aime jouer, je pense qu’on va devoir faire qqch de ce qu’il a mis en ligne. Je pense que c’est en lieu et place du nom qu’elle a mis l’URI.

A première vue le fichier peut sembler être un dump de qqch, les lignes ont toutes à peu près le même format (à part quelques lignes plus courtes), le premier champ est :1 suivi d’un compteur sur 3 nibles qui s’incrémente avec chaque ligne.

:1 CNTR 0000 ??*

En gros on a

❯ cat 2867cf25b06c.hex | awk '{ print length }' | sort | uniq -c
      1 12
      7 16
      2 20
      1 28
      2 36
  19987 44

une ligne de 12 chars, 7 de 16, 2 de 20 … et 20000 à 44

Le fichier semble avoir une certaine périodicité, il commence par un message en :0200…

❯ cat -n 2867cf25b06c.hex | grep -P ':02'
     1 :020000040000FA
  4019 :020000040001F9
  8116 :020000040002F8
 11978 :020000040003F7
 16075 :020000040004F6
 18522 :020000040007F3
 19900 :02A61000704791

Suivi de AF lignes en :0100??000 Puis on a une ligne :0100FF000 puis EFF lignes de :10???8000 ou :10???000 puis une ligne tronquée (plus courte que d’habitude) en :010FFF8000. En on recommence avec une ligne en 02.

Peut-être est ce un fichier, dont on a le hexdump modifié ?

Bon après quelques recherches ceci est un format de dump Intel hex

Nous avons donc probablement le firmware réel compilé de notre tag. On apprend qu’on peut utiliser objcopy pour récupérer le binaire qui y est encodé.

❯ objcopy -I ihex -O binary 2867cf25b06c.hex 2867cf25b06c.bin
❯ file 2867cf25b06c.bin
2867cf25b06c.bin: ARM Cortex-M firmware, initial SP at 0x20000400, reset at 0x00000a80, NMI at0x00000714, HardFault at 0x00000a60, SVCall at 0x00000aa4, PendSV at 0x00000746

On a donc récupérer le firmware de l’appareil.

Pour comparer je vais essayer de compiler la version standard en ligne, et essayer de repérer des différences.

Après avoir passé [trop] d’heures à installer toutes les bonnes versions de tous les logiciels et l’avoir recompilé, je n’obtiens pas du tout la même chose, les deux binaires ont des tailles très différentes.

Après consultation de mon oracle de solve (ticket hardware-926) on me dit à l’oreillette de savoir prendre du recul et qu’il y a plusieurs solves possibles.

Je pense que les deux façons sont:

  • reverse le firmware
  • casser le AES
  • demander à un LLM

J’ai donc opté pour la première option

Reverse

J’ai donc ouvert les deux sous ghidra et ait parcouru un peu tout le binaire propre (que j’avais compilé), puisque les noms/signatures des fonctions ne sont pas présentes dans le binaire original j’ai donc passé beaucoup de temps à renommer les fonctions critiques entre les deux. Jusqu’à réaliser que la fonction le_5_encode n’a rien à voir entre les deux binaires, elle ne prend pas les mêmes arguments, n’a pas le même code hexadécimal, n’a pas du tout la même tête une fois décompilée. En même temps dans les ruuvi on encode en mode 5 et nous on est en mode 8 (le mode chiffré experimental), donc pas etonnant que ce soit ici que la différence entre les deux versions soit présente.

On voit notamment qu’elle envoie un pointeur à une fonction et un array de données mystérieuses.

Quand on va chercher la valeur au bout de ce pointeur on obtient des fonctions qui ont l’air de faire de la crypto (red flag de reverse de “je veux pas aller plus loin”). J’en déduis que l’array mystérieux passé en argument est la clef secrète, d’autant plus qu’elle a la bonne taille.

Déchiffrement

Après plusieurs essais pour les différentes endianness et comment la clef est dérivée de l’ID que l’on a obtenu du NFC et la clef qu’on vient d’extraire, on peut finalement déchiffrer les messages

from Crypto.Cipher import AES

comm_id = bytes.fromhex('22ba6ecf2176f8f2')
static_key = bytes.fromhex("83bc4c126d8bb6ba393cb6dcb4789390")
padded_comm = comm_id + b'\x00'*8
key = bytes([i ^ j for i, j in zip(padded_comm, static_key)])

cyyyffer = AES.new(key, AES.MODE_ECB)

packets = [
'0867803db69f3de477b89f6a76c3bc9e3340d61e96ac0ca3',
'081722a78d433f28fd44a59220bdf94e0367d61e96ac0ca3',
'085073e74af1366796e6e05480fb3bb3eb0bd61e96ac0ca3',
'085073e74af1366796e6e05480fb3bb3eb0bd61e96ac0ca3',
'089bb551b4a027e698635127058395b98ae3d61e96ac0ca3',
'0835792d10b868e4e6bd4fe932cd4e3bbe5ed61e96ac0ca3',
'083ece1355b211f1b6af11fcd123664fccc4d61e96ac0ca3',
'08792c0e8b04a8ac95c26fb1d805dc5f2502d61e96ac0ca3',
'08b5f45aa366ee0e8fc9cf7d1164dc738ce9d61e96ac0ca3',
'0843afeadf8a7a4e9c5fb16276a7a6bedecad61e96ac0ca3',
'0893f2b2726861cfff184739dc312169a1c2d61e96ac0ca3',
'0893f2b2726861cfff184739dc312169a1c2d61e96ac0ca3',
'08e717d0e21c2815abb22cfeaa8ccb76473ad61e96ac0ca3',
'08e717d0e21c2815abb22cfeaa8ccb76473ad61e96ac0ca3',
'08f3a2c38ec629979fedb6a7471e961c0d07d61e96ac0ca3',
'0840ea8b0085d3e7381ce598f8b5ae595e83d61e96ac0ca3',
'08af13d869e11ee324a81ca80528e5310a0ed61e96ac0ca3',
'08774e15681ae00a6a8a4c83af59de3897e0d61e96ac0ca3',
'089e7b5848926aed011f4856edcacc848ef6d61e96ac0ca3',
'089e7b5848926aed011f4856edcacc848ef6d61e96ac0ca3',
'08936d84f8098ccfa04273503f00bd28f248d61e96ac0ca3',
'08da8500488f882236264e8829173a79cdc4d61e96ac0ca3',
'088ff9b356d91a7e7ec65240a538b6eeb215d61e96ac0ca3',
'088ff9b356d91a7e7ec65240a538b6eeb215d61e96ac0ca3',
'0820ad6d63ed019b0be0e10e6322005d7b4ad61e96ac0ca3',
'0820ad6d63ed019b0be0e10e6322005d7b4ad61e96ac0ca3',
'08482b1f03873c77bb029b4c4cc5387420edd61e96ac0ca3',
'08482b1f03873c77bb029b4c4cc5387420edd61e96ac0ca3',
'08fbb40f1ad497003a5082cf36d23979f722d61e96ac0ca3',
'08fbb40f1ad497003a5082cf36d23979f722d61e96ac0ca3',
'080e96ef0328697d1ae940effd8226208345d61e96ac0ca3',
'08dceef5d858fb614689fb201e43e18b826cd61e96ac0ca3',
'089166a9bfbd36fd6d28dcdcfa0d469a0556d61e96ac0ca3',
'0884e1829173cee42d54275d2ba05e9995f8d61e96ac0ca3',
'08bcfc38bcedeaee3b64a7cc21ef12989b09d61e96ac0ca3',
'08f269765457ca6e6321445e56c481b21201d61e96ac0ca3',
'089c3b45b28d5e68e6dd7a08080920fd2357d61e96ac0ca3',
'089c534bac1b9aa33f5c89e2313b85ed144cd61e96ac0ca3',
'08bff4e920e5c02173bfe6655b8b5934f8edd61e96ac0ca3',
'08bff4e920e5c02173bfe6655b8b5934f8edd61e96ac0ca3',
'082e769d3219710eb17b6888a958d6389e53d61e96ac0ca3',
'08011aab904f9100c665bb0af8b65e97c960d61e96ac0ca3',
'08eb4e5d3ce55239967910a793450bbe6f0ad61e96ac0ca3',
'082ca84742ae4bf707453bc6ef7aa5f3945ed61e96ac0ca3',
'082ca84742ae4bf707453bc6ef7aa5f3945ed61e96ac0ca3',
'0889dbb4a26114dd3e626ca15541df8d2717d61e96ac0ca3',
'08d8286b7cf6dd99566577da1b6be4118f6cd61e96ac0ca3',
'08558dd9e589f716fa15e3269b289f4be05cd61e96ac0ca3',
'08d0a7e1c38c6e107fb73109adfb391581ecd61e96ac0ca3',
'083cde1a26203552787856d2829dc0bff6a0d61e96ac0ca3',
'0870f3ccd8d5b0f99fead76aad03be3b1cb6d61e96ac0ca3',
'08f2f5d820229af2c9b01e164a7cab8f1d59d61e96ac0ca3',
'08d27961dd057289b11f220728fa372f31eed61e96ac0ca3',
'08170c65301eec51efae6e8b5b0ad1beb495d61e96ac0ca3',
]

FLAG = ''
for pkt in packets:
    raw = bytes.fromhex(pkt)
    payload = raw[1:17]
    dec = cyyyffer.decrypt(payload)
    ffff = dec[-4:].decode()
    if FLAG[-4:] != ffff:
        FLAG += ffff

print(FLAG)
❯ uv run solve.py
FCSC{86bdae6f788fa47c64a8a1f7496f1255}  FCSC{86bf788fa47c64a7496

C’était mon premier (et dernier) challenge trois étoiles à un FCSC. Merci beaucoup aux créateurs !

PS: Je pense que j’aurais pu gagner beaucoup de temps dans le reverse si j’avais réussi a exporter les signatures des fonctions de ghidra d’un binaire à l’autre, malheuresement ça ne marchait pas dans ma version… donc j’ai du abandonner.