Création d'un Outil de Hook : Injection de Code (Partie 1)

Introduction

Curieux depuis longtemps du fonctionnement des outils de hooking comme Frida, j'ai décidé de me lancer dans la création de mon propre outil. Ce projet, bien qu'inspiré de solutions existantes, me permettra d'approfondir mes connaissances et de partager mon parcours. Ce premier article se concentrera sur le mode d'injection attach.

Principes Fondamentaux

Pour comprendre l'injection de code, il est essentiel de maîtriser certains concepts :

  1. Isolation des Processus : Dans les systèmes d'exploitation modernes, chaque processus dispose de son propre espace d'adressage virtuel. Une adresse mémoire identique (par exemple, 0x12345678) dans deux processus distincts ne fait pas référence à la même zone de mémoire physique. Les processus sont isolés les uns des autres.
  2. Fonctions dlopen et apparentées : Sur Android/Linux, les fichiers .so (shared objects) sont des bibliothèques partagées. La fonction dlopen permet de charger dynamiquement une bibliothèque .so dans le processus courant. L'appel à System.loadLibrary() en Java aboutit finalement à un appel à dlopen.
  3. Mécanisme ptrace : Principalement utilisé par les débogueurs (comme GDB), ptrace permet à un processus de surveiller, suspendre et modifier l'état d'exécution d'un autre processus. Cet état inclut les registres, la mémoire et le contrôle d'exécution, des fonctionnalités cruciales pour l'injection.
  4. Fichier maps : Le fichier /proc/<pid>/maps détaille les zones mémoire mappées d'un processus donné, incluant les fichiers associés, leurs permissions et leurs plages d'adresses. Ces informations sont nécessaires pour localiser des fonctions spécifiques au sein d'une bibliothèque chargée, comme dlopen dans libdl.so.
  5. Convention d'appel ARM64 : Cette convention définit comment les arguments sont passés aux fonctions, où les valeurs de retour sont stockées, et quelles ressources doivent être préservées. Pour les appels directs, le compilateur gère ces détails. Cependant, lors de l'injection, nous devons simuler manuellement un appel. Sur ARM64, les huit premiers arguments entiers ou pointeurs sont passés via les registres x0 à x7. La valeur de retour est également placée dans x0.

Injection par attach : Le Choix de la Simplicité

Le mode attach est plus simple à implémenter que le mode spawn. L'injection consiste essentiellement à faire exécuter une action spécifique par un processus cible. Dans le cas le plus simple, cette action peut être un unique appel : dlopen("target.so"). Lorsque le processus cible exécute cette instruction, le chargeur dynamique de bibliothèques prend en charge le chargement de target.so et l'exécution de son code d'initialisation. L'objectif de l'injection par attach est donc de forcer le processus cible à exécuter dlopen.

Étapes Clés de l'Injection par attach :

  1. Identifier le processus cible.
  2. Attacher le processus cible à l'aide de ptrace.
  3. Déterminer l'adresse mémoire des fonctions cibles (par exemple, malloc, dlopen) dans l'espace du processus cible.
  4. Écrire le chemin du fichier .so à injecter dans la mémoire du processus cible.
  5. Déclencher l'exécution de dlopen dans le processus cible, en lui passant le chemin du .so comme argument.

Implémentation Détaillée

Structure du Projet


Ninjector/
 jni/
   common/
     log.h
   process/
     process.h
     process.cpp
   ptrace/
     ptrace_arm64.h
     ptrace_arm64.cpp
   injector/
     injector.h
     injector.cpp
   main.cpp
   Android.mk
   Application.mk
 

Cycle d'Injection :

  1. L'utilisateur lance la commande d'injection.
  2. L'outil d'injection s'attache au processus cible via ptrace.
  3. Les adresses des fonctions comme malloc et dlopen sont résolues dans le processus cible.
  4. Un appel distant à malloc est effectué pour allouer de la mémoire.
  5. Le chemin du fichier .so est écrit dans la mémoire allouée.
  6. Un appel distant à dlopen est exécuté avec le chemin comme argument.
  7. L'outil se détache du processus cible.

