1 points par GN⁺ 2025-04-16 | Aucun commentaire pour le moment. | Partager sur WhatsApp
  • Pour contrôler directement depuis Home Assistant un purificateur d’air basé sur ESP32, lié à l’app du fabricant et à son cloud, le chemin de contrôle à distance a été rétro-ingéniéré et remplacé par un serveur local.
  • L’analyse de l’app, un contournement DNS et des captures Wireshark ont montré que l’appareil envoyait des paquets UDP à smartdeviceep.---.com:41014 et utilisait un protocole propriétaire plutôt que du DTLS standard.
  • Une connexion UART et un dump de 4 Mo de la flash ont permis de récupérer dev_key.key, des certificats, la configuration serveur et la configuration WiFi, puis d’analyser la structure du firmware avec Ghidra et esp32knife.
  • Les paquets combinaient un en-tête de 13 octets, un CRC-16 final de 2 octets, une génération de clé ECDH/HKDF, AES-128-CBC et une sérialisation MessagePack ; un patch du firmware a permis d’afficher le secret partagé dans les logs série et de réussir le déchiffrement.
  • La configuration finale enchaînait un proxy MITM, un serveur local et un bridge MQTT basé sur Mosquitto, et le ventilateur MQTT de Home Assistant a contrôlé l’alimentation et la vitesse du ventilateur de façon stable pendant plusieurs semaines.

Transformer un purificateur d’air dépendant du cloud en contrôle local

  • L’objectif était de contrôler depuis Home Assistant un purificateur d’air qui ne se connecte qu’à l’application mobile du fabricant et à un compte cloud.
  • En basculant le Bluetooth, le WiFi et la 5G du téléphone, il est apparu que l’app ne contrôlait pas l’appareil via le Bluetooth ou le WiFi local, mais uniquement via une connexion Internet.
  • Comme des valeurs de contrôle, par exemple la vitesse du ventilateur, transitent quelque part entre l’appareil et le serveur cloud, le segment réseau devient le principal point d’attaque.
    • En interceptant le trafic et en modifiant les valeurs, il devient possible de contrôler l’appareil.
    • En émulant les réponses du serveur, il est possible de le faire fonctionner sans Internet ni cloud du fabricant.
  • Le travail de rétro-ingénierie est présenté à des fins pédagogiques ; les informations sensibles propres au produit, comme les clés privées, domaines et endpoints d’API, ont été obfusquées ou supprimées.
  • Modifier l’appareil peut annuler la garantie ou l’endommager de façon irréversible.

Analyse de l’app et capture du trafic UDP

  • Le .apk de l’app Android a été extrait, puis classes.dex a été ouvert avec dex2jar et jd-gui pour en inspecter le contenu.
  • Dans MainActivity.class, il a été confirmé que l’app était basée sur React Native, et une connexion WebSocket sécurisée a été trouvée dans assets/index.android.bundle.
    • Le code d’exemple contenait une connexion à wss://smartdeviceapi.---.com.
  • La fonction de consultation des requêtes DNS de Pi-hole a permis d’identifier le domaine du serveur cloud auquel l’appareil se connectait.
  • Avec la fonction Local DNS de Pi-hole, ce domaine a été redirigé vers le poste de travail local 192.168.0.10, puis le trafic de l’IP de l’appareil 192.168.0.61 a été filtré dans Wireshark.
  • L’appareil envoyait des paquets UDP vers le port 41014 du poste de travail.

Mise en place du relais et indices d’un protocole propriétaire

  • Comme le DNS local résolvait le domaine cloud vers le poste de travail, l’IP réelle du serveur a été recherchée via le résolveur DNS Cloudflare 1.1.1.1.
  • node-udp-forwarder a été utilisé pour faire du poste de travail un relais UDP entre l’appareil et le serveur cloud.
  • Le premier paquet au démarrage et la réponse du serveur ont été capturés, mais ils ressemblaient à des octets aléatoires sans chaînes lisibles, laissant penser à un chiffrement.
  • Wireshark n’a pas reconnu les paquets comme du DTLS, et le format d’en-tête de la spécification DTLS ne correspondait pas non plus aux paquets capturés.
  • Comme il ne semblait pas s’agir d’un protocole standard, il a fallu rétro-ingénier la structure des paquets et la méthode de chiffrement.

