En mars 2018, le chercheur Luca Todesco (@qwertyoruiop) a publié le code de son exploit qui contourne le mécanisme de protection d'intégrité du noyau iOS sur l'iPhone 7. Ce contournement affecte les versions d'iOS 10 et 10.1. Bien qu'il ne permette pas de modifier directement le code du noyau, il rend possible le changement des valeurs du segment de données constantes, avec les conséquences que nous expliquerons.
Introduction
Apple profite souvent de la sortie des nouveaux modèles d'iPhone pour renforcer la sécurité de son système. Avec l'arrivée de l'iPhone 7, un nouveau mécanisme de protection d'intégrité du noyau a été ajouté. Appelé KTRR (vraisemblablement Kernel Text Region Range), il succède à l'ancien mécanisme Watchtower (aussi appelé KPP) présent sur les iPhones 5S jusqu'au 6S sous iOS 9 et supérieur. Le rôle d'un tel mécanisme est d'empêcher la modification de données dans les segments du noyau que l'on souhaite protéger. C'est une mesure préventive, qui permet de restreindre le champ d'action d'un attaquant qui est capable d'aller lire et écrire dans la mémoire du noyau, après l'avoir exploité. Dans le cas d'un jailbreak, après avoir obtenu la capacité d'aller lire et écrire dans la mémoire du noyau, la voie la plus rapide pour parvenir à ses fins est d'aller y modifier code et données. Avec un mécanisme comme KTRR, cela n'est plus possible. Afin de comprendre la manière dont fonctionne l'exploit, nous expliquons dans un premier temps le fonctionnement de KTRR. Nous verrons le déroulement de l'exploit, avant de nous intéresser aux parties marquantes.
1. Prérequis
Pour comprendre les explications qui suivent, il est fortement recommandé au lecteur d'être familier avec l'architecture ARMv8-A [1] et plus particulièrement avec le mécanisme de traduction d'adresses [2]. Les liens vers la documentation correspondante ainsi que le code [3] de l'exploit sont disponibles à la fin de l'article.
2. Principe de fonctionnement de KTRR
Deux nouveaux éléments font leur apparition pour mettre en place le mécanisme. Le premier est une MMIO (Memory Mapped I/O) nommée RoRgn (Read-only Region) qui empêche l'accès en écriture à une zone de la mémoire physique. Les registres de cette MMIO sont situés dans la structure AMCC (Apple's Memory Cache Controller) comme le montre le code du noyau [4] :
//#if defined(KERNEL_INTEGRITY_KTRR)
#define rMCCGEN (*(volatile uint32_t *) (amcc_base + 0x780))
#define rRORGNBASEADDR (*(volatile uint32_t *) (amcc_base + 0x7e4))
#define rRORGNENDADDR (*(volatile uint32_t *) (amcc_base + 0x7e8))
#define rRORGNLOCK (*(volatile uint32_t *) (amcc_base + 0x7ec))
//#endif
Une fois que les adresses de base et de fin sont initialisées, les registres sont verrouillés en plaçant 1 dans rRORGNLOCK. Le second élément apparu est le groupe des registres KTRR : ARM64_REG_KTRR_LOWER_EL1, ARM64_REG_KTRR_UPPER_EL1 et ARM64_REG_KTRR_LOCK_EL1. Ils sont présents pour limiter l'ensemble des adresses exécutables en EL1 (niveau de privilège du noyau). De la même manière que pour RoRgn, en mettant 1 dans le registre ARM64_REG_KTRR_LOCK_EL1, les deux autres registres sont verrouillés. Après le verrouillage des registres RoRgn et KTRR, l'agencement des segments mémoire du noyau est le suivant :
Mapping |
Segment |
Protection |
Accès possible |
r--/r-- |
__PRELINK_TEXT |
RoRgn + KTRR |
r--/r-- |
r-x/r-x |
__PLK_TEXT_EXEC |
RoRgn + KTRR |
r-x/r-x |
r--/r-- |
__PLK_DATA_CONST |
RoRgn + KTRR |
r--/r-- |
r-x/r-x |
__TEXT |
RoRgn + KTRR |
r-x/r-x |
rw-/rw- |
__DATA_CONST |
RoRgn + KTRR |
r--/r-- |
r-x/r-x |
__TEXT_EXEC |
RoRgn + KTRR |
r-x/r-x |
rw-/rw- |
__LAST |
RoRgn |
r--/r-- |
rw-/rw- |
__KLD |
|
rw-/rw- |
rw-/rw- |
__DATA |
|
rw-/rw- |
rw-/rw- |
__BOOTDATA |
|
rw-/rw- |
r--/r-- |
__LINKEDIT |
|
r--/r-- |
rw-/rw- |
__PRELINK_DATA |
|
rw-/rw- |
rw-/rw- |
__PRELINK_INFO |
|
rw-/rw- |
Il faudrait mettre en gras (ou le style prévus) le texte de la première rangée du tableau (Mapping, Segment, Protection et Accès possible).
Le segment __LAST est particulier : il inclut la section __pinst (vraisemblablement protected ou privileged instructions) qui contient les instructions permettant de changer la valeur de certains registres système, comme TTBR1_EL1 et SCTLR_EL1. Ce segment n'étant pas compris dans l'intervalle défini par les registres KTRR, on ne peut pas y accéder en exécution lorsque la MMU est active (car il faut être dans l’intervalle défini par KTRR pour être exécutable). Ainsi, le noyau définit la valeur de ces registres avant l'activation de la MMU.
3. Description de la vulnérabilité
Lors de la sortie de veille du CPU, une structure accessible en écriture depuis le noyau (elle ne réside pas dans un segment protégé par RoRgn) permet de détourner le flot d'exécution. De plus, l'intervalle défini par les registres KTRR à ce moment-là n'est pas correct, car il inclut __LAST.pinst. Il est alors possible de créer un mapping alternatif pour les segments de données accessibles en lecture seule. L'accès en écriture à ce segment peut faciliter le jailbreak et altérer le fonctionnement du système iOS, chose qu'Apple ne souhaite en aucun cas permettre.
4. Déroulement de l'exploit
Pour mieux saisir le contexte d'exécution de l'exploit ainsi que son fonctionnement, il est possible de se référer à la Figure 1 qui donne une vision plus globale. Il est important de noter qu'un premier exploit visant le noyau est nécessaire (première étape), avant de réaliser celui de KTRR.
Figure 1 : Contexte et séquence de l’exploit
4.1 Phase préparatoire
Avant d'exécuter la charge pour contourner KTRR, plusieurs opérations sont réalisées depuis l'espace utilisateur pour permettre à l'exploit de correctement fonctionner. On peut notamment citer :
- l'allocation de pages de mémoire pour y placer les gadgets, ainsi que les données utilisées lors de l'exploit ;
- la préparation de la table de traduction TTBR0_EL1 (modification et ajouts de quelques entrées dans la table de niveau 3) qui sera utilisée par le noyau lors du reset et plus particulièrement, lors de la phase d’exploitation ;
- la création d'une fausse table de traduction (celle pointée par TTBR1_EL1) visant à remplacer l'originale.
4.2 Lancement de l'exploit
Si l’on s’intéresse au code exécuté lors de la sortie de veille du CPU, on remarque que la fonction common_start utilise la structure _const_boot_args pour convertir l'adresse physique de la fonction arm_init_tramp en adresse virtuelle avant de s'y rendre :
// Load VA of the trampoline
adrp x0, arm_init_tramp@page
add x0, x0, arm_init_tramp@pageoff
add x0, x0, x22 ; x0 += _const_boot_args.BA_VIRT_BASE
sub x0, x0, x23 ; x0 -= _const_boot_args.BA_PHYS_BASE
// Branch to the trampoline
br x0
Cette structure à la particularité d’être présente dans un segment accessible en écriture. Dès lors que l’on est en possession d’une primitive d'écriture dans la mémoire du noyau (cf. étape 1 de la Figure 1), il sera possible d’y modifier ses éléments. Si l'on regarde le code ci-dessus, cela signifie que l'on contrôle les registres x22 et x23. Les opérandes du calcul d'obtention de x0 étant connus, la modification du registre x22 ou x23 permettra de rediriger le flot d'exécution du noyau.
C’est exactement de cette manière que le lancement procède :
swritewhere = loadstruct + 8;
swritewhat = (phyzb+gadget0_off)-(G(TTBRMAGIC_BX0)+slide-gVirtBase);
/*...*/
WriteAnywhere64(swritewhere, swritewhat);
Le code ci-dessus est une partie du code exécuté depuis l'espace utilisateur. Après la phase préparatoire, cette écriture permet d'amorcer l'exploit. La fonction WriteAnywhere64 est la primitive d'écriture dans la mémoire du noyau. La variable loadstruct contient l'adresse de la structure _const_boot_args. À son offset 8 se trouve BA_VIRT_BASE (la base virtuelle du noyau).
L'écriture dans loadstruct + 8 avec la fonction WriteAnywhere64 va remplacer _const_boot_args.BA_VIRT_BASE par une valeur contrôlée. Dans notre cas, le registre x0 contiendra phyzb+gadget0_off. La MMU étant active, la traduction s'effectuera sous le contrôle de l'attaquant (car c'est TTBR0_EL1 qui sera utilisé) et dirigera le branchement vers le premier gadget. Il ne reste plus que la sortie de mise en veille (le reset) de l'appareil survient pour que l'exploit se lance.
Il faut cependant faire attention, car il y a un autre endroit où _const_boot_args.BA_VIRT_BASE est utilisé :
// Set up the exception vectors
adrp x0, EXT(ExceptionVectorsBase)@page
add x0, x0, EXT(ExceptionVectorsBase)@pageoff
add x0, x0, x22 ; x0 += _const_boot_args.BA_VIRT_BASE
sub x0, x0, x23 ; x0 -= _const_boot_args.BA_PHYS_BASE
MSR_VBAR_EL1_X0
La valeur calculée de VBAR_EL1 sera fausse, il faudra donc penser à changer la valeur erronée de VBAR_EL1 par la valeur d'origine.
4.3 Exploitation
Toute la phase d'exploitation se fait en JOP et ROP (Jump-Oriented Programming et Return-Oriented Programming), car au moment où le flot d'exécution est redirigé, KTRR est actif. Ainsi, seul le code du noyau est exécutable. Le point le plus important pour l'exploit est la réalisation d'une copie de la table de traduction d'origine du noyau, et de faire en sorte que la copie qui est modifiable remplace l'originale. On peut ainsi re-mapper en lecture et écriture les segments de données comme __DATA_CONST. Après cela, l'exploit rend la main au noyau iOS pour que le reset reprenne le cours normal de son exécution. Le Figure 2 montre en rouge la partie correspondant à l'exploitation.
Figure 2 : Exécution de l’exploit lors du reset
5. Quelques points techniques d'intérêt
5.1 Re-mapping des instructions protégées
L'opération suivante a lieu lors de la phase préparatoire. Même avec le mauvais intervalle des registres KTRR, les instructions du segment __LAST ne sont pas encore exécutables, car elles ne sont accessibles qu'en lecture seule (voir le tableau sur le mapping des segments). Pour invoquer l'instruction capable de changer le registre TTBR1_EL1, il faut donc re-mapper la page en lecture et exécution (rappelons que l'écriture n'est pas possible, car le segment __LAST est inclus dans l'intervalle défini par RoRgn). Cela est réalisé avec la table de traduction de TTBR0_EL1, qui est modifiable. En effet, un branchement vers une adresse physique translatée par TTBR0_EL1 nous amènera à l'instruction souhaitée.
5.2 Détails sur l'exécution de l'exploit
Le premier enchaînement de gadgets à être exécuté va servir à allouer de la place sur la pile, pour y copier une chaîne de ROP avec le gadget suivant :
LDR X8, [X8,#0x10] ; memmove + 4 // pour contrôler l'adresse de retour
ADD X0, SP, #8
BLR X8 ; memmove((SP + 8), chain + 8, ((0x1F * 80) - 8))
Une fois cette chaîne copiée, le flot d'exécution sera redirigé vers celle-ci comme le montre l'épilogue de la fonction memmove :
LDP X29, X30, [SP],#0x10
RET
À partir de là, plusieurs opérations seront effectuées par la chaîne de ROP. Elle commence tout d’abord par restaurer VBAR_EL1, car le noyau s’est basé sur la valeur contenue dans _const_boot_args.BA_VIRT_BASE pour calculer l’adresse virtuelle. Après cela, la fonction __pinst_set_ttbr1 (présente dans le segment _LAST) est appelée pour changer la valeur du registre TTBR1_EL1. Ces deux tâches achevées, il faut s’occuper de rendre la main au noyau. Pour que la reprise puisse se faire normalement, le registre LR doit pointer vers la bonne fonction d’initialisation (arm_init_cpu ou arm_init_idle_cpu). En effet, lors du reset, suivant le type de veille dont on sort, la gestion sera déléguée à l’une des deux fonctions. Pour faire ce choix, la structure cpu_data est utilisée. On y trouve à l'offset 0x130 le CPU_RESET_HANDLER qui indique la fonction devant être lancée par arm_init_tramp une fois l’exploit terminé.
6. Patch
À partir d'iOS 10.2, l'intervalle attribué aux registres KTRR lors du reset CPU est identique à celui du démarrage et donc correct :
// program and lock down KTRR
// subtract one page from rorgn_end to make pinst instructions NX
msr ARM64_REG_KTRR_LOWER_EL1, x17
sub x19, x19, #(1 << (ARM_PTE_SHIFT-12)), lsl #12
msr ARM64_REG_KTRR_UPPER_EL1, x19
mov x17, #1
msr ARM64_REG_KTRR_LOCK_EL1, x17
De plus, la structure qui permettait de rediriger le flot d'exécution n'est plus accessible en écriture, car elle réside dans le segment __DATA_CONST.
Conclusion
Nous avons vu comment la protection mise en place par KTRR a pu être partiellement contournée. Un tel exploit montre qu'il est devenu assez difficile de contourner les nouveaux mécanismes de protection, cela malgré les erreurs d'implémentation. Nul doute qu'avec les nouvelles sécurités apparues sur les derniers iPhones, les exploits publics se feront de plus en plus rares.
Remerciements
Je souhaite remercier Fred Raynal pour ses relectures et les conseils prodigués. Je remercie également Jean-Baptiste Bédrune, Joffrey Guilbon et Cédric Tessier pour leurs précieux retours, ainsi que Luca Todesco pour avoir publié un exploit des plus intéressants.
Références
[1] Manuel de référence de l’architecture ARMv8-A : https://static.docs.arm.com/ddi0487/da/DDI0487D_a_armv8_arm.pdf
[2] Traduction d’adresses sous ARMv8-A : https://static.docs.arm.com/100940/0100/armv8_a_address%20translation_100940_0100_en.pdf
[3] Code de l’exploit : http://yalu.qwertyoruiop.com/y7.txt
[4] Définition de RoRgn dans le noyau XNU : https://opensource.apple.com/source/xnu/xnu-4570.1.46/pexpert/pexpert/arm64/AMCC.h.auto.html