Le cœur de l'opération se résume à ces quelques lignes de code (conceptuellement) :


void *p = malloc(strlen(path) + 1); // Allouer de la mémoire dans le processus cible
strcpy(p, path);                   // Copier le chemin du SO dans la mémoire allouée
dlopen(p, RTLD_NOW | RTLD_GLOBAL); // Appeler dlopen avec le chemin comme argument
 

Module process : Utilitaires

  • Recherche de PID par Nom de Processus : Une fonction pour trouver le PID d'un processus cible en parcourant le système de fichiers /proc. Cela évite de devoir rechercher manuellement le PID.

  • Calcul de l'Adresse de Base d'un Module : Une fonction pour déterminer l'adresse de base d'une bibliothèque chargée dans l'espace d'adressage d'un processus, en analysant le fichier maps. Cette information est cruciale car les adresses des fonctions varient entre les processus. ```

    long get_module_base(pid_t pid, const char* module_name) { char maps_path[512] = {0}; snprintf(maps_path, sizeof(maps_path), "/proc/%d/maps", pid);

      FILE* fp = fopen(maps_path, "r");
      if (fp == nullptr) {
          LOGE("get_module_base: failed to open %s", maps_path);
          return 0;
      }
    
      char line[512] = {0};
      long base_addr = 0;
      while (fgets(line, sizeof(line), fp) != nullptr) {
          if (strstr(line, module_name) != nullptr) {
              char* start = strtok(line, "-");
              if (start != nullptr) {
                  base_addr = strtoull(start, nullptr, 16);
                  break;
              }
          }
      }
      fclose(fp);
      return base_addr;
    

    }

  • Calcul de l'Adresse d'une Fonction Distante : L'adresse d'une fonction dans le processus cible est calculée comme suit : ```

        adresse_distante = adresse_locale - base_module_locale + base_module_distante
    
    
     Cela permet de transposer une adresse locale à l'espace d'adressage du processus cible. ```
    
    long get_remote_addr(pid_t pid, void* local_func) {
        if (local_func == nullptr) {
            LOGE("get_remote_addr: local_func is null");
            return 0;
        }
    
        // Obtenir le nom du module localement (simplifié pour l'exemple)
        const char* module_path = "libmy_module.so"; // Doit être obtenu dynamiquement
    
        long local_base = get_module_base(-1, module_path); // Obtenir la base locale
        long remote_base = get_module_base(pid, module_path); // Obtenir la base distante
        if (local_base == 0 || remote_base == 0) {
            LOGE("get_remote_addr: failed to get module base, module=%s local=%lx remote=%lx",
                 module_path, local_base, remote_base);
            return 0;
        }
    
        // Calculer l'adresse distante
        long remote_addr = reinterpret_cast<long>(local_func) - local_base + remote_base;
        LOGD("get_remote_addr: module=%s local_func=%lx local_base=%lx remote_base=%lx remote_addr=%lx",
             module_path, reinterpret_cast<long>(local_func), local_base, remote_base, remote_addr);
        return remote_addr;
    }
    
    

