1 Punkte von GN⁺ 2025-04-16 | Noch keine Kommentare. | Auf WhatsApp teilen
  • Um einen ESP32-basierten Luftreiniger, der an Hersteller-App und Cloud gebunden ist, direkt über Home Assistant zu steuern, wurde der Remote-Steuerungspfad per Reverse Engineering analysiert und durch einen lokalen Server ersetzt.
  • Durch App-Analyse, DNS-Umleitung und Wireshark-Mitschnitte wurde festgestellt, dass das Gerät UDP-Pakete an smartdeviceep.---.com:41014 sendet und statt Standard-DTLS ein eigenes Protokoll verwendet.
  • Über eine UART-Verbindung und einen 4-MB-Flash-Dump wurden dev_key.key, Zertifikate, Serverkonfiguration und WiFi-Einstellungen extrahiert; die Firmware-Struktur wurde mit Ghidra und esp32knife analysiert.
  • Die Pakete kombinierten einen 13-Byte-Header und eine abschließende 2-Byte-CRC-16, ECDH/HKDF-Schlüsselerzeugung, AES-128-CBC und MessagePack-Serialisierung; durch einen Firmware-Patch, der das Shared Secret im seriellen Log ausgab, gelang die Entschlüsselung.
  • Der finale Aufbau bestand aus MITM-Proxy, lokalem Server und einer MQTT-Bridge auf Basis von Mosquitto; über MQTT Fan in Home Assistant konnten Stromversorgung und Lüftergeschwindigkeit über mehrere Wochen stabil gesteuert werden.

Einen cloudabhängigen Luftreiniger auf lokale Steuerung umstellen

  • Ziel war es, einen Luftreiniger, der nur mit der mobilen App und dem Cloud-Konto des Herstellers verbunden ist, über Home Assistant zu steuern.
  • Durch Umschalten von Bluetooth, WiFi und 5G am Smartphone zeigte sich, dass die App das Gerät nicht über lokales Bluetooth oder WiFi steuert, sondern ausschließlich über die Internetverbindung.
  • Da Steuerwerte wie die Lüftergeschwindigkeit irgendwo zwischen Gerät und Cloud-Server ausgetauscht werden, wurde der Netzwerkabschnitt zum zentralen Angriffspunkt.
    • Wenn man den Traffic abfängt und Werte verändert, lässt sich das Gerät steuern.
    • Wenn man Serverantworten emuliert, kann es auch ohne Internet und Hersteller-Cloud betrieben werden.
  • Die Reverse-Engineering-Inhalte dienen Bildungszwecken; produktspezifische sensible Informationen wie private Schlüssel, Domains und API-Endpunkte wurden verschleiert oder entfernt.
  • Modifikationen am Gerät können die Garantie ungültig machen oder das Gerät dauerhaft beschädigen.

App-Analyse und Mitschnitt des UDP-Traffics

  • Die .apk der Android-App wurde extrahiert und classes.dex mit dex2jar und jd-gui geöffnet, um das Innenleben zu untersuchen.
  • In MainActivity.class zeigte sich, dass die App auf React Native basiert; in assets/index.android.bundle wurde eine sichere WebSocket-Verbindung gefunden.
    • Der Beispielcode enthielt eine Verbindung zu wss://smartdeviceapi.---.com.
  • Mit der DNS-Abfrageansicht von Pi-hole wurde die Domain des Cloud-Servers ermittelt, zu dem sich das Gerät verbindet.
  • Über die Funktion Local DNS von Pi-hole wurde diese Domain auf die lokale Workstation 192.168.0.10 geleitet, und in Wireshark wurde der Traffic der Geräte-IP 192.168.0.61 gefiltert.
  • Das Gerät sendete UDP-Pakete an Port 41014 der Workstation.

