iOS 18.4 - dlsym considered harmful

Rédigé par Fabien Perigaud - 10/04/2025 - dans Reverse-engineering - Téléchargement

La semaine dernière, Apple a publié iOS 18.4 pour tous les iPhones pris en charge. Sur les appareils prenant en charge PAC (pointer authentication), nous sommes tombés sur un bug étrange lors de la résolution de symboles avec dlsym(). Cet article détaille nos observations et la cause du problème.

Vous souhaitez améliorer vos compétences ? Découvrez nos sessions de formation ! En savoir plus

Observations

Nous avons d'abord observé le bug dans une application iOS compilée pour l'architecture arm64e (qui prend donc en charge les instructions PAC). Cette application utilise la résolution dynamique de symboles pour diverses fonctions système, en utilisant à la fois dlopen() et dlsym().

  • dlopen() prend un chemin vers une bibliothèque partagée comme argument et retourne un handle, utilisable avec les autres fonctions de l’API dl.
  • dlsym() prend un handle et un nom de symbole en argument, et retourne l’adresse correspondante. Sur les appareils supportant PAC, cette adresse est signée avec la clé d'instruction A et un contexte NULL, ce qui permet son utilisation dans les appels indirects en C (car ces appels utilisent l’instruction PAC BLRAAZ).

A titre d'exemple, le code suivant charge dynamiquement l'adresse de la fonction strcpy et l'utilise comme pointeur de fonction :

void *handle = dlopen("/usr/lib/system/libsystem_c.dylib", RTLD_NOW);
syslog(LOG_USER, "handle libsystem_c.dylib %p\n", handle);

char *(*strcpy_ptr)(char *, char *) = (char *(*)(char *, char *))dlsym(handle, "strcpy");
syslog(LOG_USER, "strcpy %p\n", (void *)strcpy_ptr);

strcpy_ptr(my_str, "Hello world");
syslog(LOG_USER, "%s\n", my_str);

Les logs montrent le handle opaque, le pointeur signé, et le résultat de l’appel dynamique :

<Notice>: handle libsystem_c.dylib 0x24ae83508d0b60
<Notice>: strcpy 0x212da2022c4a43f8
<Notice>: Hello world

Le code assembleur généré correspond bien à ce que l’on attend, avec la valeur de retour de dlsym() (X0) utilisée comme destination de l’instruction BLRAAZ.

Dynamic resolution assembly code
Code assembleur généré de l'exemple 1 dans IDA Pro

Rien de surprenant jusque là... mais où est ce fameux bug ? Il se trouve qu'il ne se manifeste qu'avec certaines fonctions spécifiques. Essayons un nouvel exemple !

Cette fois, nous allons résoudre dynamiquement la fonction strcmp() puis l'utiliser.

void *handle = dlopen("/usr/lib/system/libsystem_c.dylib", RTLD_NOW);
int (*strcmp_ptr)(char *, char *) = (int (*)(char *, char *))dlsym(handle, "strcmp");
syslog(LOG_USER, "strcmp %p\n", (void *)strcmp_ptr);

int res = strcmp_ptr("Hello friend", "Hello world");
syslog(LOG_USER, "strcmp returned %d\n", res);

Au lieu d'avoir une ligne de log indiquant que les deux chaînes sont différentes, l'application plante...

Apr  3 08:47:42 App[1219] <Notice>: strcmp 0xdab738822c4a2890
Apr  3 08:47:42 kernel[0] <Notice>: App[1219] Corpse allowed 1 of 5

L'analyse du fichier IPS généré indique la raison suivante :

KERN_PROTECTION_FAILURE at 0x00f0ff822c4a2890 -> 0xffffff822c4a2890 (possible pointer authentication failure)
[...]
ESR 0x82000004 Description : (Instruction Abort) Translation fault at far 0xa8f0ff822c4a2890
PC: 0xffffff822c4a2890, LR: 0x102d70068, SP: 0x16d0e0ca0, FP: 0x16d0e0f90

