Neutron, notre moteur d’IA, a obtenu un score de 96,75 % sur le benchmark CyberGym de l’UC Berkeley. En savoir plus

Ingénierie

Ingénierie

Swift au microscope : instrumentation dynamique en pratique

Article sur l'instrumentation dynamique de Swift. Il explique les étapes de l'analyse dynamique d'une application Swift, en abordant le name mangling, l'ABI de Swift et l'extraction des arguments de fonctions en Swift.

Introduction

Les experts en sécurité peuvent sonder en profondeur le comportement des programmes à l'exécution pour identifier des failles de sécurité et des activités malveillantes. Même pour les applications iOS, en tant que chercheur en sécurité, vous pouvez intercepter et inspecter les appels système, suivre les allocations de mémoire et injecter du code personnalisé dans des processus en cours d'exécution. Vous mettez ainsi au jour des vulnérabilités cachées, et vous identifiez et neutralisez rapidement (« swiftly », jeu de mots assumé) les menaces émergentes.

L'instrumentation dynamique expliquée

Contrairement aux méthodes traditionnelles d'analyse statique, qui examinent le code avant son exécution, l'instrumentation dynamique fonctionne en temps réel. Elle établit une connexion avec les programmes pendant leur exécution afin de surveiller, ajuster et enrichir leur comportement.

En insérant avec soin du code d'instrumentation spécialisé, aussi appelé sondes ou hooks, dans le binaire ou le bytecode de l'application cible dès le début de l'opération, les chercheurs peuvent surveiller des événements importants comme les appels système, les accès mémoire et les interactions réseau.

Ces hooks permettent la surveillance, le profilage, le débogage et l'analyse de sécurité en temps réel pendant l'exécution du programme instrumenté, en libérant des données ou en déclenchant des actions en réponse à des situations prédéfinies. Voyez-les comme des points d'arrêt que vous insérez dans votre code et qui déclenchent un callback lorsque le code les atteint. La dernière étape consiste à analyser les données collectées pour comprendre le comportement de l'application à l'exécution. Notons que cette analyse ne se limite pas à l'analyse de sécurité : elle peut aussi servir au débogage et au profilage, selon vos objectifs.

C'est parti

Le déroulement complet du processus peut se résumer ainsi :
1. Trouver l'application cible.
2. Identifier les points de surveillance, autrement dit les hooks.
3. Écrire le code d'instrumentation avec n'importe quel framework d'instrumentation.
4. Injecter le code d'instrumentation.
5. Et enfin, analyser les résultats et les stack traces.

Identifier les hooks

Une application contient des milliers de fonctions. Seules quelques-unes intéressent les chercheurs en sécurité : la première étape de tout le processus consiste donc à identifier les fonctions à surveiller. Nous devons ensuite trouver les symboles à utiliser pour le hooking.

Nous pouvons y parvenir avec l'outil en ligne de commande nm, conçu spécialement pour lister les symboles des fichiers objets.

Supposons que nous voulions instrumenter une fonction dummyFunction, située dans DummyModule.swift :

public class Dummy {
    public class AnotherDummyClass {
        public func dummyFunction(intDummyArg: Int, stringDummyArg: String, booleanDummyArg: Bool) -> String {
            return "I am just a dummy function."
        }
    }
}

Nous compilons et exécutons la commande nm -g DummyModule, et notre symbole ressemble à ceci :

$s11DummyModule0A0C07AnotherA5ClassC13dummyFunction03intA3Arg06stringaH007booleanaH0SSSi_SSSbtF

Le name mangling de Swift

La résolution des symboles fait partie des tâches de l'éditeur de liens dans la conception d'un compilateur : il associe les symboles déclarés dans un fichier aux références à ces symboles dans un autre fichier, afin de résoudre les références symboliques entre fichiers objets.
Le name mangling n'est pas nécessaire dans un langage comme C, puisqu'il ne peut exister qu'une seule fonction ou donnée pour un nom (un symbole) donné. Les choses se compliquent dans les langages qui autorisent la surcharge et les templates de sélecteurs similaires sur une même classe avec des signatures différentes.

Par exemple :

    int add(int a, int b)  

    float add(float a, float b)