Démontage de l’ESP32 et accès série

  • Une fois l’appareil démonté, la carte PCB principale, le port de connexion du ventilateur et la nappe du panneau de contrôle avant étaient visibles.
  • Le contrôleur principal portait le marquage ESP32-WROOM-32D, un microcontrôleur de la famille ESP32 doté du WiFi et du Bluetooth.
  • Le dépôt ESP32-reversing a servi de référence pour la rétro-ingénierie de l’ESP32.
  • Les broches TXD0 et RXD0 ont été identifiées dans la fiche technique de l’ESP32, puis les pistes reliées aux trous de test de débogage du PCB ont été suivies pour trouver les points de connexion série.
  • La connexion UART a été configurée avec le USB-UART Bridge du Flipper Zero.
    • Le TX du Flipper Zero est connecté au RX de l’ESP32.
    • Le RX du Flipper Zero est connecté au TX de l’ESP32.
    • GND est connecté à GND.
  • En se connectant avec Putty sur COM7 à 115200, les logs de démarrage se sont affichés.

Fichiers et configuration serveur révélés par les logs de démarrage

  • Les logs série indiquaient que l’ESP32 était une puce avec 2 cœurs CPU, WiFi/BT/BLE et 4 Mo de flash externe.
  • L’application s’exécutait depuis la partition factory.
  • Un système de fichiers FAT était monté, avec 122 KiB d’espace total et 0 KiB d’espace disponible.
  • L’application lisait les fichiers suivants :
    • serial
    • dev_key.key
    • SmartDevice-root-ca.crt
    • SmartDevice-signer-ca.crt
    • server_config
  • La configuration serveur contenait smartdeviceep.---.com:41014.

Dump de la flash et structure des partitions

  • Pour démarrer l’ESP32 en mode Download Boot, l’appareil a été mis sous tension avec la broche IO0 reliée à GND.
  • esptool a été utilisé pour dumper l’intégralité de la flash de 4 Mo.
    • La commande était esptool -p COM7 -b 115200 read_flash 0 0x400000 flash.bin.
  • Le dump a été répété plusieurs fois afin de vérifier la bonne lecture et de disposer d’une sauvegarde permettant de reflasher en cas de problème.
  • Le dump a été analysé avec esp32knife, ce qui a permis d’obtenir partitions.csv.
  • La structure des partitions contenait les éléments suivants :
    • nvs : stockage clé-valeur de 16K
    • otadata : données OTA de 8K
    • phy_init : données PHY de 4K
    • factory : partition d’application de 768K
    • ota_0, ota_1 : partitions d’application OTA de 768K chacune
    • storage : partition de données FAT de 1M
  • Selon un signalement de lecteur, ce dump de flash aurait pu être protégé si le chiffrement de la flash avait été activé, mais il ne l’était pas sur cet appareil.

Clés et certificats trouvés dans le stockage

  • L’état le plus récent de la partition nvs contenait le SSID et le mot de passe WiFi, et les journaux d’historique montraient aussi des identifiants WiFi utilisés précédemment.
  • La partition FAT storage a été montée comme un disque virtuel avec OSFMount pour être inspectée.
  • Le stockage contenait les fichiers suivants :
    • dev_info
    • dev_key.key
    • serial
    • server_config
    • SmartDevice-root-ca.crt
    • SmartDevice-signer-ca.crt
    • wifi_config
  • dev_key.key était une clé privée Elliptic Curve commençant par -----BEGIN EC PRIVATE KEY-----, vérifiée avec openssl ec -in dev_key.key -text -noout.
  • Les deux fichiers .crt étaient des certificats commençant par -----BEGIN CERTIFICATE-----, vérifiés avec openssl x509.
  • Le fait que les certificats et la clé de l’appareil soient stockés sur l’appareil renforçait la probabilité qu’ils servent au chiffrement des données des paquets UDP.

