1 ポイント 投稿者 GN⁺ 2025-04-16 | まだコメントはありません。 | WhatsAppで共有
  • メーカー製アプリとクラウドに縛られたESP32ベースの空気清浄機を Home Assistant から直接制御するため、リモート制御経路をリバースエンジニアリングし、ローカルサーバーに置き換えた
  • アプリ解析、DNSバイパス、Wiresharkキャプチャにより、デバイスが smartdeviceep.---.com:41014UDPパケット を送信し、標準DTLSではない独自プロトコルを使っていることを確認した
  • UART接続と4MBフラッシュダンプを通じて dev_key.key、証明書、サーバー設定、WiFi設定を確保し、Ghidra・esp32knifeでファームウェア構造を解析した
  • パケットは13バイトのヘッダーと末尾2バイトの CRC-16、ECDH/HKDF鍵生成、AES-128-CBC、MessagePackシリアライズを組み合わせており、ファームウェアパッチで共有秘密をシリアルログに出力させて復号に成功した
  • 最終構成はMITMプロキシ、ローカルサーバー、Mosquittoベースの MQTTブリッジ につながり、Home AssistantのMQTT Fanで電源とファン速度を数週間安定して制御した

クラウド依存の空気清浄機をローカル制御に変える

  • 目標は、メーカーのモバイルアプリとクラウドアカウントにしか接続できない空気清浄機を Home Assistant で制御することだった
  • スマートフォンのBluetooth、WiFi、5Gを切り替えて確認した結果、アプリはローカルBluetoothやWiFiではなく、インターネット接続 を通じてのみデバイスを制御していた
  • デバイスとクラウドサーバーの間のどこかでファン速度のような制御値がやり取りされるため、ネットワーク区間が主要な攻撃ポイントになる
    • トラフィックを傍受して値を改変すれば、デバイス制御が可能になる
    • サーバー応答をエミュレートすれば、インターネットやメーカーのクラウドなしでも動作させられる
  • リバースエンジニアリングの内容は教育目的であり、製品ごとの機微情報である秘密鍵、ドメイン、APIエンドポイントは難読化または削除されている
  • デバイス改造は保証を無効にしたり、デバイスを恒久的に損傷させたりする可能性がある

アプリ解析とUDPトラフィックのキャプチャ

  • Androidアプリの .apk を抽出し、classes.dexdex2jarjd-gui で開いて内部を調べた
  • MainActivity.class でアプリが React Native ベースであることを確認し、assets/index.android.bundle でセキュアなWebSocket接続を見つけた
    • 例示コードには wss://smartdeviceapi.---.com 接続が含まれていた
  • Pi-holeのDNSクエリ参照機能で、デバイスが接続するクラウドサーバーのドメインを確認した
  • Pi-holeの Local DNS 機能でそのドメインをローカルワークステーション 192.168.0.10 に向け、Wireshark でデバイスIP 192.168.0.61 のトラフィックをフィルタリングした
  • デバイスはワークステーションの 41014 ポートに UDPパケット を送っていた

リレー構成と独自プロトコルの手がかり

  • ローカルDNSがクラウドドメインをワークステーションへ解決するようにしたため、実際のサーバーIPは Cloudflare DNS resolver 1.1.1.1 で問い合わせた
  • node-udp-forwarder を使って、ワークステーションがデバイスとクラウドサーバーの間でUDPリレーの役割を担うようにした
  • 起動時の最初のパケットとサーバー応答をキャプチャしたが、読める文字列はなくランダムなバイト列に見えたため、暗号化されている可能性があった
  • WiresharkはパケットをDTLSとして認識せず、DTLS仕様のヘッダー形式もキャプチャしたパケットとは異なっていた
  • 標準プロトコルではないように見えたため、パケット構造と暗号化方式を自分でリバースエンジニアリングする必要があった

ESP32の分解とシリアルアクセス

  • デバイスを分解すると、メインPCB、ファン接続ポート、前面制御パネルのリボンケーブルが見えた
  • メインコントローラーには ESP32-WROOM-32D と表示されており、WiFiとBluetooth機能を備えたESP32系マイクロコントローラーだった
  • ESP32-reversing リポジトリのESP32リバースエンジニアリング関連資料を参考にした
  • ESP32データシートで TXD0RXD0 ピンを確認し、PCB上のデバッグ用ピンホールにつながるトレースをたどってシリアル接続ポイントを見つけた
  • Flipper Zeroの USB-UART Bridge でUART接続を構成した
    • Flipper Zero の TX は ESP32 の RX に接続した
    • Flipper Zero の RX は ESP32 の TX に接続した
    • GNDGND に接続した
  • Puttyで COM7115200 の速度で接続すると、起動ログが出力された

