Sécurité des Applications iOS - Compromission de binaire
Nous avons vu dans notre précédent billet sur la sécurité des applications iOS qu’un débogueur peut être utilisé pour déchiffrer une application et avoir accès à sa table des symbôles. Grâce à cette table des symbôles nous pouvons changer le comportement de l’application car nous connaissons son architecture interne. Aujourd’hui, nous allons nous intéresser à un des nombreux moyens de modifier le comportement d’une application : la compromission de binaire.
Nous allons présenter dans cet article comment tirer profit de la modification de l’assembleur contenu dans le fichier exécutable d’une application.
Un fichier exécutable d’une application iOS est un fichier Mach-O qui contient toutes les informations sur l’application (voir Mach-O File Format Reference) : architecture, librairies dynamiques, etc. En particulier, ce fichier Mach-O contient le code assembleur de l’application.
Les applications iOS sont compilées pour les architectures ARM. Cela signifie que vous devez partiellement connaître l’assembleur ARM pour modifier le binaire d’un programme. Pour le moment, gardez juste en tête que nous appelons opcode le code hexadécimal d’une opération assembleur. Par exemple, en ARM, l’opération nop
(“no operation”) est associée à l’opcode 0xbf00
.
Pour contourner les sécurités d’une application, il suffit de localiser la protection dans le binaire et de changer les opcodes associés. Par exemple, nous pouvons modifier le résultat d’une condition if
en changeant simplement un opcode bne
(branch if not equal) en un opcode beq
(branch if equal). Ou nous pouvons charger des valeurs spécifiques dans les registres pour modifier la valeur de retour d’une fonction.
Modifier les opcodes assembleurs
Nous présentons ici un exemple simple de compromission de binaire. Rappelons-nous la fonction ptrace
discutée en détail dans notre dernier blogpost. Cette fonction désactive tout débogueur attaché à l’application quand elle est appelée avec le paramètre PT_DENY_ATTACH
. Maintenant, imaginons que nous voulions supprimer un appel à ptrace
dans une application et le remplacer par “rien”, c’est-à-dire une opération nop
. De cette manière, l’application n’appellera jamais la fonction ptrace
et nous pourrons alors lui attacher un débogueur.
Imaginons que nous ayons trouvé l’adresse exacte (0x5f5b2
) où l’appel à ptrace
est fait dans le fichier exécutable :
0x5f5ae: dd f8 0c 90 f8dd900c ldr.w r9, [sp, #12]
0x5f5b2: c8 47 47c8 blx r9 # call to ptrace
0x5f5b4: 0a 99 990a ldr r1, [sp, #40]
0x5f5b6: 01 90 9001 str r0, [sp, #4]
Pour contourner cet appel à ptrace
, nous devons remplacer avec un éditeur hexadécimal le code 0x47c8
contenu à l’adresse 0x5f5b2
par le code 0xbf00
qui représente l’opcode d’une opération nop
. Après modification, le binaire ressemblera à ça :
0x5f5ae: dd f8 0c 90 f8dd900c ldr.w r9, [sp, #12]
0x5f5b2: 00 bf bf00 nop # changed with nop
0x5f5b4: 0a 99 990a ldr r1, [sp, #40]
0x5f5b6: 01 90 9001 str r0, [sp, #4]
Quand cette partie du code sera exécutée dans l’application, l’opération nop
ne fera rien et substituera l’appel à ptrace
. De cette manière, l’application ne sera plus en mesure de détecter et de désactiver les débogueurs qui lui sont rattachés.
Calculer une signature du binaire
Une attaque au niveau binaire est difficile à contrer. Si un hacker est capable de réécrire du code assembleur, il sera certainement capable de contourner toutes les sécurités que nous avons implémentées dans nos applications iOS.
Mais quelque chose d’intéressant à savoir à propos des programmes Objective-C, c’est qu’ils sont capables d’inspecter leurs propres binaires. Une solution pour vérifier l’intégrité des opcodes assembleurs consite donc à parcourir les load commands du fichier Mach-O, à trouver la section assembleur (c’est-à-dire la section __text
du segment __TEXT
) et à calculer une signature MD5
des données binaires. Cette signature sera unique et si le binaire est modifié entre sa compilation et son exécution la signature de l’application changera. Nous avons réalisé une fonction qui calcule et vérifie la correction de la signture d’une programme Objecitve-C. Le code est légèrement difficile à comprendre, mais est commenté en détail.
#include <CommonCrypto/CommonCrypto.h>
#include <dlfcn.h>
#include <mach-o/dyld.h>
int correctCheckSumForTextSection() {
const char * originalSignature = "098f66dd20ec8a1ceb355e36f2ea2ab5";
const struct mach_header * header;
Dl_info dlinfo;
//
if (dladdr(main, &dlinfo) == 0 || dlinfo.dli_fbase == NULL)
return 0; // Can't find symbol for main
//
header = dlinfo.dli_fbase; // Pointer on the Mach-O header
struct load_command * cmd = (struct load_command *)(header + 1); // First load command
// Now iterate through load command
//to find __text section of __TEXT segment
for (uint32_t i = 0; cmd != NULL && i < header->ncmds; i++) {
if (cmd->cmd == LC_SEGMENT) {
// __TEXT load command is a LC_SEGMENT load command
struct segment_command * segment = (struct segment_command *)cmd;
if (!strcmp(segment->segname, "__TEXT")) {
// Stop on __TEXT segment load command and go through sections
// to find __text section
struct section * section = (struct section *)(segment + 1);
for (uint32_t j = 0; section != NULL && j < segment->nsects; j++) {
if (!strcmp(section->sectname, "__text"))
break; //Stop on __text section load command
section = (struct section *)(section + 1);
}
// Get here the __text section address, the __text section size
// and the virtual memory address so we can calculate
// a pointer on the __text section
uint32_t * textSectionAddr = (uint32_t *)section->addr;
uint32_t textSectionSize = section->size;
uint32_t * vmaddr = segment->vmaddr;
char * textSectionPtr = (char *)((int)header + (int)textSectionAddr - (int)vmaddr);
// Calculate the signature of the data,
// store the result in a string
// and compare to the original one
unsigned char digest[CC_MD5_DIGEST_LENGTH];
char signature[2 * CC_MD5_DIGEST_LENGTH]; // will hold the signature
CC_MD5(textSectionPtr, textSectionSize, digest); // calculate the signature
for (int i = 0; i < sizeof(digest); i++) // fill signature
sprintf(signature + (2 * i), "%02x", digest[i]);
return strcmp(originalSignature, signature) == 0; // verify signatures match
}
}
cmd = (struct load_command *)((uint8_t *)cmd + cmd->cmdsize);
}
return 0;
}
Une fois cette fonction intégrée dans notre application, nous pouvons vérifier que la signature originale (celle calculée lors de la compilation du projet) correspond à la signature courante de l’application (celle calculée au runtime). Quand l’application s’exécute, si quelqu’un a précédemment modifié un opcode du binaire, les deux signatures ne correspondront plus.
Conclusion
Le code source présenté est un point de départ intéressant pour vérifier l’intégrité d’un fichier exécutable. Mais ce code n’est certainement pas sans défauts. Vous auriez par exemple intérêt à bien cacher la signature MD5 dans votre code afin de la rendre difficile à trouver et à modifier.
Enfin, gardez en tête que les solutions présentées dans ces articles sur la sécurité sont seulement des idées qui méritent d’être creusées et approfondies. C’est le point de départ de votre sécurité. À vous d’imaginer une sécurité difficile à attaquer pour que votre application reste plus longtemps inviolée que celles de vos concurrents.