Le registre LR pointe juste après l’instruction BLRAAZ. Le code assembleur ne montre rien d’anormal entre la sortie de dlsym() et l’appel indirect :

Generated assembly code for example 2
Code assembleur généré de l'exemple 2 dans IDA Pro

Que s'est-il passé ? Pourquoi le pointeur est-il incorrectement signé ? Pourquoi est-ce que nous avons un pointeur kernel dans PC ?

Expérimentations avec PAC

Voyons si le bug est reproductible en lançant l'application plusieurs fois.

<Notice>: strcmp 0x239044022c4a2890
<Notice>: App[1219] Corpse allowed 1 of 5

<Notice>: strcmp 0x9db91d022c4a2890
<Notice>: App[1219] Corpse allowed 1 of 5

<Notice>: strcmp 0x8919f822c4a2890
<Notice>: App[1219] Corpse allowed 1 of 5

<Notice>: strcmp 0x4bb462822c4a2890
<Notice>: App[1219] Corpse allowed 1 of 5

<Notice>: strcmp 0xe1d732022c4a2890
<Notice>: App[1219] Corpse allowed 1 of 5

<Notice>: strcmp 0x22c4a2890
<Notice>: App[1219] Corpse allowed 1 of 5

<Notice>: strcmp 0x1094dc022c4a2890
<Notice>: App[1219] Corpse allowed 1 of 5

<Notice>: strcmp 0x22c4a2890
<Notice>: App[1219] Corpse allowed 1 of 5

<Notice>: strcmp 0x22c4a2890
<Notice>: App[1219] Corpse allowed 1 of 5

<Notice>: strcmp 0x22c4a2890
<Notice>: App[1219] Corpse allowed 1 of 5

<Notice>: strcmp 0x8d945a822c4a2890
<Notice>: App[1219] Corpse allowed 1 of 5

<Notice>: strcmp 0x22c4a2890
<Notice>: App[1219] Corpse allowed 1 of 5

Encore plus étrange, les pointeurs retournés sont soit non signés, soit signés avec une signature invalide !

Essayons de manuellement supprimer la signature et de resigner le pointeur, il ne s'agit peut-être que d'une corruption d'un bit ou d'un octet.

<Notice>: strcmp 0xed71e822c4a2890
<Notice>: strcmp re-signed 0x905ffa022c4a2890

<Notice>: strcmp 0x22c4a2890
<Notice>: strcmp re-signed 0x1b1834022c4a2890

<Notice>: strcmp 0x5ef5c822c4a2890
<Notice>: strcmp re-signed 0xce00a9822c4a2890

Quand le pointeur a une signature invalide, la nouvelle signature calculée est complètement différente, et si l'on tente d'appeler le pointeur nouvellement signé, tout fonctionne correctement.

Investigation du bug

Les essais avec PAC ne nous ont pas aidé à comprendre le bug. L'étape suivante est de regarder spécifiquement l'export de strcmp pour comprendre pourquoi il se comporte différemment des autres, et de creuser l'implémentation de dlsym() dans iOS.

Tout d'abord, nous avons extrait la bibliothèque libsystem_c.dylib du shared cache grâce au merveilleux outil qu'est ipsw :

$ ipsw dyld extract dyld_shared_cache_arm64e libsystem_c.dylib
   • Created libsystem_c.dylib

Pour s'assurer de bien comprendre ce qu'il se passe, nous avons écrit un parser minimaliste de la commande LC_DYLD_EXPORTS_TRIE, et l'avons exécuté sur libsystem_c.dylib :

$ python exports_trie.py libsystem_c.dylib | grep strcmp -C3
TRIE _strcat  f38c
TRIE _strchr EXPORT_SYMBOL_FLAGS_REEXPORT (__platform_strchr) 6
TRIE _strchrnul  5ed3c
TRIE _strcmp EXPORT_SYMBOL_FLAGS_REEXPORT (__platform_strcmp) 6
TRIE _strcoll  a0d4
TRIE _strcoll_l  9fc8
TRIE _strcpy EXPORT_SYMBOL_FLAGS_REEXPORT (__platform_strcpy) 6