起動ログで判明したファイルとサーバー設定

  • シリアルログには、ESP32が2つのCPUコア、WiFi/BT/BLE、4MBの外部フラッシュを持つチップであると表示された
  • アプリケーションは factory パーティションから実行されていた
  • FATファイルシステムがマウントされ、全体容量 122 KiB と使用可能容量 0 KiB が表示された
  • アプリケーションは次のファイルを読み込んでいた
    • serial
    • dev_key.key
    • SmartDevice-root-ca.crt
    • SmartDevice-signer-ca.crt
    • server_config
  • サーバー設定には smartdeviceep.---.com:41014 が含まれていた

フラッシュダンプとパーティション構造

  • ESP32を Download Boot モードで起動するため、IO0 ピンを GND に接続した状態で電源を入れた
  • esptool を使って4MBのフラッシュ全体をダンプした
    • コマンドは esptool -p COM7 -b 115200 read_flash 0 0x400000 flash.bin
  • ダンプは複数回行って正常に読み出せることを確認し、問題が起きた場合に再フラッシュできるようバックアップした
  • esp32knife でダンプを解析し、partitions.csv を取得した
  • パーティション構造には次の項目が含まれていた
    • nvs: 16Kのキー・バリューストレージ
    • otadata: 8KのOTAデータ
    • phy_init: 4KのPHYデータ
    • factory: 768Kのアプリパーティション
    • ota_0ota_1: それぞれ768KのOTAアプリパーティション
    • storage: 1MのFATデータパーティション
  • 読者からの指摘によれば、このフラッシュダンプはフラッシュ暗号化が有効なら保護されていた可能性があるが、このデバイスでは有効化されていなかった

ストレージで見つかった鍵と証明書

  • nvs パーティションの最新状態にはWiFi SSIDとパスワードがあり、履歴ログには過去に使われたWiFi認証情報も見えていた
  • FATの storage パーティションは OSFMount で仮想ディスクのようにマウントして確認した
  • ストレージには次のファイルがあった
    • dev_info
    • dev_key.key
    • serial
    • server_config
    • SmartDevice-root-ca.crt
    • SmartDevice-signer-ca.crt
    • wifi_config
  • dev_key.key-----BEGIN EC PRIVATE KEY----- で始まる 楕円曲線秘密鍵 で、openssl ec -in dev_key.key -text -noout で確認した
  • 2つの .crt ファイルは -----BEGIN CERTIFICATE----- で始まる証明書で、openssl x509 で確認した
  • 証明書とデバイス鍵がデバイス内に保存されていることから、UDPパケットデータの暗号化に使われている可能性が高まった

Ghidra分析環境の構築

  • 実行中のfactoryパーティションイメージをGhidra CodeBrowserで開いて分析した
  • ESP32はXtensa命令セットを使用するため、Tensilica Xtensa 32-bit little-endian言語を選択した
  • 生のパーティションイメージは仮想メモリマッピングを正しく反映できないため、esp32knifeでpart.3.factory.elfを生成して再インポートした
  • RTC_DATAセグメントをサポートするようesp32knifeを修正したコミットも公開した
  • SVD-Loader-GhidraでESP32の周辺機器構造とメモリマップを読み込んだ
  • GhidraのSymbolImportScriptでESP32 ROM関数ラベルを読み込み、printfのような共通ROM関数を識別しやすくした

文字列から見つけた暗号化の手がかり

  • GhidraのDefined Stringsで、シリアルログに現れていた文字列とその周辺文字列を追跡した
  • 周辺文字列には次の手がかりがあった
    • 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は、暗号プリミティブ、X509証明書操作、SSL/TLSおよびDTLSを実装するオープンソースライブラリである
  • ECDHとHKDF関数が直接使われておりDTLSではないことから、独自プロトコル内で鍵交換と鍵導出を実装したものと分析された
  • ECC conn packetという文字列は、初回接続パケットがECDH鍵交換プロセスに関連していることを示している