Le compilateur mangle les symboles, c'est-à-dire qu'il donne aux fonctions des identifiants uniques que l'éditeur de liens comprendra.
La première idée intuitive est d'utiliser la signature complète : add(int, int)->int, mais cela entraînerait beaucoup de code supplémentaire dans l'éditeur de liens et de la confusion lorsque plusieurs noms de types correspondent au même type sous-jacent, comme unsigned et unsigned int. Le name mangling de Swift n'étant pas le sujet principal de cet article, nous essayons d'en expliquer certaines règles à travers l'exemple précédent de dummyFunction :

public class Dummy {
    public class AnotherDummyClass {
        public func dummyFunction(intDummyArg: Int, stringDummyArg: String, booleanDummyArg: Bool) -> String {
            return "I am just a dummy function."
        }
    }
}

Son symbole mangled est : $s11DummyModule0A0C07AnotherA5ClassC13dummyFunction03intA3Arg06stringaH007booleanaH0SSSi_SSSbtF :

  • $s : préfixe global des symboles Swift ;
  • 11DummyModule : le nom du module, long de 11 caractères ;
  • 0A0C : l'identifiant commence par 0 car la substitution sera utilisée ; le mot Dummy sera représenté par le caractère A dans les occurrences suivantes. Dummy étant le dernier identifiant, nous terminons sa version mangled par un autre 0, et enfin C signifie qu'il s'agit d'une Class ;
  • 07AnotherA5ClassC : 0 signifie que l'identifiant comporte une substitution de mot, Another compte 7 caractères, A est la substitution de Dummy, Class compte 5 caractères et, comme pour l'identifiant précédent, C signifie qu'il s'agit d'une classe ;
  • 13dummyFunction : le nom de la fonction dummyFunction, long de 13 caractères ;
  • 03intA3Arg : 0 pour la substitution, int : 3 caractères, A se substitue à Dummy, et enfin 3 caractères pour Arg ;
  • 06stringaH0 : similaire à l'argument précédent, la seule différence étant que Arg sera substitué ;
  • 007booleanaH0 : similaire à l'argument précédent ;
  • SS : type de retour Swift.String ;
  • Si : type d'argument Swift.Int ;
  • SS : type d'argument Swift.String ;
  • Sb : type d'argument Swift.Bool ;
  • F : le dernier symbole signifie qu'il s'agit d'un symbole de Function.

Voici la sortie de swift-demangle, un programme conçu pour démangler les symboles Swift.

Sortie de swift-demangle

Préparer l'application

À l'aide de frameworks comme Frida, nous préparons l'application à l'instrumentation en y incorporant les bibliothèques nécessaires et en activant le FridaGadget. La configuration suivante permet d'injecter du code personnalisé et de surveiller le comportement de l'application.

  • Téléchargez le FridaGadet.dylib correspondant à votre architecture depuis la page principale des releases ;
  • Déplacez le FridaGadget.dylib dans le dossier Frameworks de votre application ;
  • Insérez une load command pour le gadget ; insert_dylib : « A command line utility for inserting a dylib load command into a Mach-O binary » ;
  • Lorsque vous lancez l'application, elle est censée rester bloquée : elle attend que le frida-client s'y attache ;
  • Ici, plusieurs approches sont possibles :
    • Pour du prototypage rapide : frida-ps
    • Pour les développeurs qui surveillent leur application, un simple script Python peut servir à s'attacher à l'application ;
    import frida
    def on_frida_message(message, data):
        # Callback to execute when a frida-message is received.

    device_id = "your-device-id"
    frida_device = frida.get_device(device_id)
    frida_session = frida_device.attach("gadget")
    script_path = "path/to/instrument.js"
    with open(script_path, "r") as f:
        frida_script = frida_session.create_script(f.read())
        frida_script.on("message", on_frida_message)
        frida_script.load()
  • Selon le cas d'usage, le corps du callback on_frida_message peut aller du simple affichage dans le terminal à l'enregistrement des stack traces dans un fichier, en passant par leur transmission à des règles de sécurité pour analyse ;
  • La pièce manquante du puzzle est le contenu de instrument.js. Que ferons-nous exactement lorsque notre fonction sera interceptée ? Pour répondre à cette question, nous devons comprendre quelques points.

Code d'instrumentation

ABI de Swift : Application Binary Interface

À l'exécution, les binaires des programmes Swift interagissent avec d'autres bibliothèques et composants via une ABI, une « Application Binary Interface ». C'est la spécification à laquelle des entités binaires compilées indépendamment doivent se conformer pour être liées et exécutées ensemble.

