
今回は、Android セキュリティ ブルテンでCVE-2023-45777の修正として登場したパッチから始めましょう。```diff diff --git a/services/core/java/com/android/server/accounts/AccountManagerService.java b/services/core/java/com/android/server/accounts/AccountManagerService.java index 7a19d034c2c8..5238595fe2a2 100644 --- a/services/core/java/com/android/server/accounts/AccountManagerService.java +++ b/services/core/java/com/android/server/accounts/AccountManagerService.java @@ -4923,7 +4923,7 @@ public class AccountManagerService p.setDataPosition(0); Bundle simulateBundle = p.readBundle(); p.recycle();
Intent intent = bundle.getParcelable(AccountManager.KEY_INTENT);
Intent intent = bundle.getParcelable(AccountManager.KEY_INTENT, Intent.class);
if (intent != null && intent.getClass() != Intent.class) {
return false;
}
この問題について私に尋ねるほど興味を持った人はほとんどいませんでした。以前はヒントを返信していましたが、今回この問題の完全な解説を公開します
しかし、まずこのパッチで何が起きているのかについて、いくつか背景を説明しましょう
これは[`checkKeyIntent()` メソッド](https://cs.android.com/android/platform/superproject/main/+/main:frameworks/base/services/core/java/com/android/server/accounts/AccountManagerService.java;l=4938-4954;drc=47de64a38aa1799cb41f41b2ea0c539ee61de64d)の変更です。このメソッドは、アプリが提供する `Intent` がシステムにとって(システムの権限を使用して)起動しても安全であることを確認するために、複数のチェックを実行します
まず、このメソッドは `checkKeyIntentParceledCorrectly()` を使用します。これは、チェック対象の `Bundle` をシリアライズしてから再度デシリアライズし、その前に `Bundle` から取得した `Intent` が、そのようなサイクルの後に `Bundle` から取得した `Intent` と一致するかをチェックします。`Intent` の起動は検証を実行するプロセスとは異なるシステムアプリのプロセスで行われるため、以前は[`AccountManagerService` 内での検証中は安全に見えるが、次のプロセスに送信された後は異なる `Intent` を含む `Bundle` を構築することが可能でした](https://github.com/michalbednarski/IntentsLab/issues/2#issuecomment-344365482)。これは、そのような状況を検出するために、`Bundle` を次のプロセスに送信することをシミュレートします
`checkKeyIntentParceledCorrectly()` の後には `bundle.getParcelable()` 呼び出しがあります。このパッチは、任意のオブジェクトを構築できる非推奨バージョンから、デシリアライズされようとしているオブジェクトが2番目のパラメータで指定された型であることを検証するバージョンに切り替えます
型パラメータ付きのそのバージョンは、より大規模な `Parcel`/`Bundle` の堅牢化の一環として Android 13 で導入されました。具体的には、Android 13 より前では、`Bundle` がプロセス間で送信されるとき、項目にアクセスされるまでシリアライズされたデータ全体の生のコピーを保持し、その時点で全ての値がデシリアライズされていました。現在では、`Bundle` を受信した後に何らかの値に初めてアクセスするとき、`String` キーとプリミティブ型の値のみがデシリアライズされ、非プリミティブ値は `LazyValue` として残されます。`LazyValue` は、シリアライズ/デシリアライズのロジックが不一致であっても、そのような不一致が他のエントリに影響しないように、シリアライズされたデータの一部として長さを保持します
本題に入る前に、`LazyValue` を見てみましょう。そのソースコードには[データ構造を説明する素晴らしいコメントがあります](https://cs.android.com/android/platform/superproject/+/master:frameworks/base/core/java/android/os/Parcel.java;l=4392-4399;drc=03c34f57c05feecfb090de3917787f049cb5f804)```
| 4B | 4B |
mSource = Parcel{... | type | length | object | ...}
a b c d
length = d - c
mPosition = a
mLength = d - a
mPosition と mLength は、元の Parcel 内の LazyValue データ全体の位置を表し、type と length の両方を含みます。「length」(先頭に「m」がないもの)は、Parcel に書き込まれた長さの値を指し、ヘッダー(type と length)は含みません。
LazyValue を含む Bundle が別のプロセスに転送される場合、type と length フィールドを含む LazyValue 全体が Bundle.mParcelledData から宛先の Parcel にそのままコピーされます。
LazyValue で表される Bundle の項目にアクセスすると、Parcel は mPosition まで巻き戻され、readValue() が呼び出されます。bundle.getParcelable() に型引数が渡された場合、それは readValue() に伝播され、アンパーセルされようとしている型が期待される型であることを保証するとともに、アンパーセル後に、アンパーセルされた値の型が期待される型であることを検証します。アンパーセル後、LazyValue は置き換えられるため、次回 Bundle が Parcel に書き込まれる際には、値は再び writeValue() を通じてシリアライズされます。
型付きの Bundle.get*()/Parcel.read*() パラメータの使用は、主に Parcel.readParcelableList() などのメソッドに関連します。これは ArrayList を返し、Java の型消去により、List<SomeParcelableType> field = parcel.readParcelableList(); のようなことを行ったとしても、<SomeParcelableType> の部分は実行時に強制されず、そのような List にはシステム内で利用可能な任意の Parcelable クラスが含まれる可能性があり、したがってシステム内で利用可能なすべての createFromParcel/writeToParcel が、そのような List を含む型のシリアライズ/デシリアライズの一部として使用される可能性があります。
また、Android Security and Privacy チームによるこれらのメカニズムの導入に関するプレゼンテーション(スライド、ビデオ)もご覧になることをお勧めします。
ただし、ここでは返されたオブジェクトの型も明示的にチェックしているため、型付きバージョンの使用は冗長であるように思われます。では、ここで何が起きていて、どのような脆弱性が修正されているのでしょうか?
最初からパッチをもう一度見てみましょう
"intent" キーの下でデシリアライズされた値が Intent の場合
Intent オブジェクトから不一致が引き起こされる可能性がある場合、はるかに大きな問題になりますIntent でない場合
Bundle 内に Intent が存在する必要がありますが、Parcelable の型は不一致が発生する可能性のあるオフセットよりも前に保存されており、LazyValue の長さプレフィックスにより、writeToParcel/createFromParcel の不一致が発生した場合に後続のキーと値のペアを変更することが防止されますでは、型引数なしの bundle.getParcelable(AccountManager.KEY_INTENT) の呼び出しが、ここでどのような危険なことを引き起こす可能性があるのでしょうか?
[答えは次の段落にあります。読む前に推測してみてください。もし私にファーソナがいたら、ここにアートを置く場所になるでしょう]
答えは、別のキーの下に保存され、次のプロセスにそのまま渡される LazyValue の生データを実際に変更する、無関係な createFromParcel() を呼び出すことです。
提供された Parcel に対して実際に writeInt() を呼び出すことができる createFromParcel() 実装があります。
しかし、これは writeInt が誤って配置されているためではなく、無制限のリフレクションによるものです。具体的には、PackageParser 内に次のコードがあります:```java
final Class cls = (Class) Class.forName(componentName);
final Constructor cons = cls.getConstructor(Parcel.class);
intentsList = new ArrayList<>(N); for (int i = 0; i < N; ++i) { intentsList.add(cons.newInstance(in)); }
We can have `Parcel` object which was passed to `createFromParcel` passed to any available in system `public` constructor that accepts single `Parcel` argument
And then [we have following code](https://cs.android.com/android/platform/superproject/main/+/main:frameworks/base/core/java/android/os/PooledStringWriter.java;l=51-56;drc=782d49826862cbdc9d020fc9d85f8a6f64675dcb):```java
public PooledStringWriter(Parcel out) {
mOut = out;
mPool = new HashMap<>();
mStart = out.dataPosition();
out.writeInt(0); // reserve space for final pool size.
}
コンストラクタは、渡された Parcel に対して writeInt(0) を呼び出しますが、悪用を複雑にする点がいくつかあります。
まず、ソースコードには直接見えませんが、newInstance() が呼び出された直後にキャストが実行され、ClassCastException がスローされます。
createFromParcel の実行中に、別のクラスの createFromParcel を try ブロック内で呼び出し、その後、捕捉した例外を伝播させずに失敗させる何かが必要でした。
ここが、このエクスプロイトが純粋なAOSPでは実際には動作しない部分であり、Samsung固有のクラスを使用しました。
このリポジトリには、それをAOSPに統合するスクリプト も含まれているため、テスト用に実行できます(AOSPチェックアウトへのパスを引数として渡します。例: ./make-aosp-buggy.sh /path/to/aosp)。レポート冒頭で説明した変更を元に戻し、このエクスプロイトをAOSPビルドに対して実行してください。