Relay-Aufbau und Hinweise auf ein proprietäres Protokoll

  • Da das lokale DNS die Cloud-Domain auf die Workstation auflöste, wurde die tatsächliche Server-IP über den Cloudflare DNS resolver 1.1.1.1 abgefragt.
  • Mit node-udp-forwarder übernahm die Workstation die Rolle eines UDP-Relays zwischen Gerät und Cloud-Server.
  • Das erste Paket beim Booten und die Serverantwort wurden mitgeschnitten, wirkten jedoch ohne lesbare Zeichenfolgen wie zufällige Bytes, was auf Verschlüsselung hindeutete.
  • Wireshark erkannte die Pakete nicht als DTLS, und auch das Header-Format der DTLS-Spezifikation unterschied sich von den aufgezeichneten Paketen.
  • Da es sich offenbar nicht um ein Standardprotokoll handelte, mussten Paketstruktur und Verschlüsselungsverfahren direkt per Reverse Engineering untersucht werden.

ESP32 zerlegen und seriellen Zugriff herstellen

  • Nach dem Zerlegen des Geräts waren die Hauptplatine, der Anschluss für den Lüfter und das Flachbandkabel zum vorderen Bedienfeld sichtbar.
  • Der Hauptcontroller war als ESP32-WROOM-32D gekennzeichnet, ein Mikrocontroller der ESP32-Familie mit WiFi- und Bluetooth-Funktionen.
  • Als Referenz wurden die Materialien zum ESP32-Reverse-Engineering im Repository ESP32-reversing genutzt.
  • Im ESP32-Datenblatt wurden die Pins TXD0 und RXD0 identifiziert; anschließend wurden die Leiterbahnen zu Debug-Pinholes auf der Platine verfolgt, um die seriellen Anschlusspunkte zu finden.
  • Mit der USB-UART Bridge des Flipper Zero wurde eine UART-Verbindung aufgebaut.
    • Flipper Zero TX wurde mit ESP32 RX verbunden.
    • Flipper Zero RX wurde mit ESP32 TX verbunden.
    • GND wurde mit GND verbunden.
  • Nach dem Verbinden in Putty mit COM7 und 115200 Baud wurden Boot-Logs ausgegeben.

Dateien und Serverkonfiguration aus dem Boot-Log

  • Das serielle Log gab aus, dass der ESP32 ein Chip mit 2 CPU-Kernen, WiFi/BT/BLE und 4 MB externem Flash ist.
  • Die Anwendung lief aus der Partition factory.
  • Ein FAT-Dateisystem wurde gemountet; angezeigt wurden 122 KiB Gesamtspeicher und 0 KiB verfügbarer Speicher.
  • Die Anwendung las die folgenden Dateien:
    • serial
    • dev_key.key
    • SmartDevice-root-ca.crt
    • SmartDevice-signer-ca.crt
    • server_config
  • Die Serverkonfiguration enthielt smartdeviceep.---.com:41014.

Flash-Dump und Partitionsstruktur

  • Um den ESP32 im Modus Download Boot zu starten, wurde das Gerät eingeschaltet, während der Pin IO0 mit GND verbunden war.
  • Mit esptool wurde der gesamte 4-MB-Flash gedumpt.
    • Der Befehl lautete esptool -p COM7 -b 115200 read_flash 0 0x400000 flash.bin.
  • Der Dump wurde mehrfach durchgeführt, um korrektes Auslesen zu bestätigen, und als Backup gesichert, damit er bei Problemen wieder geflasht werden konnte.
  • Der Dump wurde mit esp32knife analysiert, wodurch partitions.csv gewonnen wurde.
  • Die Partitionsstruktur enthielt folgende Einträge:
    • nvs: 16K-Key-Value-Speicher
    • otadata: 8K-OTA-Daten
    • phy_init: 4K-PHY-Daten
    • factory: 768K-App-Partition
    • ota_0, ota_1: jeweils 768K-OTA-App-Partition
    • storage: 1M-FAT-Datenpartition
  • Einem Hinweis eines Lesers zufolge hätte dieser Flash-Dump geschützt sein können, wenn Flash-Verschlüsselung aktiviert gewesen wäre; auf diesem Gerät war sie jedoch nicht aktiviert.