Ces entités binaires doivent s'accorder sur de nombreux détails de bas niveau : comment appeler les fonctions ? Comment leurs données sont-elles représentées en mémoire ? Et même où se trouvent leurs métadonnées et comment y accéder. Les fonctions doivent aussi savoir comment s'appeler entre elles, ce qui implique des éléments comme la disposition de la pile d'appels, les registres préservés et les conventions de propriété.

Convention d'appel

Voici un extrait du tableau d'utilisation des registres pour ARM64 et x86-64 ; pour la liste complète et plus de détails, consultez le dépôt GitHub de Swift :

ARM64

Registre Spécial Rôle Swift
x0 Argument entier 1 (1re valeur de retour)
x1 Argument entier 2 (2e valeur de retour)
x2 - x7 Arguments entiers 3 à 8
x8 Registre d'emplacement du résultat indirect
x16 ip0 Registres temporaires
x17 ip1
x18 RÉSERVÉ, NE PAS UTILISER
x19 Registre sauvegardé par l'appelé self
.. .. ..... ..

X86-64

Registre Rôle Swift
rax Valeur de retour ; également, pour les var-args, nombre de registres xmm utilisés
rbx Registre sauvegardé par l'appelé
rdi Argument entier 1
rsi Argument entier 2
rdx Argument entier 3 (2e valeur de retour)
rcx Argument entier 4 (3e valeur de retour)
.. ..... ..

Arguments

Nous savons maintenant où résideront les paramètres d'une fonction ; essayons de les extraire.

Booléens

Les booléens s'obtiennent en lisant directement le registre qui contient l'argument.

/** Get the boolean argument of a function.
 * 
 * @param context Frida context giving access to register values.
 * @param argIndex Argument index to determine which offset is the arg pointer.
 * @returns The boolean value of the argument as an integer.
 */
function GetSwiftBoolArgument(context, argIndex, swiftRegisterShiftingIndex) {
    argIndex+=swiftRegisterShiftingIndex
    let register = getSwiftArgumentCorrespondingRegisterForARM64(context, argIndex);
    return Boolean(register.and(0x1).toInt32())
}

Entiers

Comme pour les booléens, les entiers s'obtiennent simplement en accédant à la valeur du registre.

/** Get the integer argument of a function.
 * 
 * @param context Frida context giving access to register values.
 * @param argIndex Argument index to determine which offset is the arg pointer.
 * @returns The integer value of the argument.
 */
function GetSwiftIntArgument(context, argIndex, swiftRegisterShiftingIndex) {
    argIndex+=swiftRegisterShiftingIndex
    let register = getSwiftArgumentCorrespondingRegisterForARM64(context, argIndex);
    return  register.toInt32();
}

Chaînes de caractères

Lorsque des chaînes Swift sont passées à une fonction, elles peuvent l'être de deux façons selon leur taille. Si la taille est inférieure à 16 octets, la chaîne est passée sur la pile ; sinon, elle est passée sur le tas. Dans tous les cas, quelle que soit la taille, l'objet lui-même suit la structure présentée dans le schéma suivant.

Structure d'une chaîne Swift

Moins de 16 octets

La chaîne peut faire au maximum 16 octets de longueur. Sur les architectures 64 bits, les registres font 64 bits, ce qui signifie qu'il faut 2 registres pour contenir la chaîne.
Cette information change toute la compréhension du tableau de convention d'appel vu plus haut. Si une fonction prend 2 arguments, dont le premier est de type Swift.String et le second de type Swift.Int, la règle s'applique-t-elle uniquement aux arguments entiers ?

    ```
    rdi:  Integer argument 1
    rsi:  Integer argument 2
    ```

Si la règle ne s'applique pas uniquement aux arguments entiers, utilisons-nous le premier registre pour stocker la chaîne ? Y a-t-il un décalage ? Si nous avons plus d'un argument, sont-ils tous décalés ?

Pour répondre à cette question, nous pouvons simplement lancer une application, nous y attacher avec LLDB et jeter un œil aux valeurs des registres.

  • D'abord, nous lançons lldb sur le module factice vu dans la section sur le mangling ;
-> ~ lldb DummyModule
(lldb) target create "DummyModule"
  • Nous posons un point d'arrêt sur la fonction « dummyFunction » ;