Configuration de l’environnement d’analyse Ghidra

  • L’image de la partition factory en cours d’exécution a été ouverte et analysée dans le CodeBrowser de Ghidra
  • Comme l’ESP32 utilise le jeu d’instructions Xtensa, le langage Tensilica Xtensa 32-bit little-endian a été sélectionné
  • L’image brute de la partition ne reflétait pas correctement le mappage de mémoire virtuelle ; part.3.factory.elf a donc été généré avec esp32knife puis réimporté
  • Un commit modifiant esp32knife pour prendre en charge le segment RTC_DATA a également été publié
  • Les structures des périphériques et la carte mémoire de l’ESP32 ont été chargées avec SVD-Loader-Ghidra
  • Les labels des fonctions de la ROM ESP32 ont été importés avec SymbolImportScript de Ghidra, afin de faciliter l’identification des fonctions ROM courantes comme printf

Indices de chiffrement trouvés via les chaînes

  • Dans Defined Strings de Ghidra, les chaînes observées dans les logs série et les chaînes voisines ont été suivies
  • Les chaînes voisines contenaient les indices suivants
    • Message CRC error
    • Seed Error
    • PRNG fail
    • ECDH setup failed
    • mbedtls_ecdh_gen_public failed
    • mbedtls_ecdh_compute_shared failed
    • MBED HKDF failed
    • Write ECC conn packet
  • mbedtls est une bibliothèque open source qui implémente des primitives cryptographiques, la manipulation de certificats X509, SSL/TLS et DTLS
  • Comme les fonctions ECDH et HKDF sont utilisées directement, et non DTLS, l’analyse indique que l’échange de clés et la dérivation de clés sont implémentés dans un protocole propriétaire
  • La chaîne ECC conn packet montre que le premier paquet de connexion est lié au processus d’échange de clés ECDH

Patch du firmware supprimant la dépendance au panneau de contrôle

  • Comme il était peu pratique d’analyser le PCB tout en le gardant connecté au ventilateur et au panneau de contrôle, le panneau de contrôle a été débranché, mais un panic se produisait au démarrage avec le log No Cap device found!
  • La fonction autour de la chaîne No Cap device found! affichant CapSense Init, elle a été considérée comme la logique d’initialisation de l’entrée capacitive du panneau avant
  • Dans Ghidra, cette fonction a été nommée InitCapSense, et le service qui l’appelle StartCapSenseService
  • L’instruction appelant StartCapSenseService a été remplacée par nop afin de supprimer le démarrage du service du panneau de contrôle
  • Après modification des octets dans l’image brute part.3.factory et nouveau flash à l’offset 0x10000, l’appareil ne démarrait pas à cause d’une erreur de checksum de l’image ESP32
  • Un script corrigeant le checksum de la partition applicative a été ajouté en s’appuyant sur la logique interne d’esptool
  • Une fois l’image au checksum réparé flashée, l’appareil a fonctionné normalement sans panneau de contrôle, confirmant la réussite de la modification du firmware

Structure de l’en-tête de paquet et du CRC

  • Après comparaison de paquets sur plusieurs démarrages, les 13 premiers octets étaient similaires, tandis que le reste semblait chiffré
  • Le format de l’en-tête de paquet était le suivant
    • 55 : octet magique d’identification du protocole
    • 00 31 : longueur du paquet
    • 02 : identifiant du message
    • 01 23 45 67 89 AB CD EF FF : numéro de série de l’appareil sur 9 octets
  • Le schéma des identifiants de message était le suivant
    • 0x02 : premier paquet envoyé par l’appareil connecté
    • 0x82 : première réponse envoyée par le serveur cloud
    • 0x01 : paquets suivants envoyés par l’appareil connecté
    • 0x81 : réponses suivantes envoyées par le serveur
  • Le bit de poids fort distingue les requêtes client des réponses serveur, tandis que le bit de poids faible distingue l’échange initial des paquets suivants
  • En suivant la fonction référencée par la chaîne Message CRC error, la logique de vérification du CRC a été identifiée
  • Les 2 derniers octets étaient un checksum CRC-16 calculé sur tout le reste du paquet
    • Le polynôme était 0x1021
    • La valeur initiale était 0xFFFF
    • La même méthode a été vérifiée sur plusieurs paquets capturés