Intéressant! _strcmp a un flag spécifique, EXPORT_SYMBOL_FLAGS_REEXPORT associé à la chaîne __platform_strcmp, ce qui indique que la dylib ré-exporte le symbol en tant que __platform_strcmp, lui-même importé depuis libsystem_platform.dylib. Toutefois, nous observons la même chose pour strcpy, qui ne provoque pas le bug...

Il faut creuser plus profondément !

Exécutons le même outil sur la bibliothèque libsystem_platform.dylib :

$ python exp.py libsystem_platform.dylib | grep strcmp -C3
TRIE __platform_memset_pattern4  3640
TRIE __platform_memset_pattern8  3660
TRIE __platform_strchr  1940
TRIE __platform_strcmp EXPORT_SYMBOL_FLAGS_STUB_AND_RESOLVER (resolver @ 3890) 6860
TRIE __platform_strcpy  23f8
TRIE __platform_strlcat  1e34
TRIE __platform_strlcpy  2380

Encore plus intéressant ! Cette fois, seul __platform_strcmp dispose d'un flag spécifique, EXPORT_SYMBOL_FLAGS_STUB_AND_RESOLVER. Ce flag indique une résolution "paresseuse" de la véritable implémentation lors de la première exécution. Plus précisément, l'export __platform_strcmp saute sur un pointeur de fonction (__platform_strcmp_ptr) situé dans la section __la_resolver de la bibliothèque, qui pointe au départ vers une fonction qui va chercher la véritable implémentation, puis remplacer le pointeur afin que les appels suivants se fassent directement vers la véritable implémentation.

__platform_strcmp lazy resolution
Résolution paresseuse de __platform_strcmp

Voyons maintenant comment dyld gère ce type d'export lors de l'appel à dlsym().

L'implémentation de dlsym() est un simple wrapper vers APIs::dlsym(), qui finit par appeler Loader::hasExportedSymbol() avec Loader::runResolver en tant que 5e argument. Si le symbole n'a pas le flag EXPORT_SYMBOL_FLAGS_REEXPORT, le code suivant est exécuté :

    if ( diag.hasError() )
        return false;
    bool isAbsoluteSymbol           = ((flags & EXPORT_SYMBOL_FLAGS_KIND_MASK) == EXPORT_SYMBOL_FLAGS_KIND_ABSOLUTE);
    uintptr_t targetRuntimeOffset   = (uintptr_t)MachOLoaded::read_uleb128(diag, p, trieEnd);

    bool isResolver = (flags & EXPORT_SYMBOL_FLAGS_STUB_AND_RESOLVER);
    if ( isResolver && (resolverMode == runResolver) ) { // [1]
        uintptr_t resolverFuncRuntimeOffset = (uintptr_t)MachOLoaded::read_uleb128(diag, p, trieEnd); // [2]
        const uint8_t* dylibLoadAddress = (const uint8_t*)this->loadAddress(state);
        typedef void* (*ResolverFunc)(void);
        ResolverFunc resolver = (ResolverFunc)(dylibLoadAddress + resolverFuncRuntimeOffset); // [3]
#if __has_feature(ptrauth_calls)
        resolver = __builtin_ptrauth_sign_unauthenticated(resolver, ptrauth_key_asia, 0);
#endif
        const void* resolverResult = (*resolver)(); // [4]
#if __has_feature(ptrauth_calls)
        resolverResult = __builtin_ptrauth_strip(resolverResult, ptrauth_key_asia);
#endif
        targetRuntimeOffset = (uintptr_t)resolverResult - (uintptr_t)dylibLoadAddress; // [5]
    }

    result->targetLoader            = this;
    result->targetSymbolName        = symbolName;
    result->targetRuntimeOffset     = targetRuntimeOffset;
    result->kind                    = isAbsoluteSymbol ? ResolvedSymbol::Kind::bindAbsolute : ResolvedSymbol::Kind::bindToImage;
    result->isCode                  = this->mf(state)->inCodeSection((uint32_t)(result->targetRuntimeOffset));
    result->targetAddressForDlsym   = resolvedAddress(state, *result); // [6]
    result->targetAddressForDlsym   = interpose(state, result->targetAddressForDlsym);