Module ptrace : Interface avec le Système

  • attach_process et detach_process : Encapsulent les appels système ptrace(PTRACE_ATTACH, ...) et ptrace(PTRACE_DETACH, ...). L'attachement déclenche une suspension du processus cible, nécessitant une synchronisation via waitpid. ```

    bool attach_process(pid_t pid) { if (pid <= 0) return false; if (xptrace(PTRACE_ATTACH, pid, nullptr, nullptr) == -1) return false; int status = 0; if (waitpid(pid, &status, WUNTRACED) == -1) return false; return true; }

  • Lecture/Écriture Mémoire (ptrace_read, ptrace_write) : Les opérations de lecture et d'écriture mémoire avec ptrace (PTRACE_PEEKDATA, PTRACE_POKEDATA) fonctionnent par "mots machine" (généralement 8 octets sur ARM64). La gestion des données dont la taille n'est pas un multiple de la taille d'un mot machine nécessite un traitement particulier pour ne pas corrompre les données adjacentes. ```

    void ptrace_write(pid_t pid, long address, void* data, size_t size) { if (pid <= 0 || address == 0 || data == nullptr || size == 0) return;

      const size_t word_size = sizeof(unsigned long);
      size_t full_words = size / word_size;
      size_t remain = size % word_size;
      auto* bytes = reinterpret_cast<unsigned char*>(data);
    
      for (size_t i = 0; i < full_words; ++i) {
          unsigned long word = *reinterpret_cast<unsigned long*>(bytes + i * word_size);
          xptrace(PTRACE_POKEDATA, pid, reinterpret_cast<void*>(address + i * word_size), reinterpret_cast<void*>(word));
      }
    
      if (remain > 0) {
          long tail_addr = address + full_words * word_size;
          unsigned long word = static_cast<unsigned long>(xptrace(PTRACE_PEEKDATA, pid, reinterpret_cast<void*>(tail_addr), nullptr));
          memcpy(reinterpret_cast<char*>(&word) + (word_size - remain), bytes + full_words * word_size, remain); // Ajustement pour écrire à la fin
          xptrace(PTRACE_POKEDATA, pid, reinterpret_cast<void*>(tail_addr), reinterpret_cast<void*>(word));
      }
    

    }

  • Appel de Fonction Distante (call_remote_function) : Cette fonction simule l'appel d'une fonction dans le processus cible. Elle ajuste les registres (en particulier PC, SP, et les registres d'arguments x0-x7) pour préparer l'exécution de la fontcion cible, puis reprend l'exécution du processus. Une fois la fonction exécutée, elle récupère le résultat (depuis x0) et restaure l'état des registres. ```

    template<typename Ret> Ret call_remote_function(pid_t pid, void* remote_func, ...) { pt_regs regs{}; pt_regs backup_regs{};

      iovec regs_iov{&regs, sizeof(pt_regs)};
      iovec backup_iov{&backup_regs, sizeof(pt_regs)};
    
      // Sauvegarde des registres actuels et récupération des registres généraux
      xptrace(PTRACE_GETREGS, pid, nullptr, &regs_iov);
      backup_regs = regs;
    
      // Préparation des arguments dans les registres et sur la pile si nécessaire
      va_list args;
      va_start(args, remote_func);
      for (int i = 0; i < REGS_ARG_NUM; ++i) { // REGS_ARG_NUM est généralement 8 pour ARM64
          regs.uregs[i] = va_arg(args, long);
      }
      // Gestion des arguments supplémentaires sur la pile si argc > REGS_ARG_NUM
    
      regs.ARM_lr = 0; // Pas de retour à une adresse spécifique
      regs.ARM_pc = reinterpret_cast<long>(remote_func); // Point d'entrée de la fonction distante
    
      // Ajustement pour le mode Thumb si nécessaire
      if (regs.ARM_pc & 1) {
          regs.ARM_pc &= ~1u;
          regs.ARM_cpsr |= CPSR_T_MASK;
      } else {
          regs.ARM_cpsr &= ~CPSR_T_MASK;
      }
    
      // Application des nouveaux registres
      xptrace(PTRACE_SETREGS, pid, nullptr, &regs_iov);
      // Reprise de l'exécution du processus cible
      xptrace(PTRACE_CONT, pid, nullptr, nullptr);
    
      // Attente de la fin de l'exécution de la fonction distante
      int status = 0;
      waitpid(pid, &status, WUNTRACED);
      while (!WIFSTOPPED(status) || WSTOPSIG(status) != (SIGTRAP | 0x80)) { // Attendre le signal SIGTRAP
          xptrace(PTRACE_CONT, pid, nullptr, nullptr);
          waitpid(pid, &status, WUNTRACED);
      }
    
      // Récupération des registres après exécution
      xptrace(PTRACE_GETREGS, pid, nullptr, &regs_iov);
      // Restauration des registres d'origine
      xptrace(PTRACE_SETREGS, pid, nullptr, &backup_iov);
    
      if constexpr (std::is_void_v<Ret>) {
          return;
      } else {
          return reinterpret_cast<Ret>(regs.uregs[0]); // Retourné dans x0
      }
    

    }

    
    

Module injector : Orchestration

Ce module assemble les composants précédents pour réaliser l'injection.

  • inject_so_handle_by_pid : La fonction principale qui orchestre le processus d'injection :

    1. Vérification des paramètres (PID, chemin du SO).
    2. Attachement au processus cible.
    3. Allocation de mémoire dans le processus cible pour stocker le chemin du SO (via un appel distant à malloc).
    4. Écriture du chemin du SO dans la mémoire allouée.
    5. Appel distant à dlopen avec le chemin comme argument.
    6. Gestion des erreurs (appel distant à dlerror si dlopen échoue).
    7. Libération de la mémoire allouée (appel distant à free).
    8. Détachement du processus cible.
    
    static void* inject_so_handle_by_pid(pid_t pid, const char* so_path) {
       if (pid <= 0 || so_path == nullptr || so_path[0] == '\0') {
           LOGE("inject_so_handle_by_pid: invalid args");
           return nullptr;
       }
    
       bool attached = false;
       void* remote_path_ptr = nullptr;
       void* handle = nullptr;
    
       if (!attach_process(pid)) {
           LOGE("inject_so_handle_by_pid: attach failed, pid=%d", pid);
           return nullptr;
       }
       attached = true;
    
       // 1. Allouer de la mémoire distante pour le chemin du SO
       size_t path_len = strlen(so_path) + 1;
       remote_path_ptr = call_remote_function<void*, size_t>(pid, reinterpret_cast<void*>(malloc), path_len);
       if (remote_path_ptr == nullptr) {
           LOGE("inject_so_handle_by_pid: remote malloc failed");
           goto cleanup;
       }
    
       // 2. Écrire le chemin du SO dans la mémoire distante
       ptrace_write(pid, reinterpret_cast<long>(remote_path_ptr), const_cast<char*>(so_path), path_len);
    
       // 3. Obtenir l'adresse distante de dlopen (déjà calculée ou via get_remote_addr)
       void* remote_dlopen_addr = get_remote_addr(pid, reinterpret_cast<void*>(dlopen)); // Nécessite une implémentation correcte
    
       // 4. Appeler dlopen à distance
       handle = call_remote_function<void*, const char*, int>(
           pid,
           remote_dlopen_addr,
           reinterpret_cast<const char*>(remote_path_ptr),
           RTLD_NOW | RTLD_GLOBAL
       );
    
       if (handle == nullptr) {
           LOGE("inject_so_handle_by_pid: remote dlopen failed");
           // Optionnel : Appeler dlerror à distance pour obtenir le message d'erreur
           void* remote_dlerror_addr = get_remote_addr(pid, reinterpret_cast<void*>(dlerror));
           char error_msg[512] = {0};
           char* remote_error_ptr = call_remote_function<char*>(pid, remote_dlerror_addr);
           if (remote_error_ptr != nullptr) {
               ptrace_read(pid, reinterpret_cast<long>(remote_error_ptr), reinterpret_cast<uint8_t*>(error_msg), sizeof(error_msg) - 1);
               LOGE("inject_so_handle_by_pid: dlerror=%s", error_msg);
           }
           goto cleanup;
       }
    
       // 5. Libérer la mémoire allouée pour le chemin
       call_remote_function<void, void*>(pid, reinterpret_cast<void*>(free), remote_path_ptr);
       remote_path_ptr = nullptr;
    
    cleanup:
       if (attached) {
           detach_process(pid);
       }
       return handle;
    }
    
    

Étiquettes: ptrace injection de code dlopen ARM64 ingénierie inverse

Publié le 28 juillet à 19h36