Flux de génération de clé ECDH/HKDF

  • Dans le paquet semblant correspondre au premier échange de clés, les données hors en-tête de 13 octets et CRC de 2 octets faisaient 32 octets, ce qui correspond à la taille d’une clé publique 256 bits
  • La requête client était précédée de 00 01, et comme cette valeur ne changeait pas d’un démarrage à l’autre, elle a été traitée comme un descripteur de données
  • Dans Ghidra, la fonction de génération de clé a été retrouvée en suivant les chaînes d’erreur, puis résumée sous forme de pseudo-code en la comparant au code source de mbedtls
  • La fonction de génération de clé effectue les opérations suivantes
    • Génération d’une paire de clés ECDH avec mbedtls_ecdh_gen_public
    • Une forme d’écrasement de la clé générée par une autre clé en mémoire est observée
    • Chargement d’une autre clé publique
    • Calcul du secret partagé avec mbedtls_ecdh_compute_shared
    • Génération d’une valeur aléatoire de 32 octets avec mbedtls_ctr_drbg_random
    • Dérivation de la clé finale avec mbedtls_hkdf
  • La configuration HKDF était la suivante
    • Hachage : SHA-256
    • salt : secret partagé ECDH
    • input : valeur aléatoire de 32 octets générée par l’appareil
    • info : numéro de série de l’appareil sur 9 octets
    • Taille de la clé de sortie : 0x10, soit 16 octets
  • La fonction appelante ajoutait la valeur aléatoire de 32 octets après 00 01 et transmettait 0x22 octets, ce qui correspond au format du premier paquet d’échange de clés capturé

Affichage du secret partagé et déchiffrement AES

  • Pour calculer la clé finale de déchiffrement, le secret partagé ECDH était nécessaire
  • Plutôt que d’utiliser le débogage JTAG, le firmware a été patché en écrasant l’emplacement de la logique CapSense déjà désactivée avec une fonction personnalisée affichant le secret partagé sur le port série
  • Dans GenerateNetworkKey, un appel de fonction a été inséré juste après la génération du secret partagé, et le pointeur de clé dans les registres a été utilisé pour afficher les 32 octets
  • Au démarrage, le secret partagé était affiché en hexadécimal après Write ECC conn packet, et sa valeur ne changeait pas même après plusieurs redémarrages
  • La clé de sortie HKDF a également été confirmée avec un patch séparé, ce qui a permis de reproduire la même logique de génération de clé à partir des paquets capturés
  • Dans la fonction de chiffrement, une table statique commençant par 63 7C 77 7B F2 6B 6F C5 a été trouvée, correspondant à l’AES Forward S-Box de mbedtls
  • Le mode de chiffrement final était AES-128-CBC, et la valeur aléatoire de 16 octets dans le paquet était utilisée comme IV
  • Dans les paquets déchiffrés, des valeurs lisibles comme mirror_data_get, FAN_SPEED, BOOST, FILTER1 et FILTER2 ont été confirmées

Implémentation d’un proxy MITM

  • La clé privée de l’appareil et la logique de dérivation de clé ayant été obtenues, et les données dynamiques nécessaires étant exposées sur le réseau, il était possible d’écrire un proxy MITM sans patcher le firmware
  • Le script Node.js crée un socket UDP local et un socket UDP destiné au serveur cloud, puis relaie les paquets dans les deux sens
  • Les paquets reçus de l’appareil connecté sont journalisés puis envoyés au serveur cloud ; les paquets reçus du serveur cloud sont journalisés puis envoyés à l’appareil connecté
  • Les paquets dont le messageId vaut 2 sont considérés comme des paquets d’échange de clés, et la valeur aléatoire qu’ils contiennent est utilisée pour calculer la clé AES des paquets suivants
  • En contrôlant l’appareil depuis l’application mobile tout en accumulant les logs MITM, les formes des requêtes et réponses nécessaires à l’implémentation d’un serveur local ont été identifiées