制御パネル依存を取り除いたファームウェアパッチ

  • PCBをファンと制御パネルに接続したまま分析するのが不便だったため制御パネルを取り外したが、起動中にNo Cap device found!というログとともにパニックが発生した
  • No Cap device found!文字列周辺の関数はCapSense Initを出力するため、前面パネルの静電容量式入力の初期化ロジックと判断された
  • Ghidraでこの関数をInitCapSense、それを呼び出すサービスをStartCapSenseServiceと命名した
  • StartCapSenseService呼び出し命令をnopに変更し、制御パネルサービスの起動を除去した
  • 生のpart.3.factoryイメージでバイトを修正し、0x10000オフセットに再フラッシュしたが、ESP32イメージのチェックサムエラーで起動しなかった
  • esptoolの内部ロジックを基に、アプリパーティションのチェックサムを修復するスクリプトを追加した
  • チェックサムを復旧したイメージをフラッシュすると、制御パネルなしでもデバイスは正常に動作し、ファームウェア改変に成功した

パケットヘッダーとCRC構造

  • 何度も起動しながらパケットを比較した結果、先頭13バイトは似ており、残りは暗号化されているように見えた
  • パケットヘッダー形式は次のとおりだった
    • 55: プロトコル識別用マジックバイト
    • 00 31: パケット長
    • 02: メッセージ識別子
    • 01 23 45 67 89 AB CD EF FF: 9バイトのデバイスシリアル
  • メッセージIDのパターンは次のとおりだった
    • 0x02: スマートデバイスが最初に送るパケット
    • 0x82: クラウドサーバーが最初に送る応答
    • 0x01: その後にスマートデバイスが送るパケット
    • 0x81: その後にサーバーが送る応答
  • 上位ビットはクライアント要求とサーバー応答を区別し、下位ビットは最初の交換とそれ以降のパケットを区別する
  • Message CRC error文字列を参照する関数をたどってCRC検証ロジックを確認した
  • 最後の2バイトは、それ以外のパケット全体に対するCRC-16チェックサムだった
    • 多項式は0x1021
    • 初期値は0xFFFF
    • 複数のキャプチャパケットで同じ方式により検証された

ECDH/HKDF鍵生成フロー

  • 初回鍵交換と見られるパケットでは、ヘッダー13バイトとCRC 2バイトを除いたデータが32バイトで、これは256ビット公開鍵サイズと一致した
  • クライアント要求には先頭に00 01が付いており、起動のたびに値が変わらなかったため、データ記述子のようなものとして扱われた
  • Ghidraでエラー文字列をたどって鍵生成関数を見つけ、mbedtlsのソースと比較して擬似コードレベルで整理した
  • 鍵生成関数は次の動作を行う
    • mbedtls_ecdh_gen_publicでECDH鍵ペアを生成する
    • 生成した鍵をメモリ上の別の鍵で上書きする形が見られる
    • 別の公開鍵をロードする
    • mbedtls_ecdh_compute_sharedで共有秘密を計算する
    • mbedtls_ctr_drbg_randomで32バイトのランダム値を生成する
    • mbedtls_hkdfで最終鍵を導出する
  • HKDF設定は次のとおりだった
    • ハッシュ: SHA-256
    • salt: ECDH共有秘密
    • input: デバイスが生成した32バイトのランダム値
    • info: 9バイトのデバイスシリアル
    • 出力鍵サイズ: 0x10、つまり16バイト
  • 呼び出し関数は00 01の後ろに32バイトのランダム値を付けて0x22バイトを送信しており、これはキャプチャした初回鍵交換パケットの形式と一致した

共有秘密の出力とAES復号

  • 最終的な復号鍵を計算するにはECDH共有秘密が必要だった
  • JTAGデバッグの代わりに、すでに無効化していたCapSenseロジックの位置にカスタム関数を上書きし、共有秘密をシリアルに出力するようファームウェアをパッチした
  • GenerateNetworkKeyで共有秘密が生成された直後に関数呼び出しを挿入し、レジスタ内の鍵ポインタを使って32バイトを出力した
  • 起動時にWrite ECC conn packetの直後に共有秘密が16進数で出力され、何度再起動しても値は変わらなかった
  • HKDF出力鍵も別のパッチで確認でき、キャプチャパケットから同じ鍵生成ロジックを再現できた
  • 暗号化関数内部で63 7C 77 7B F2 6B 6F C5で始まる静的テーブルが見つかり、mbedtlsのAES Forward S-Boxと一致した
  • 最終的な暗号方式はAES-128-CBCで、パケット内の16バイトのランダム値はIVとして使用されていた
  • 復号したパケットでは、mirror_data_getFAN_SPEEDBOOSTFILTER1FILTER2のような可読値が確認された

