
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;
}