Im Speicher gefundene Schlüssel und Zertifikate

  • Der neueste Zustand der Partition nvs enthielt WiFi-SSID und Passwort; in den Verlaufslogs waren außerdem früher verwendete WiFi-Zugangsdaten zu sehen.
  • Die FAT-Partition storage wurde mit OSFMount wie ein virtuelles Laufwerk gemountet und untersucht.
  • Im Speicher befanden sich folgende Dateien:
    • dev_info
    • dev_key.key
    • serial
    • server_config
    • SmartDevice-root-ca.crt
    • SmartDevice-signer-ca.crt
    • wifi_config
  • dev_key.key war ein Private Key für Elliptic Curve, der mit -----BEGIN EC PRIVATE KEY----- begann und mit openssl ec -in dev_key.key -text -noout geprüft wurde.
  • Die beiden .crt-Dateien waren Zertifikate, die mit -----BEGIN CERTIFICATE----- begannen, und wurden mit openssl x509 geprüft.
  • Da Zertifikate und Geräteschlüssel auf dem Gerät gespeichert waren, lag nahe, dass sie zur Verschlüsselung der UDP-Paketdaten verwendet wurden.

Ghidra-Analyseumgebung einrichten

  • Das laufende factory-Partitionsimage wurde im CodeBrowser von Ghidra geöffnet und analysiert
  • Da der ESP32 den Xtensa-Befehlssatz verwendet, wurde die Sprache Tensilica Xtensa 32-bit little-endian ausgewählt
  • Da das rohe Partitionsimage das virtuelle Memory-Mapping nicht korrekt abbildete, wurde mit esp32knife part.3.factory.elf erzeugt und erneut importiert
  • Ein Commit, der esp32knife um Unterstützung für das Segment RTC_DATA erweitert, wurde ebenfalls veröffentlicht
  • Mit SVD-Loader-Ghidra wurden die Peripheriestruktur und die Memory-Map des ESP32 geladen
  • Mit Ghidras SymbolImportScript wurden Labels für ESP32-ROM-Funktionen importiert, um gemeinsame ROM-Funktionen wie printf leichter identifizieren zu können

Über Strings gefundene Hinweise auf Verschlüsselung

  • In Ghidras Defined Strings wurden die im seriellen Log sichtbaren Strings und die umgebenden Strings nachverfolgt
  • Unter den umgebenden Strings fanden sich folgende Hinweise
    • 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 ist eine Open-Source-Bibliothek, die kryptografische Primitive, die Verarbeitung von X509-Zertifikaten sowie SSL/TLS und DTLS implementiert
  • Da ECDH- und HKDF-Funktionen direkt verwendet werden, DTLS jedoch nicht, wurde analysiert, dass Schlüsselaustausch und Schlüsselableitung innerhalb eines eigenen Protokolls implementiert sind
  • Der String ECC conn packet zeigt, dass das erste Verbindungspaket mit dem ECDH-Schlüsselaustauschprozess zusammenhängt

Firmware-Patch zum Entfernen der Abhängigkeit vom Bedienpanel

  • Da die Analyse bei angeschlossener PCB an Lüfter und Bedienpanel unpraktisch war, wurde das Bedienpanel getrennt; beim Booten trat jedoch zusammen mit dem Log No Cap device found! eine Panic auf
  • Die Funktion in der Nähe des Strings No Cap device found! gibt CapSense Init aus und wurde daher als Initialisierungslogik für die kapazitive Eingabe des Frontpanels interpretiert
  • In Ghidra wurde diese Funktion InitCapSense genannt, der aufrufende Dienst StartCapSenseService
  • Die Aufrufinstruktion von StartCapSenseService wurde durch nop ersetzt, um den Start des Bedienpanel-Dienstes zu entfernen
  • Im rohen Image part.3.factory wurden Bytes geändert und es erneut an Offset 0x10000 geflasht, doch wegen eines ESP32-Image-Checksum-Fehlers bootete es nicht
  • Auf Basis der internen Logik von esptool wurde ein Skript zum Reparieren der Checksumme der App-Partition hinzugefügt
  • Nach dem Flashen des Images mit reparierter Checksumme funktionierte das Gerät auch ohne Bedienpanel normal, und die Firmware-Modifikation war erfolgreich

