スマートホームデバイスのハッキング(2024)
(jmswrnr.com)- メーカー製アプリとクラウドに縛られたESP32ベースの空気清浄機を Home Assistant から直接制御するため、リモート制御経路をリバースエンジニアリングし、ローカルサーバーに置き換えた
- アプリ解析、DNSバイパス、Wiresharkキャプチャにより、デバイスが
smartdeviceep.---.com:41014に UDPパケット を送信し、標準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.dexを dex2jar と jd-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 でデバイスIP192.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データシートで
TXD0とRXD0ピンを確認し、PCB上のデバッグ用ピンホールにつながるトレースをたどってシリアル接続ポイントを見つけた - Flipper Zeroの
USB-UART BridgeでUART接続を構成した- Flipper Zero の
TXは ESP32 のRXに接続した - Flipper Zero の
RXは ESP32 のTXに接続した GNDはGNDに接続した
- Flipper Zero の
- Puttyで
COM7、115200の速度で接続すると、起動ログが出力された
起動ログで判明したファイルとサーバー設定
- シリアルログには、ESP32が2つのCPUコア、WiFi/BT/BLE、4MBの外部フラッシュを持つチップであると表示された
- アプリケーションは
factoryパーティションから実行されていた - FATファイルシステムがマウントされ、全体容量
122 KiBと使用可能容量0 KiBが表示された - アプリケーションは次のファイルを読み込んでいた
serialdev_key.keySmartDevice-root-ca.crtSmartDevice-signer-ca.crtserver_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_0、ota_1: それぞれ768KのOTAアプリパーティションstorage: 1MのFATデータパーティション
- 読者からの指摘によれば、このフラッシュダンプはフラッシュ暗号化が有効なら保護されていた可能性があるが、このデバイスでは有効化されていなかった
ストレージで見つかった鍵と証明書
nvsパーティションの最新状態にはWiFi SSIDとパスワードがあり、履歴ログには過去に使われたWiFi認証情報も見えていた- FATの
storageパーティションは OSFMount で仮想ディスクのようにマウントして確認した - ストレージには次のファイルがあった
dev_infodev_key.keyserialserver_configSmartDevice-root-ca.crtSmartDevice-signer-ca.crtwifi_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 errorSeed ErrorPRNG failECDH setup failedmbedtls_ecdh_gen_public failedmbedtls_ecdh_compute_shared failedMBED HKDF failedWrite 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_get、FAN_SPEED、BOOST、FILTER1、FILTER2のような可読値が確認された
MITMプロキシの実装
- デバイス秘密鍵と鍵導出ロジックを確保しており、必要な動的データはネットワーク上に露出しているため、ファームウェアパッチなしでMITMプロキシを書けた
- Node.jsスクリプトはローカルUDPソケットとクラウドサーバー用UDPソケットを作成し、双方向にパケットを転送する
- スマートデバイスから受け取ったパケットはログを残した後にクラウドサーバーへ送り、クラウドサーバーから受け取ったパケットはログを残した後にスマートデバイスへ送る
messageIdが2のパケットを鍵交換パケットと見なし、その中のランダム値を使って以降のパケットのAES鍵を計算する- モバイルアプリでデバイスを操作しながらMITMログを蓄積し、ローカルサーバー実装に必要な要求と応答の形式を確認した
MessagePackのメッセージ構造
- 復号されたデータは依然としてバイナリ直列化形式だった
- 内部データヘッダーはリトルエンディアンのIDと長さのように見えた
01 00: パケットID64 00: トランザクションID29 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 Assistant↔MQTT Broker↔Custom Server↔Smart 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の数値が高くなりすぎると、空気清浄機を一定時間ブーストする自動化も構成した
まだコメントはありません。