(lldb) breakpoint set --file main.swift --line 37
Breakpoint 1: where = DummyModule`$s11DummyModule0A0C07AnotherA5ClassC13dummyFunction03intA3Arg06stringaH007booleanaH0SSSi_SSSbtF + 58 address = 0x00000000000c446a
  • Exécutons le programme ;
(lldb) run
Process 3162136 launched: '/home/haddadi/Documents/swift-nio/.build/install/DummyModule' (x86_64)
Process 3162136 stopped
* thread #1, name = 'DummyModule', stop reason = breakpoint 1.1
    frame #0: 0x000055555561846a DummyModule`$s11DummyModule0A0C07AnotherA5ClassC13dummyFunction03intA3Arg06stringaH007booleanaH0SSSi_SSSbtF at main.swift:37:20
  • Le premier argument est de type entier et doit se trouver dans le registre « rdi » ;
(lldb) register read rdi -f d
    rdi = 42
  • Le deuxième argument est de type chaîne, dans le deuxième registre « rsi » ;
(lldb) register read rsi -f s
    rsi = "42"
  • Le registre suivant, « rdx » ;
(lldb) register read rdx -f s
    rdx = ""

(lldb) register read rdx -f b
    rdx = 1110001000000000000000000000000000000000000000000000000000000000

Le premier octet 11100010 contient des métadonnées sur notre objet :

  • b63 : isImmortal ; indique si le runtime Swift doit ignorer l'ARC. Les petites chaînes sont de simples valeurs, toujours immortelles ;
  • b62 : (grande chaîne) isBridged / (petite chaîne) isASCII ;
  • b61 : isSmall : bit dédié pour indiquer les petites chaînes ;
  • b60 : isForeign : autrement dit, est bas, ne peut pas donner accès à de l'UTF-8 contigu ;
  • Les 4 derniers bits représentent la longueur : 0010 => 2.

Pour confirmer l'hypothèse, nous lirons la valeur du registre rcx, responsable de l'Integer argument 4 (voir le tableau ci-dessus), au format booléen -f B.

(lldb) register read rcx -f B
    rcx = true

Lecture des registres dans LLDB

Plus de 16 octets

Le littéral de chaîne est alloué sur le tas. Les registres correspondants (selon l'index de cet argument chaîne) contiennent des métadonnées sur la chaîne et le pointeur vers le littéral sur le tas. Les 8 octets de l'_object sont stockés dans le deuxième registre, conformément au schéma ci-dessous.

Métadonnées d'une chaîne

Ce qui se traduit par

/** Extract the string value of the argument; case of strings with length > 16 bytes.
 * 
 * @param secondRegister The second register used to hold the _object value.   
 * @returns The string value of argument.
 */
function GetSwiftLargeStringArgument(secondRegister) {
      const ptr2hex = '0x' + secondRegister.toString(16);
      let ptr2value = BigInt(ptr2hex);

   // low 56 bits (check drawing above)
      let strAddress = '0x' + (ptr2value & 0xFFFFFFFFFFFFFFn).toString(16);
      let strPtr = new NativePointer(strAddress);
      let cstrPtr = strPtr.add(32); // Skip the offset (check drawing above)
      const message = cstrPtr.readCString() ?? "";

      return message
}

Cette distinction de traitement selon la taille de la chaîne est essentielle pour extraire les données avec exactitude pendant l'instrumentation. En comprenant et en utilisant ces conventions, les chercheurs peuvent lire et manipuler efficacement les arguments de type chaîne, ce qui donne un aperçu plus approfondi du comportement de l'application à l'exécution.

Conclusion

En conclusion, l'instrumentation dynamique de Swift offre une approche de pointe de l'automatisation de la sécurité, permettant l'analyse et le contrôle en temps réel du comportement des logiciels. Nous avons essayé, dans les paragraphes précédents, d'expliquer les principales étapes de l'instrumentation dynamique et de les adapter au cas de Swift, en passant en revue le name mangling, l'ABI de Swift, l'endroit où sont stockés les arguments de type primitif des fonctions et, surtout, la manière dont ils le sont. Dans l'article suivant, nous étendrons l'analyse aux arguments de types non primitifs et à la gestion des fonctions qui s'exécutent dans le runtime Objective-C, avec UIKit et AppKit comme exemples.