#if __has_feature(ptrauth_calls)
    if ( result->isCode )
        result->targetAddressForDlsym = (uintptr_t)__builtin_ptrauth_sign_unauthenticated((void*)result->targetAddressForDlsym, ptrauth_key_asia, 0); // [7]
#endif
    result->isWeakDef               = (flags & EXPORT_SYMBOL_FLAGS_WEAK_DEFINITION);
    result->isMissingFlatLazy       = false;
    result->isMaterializing         = false;
    return true;

Si le symbole a le flag EXPORT_SYMBOL_FLAGS_STUB_AND_RESOLVER et que l'argument Loader::runResolver est passé à la fonction ([1]), l'offset du resolver est récupéré ([2]), convertit en une adresse ([3]), puis signé et appelé ([4]). Ensuite, la signature est supprimée de l'adresse retournée, et celle-ci est convertie en un offset ([5]). Enfin, l'offset est convertit en l'adresse finale ([6]) qui est signée ([7]) avant d'être retournée à APIS::dlsym(). En regardant le code, tout semble correct...

La structure result, qui est remplie avant de retourner dans l'appelant, est située sur la stack frame de APIs::dlsym(). Par conséquent, en lisant la pile juste après l'appel à dlsym() et en recherchant certains motifs, nous devrions pouvoir récupérer cette structure et vérifier si tout est correct.

Nous avons alors modifié notre code et l'avons à nouveau exécuté :

<Notice>: Candidate for structure result:
<Notice>: result->targetLoader	0x29834ae98
<Notice>: result->targetSymbolName	0x2215d6bd4
<Notice>: result->targetRuntimeOffset	9b4ec00000000890
<Notice>: result->targetAddressForDlsym	369d2c822c4a2890
<Notice>: result->isCode	1
<Notice>: strcmp 0x369d2c822c4a2890
<Notice>: strcmp re-signed 0x9b4ec0022c4a2890

Il y a clairement un problème :

  • Le champ targetRuntimeOffset a ses bits de poids fort non-nuls ;
  • Ces bits sont les mêmes que la signature correcte du pointeur !

Il semblerait que le pointeur retourné par le resolver n'ait pas été strippé avant d'être convertit en offset !

Observons le code compilé de dyld qui appelle le resolver :

dyld code, calling resolver
Appel du resolver dans IDA Pro

Une instruction XPACI est clairement manquante, on peut voir que la valeur de retour (X0) du BLRAAZ est directement convertie en offset par l'instruction SUB. Sur iOS 18.3.2, cette instruction XPACI est bien présente.

Questions en suspens

Nous avons identifié la cause du bug, mais nous ne savons pas (encore ?) si Apple a changé le code source de dyld ou si le compilateur a considéré que le strip de signature n'était pas nécessaire.

Une première question à laquelle nous pouvons répondre est : "D'où provient la signature invalide ?". D'après notre analyse, il s'avère que le pointeur est en fait signé deux fois ! Essayons de reproduire le problème en exécutant plusieurs fois notre application après avoir ajouté une double signature :

<Notice>: strcmp 0xdffe55022c4a2890
<Notice>: strcmp re-signed 0xf156d8822c4a2890
<Notice>: strcmp re-signed twice 0xdffe55022c4a2890

<Notice>: strcmp 0x22c4a2890
<Notice>: strcmp re-signed 0x650330822c4a2890
<Notice>: strcmp re-signed twice 0x22c4a2890