Paket-Header und CRC-Struktur

  • Beim Vergleich der Pakete über mehrere Bootvorgänge hinweg zeigte sich, dass die ersten 13 Byte ähnlich waren und der Rest verschlüsselt wirkte
  • Das Paket-Header-Format sah wie folgt aus
    • 55: Magic-Byte zur Protokollidentifikation
    • 00 31: Paketlänge
    • 02: Nachrichtenkennung
    • 01 23 45 67 89 AB CD EF FF: 9-Byte-Geräteseriennummer
  • Das Muster der Message-IDs war wie folgt
    • 0x02: erstes vom Smart-Gerät gesendetes Paket
    • 0x82: erste Antwort des Cloud-Servers
    • 0x01: nachfolgende vom Smart-Gerät gesendete Pakete
    • 0x81: nachfolgende Antworten des Servers
  • Das höchstwertige Bit unterscheidet Client-Anfragen von Server-Antworten, die unteren Bits unterscheiden den initialen Austausch von späteren Paketen
  • Über die Funktionsreferenz auf den String Message CRC error wurde die CRC-Prüflogik nachvollzogen
  • Die letzten 2 Byte waren eine CRC-16-Checksumme über den gesamten restlichen Paketinhalt
    • Das Polynom war 0x1021
    • Der Initialwert war 0xFFFF
    • Dies wurde bei mehreren mitgeschnittenen Paketen auf dieselbe Weise verifiziert

Ablauf der ECDH/HKDF-Schlüsselerzeugung

  • In einem Paket, das wie der erste Schlüsselaustausch aussah, waren die Daten ohne den 13-Byte-Header und die 2-Byte-CRC 32 Byte lang, was zur Größe eines 256-Bit Public Keys passt
  • Der Client-Anfrage war 00 01 vorangestellt; da sich der Wert bei jedem Bootvorgang nicht änderte, wurde er wie ein Datendeskriptor behandelt
  • In Ghidra wurde über die Fehler-Strings die Schlüsselerzeugungsfunktion gefunden und durch Vergleich mit dem mbedtls-Quellcode auf Pseudocode-Ebene zusammengefasst
  • Die Schlüsselerzeugungsfunktion führt folgende Aktionen aus
    • Erzeugt mit mbedtls_ecdh_gen_public ein ECDH-Schlüsselpaar
    • Es ist ein Muster zu sehen, bei dem der erzeugte Schlüssel durch einen anderen Schlüssel im Speicher überschrieben wird
    • Lädt einen anderen Public Key
    • Berechnet mit mbedtls_ecdh_compute_shared das Shared Secret
    • Erzeugt mit mbedtls_ctr_drbg_random einen 32-Byte-Zufallswert
    • Leitet mit mbedtls_hkdf den finalen Schlüssel ab
  • Die HKDF-Konfiguration sah wie folgt aus
    • Hash: SHA-256
    • salt: ECDH Shared Secret
    • input: vom Gerät erzeugter 32-Byte-Zufallswert
    • info: 9-Byte-Geräteseriennummer
    • Größe des Ausgabeschlüssels: 0x10, also 16 Byte
  • Die aufrufende Funktion hängte an 00 01 den 32-Byte-Zufallswert an und sendete 0x22 Byte; dies entspricht dem Format des mitgeschnittenen ersten Schlüsselaustauschpakets