Structure des messages MessagePack

  • Les données déchiffrées restaient dans un format de sérialisation binaire
  • L’en-tête des données internes ressemblait à un ID et une longueur en little-endian
    • 01 00 : ID de paquet
    • 64 00 : ID de transaction
    • 29 00 : longueur des données sérialisées
  • Le format de sérialisation a d’abord été en partie rétro-ingéniéré manuellement, mais il s’est avéré qu’il s’agissait de MessagePack
  • Avec une implémentation comme msgpackr, il était facile de décoder les données binaires sous forme de JSON
  • Les principaux messages identifiés étaient les suivants
    • Échange de clés : l’appareil envoie au serveur des octets aléatoires à utiliser avec HKDF
    • mirror_data_get : récupère l’état initial depuis le serveur au démarrage
    • connect : envoie l’UUID du firmware actuel, et le serveur répond avec des informations sur le firmware, la configuration, l’heure et les adresses de serveur
    • mirror_data : le serveur modifie l’état de l’appareil, ou l’appareil signale au serveur un changement d’état
    • keep_alive : l’appareil envoie périodiquement son état, notamment le RSSI, le RTT, les paquets perdus, le nombre de connexions et l’uptime

Pont MQTT et intégration avec Home Assistant

  • MQTT a été utilisé pour connecter Home Assistant au serveur personnalisé
  • L’add-on Mosquitto, un broker MQTT open source, a été configuré dans Home Assistant
  • La structure de connexion est de la forme Home AssistantMQTT BrokerCustom ServerSmart Device
  • Le serveur personnalisé fonctionne de la manière suivante
    • Quand l’appareil demande son état avec mirror_data_get, il répond en utilisant la valeur retained du broker MQTT ou une valeur par défaut
    • Quand Home Assistant envoie une commande de changement d’état sur un topic MQTT, le serveur personnalisé la transmet à l’appareil
    • Si l’état de l’appareil change pour une raison quelconque, le paquet mirror_data de l’appareil est publié sur le broker MQTT et marqué comme retained
  • La source de vérité pour l’état reste toujours l’appareil
    • Si la mise à jour de l’état échoue, elle n’est pas affichée comme mise à jour dans le broker MQTT
    • Même si l’état est modifié via le panneau de contrôle physique, le changement est répercuté dans le broker MQTT
  • L’intégration MQTT Fan de Home Assistant a été utilisée pour mapper le purificateur d’air comme un appareil de type ventilateur
  • Dans configuration.yaml, les topics d’état d’alimentation, de commande, d’état de vitesse du ventilateur et de commande de vitesse du ventilateur, ainsi que la plage de vitesses 1 à 4, ont été configurés
  • Le DNS local de Pi-hole a été configuré pour résoudre le domaine cloud du fabricant vers le serveur personnalisé, afin que le serveur local joue le rôle de serveur pour l’appareil

Évaluation de sécurité et résultats

  • Le fabricant a implémenté son propre protocole au lieu d’utiliser un protocole standard comme DTLS
  • Il n’est pas certain que chaque appareil dispose de sa propre clé privée, mais les deux scénarios ont des inconvénients
    • Si tous les appareils partagent la même clé privée du firmware, il suffit de rétro-ingénier un seul appareil pour tenter une attaque MITM contre d’autres appareils
    • Si chaque appareil possède une clé privée unique, le serveur doit conserver une correspondance entre numéros de série et clés d’appareil, et en cas de perte de ces données, il ne peut plus répondre aux communications des appareils
  • Comme le firmware contient une clé privée statique, un attaquant peut obtenir la clé à partir d’un simple dump de firmware et mener une attaque MITM
  • L’implémentation n’est pas entièrement mauvaise du point de vue de la sécurité, et l’attaque nécessite toujours un accès physique
  • Cette implémentation maison a rendu les communications réseau opaques, mais le Security through obscurity revient surtout à bloquer temporairement les attaques courantes visant les implémentations standard, et reste un obstacle franchissable pour un attaquant
  • L’objectif final, l’intégration avec Home Assistant, a été atteint, et le purificateur d’air a fonctionné sans problème pendant plusieurs semaines
  • Une automatisation a aussi été configurée pour mettre le purificateur d’air en mode boost pendant un certain temps lorsque les niveaux de PM2.5 ou de COV mesurés par un moniteur d’air séparé deviennent trop élevés

Aucun commentaire pour le moment.

Aucun commentaire pour le moment.