<Notice>: strcmp 0x64ea2d022c4a2890
<Notice>: strcmp re-signed 0xda6077022c4a2890
<Notice>: strcmp re-signed twice 0x64ea2d022c4a2890

Nous sommes capables de reproduire la mauvaise signature !

Pour comprendre comment cette valeur est calculée, du contexte est nécessaire : l'équipement sur lequel les tests sont effectués est un iPhone SE de 3e génération, qui dispose d'un SoC A15. L'Apple A15 supporte les améliorations de PAC introduits dans l'architecture Armv8.6-A, notamment EnhancedPAC2. Dans ce cas, la signature PAC ne remplace plus les bits de poids fort, mais est XORée avec.

Voici le pseudo-code du calcul de signature selon la spécification Arm :

if HaveEnhancedPAC2() && ConstPACField() then selbit = ptr<55>;
integer bottom_PAC_bit = CalculateBottomPACBit(selbit);

// The pointer authentication code field takes all the available bits in between
extfield = Replicate(selbit, 64);

// Compute the pointer authentication code for a ptr with good extension bits
ext_ptr = extfield<(64-bottom_PAC_bit)-1:0>:ptr<bottom_PAC_bit-1:0>;
PAC = ComputePAC(ext_ptr, modifier, K<127:64>, K<63:0>);

result = (ptr<63:56> EOR PAC<63:56>):selbit:(ptr<54:bottom_PAC_bit> EOR PAC<54:bottom_PAC_bit>):ptr<bottom_PAC_bit-1:0>;
return result;

Quand un pointeur est signé, ses bits de poids fort sont passés à 0 ou à 1 selon la valeur de selbit.

Quand le pointeur retourné par dlsym() n'est pas signé, c'est parce que la seconde signature est xorée avec la première, et l'annule donc.

Toutefois, quand une signature invalide est retournée, la seule raison possible est que la seconde signature a été faite avec les bits de poids fort du pointeur positionnés à 1 (comme si le pointeur était un pointeur kernel). Selon la spécification, il est impossible que cela arrive, car selbit représente le bit 55, et il ne peut pas être changé par la première signature... Des tests répétés ont montré que, contrairement à la spécification, le pointeur est considéré comme un pointeur kernel (lors d'une opération de signature) si le bit 63 est positionné ! Vérifions notre théorie en calculant la signature de notre pointeur avec tous les bits de poids fort à 1, puis en la XORant avec le pointeur pour récupérer la sortie de ComputePAC ; nous pourrons alors la comparer avec le résultat d'un XOR entre le pointeur correctement signé et le pointeur doublement signé:

<Notice>: strcmp 0xef9b29022c4a2890
<Notice>: strcmp re-signed 0xf70977822c4a2890
<Notice>: (k)strcmp (0xffffff822c4a2890) signed 0xe7eda1022c4a2890
<Notice>: 0xffffff822c4a2890 XOR 0xe7eda1022c4a2890 -> 0x18125e8000000000
<Notice>: 0xef9b29022c4a2890 XOR 0xf70977822c4a2890 -> 0x18925e8000000000

Et voilà ! Le résultat de ComputePAC est identique (le bit 55 change, mais il est directement conservé du pointeur pré-signé), ce qui valide la théorie que le pointeur signé peut être considéré comme un pointeur kernel lors de la deuxième signature.

Il reste une dernière question : pourquoi est-ce que les applications qui ont strcmp dans leurs imports ne plantent pas ? En regardant à nouveau les sources de dyld, il apparaît que le loader appelle toujours hasExportedSymbol avec l'argument Loader::skipResolver, ce qui ne déclenche pas le bug !

Conclusion

La decouverte de la cause de ce bug a nécessité de nombreuses investigations, mais a permis de creuser dans les entrailles du shared cache, de dyld et de l'implémentation de PAC sur les iPhones récents. Nous espérons que ce bug va être rapidement corrigé par Apple :)