Ausgabe des Shared Secrets und AES-Entschlüsselung

  • Um den finalen Entschlüsselungsschlüssel zu berechnen, wurde das ECDH Shared Secret benötigt
  • Statt JTAG-Debugging wurde die Firmware so gepatcht, dass an der Position der bereits deaktivierten CapSense-Logik eine Custom-Funktion überschrieben wurde, die das Shared Secret seriell ausgibt
  • Direkt nachdem in GenerateNetworkKey das Shared Secret erzeugt wurde, wurde ein Funktionsaufruf eingefügt; über den Schlüsselzeiger im Register wurden 32 Byte ausgegeben
  • Beim Booten wurde nach Write ECC conn packet das Shared Secret hexadezimal ausgegeben, und der Wert änderte sich auch nach mehreren Reboots nicht
  • Auch der HKDF-Ausgabeschlüssel wurde mit einem separaten Patch bestätigt, und die identische Schlüsselerzeugungslogik ließ sich für mitgeschnittene Pakete reproduzieren
  • Innerhalb der Verschlüsselungsfunktion wurde eine statische Tabelle gefunden, die mit 63 7C 77 7B F2 6B 6F C5 beginnt und mit der AES Forward S-Box von mbedtls übereinstimmt
  • Das finale Verschlüsselungsverfahren war AES-128-CBC, und der 16-Byte-Zufallswert im Paket wurde als IV verwendet
  • In den entschlüsselten Paketen wurden lesbare Werte wie mirror_data_get, FAN_SPEED, BOOST, FILTER1 und FILTER2確認iert

Implementierung eines MITM-Proxys

  • Da der private Geräteschlüssel und die Schlüsselableitungslogik vorlagen und die nötigen dynamischen Daten im Netzwerk offengelegt werden, konnte ohne Firmware-Patch ein MITM-Proxy geschrieben werden
  • Das Node.js-Skript erstellt einen lokalen UDP-Socket und einen UDP-Socket für den Cloud-Server und leitet Pakete in beide Richtungen weiter
  • Von dem Smart-Gerät empfangene Pakete werden protokolliert und anschließend an den Cloud-Server gesendet; vom Cloud-Server empfangene Pakete werden protokolliert und anschließend an das Smart-Gerät gesendet
  • Pakete mit messageId gleich 2 werden als Schlüsselaustauschpakete betrachtet; mit dem darin enthaltenen Zufallswert wird der AES-Schlüssel für die folgenden Pakete berechnet
  • Beim Bedienen des Geräts über die mobile App wurden MITM-Logs gesammelt, um die für die Implementierung eines lokalen Servers nötigen Anfrage- und Antwortformen zu ermitteln

MessagePack-Nachrichtenstruktur

  • Die entschlüsselten Daten lagen weiterhin in einem binären Serialisierungsformat vor
  • Der interne Daten-Header sah nach einer ID und einer Länge im Little-Endian-Format aus
    • 01 00: Paket-ID
    • 64 00: Transaktions-ID
    • 29 00: Länge der serialisierten Daten
  • Das Serialisierungsformat wurde zunächst teilweise per Reverse Engineering untersucht; wie sich herausstellte, handelte es sich um MessagePack
  • Mit Implementierungen wie msgpackr ließen sich die Binärdaten leicht in JSON-Form dekodieren
  • Die wichtigsten identifizierten Nachrichten waren:
    • Schlüsselaustausch: Das Gerät sendet zufällige Bytes an den Server, die für HKDF verwendet werden
    • mirror_data_get: Ruft beim Booten den Anfangszustand vom Server ab
    • connect: Sendet die aktuelle Firmware-UUID; der Server antwortet mit Informationen zu Firmware, Einstellungen, Zeit und Serveradresse
    • mirror_data: Der Server ändert den Gerätestatus, oder das Gerät meldet dem Server einen geänderten Status
    • keep_alive: Das Gerät sendet regelmäßig Statusdaten wie RSSI, RTT, Paketverluste, Anzahl der Verbindungen und Uptime