MITMプロキシの実装

  • デバイス秘密鍵と鍵導出ロジックを確保しており、必要な動的データはネットワーク上に露出しているため、ファームウェアパッチなしでMITMプロキシを書けた
  • Node.jsスクリプトはローカルUDPソケットとクラウドサーバー用UDPソケットを作成し、双方向にパケットを転送する
  • スマートデバイスから受け取ったパケットはログを残した後にクラウドサーバーへ送り、クラウドサーバーから受け取ったパケットはログを残した後にスマートデバイスへ送る
  • messageId2のパケットを鍵交換パケットと見なし、その中のランダム値を使って以降のパケットのAES鍵を計算する
  • モバイルアプリでデバイスを操作しながらMITMログを蓄積し、ローカルサーバー実装に必要な要求と応答の形式を確認した

MessagePackのメッセージ構造

  • 復号されたデータは依然としてバイナリ直列化形式だった
  • 内部データヘッダーはリトルエンディアンのIDと長さのように見えた
    • 01 00: パケットID
    • 64 00: トランザクションID
    • 29 00: 直列化データ長
  • 直列化形式は一部を独自にリバースエンジニアリングしたが、確認したところ MessagePack だった
  • msgpackr のような実装を使えば、バイナリデータをJSON形式に簡単に展開できた
  • 確認された主なメッセージは次のとおりだった
    • 鍵交換: デバイスがHKDFに使用するランダムバイトをサーバーに送信する
    • mirror_data_get: 起動時に初期状態をサーバーから取得する
    • connect: 現在のファームウェアUUIDを送信し、サーバーはファームウェア・設定・時刻・サーバーアドレス情報を応答する
    • mirror_data: サーバーがデバイスの状態を変更するか、デバイスが変更された状態をサーバーに報告する
    • keep_alive: デバイスがRSSI、RTT、パケットドロップ、接続回数、アップタイムなどの状態を定期的に送信する

MQTTブリッジとHome Assistant連携

  • Home Assistantとカスタムサーバーを接続するために MQTT を使用した
  • Home AssistantにはオープンソースのMQTTブローカーである Mosquitto アドオンを設定した
  • 接続構成は Home AssistantMQTT BrokerCustom ServerSmart Device の形だった
  • カスタムサーバーは次のように動作する
    • デバイスが mirror_data_get で状態を要求すると、MQTTブローカーのretained値を使うか、デフォルト値で応答する
    • Home Assistantが状態変更コマンドをMQTTトピックに送信すると、カスタムサーバーがそれをデバイスに伝達する
    • デバイス状態が何らかの理由で変更されると、デバイスの mirror_data パケットをMQTTブローカーにpublishしてretainする
  • 状態のソースオブトゥルースは常にデバイスである
    • 状態更新に失敗した場合、MQTTブローカー上で更新されたようには表示しない
    • 物理制御パネルで状態が変わってもMQTTブローカーに反映される
  • Home Assistantの MQTT Fan 統合を使って空気清浄機をファンデバイスとしてマッピングした
  • configuration.yaml には電源状態トピック、コマンドトピック、ファン速度状態トピック、ファン速度コマンドトピック、速度範囲 1~4 を設定した
  • Pi-holeのローカルDNSがメーカーのクラウドドメインをカスタムサーバーに名前解決するよう設定し、ローカルサーバーがデバイスのサーバー役を担うようにした

セキュリティ評価と結果

  • メーカーはDTLSのような標準プロトコルではなく独自プロトコルを実装していた
  • デバイスごとに固有の秘密鍵があるかは確実ではないが、どちらの場合にも欠点がある
    • すべてのデバイスが同じファームウェア秘密鍵を共有していれば、1台のデバイスだけをリバースエンジニアリングしても他のデバイスにMITM攻撃を試みられる
    • デバイスごとに固有の秘密鍵がある場合、サーバーはシリアル番号とデバイス鍵のマッピングを保持しなければならず、そのデータを失うとサーバーはデバイス通信に応答できなくなる
  • ファームウェアに静的な秘密鍵が含まれているため、攻撃者は単一のファームウェアダンプから鍵を取得してMITM攻撃を実行できる
  • 実装がセキュリティの観点から完全にひどいわけではなく、攻撃には依然として物理アクセスが必要である
  • 独自実装はネットワーク通信を不透明にしたが、Security through obscurity は標準実装を狙った一般的な攻撃を一時的に防ぐ程度で、攻撃者にとっては通過可能な障害物に近い
  • 最終目標だったHome Assistant連携は達成され、空気清浄機は数週間にわたって問題なく動作した
  • 別の空気モニターでPM2.5やVOCの数値が高くなりすぎると、空気清浄機を一定時間ブーストする自動化も構成した

まだコメントはありません。

まだコメントはありません。