エグゼクティブサマリー
現代の自動車は機械とコンピュータが統合された複雑なマシンです。自動車が進化するにつれて、車両内外の環境を把握するためのセンサーやデバイスも続々と追加されています。センサーによって運転手にリアルタイムで情報が提供され、多数の車両によるグローバルなネットワークに車両を接続できます。リアルタイムデータを車両の操作目的で能動的に分析、使用する場合もあります。
車両でモバイルコンポーネントとクラウドコンポーネントを統合してエンドユーザ エクスペリエンスを向上させるケースもよくあります。車両のモニタリング、リモートでの発車と停車、無線での更新、ロードサービスなどの機能を追加サービスとして利用できるため、生活の質の向上にもつながるからです。
しかし電子システムとコンピュータシステムに接続された車両(コネクテッドカー)には、Bluetooth、デジタルラジオ(HD Radio/DAB)、USB、CAN バス、Wi-Fi、場合によってはモバイルデータなど、さまざまな攻撃ベクトルが発生します。他の組み込みシステムと同様に、コネクテッドカーもサイバー攻撃やセキュリティ脅威と無縁ではないのです。コネクテッドカーが直面する脅威には、ソフトウェアの脆弱性、ハードウェアベースの攻撃に加え、車両のリモート制御などもあります。シスコのカスタマー エクスペリエンス アセスメント & ペネトレーション チーム(CX APT)による最近の調査では、ARMv7 用の GNU libc にメモリ破損の脆弱性が発見されました。脆弱性の識別 ID は TALOS-2020-1019/CVE-2020-6096 で、ARMv7 で動作する Linux システムが影響を受けます。
CX APT は、NDS、Neohapsis、Portcullis の 3 社の買収により実現した専門家のグループです。世界中のお客様にさまざまなセキュリティアセスメントと攻撃シミュレーションのサービスを提供しています。CX APT による IoT セキュリティ業務は、コネクテッドカーのコンポーネントで脆弱性を特定することに特化しています。この脆弱性の詳細については、こちらからアドバイザリ全文をご覧ください。今回の発見は CX APT と Cisco Talos の連携によるものです。libc ライブラリのメンテナンス担当者は、脆弱性を修正した更新プログラムを 8 月にリリースする予定です。
最初のオーバーフローの分析
エンジニアやプログラマには、ライブラリ(特に libc のような確立された標準的なライブラリ)の関数の動作について、多くの想定事項があります。これらの想定事項が正しくないと、プログラムの実行が影響を受け、想定外の動作が発生する可能性があります。今回、シスコは ARMv7 における memcpy () の実装に脆弱性を発見しました。この脆弱性が原因でプログラムが想定外の状態に陥り、対象のアプリケーションでリモートから不正コードを実行される危険性があります。結果として、セグメンテーションの障害やクラッシュが発生する可能性がある状態で、プログラムが継続して実行されることになります。つまり、ランタイムが破損した状態でプログラムの実行が継続され、エクスプロイトを許す危険性があるのです。
コネクテッドカーに関する最近のペネトレーションテストの実施中に、組み込みの Web サーバ内で整数アンダーフローの脆弱性が確認されました。問題の Web サーバには、車両の Wi-Fi ネットワーク経由で外部からアクセスできることが判明しています。つまり、ネットワークへのアクセス権を持つ人物であれば、誰でもアクセスできるのです。この整数アンダーフローが原因で最終的には車両に対してコードをリモート実行できましたが、組み込みデバイスの memcpy() 関数の動作はそれよりもはるかに興味深いものでした。
問題の組み込み Web サーバは C++ で記述されています。Web サーバがサイズの大きな GET リクエストを受信すると、クラッシュしてセグメンテーションの障害が発生することが確認されました。当然、このクラッシュはさらに調査する必要がありました。
さらなる分析によって、クラッシュの原因は以下に示すコードスニペットにあることが判明しました。このコードは、組み込みの Web サーバの実行可能イメージから再構築されたものです。
図 1:再構築されたコードスニペット
上記のロジックでは、行末文字が検出されるまで HTTP リクエストを解析します。HTTP リクエストの行末文字は 0x0D と 0x0A の 16 進数で表される CR/LF の文字シーケンスで区切られます。留意すべき点は、行末文字のいずれか(CR または LF)を検出すればコードが終わることです。
解析された GET リクエストの状態は sLineBuffer 構造体に保持されます。この構造体は次の 4 つの要素で構成されています。
図 2:逆アセンブルで取得した sLineBuffer 構造体
- bufsz:バッファのサイズ
- nl_pos:CR/LF の文字が検出されたバッファへのオフセット
- len:現在の行の長さ
- buf:解析中の現在のバッファ
整数アンダーフローからリモートコードの実行に進むために、上記の HTTP リクエスト解析ループが 4 回繰り返されて、PC で制御できるようスタックが設定されます。
反復処理 1 回目
ループの 1 回目の処理では、sLineBuffer 構造体の全項目に次のデフォルト値が設定されます。
- bufsz = 2048
- nl_pos = 0
- len = 0
図 3:エクスプロイト開始時の sLineBuffer 構造体の状態
sLineBuffer->len と recv_len がどちらも 0 に設定されているため、10 行目の「for」ループはスキップされ、23 行目の「recv」関数に処理が進みます。recv() 関数がソケットから 2048 バイトを読み取り、sLineBuffer->buf[0] の場所に書き込みます。
recv() が返されると、recv_len 変数は戻り値である 2048 に設定されます。処理は 31 行目に進み、sLineBuffer->len は recv_len と等しい値に設定されます。
反復処理 2 回目
2 回目の反復処理の開始時点で、sLineBuffer 構造体には前のステップの最後で設定された値が格納されています。
- bufsz = 2048
- nl_pos = 0
- len = 2048
図 4:2 回目の反復処理開始時の sLineBuffer 構造体の状態
処理は 10 行目の for() ループに進み、受け取ったリクエストで CR/LF 文字を検索します。for() ループは、バッファの開始からオフセット sLineBuffer->len + recv_len まで繰り返されます。ただし、sLineBuffer->len と recv_len のサイズがどちらも 2048 であるため、for() ループはバッファの末尾を越えても反復処理を続け、スタックで CR/LF 文字を検索します。
図 5:for() ループがバッファの末尾を越えて CRLF 文字を検索
最初の 2048 文字内に CR/LF 文字がない場合、このスタックでは、バッファの末尾を越えた場所で CR/LF 文字が検出されることになります。この例では、オフセット 2760 で別の改行文字が検出されました。
図 6:オフセット 2760 で検出された改行文字
改行文字が検出されると、sLineBuffer の nl_pos 変数および len 変数がそれに応じて更新されます(14 行目と 17 行目を参照)。変数 sLineBuffer->nl_pos は 0xAC9(2761)に設定され、sLineBuffer->len は 0xfffffd37(-713)に設定されます。
反復処理 3 回目
7 行目にあるように、3 回目のループ処理に入ってすぐに memcpy () の呼び出しが実行されます。ただし、前のステップで示したように、この時点での sLineBuffer->len の値には負の値が含まれています。
図 7:memcpy() 呼び出し時のコンテンツの登録
上記のように、関数の引数 num(コピーするバイト数)は 0xfffffd37 に設定されています。この 16 進数の値は、符号なし 10 進数では +4294966583、符号付き 10 進数では -713 となります。memcpy() は符号なし整数を想定しているため、memcpy() を呼び出すと、4,294,966,583 バイトをコピーしようとしてセグメンテーションの障害が発生する場合があります。
ただし、この場合、セグメンテーションの障害は発生せず、memcpy() が正常に返されました。
memcpy の分析
memcpy() を呼び出すと、memcpy() 実装のアドレスが PC レジスタにロードされ、最初の命令に処理が移ります。ARMv7 の Memcpy() 実装は、このアーキテクチャに固有のものです。
図 8:ARMv7 の memcpy() 実装のアセンブリ
ARM での呼び出しの規則により、memcpy() 関数のパラメータは次のレジスタに格納されます。
- R0:宛先アドレス
- R1:送信元アドレス
- R2:コピーするバイト数(「num」)
2 行目に示されているに、CMP 命令では num(コピーするバイト数)と 64 が比較されます。定義により、ARM の CMP 命令では、レジスタ値からオペランドの値を減算して適切な条件コードを生成します。
CMP{cond} Rn, Operand2
CMP 命令で Rn の値から Operand2 の値を減算します。結果が破棄される点を除けば、これは SUBS 命令と同じです。
これらの条件コードは、カレント プログラム ステータス レジスタ(CPSR)の最初の 4 バイトに格納されます。
図 9:最も重要な 4 ビットに条件コードが設定された ARM CPSR のレイアウト
num が 64 と比較されると、3 行目の BGE 命令がこれらの結果を解釈し、それに応じて分岐を実行します。
コピー対象が 64 バイト以上である場合は、プログラムを実行する際に分岐が実行され、処理はアドレス 0x405ffcb4 に移ります。コピー対象が 64 バイト未満である場合は分岐が発生せず、処理は下方に進みます。この実装の残りの行は、64 バイト未満のコピーに最適化された一連の ARM NEON 命令です。
ただし、このシナリオでは、memcpy() の呼び出しに渡された num の値は 0xfffffd37 と等しくなります(これは、memcpy() が呼び出される前に呼び出し元の関数に設定されていた値です)。レジスタ R2 には負の値が格納されているため、CMP 命令は結果が負になり、それに応じて条件コードが設定されます。CMP 命令の前後の条件コードを以下に示します。
図 10:CMP 命令実行前の CPSR の状態
図 11:CMP 命令実行後の CPSR レジスタのコンテンツ
以下のように、条件コードの値が 0 から 1 に変わります。
BGE は符号を考慮した分岐で、「大なりイコール」の符号付きの分岐です。そのため、BGE では、負(「 n」)の条件コードが設定されている場合に分岐が実行されません。その結果、長さが負である値が指定された場合、memcpy() 関数はこの分岐を実行せず、64 バイト未満のコピーを続行します。
つまり、num パラメータの符号なし値である 4,294,966,583 バイト全体をコピーするのではなく、重要ではないバイト数だけがコピーされます。
この脆弱性に加え、ARMv7 と他のプラットフォームにおける memcpy() 実装の違いを示すために、小規模なテストプログラムを作成しました。このプログラムでは、合計 0xfffffd37(符号なしの 10 進数では +4294966583、符号付きの 10 進数では -713)バイトをメモリ内の場所にコピーしようとします。
図 12:memcpy() での違いを示すテストプログラムのコード
他のプラットフォームで実行すると、このプログラムのセグメンテーションで障害が発生します。memcpy () の num 引数である 0xfffffd37 が「size_t」の値(符号なし整数)として適切に解釈されるためです。
図 13:ARMv7 アーキテクチャで実行される memcpy() のテストプログラム
図 14:x64 アーキテクチャで実行されるテスト memcpy() プログラム
驚くべきことに、memcpy() の ARMv7 実装では、num パラメータが「size_t」値として扱われるのではなく、符号付き整数として扱われます。0xfffffd37 の num 値は -713 として解釈されます。これは、55 バイトのみがコピーされることを意味します。コピーされると、このプログラムは正常に完了し、プログラムが終了します。
memcpy() の定義では、コピーするバイト数(num)を符号なし整数と想定しているため、memcpy() 実装のどの時点でも num パラメータを使って符号付きの分岐操作を実行してはなりません。BGE の代わりに、符号なしの「大なりイコール」である対応する値を使用する必要があります。これにより num パラメータは符号なしとして扱われ、memcpy() は想定どおりに機能します。また、呼び出し元プログラムで未定義の動作が発生することはありません。
エクスプロイトの終了
memcpy() で 4,294,966,583 バイトよりはるかに少ない 55 バイトをコピーすると、memcpy () によって(セグメンテーションの障害が発生せずに)プログラムが実行され、GET リクエストの処理が続行されます。
図 15:再構築されたコードスニペット
反復処理 3 回目(続き)
memcpy() から結果が返されても、sLineBuffer->len は負の値のままです。sLineBuffer 構造体の変数には次の値が設定されます。
図 16:memcpy() の呼び出し後の sLineBuffer 構造体の状態
- bufsz = 2048
- nl_pos = 2761
- len = -713
処理が下に進んでいくと、もう一度 24 行目の recv() 関数に到達します。データの書き込み先を決定するために、プログラムでは sLineBuffer->len を sLineBuffer->buf へのオフセットとして使用します。ただし、sLineBuffer->len は負であるため、recv() がスタックに内容を書き込み、sLineBuffer 構造体が上書きされます。
図 17:recv() 関数による上書き前後のスタック
反復処理 4 回目
sLineBuffer 構造体が上書きされて必要な値が含まれるようになると、HTTP 解析ループの最後の反復処理では上書きされた値が使用されて、recv() 関数を完全に制御できるようになります。
recv() の最後の呼び出しのすぐ後で、recv() によってスタック上の R0 から R3 のレジスタとリンクレジスタ(LR)が保存されていることがわかります。この操作前後のスタックの内容を以下に示します。
図 18:recv() によってレジスタが保存される前のスタックの内容
図 19:recv() によってレジスタが保存された後のスタックの内容
次に示すように、リターンアドレス(レジスタ LR の内容)は、場所 0x762806f8 のスタックに保存されます。
ただし、recv() 関数は sLineBuffer 構造体の変数を引数として使用するため、上書きされた値を使用して、書き込みの場所と量を制御できます。
図 20:リターンアドレスを recv() で上書きした後のスタック
これにより、PC をオーバーライドして任意のコードを実行できます。
返される直前に、recv() は保存済みレジスタ(具体的には LR レジスタ)の一部をスタックからポップしようとします。保存済みの LR レジスタの値がバッファで上書きされたため、recv() が返す場所を制御できるようになります。
図 21:返す前に上書きされた値で LR レジスタを上書きする recv() 関数の末尾
下に示すように、スタックポインタ(SP)が参照するアドレスの値は、mcount() 関数内にある ROP ガジェットのアドレスです。
図 22:ROP ガジェットのアドレスで上書きされた recv() からのリターンアドレス
この値をスタックからポップした後、LR レジスタは制御されたリターンアドレスで上書きされ、recv() のリターンアドレスが正常に上書きされました。mcount() のこの命令によってスタックから引数がポップされ、プログラムの制御が system() に移ります。ここから、コネクテッドカーに対してリモートでコードを実行できます。
図 23:コネクテッドカーから返されたリバースシェル
まとめ
標準的なライブラリの関数については、「このように動作すべき」という想定の下で作業する傾向にあります。しかし想定が正しくなければ、最終的なプログラムの整合性が低下して脆弱性を生み出し、エクスプロイトされる危険性もあります。
今回のケースでは、ARMv7 の memcpy() 実装の脆弱性により、プログラムが未定義の状態になって、最終的にはリモートでコードを実行される危険性がありました。脆弱性がエクスプロイトされると、セグメンテーションの障害やクラッシュが発生する可能性がある状態で、プログラムの実行が継続されることになります。つまり、ランタイムが破損した状態でプログラムの実行が継続され、エクスプロイトを許す危険性があるのです。
カスタマー エクスペリエンス アセスメント & ペネトレーション チーム(CX APT)は NDS 社、Neohapsis 社、Portcullis 社の 3 社の買収により実現した専門家のグループで、コネクテッドカーのコンポーネントの脆弱性を特定することに特化しています。世界中のお客様にさまざまなセキュリティアセスメントと攻撃シミュレーションのサービスを提供しています。CX APT による IoT セキュリティ業務の詳細については、こちらをご覧ください。
本稿は 2020 年 5 月 21 日に Talos Group のブログに投稿された「Vulnerability Spotlight: Memory corruption vulnerability in GNU Glibc leaves smart vehicles open to attack」の抄訳です。