MQTT-Bridge und Home-Assistant-Integration

  • Zur Verbindung von Home Assistant mit einem Custom Server wurde MQTT verwendet
  • In Home Assistant wurde das Add-on Mosquitto, ein Open-Source-MQTT-Broker, eingerichtet
  • Die Verbindungsstruktur lautet Home AssistantMQTT BrokerCustom ServerSmart Device
  • Der Custom Server arbeitet folgendermaßen:
    • Wenn das Gerät per mirror_data_get den Status anfordert, antwortet er mit dem retained Wert des MQTT-Brokers oder mit einem Standardwert
    • Wenn Home Assistant einen Befehl zur Statusänderung an ein MQTT-Topic sendet, leitet der Custom Server ihn an das Gerät weiter
    • Wenn sich der Gerätestatus aus irgendeinem Grund ändert, published der Custom Server das mirror_data-Paket des Geräts an den MQTT-Broker und setzt es auf retain
  • Die Source of Truth für den Status ist immer das Gerät
    • Schlägt ein Status-Update fehl, wird es im MQTT-Broker nicht so angezeigt, als sei es aktualisiert worden
    • Auch wenn sich der Status über das physische Bedienpanel ändert, wird dies im MQTT-Broker abgebildet
  • Über die MQTT-Fan-Integration von Home Assistant wurde der Luftreiniger als Lüftergerät gemappt
  • In configuration.yaml wurden Topic für den Stromstatus, Command-Topic, Topic für den Lüftergeschwindigkeitsstatus, Command-Topic für die Lüftergeschwindigkeit sowie der Geschwindigkeitsbereich 1 bis 4 konfiguriert
  • Pi-hole Local DNS wurde so eingerichtet, dass die Cloud-Domain des Herstellers auf den Custom Server aufgelöst wird, sodass der lokale Server die Rolle des Geräteservers übernimmt

Sicherheitsbewertung und Ergebnis

  • Der Hersteller implementierte ein eigenes Protokoll statt eines Standardprotokolls wie DTLS
  • Es ist nicht sicher, ob jedes Gerät einen eigenen privaten Schlüssel hat; in jedem Fall gibt es Nachteile
    • Wenn alle Geräte denselben privaten Firmware-Schlüssel teilen, kann bereits das Reverse Engineering eines einzigen Geräts ausreichen, um MITM-Angriffe auf andere Geräte zu versuchen
    • Wenn jedes Gerät einen eigenen privaten Schlüssel hat, muss der Server eine Zuordnung von Seriennummern zu Geräteschlüsseln speichern; bei Verlust dieser Daten kann der Server nicht mehr auf die Gerätekommunikation antworten
  • Da die Firmware einen statischen privaten Schlüssel enthält, kann ein Angreifer den Schlüssel aus einem einzigen Firmware-Dump gewinnen und einen MITM-Angriff durchführen
  • Die Implementierung ist aus Sicherheitssicht nicht völlig schlecht, und für einen Angriff ist weiterhin physischer Zugriff erforderlich
  • Die Eigenimplementierung machte die Netzwerkkommunikation undurchsichtig, aber Security through obscurity kann gängige Angriffe auf Standardimplementierungen höchstens vorübergehend abhalten und ist für Angreifer ein überwindbares Hindernis
  • Das eigentliche Ziel, die Home-Assistant-Integration, wurde erreicht, und der Luftreiniger lief mehrere Wochen lang problemlos
  • Außerdem wurde eine Automatisierung eingerichtet, die den Luftreiniger für eine bestimmte Zeit in den Boost-Modus versetzt, wenn ein separater Luftmonitor zu hohe PM2.5- oder VOC-Werte misst

Noch keine Kommentare.

Noch keine Kommentare.