Microsoft Azure Sphere に関して継続的な調査を行う中で、特に危険と思われる 2 件の脆弱性を発見しました。過去 1 年間に Talos が発見した 31 件の脆弱性の詳細については、こちらのまとめ記事をご覧ください。
本記事では、2 つ目の Azure Sphere ローカル権限昇格のバグチェーン全体について取り上げます(1 つ目についてはこちらの記事を参照)。このローカル権限昇格は Azure Sphere カーネルの完全なエクスプロイトであり、カーネルデバッガにアクセスせずに記述されました。調査の成果は、Hitcon 2021 で発表しました。
Linux カーネル /proc/pid/syscall における情報漏洩の脆弱性(TALOS-2020-1211)
Microsoft Azure Sphere カーネル pwm_ioctl_apply_state kfree() におけるコード実行の脆弱性(TALOS-2021-1262)
バージョン 21.03 では修正済みですが、pwm_ioctl_apply_state のバグにより、任意のアドレスでかなり強力なプリミティブである kfree() を呼び出すことができました。そこで、現在のユーザーランドプロセスに接続されている azure_sphere_task_cred のヒープオブジェクトの解放を試みることにしました。この cred 構造体は、Azure Sphere 固有の機能を制御します。/dev/pluton および /dev/security-monitor と通信するために必要な権限であり、さらに権限を昇格するための要件となります。
当初は、「疑わしきはすべて試す」という方針で考えていました。すべてのカーネルヒープチャンクに対して kfree() を実行しようとすれば、動作するアドレスが必要です。azure_sphere_task_cred のサイズは 0x58 バイトで、kmalloc-128 kmem_cache に割り当てられています。つまり、0x80 バイトごとに繰り返せば、検索スペースを減らせることになります。
実際のところ、kfree() が azure_sphere_task_cred を正常に解放したかどうかを確認するのは非常に簡単で、方法もいろいろありました。最も簡単なのは、kfree() を実行した後にサイズ 0x80 のスラブでカーネルヒープスプレーを実行し、/proc/self/attr/current を確認するというものです。これだけで、azure_sphere_task_cred の大部分をダンプできます。
CAPS: 00000000 が表示されている限り、kfree() を実行しても重大な問題は発生しなかったということになります。もしデバイスがクラッシュすれば、極めて重大な問題が発生したということです。
このブルートフォースの実装はかなりシンプルなものでした。ただ、完全に予想外だったのですが、Azure Sphere デバイスが特定のカーネルアドレスを解放した際にやや手に負えなくなってしまい、再起動用の USB コマンドに応答しなくなりました。そのため、ネットワークに接続された電源コンセントを抜いて、強制的に再起動しなければなりませんでした。
こうして若干回り道をしたものの、アドレスを kfree で反復処理することに成功しました。最終的に(通常は一晩かけて)プロセスの azure_sphere_task_cred に対して正常に kfree が実行され、(ほぼ)完全に制御された Linux mqueue メッセージのカーネルヒープスプレーで上書きされた状態になりました。なお、mqueue メッセージは汎用の kmem キャッシュに最適化していました。
ただ残念ながら、8 ~ 10 時間の実行時間、数千回の再起動、特定のハードウェア設定を要するようでは、エクスプロイトの要件としておそらく最適ではないというところに依然として問題が残りました。
正しいアドレスを解放するだけなら話は簡単か?
驚くべきことであり同時に残念でもあることに、ヒープは通常非決定論的です。azure_sphere_task_cred の場所は固定アドレスではなく、再起動のたびに変わります。幸い奥の手として、上述したとおり Linux カーネルメモリのリークというバグがありました。これはすべての 32 ビット Linux ARM システムに影響します。Azure Sphere にはデバイス上に有効な KASLR が存在しないため、当初は修正するほどのことはないとみなされていましたが、21.02 で修正されました。おそらくメモリのリークがエクスプロイトに使えることがこのエクスプロイトチェーンにより判明したためと思われます。なお、Linux のバージョン 5.10 ではこの問題が修正されています。
リーク自体は非常に基本的なバグであり、最も簡単な POC は cat /proc/self/syscall です。Azure Sphere では、この要求の結果は次のようになります。最後の 5 つの列が、カーネルメモリのリークが発生している部分です。
この脆弱性はスタックベースの性質のものです。したがって、漏洩させることができるデータは、カーネルスタックのベースから特定の位置にオフセットされた特定のバイトセットだけです。エクスプロイトの要件としては明らかに最適ではありませんが、特別なものを用意せずに実行できるため、他の Linux カーネル情報のリークを見つけるよりは多少簡単です。
最初の頃テストに使用していたのは、内部で sendfile() syscall を使用する BusyBox の cat コマンドです。これで、リークが $sp-0x3c8 から $sp-0x3b0 までのオフセットに存在することが分かりました(別の 32 ビット ARM QEMU イメージでテスト)。プロセスのカーネルスタックベースからかなり高い位置にあるオフセットなので、有用なデータをカーネルスタックの十分高い位置にスプレーできるよう、まず通常のユーザーがアクセスできる syscall コードパスの候補を見つけようとしました。
/mnt/config/<uuid>(不揮発性ストレージ用の特定のアプリケーションごとに割り当てられた一意のファイルパス)で同じファイルの作成と削除を繰り返した際に、littlefs コードパスに該当する関数を発見しました。ですが、リークされたスタックスロットには有用なデータが入力されておらず、アプローチとしては使えるものではありませんでした。それよりも重要な発見は、このコードパスによってスレッドのリーク速度が低下し、個別のリークの数はある程度無視できるようになったことです。リークすることだけに集中した結果、さらに多くの固有データを収集できました。カーネルスタックに自力でヒープスプレーを実行するのではなく、データの生成を別の kthread や他のプロセスの syscall に任せることにしたのです。
次に、ファイルを読み取る Linux syscall が違えば、リークされるスタックオフセットも違うということに気づきました。カーネルへの初期コードフローに十分な違いがあるのが原因です。syscall とそれに対応するリークオフセットは次のとおりです。
各 syscall には独自のデータセットがあり、それぞれにリークされたポインタのセットがあります。まず試したのが、さまざまな syscall のすべてのリークされたポインタを解放し、できるだけ多くの固有データを収集することです。カーネルの .text アドレス(参照ポイントとして使用)を持つリークが、0x80 に配置されたカーネルヒープポインタも持つ場合、ポインタの解放を試みました。
しかしまたしても失敗に終わりました。ポインタをリークすると、即座にクラッシュするか、現在の Azure Sphere の機能を変更せずに終わるかのどちらかの結果となったのです。新しいアプローチが必要でした。
ついに発見
試行錯誤の後、結局元に戻って、文字通りすべての状況に例外なく適用できる方法を採用することにしました。つまりブルートフォースです。まず、kfree() のブルートフォースを実行して権限が昇格されるまで一晩待機し、次に /proc/self/syscall でメモリをリークします。
この方法により、実際の azure_sphere_task_cred スラブのアドレスを把握しつつ、リークのサンプルを収集できるようになりました。これで、リークされたポインタそれぞれの有用性に関してより多くの情報が得られるようになります。再度すべての一意のリークポインタアドレスを試すことで、当たりのポインタを見つけることができました。
カーネルの .text アドレスが、一般的な 0xc0000000 ではなく 0xbf800000 に戻ったので、6 番目の列(一定であることが望ましい)で 0xbf86f5ec、0xbf86f065、0xbf86f4f2、0xbf86f327 を検索しました。そのうえで、列 4 のスラブポインタのページ内に配置されたすべての 0x80 アドレスを解放してみます。この結果、約 97% という非常に高いエクスプロイトの安定性を達成しました。
任意のカーネルコードの実行への変換
Azure Sphere のすべての機能を使用することで、/dev/security-monitor と /dev/pluton の ioctl のほぼすべてを利用できるようになりました。ただし、ioctl() から厳密にはアクセスできない可能性のある潜在的な攻撃対象領域を調査する必要がありました。そのため、現状のバグ一式を任意のカーネルコード実行に変更することにしました。
azure_sphere_task_cred は Linux の mqueue msg_msg 構造体で上書きされています(前述のとおり、どちらも汎用の kmem_cache)。したがって、msg_msg 構造体がなぜこれほど強力なエクスプロイトプリミティブなのか、なぜ最初に名前付きの kmem キャッシュへと移されるのかを、正確に調査する必要があります。
まず第 1 に、可変長(コンパイル時の設定最大値まで)であるため、基本的に任意の汎用 kmem_cache に配置できることが挙げられます(名前付き kmem_cache にある場合は別)。第 2 に、実際のメッセージの内容を完全に制御できることが挙げられます。
そして第 3 に、構造体 list_head m_list のメンバーを破損させた後に mq_receive を介して解放することができれば、msg_msg 構造体がリンクリストから削除される際に、任意のアドレスに対し、攻撃者が制御する 4 バイトのミラーリングの書き込みが可能になることが挙げられます。
では、azure_sphere_task_cred/struct msg_msg スラブを再度破損させるにはどうすればよいのでしょうか。ブール値 is_app_man が設定されていればかなり簡単です。というのも、/proc/self/attr/exec を使用して azure_sphere_security_setprocattr 関数から azure_sphere_task_cred に書き込むことが可能なためです。通常、is_app_man ブール値は設定されません。設定されるのは、application_manager(Azure Sphere の init)がプロセスを生成し、AZURE_SPHERE_CAP のサブセットを生成する場合だけです。ただ、azure_sphere_task_cred の内容をスプレーしようとしているので、最初に is_app_man の値を制御し、任意で 1 に設定してしまいます。これで、同じスラブを再度破損させて msg_msg 構造体を破損させることができます。
したがって、任意のミラーリングの書き込みを設定する場合、カーネルスラブに対してもう一度 kfree() を実行して再度破損させた後、echo aaaaaaaaaaaaaaaaaa > /proc/self/attr/exec をこのとおりに実行します。通常の Linux のログイン情報の構造体のメンテナンスコードでは新しい azure_sphere_task_cred が割り当てられます。ですが、ここでは同じスラブアドレスを維持する必要があるため、再度 kfree() を実行する必要があります。
では、何をどこに記述すればよいのでしょうか。書き込みプリミティブはミラーリングされているため、両方のポインタが書き込み可能となっている必要があることに注意してください。カーネル内の関数ポインタを上書きし、ユーザーランドに保存されているシェルコードにジャンプすれば、簡単に対応できるはずです。ですが間もなく、ユーザーランドのシェルコードを呼び出そうすると、必ずデバイスが再起動して PXN が有効になってしまうことが判明しました。ただ、手動テストにより、PAN は有効にならないのでカーネルからユーザーランドデータを読み書きできることが分かりました。
この知識を利用して、LSM リンクリストをエクスプロイトし、prctl の LSM エントリを上書きしました。すべての LSM フック関数の引数を完全に制御できるようにするためです。こうして、ユーザーランドから prctl syscall を呼び出し、任意の関数ポインタ(memcpy など)で上書きした prctl の LSM フックをトリガーします。これで、任意の関数ポインタを大半の任意パラメータ(prctl syscall で指定されたパラメータ)で呼び出せるようになります。任意のパラメータで任意の関数を呼び出せるため、実質的にカーネル内で任意のコードを実行できることになります。また、必要に応じてシェルコードスタブを挿入して実行することもできます。
なお、PAN が有効になっていたとしても、msg_msg 構造体のスプレーを使用してカーネルとデータ交換できたはずです。
カーネル関数呼び出しの例
カーネル後の状況
これ以降は、ARM TrustZone の実装を「Secmon」、M4 セキュリティコアを「Pluton」と呼びます。カーネルドライバについては、パス(/dev/security-monitor と /dev/pluton)で表します。
カーネルが Pluton または Secmon と通信する場合、通常はこれらの /dev カーネルドライバへの ioctl を介して行われます。ドライバのサブセットがそれぞれのエンドポイントと実際に通信するわけではありません。ただほとんどの場合、ioctl は azure_sphere_syscall 構造体に格納されます。
この構造体は、処理とサニタイズが行われた後、コア間で共有される DMA メモリバッファにオーバーレイされた Linux メールボックスを介して Pluton へと送られるか、ARM の SMC 命令により Secmon へと送られます。バッファは、共有される DMA メモリバッファに再度渡されます。共有される DMA メモリ領域の範囲は 0x80000000 〜 0x803d0000 です。
各エンドポイントで受信された後、個々の syscall の処理に入るまでは、Secmon と Pluton のコードは基本的に同じです。各エンドポイントには、使用可能な syscall、各引数のタイプ、syscall 自体、および事前に合格する必要がある syscall 固有の検証を定義する一連の構造体があります。Pluton と Secmon の構造体の例を以下に示します。
Secmon と Pluton の両方の syscall をすべてリストすると、かなりの量があります。
Pluton について注意すべき点は、Linux Normal World から実際にアクセスできるのが、上記の Pluton syscall のサブセットのみだということです。その他の syscall へのアクセスは、Pluton との専用の共有メモリメールボックスを持つ Secmon によって行う必要があります。
Pluton/Secmon 内では、これらの syscall のそれぞれに固有の引数セットがあり、すべてのポインタ引数が共有 DMA 0x80000000 〜 0x803d0000 領域にあることが検証されます。その後、引数とポインタが固定サイズのプライベートバッファにコピーされます。これは、time-of-check-to-time-of-use(TOCTOU)の脆弱性を防ぐためと考えられます。このプロセスは、Linux のユーザーランドが Linux カーネルに syscall を使用して引数を渡すのと同様に機能します。カーネルは copy_from_user/copy_to_user を使用して境界を越えてデータを安全に転送します。また、通常はカーネルスタックに値をコピーしてさらなるサニタイズと処理を行います。
その後、syscall ごとのサニタイズ関数も呼び出され、ポインタと参照引数のバッファサイズが検証されます。そしてすぐに実際の syscall が呼び出されます。
現時点では、今回取り上げたエクスプロイトチェーンは、Secmon と Pluton の広大な攻撃対象領域の一角に入り込んだということでしかありません。最終的に、ここから Secmon syscall を分析し、複数の問題を特定して報告することができました。これらの問題については、TALOS-2021-1309、TALOS-2021-1310、TALOS-2021-1341、TALOS-2021-1342、TALOS-2021-1343、TALOS -2021-1344 および関連するブログ記事をご覧ください。
本稿は 2021 年 11 月 29 日に Talos Group のブログに投稿された「An Azure Sphere kernel exploit — or how I learned to stop worrying and love the IoT」の抄訳です。