Spiria logo.
Christian Bernier
dans «  Développement desktop  »,
10 avril 2015.

Création d'un plugin pour une application x86 à source fermée

Introduction Je ne suis pas un grand joueur, mais j’aime créer des choses et ensuite jouer avec ce que je crée, c’est pourquoi je me suis rapidement attaché à Neverwinter Nights 1. Ce jeu nous permet de créer des mondes persistants, de les scénariser et d’y jouer ensuite avec d’autres personnes réelles. Il est sorti en 2002 et a fait l’objet d’une dernière mise à jour en 2008. Son code source est depuis lors enfermé quelque part et il restera probablement intact pendant les dix prochaines années : avec toutes les parties impliquées dans son développement, il ne serait probablement pas rentable de rendre le code source public.

En tant que joueur et hôte du serveur de ce jeu, j'étais impatient de recevoir de nouvelles corrections et mises à jour, mais je savais que j'attendrais en vain. C'est à ce moment que j'ai commencé à chercher comment faire ce que je voulais, malgré le fait que je n'avais pas de code source ni même de connaissances techniques des internes du moteur.

Ces cinq dernières années, j'ai prolongé Neverwinter Nights 1 avec un plugin et je vais vous montrer comment j'ai réussi à créer mes premières corrections et fonctionnalités supplémentaires pour ce jeu. En utilisant l'exemple suivant, je montrerai comment je peux créer une boîte de message qui affiche tout ce qui est tapé dans la barre de chat. Neverwinter Nights est conçu pour un processeur 32 bits / x86 et est disponible sous Windows et Linux. Mon code ici sera pour cette famille de processeurs et ces 2 systèmes d'exploitation, mais les concepts suivants devraient être applicables sur d'autres OS et, au moins, sur les processeurs x64 également.

Création du plugin

La première étape consiste à créer une bibliothèque partagée (.dll sous Windows ou .so sous Linux) qui sera chargée par l'application, afin d'avoir accès à la mémoire du processus. Pour être multi-plateforme, ma bibliothèque est un objet global et j'utilise son constructeur comme fonction "init". C'est là que je vais commencer à hooker :

struct MyPlugin
{
    MyPlugin()
    {
        //TODO: Hook functions
    }
};
MyPlugin my_plugin;

Injection du plugin dans l'application

L'étape suivante consiste à faire en sorte que l'application charge le plugin. Sous Linux, il suffit de l'ajouter à la variable LD_PRELOAD avant d'exécuter l'application :

LD_PRELOAD=myplugin.so:$LD_PRELOAD

Sous Windows, nous avons besoin d'un lanceur dédié, de démarrer l'application par programme et d'y attacher la bibliothèque. L'attachement peut être implémenté en appelant la fonction CreateRemoteThread avec la LoadLibraryA comme point d'entrée du fil :

void LoadLibraryInProcessSync(HANDLE process, const char* library_path)
{
    size_t library_path_size = strlen(library_path)+1;
    char* injected_library_path = (char*)VirtualAllocEx(process, NULL, library_path_size, MEM_COMMIT, PAGE_READWRITE);
    WriteProcessMemory(process, injected_library_path, library_path, library_path_size, NULL);
    HANDLE lib_thread = CreateRemoteThread(process, NULL, 0, (LPTHREAD_START_ROUTINE)GetProcAddress(LoadLibraryA("kernel32.dll"), "LoadLibraryA"), injected_library_path, 0, NULL);
    WaitForSingleObject(lib_thread, INFINITE);
    CloseHandle(lib_thread);
    VirtualFreeEx(process, injected_library_path, 0, MEM_RELEASE);
}

Trouver les fonctions à accrocher

C'est la partie amusante : analyser le code assembleur de l'application pour découvrir comment une fonctionnalité spécifique pourrait être implémentée ou ce qui provoque un plantage.

J'ai commencé par ouvrir l'application dans OllyDbg, j'ai ensuite analysé le code assembleur, regardé les références des chaînes de caractères, démarré le débogueur et inséré un point d'arrêt aléatoire. Quand un de mes points d'arrêt a été touché, j'ai examiné de près le code assembleur environnant. Après une journée infructueuse avec cette approche, j'ai abandonné et j'ai commencé à penser à une tactique plus productive.

Quelques mois plus tard, j'ai décidé de recommencer et cette fois-ci, les choses se sont mieux passées. Mon premier conseil est le suivant : ne vous précipitez pas ! Essayez de recueillir toutes les informations possibles sur les internes de l'application en utilisant les approches suivantes :

  • Essayez de trouver une version (par exemple une version bêta) qui inclut les symboles de débogage. Si vous pouvez en trouver une, il est alors relativement facile de faire correspondre les adresses, en recherchant le même code assembleur.
  • Comme ci-dessus, essayez de trouver le code source de toutes les bibliothèques liées statiquement utilisées par l'application afin de pouvoir attribuer un nom de fonction aux adresses de votre application, qui deviennent alors très utiles pour comprendre ce que font les appelants.
  • Regardez les symboles importés.

En fin de compte, ce dont vous avez besoin, c'est d'une fonction qui sera appelée à proximité de l'endroit où vous devez accrocher.

Même si vous ne trouvez aucun symbole, il doit y avoir au moins une fonction native utilisée près de l'endroit où vous devez accrocher. Par exemple, si vous savez que l'application doit lire un fichier, vous pouvez hooker la fonction fopen/CreateFileA et vous pouvez même hooker les fonctions malloc/HeapAlloc free/HeapFree pour tracer où une chaîne spécifique est allouée et libérée. Dans mon cas, Neverwinter Nights est une application client/serveur, je sais donc qu'elle enverra le message de chat via une socket et je vais donc me brancher sur la fonction sendto.

Grâce à ce crochet, vous pouvez vérifier par programmation si les conditions sont remplies (dans mon cas, si le paquet envoyé contient "!msgbox", ce que je vais taper dans la barre de chat) et vous donner une chance de casser à ce moment, en utilisant une MessageBox par exemple :

 int __stdcall sendto_hook(SOCKET s, const char* buf, int len, int flags, const struct sockaddr* to, int tolen)
{
   for (int i=0; i<=len-7; i++)
   {
      if (memcmp(buf+i, "!msgbox", 7) == 0)
      {
         MessageBoxA(0, "sendto(!msgbox) found!", "attach the debugger", 0);
      }
   }
   return sendto(s, buf, len, flags, to, tolen);
} 
hook_api(GetProcAddress(GetModuleHandleA("Ws2_32.dll"), "sendto"), sendto_hook);

Sendto_hook.

Les outils : IDA

Enfin, il est temps d'ouvrir un désassembleur et un débogueur pour commencer à enquêter sur ce qui se trouve autour de cette boîte à messages. Le meilleur outil pour ce travail est IDA Pro : il vous aidera à comprendre facilement le code assembleur en affichant les fonctions et leurs paramètres. Il possède de nombreuses fonctionnalités, mais la plus importante est la base de données, car elle vous permet de remplir les "blancs" du code assembleur, tels que le nom des fonctions, le nom des paramètres des fonctions, les noms des variables locales et d'entrer des commentaires :

Ida_comments.

Garder toutes vos découvertes structurées (comme dans IDA) est vraiment la clé pour étendre une application sans avoir le code source. Au début, cela peut être lent parce qu'il y a beaucoup de rétro-ingénierie à faire, mais au fur et à mesure que ces blancs sont remplis, cela devient beaucoup plus facile. Au bout d'un certain temps, il devient presque plus facile de mettre en œuvre une fonctionnalité de cette manière que si vous aviez accès au code source. Je vous expliquerai pourquoi plus tard.

Débogage

Lorsque vous obtenez la boîte de message que vous vouliez, attachez le débogueur, interrompez l'exécution et vérifiez la trace de la pile. Soit votre hook est trop tôt et vous devez alors monter, soit il est trop tôt et vous devez alors suivre l'exécution pour voir quelles fonctions seront exécutées ensuite.

Sendto_hook_debug.

(Je préfère Visual Studio quand il s'agit de déboguer, surtout quand mon plugin devient gros et que je veux voir mon code en même temps).

Obtenir une trace de la pile lorsque l'application est compilée avec le paramètre "omettre les pointeurs de pile

Si votre application est compilée avec le drapeau "omettre les pointeurs de pile", comme le client Neverwinter Nights, il est normal que vous ne puissiez pas obtenir l'impression d'une trace de pile pour la raison suivante : une application que vous déboguez utilise le registre "ebp" pour stocker la position de la pile au début de chaque fonction :

Dump of assembler code for function GetID__4CRes:
   0x082b376c <+0>:     push   %ebp ;save the ebp register
   0x082b376d <+1>:     mov    %esp,%ebp ;save esp (the stack pointer) in ebp
   0x082b376f <+3>:     mov    0x8(%ebp),%eax ;we can use ebp to access the function parameters
   { here the function could be initialising local variables, calling functions, so the esp register will change and so if we didn't had ebp, the only way to know how to access }
   0x082b3772 <+6>:     mov    0x4(%eax),%eax ;set the return value
   0x082b3775 <+9>:     pop    %ebp ;restore the ebp register
   0x082b3776 <+10>:    ret
End of assembler dump.

Il est donc possible d'obtenir la trace de la pile en utilisant une fonction comme celle-ci :

 void print_stack_trace()
{
   long* stack_frame;
   __asm{mov stack_frame, ebp}; //stack_frame = %ebp
   while (*stack_frame)
   {
      printf("%x\n", *(stack_frame+1));
      stack_frame = (long*)*stack_frame;
   }
} 

Mais cela n'est pas possible sans connaître la position de la pile lorsque la fonction a été appelée. Mon travail préféré (bien que cela représente un peu plus de travail) est d'utiliser le débogueur pour sortir et ensuite prendre note de l'endroit où il se trouve.

Hooking

Une fois que vous avez trouvé la fonction dans l'application qui doit être modifiée, vous devez l'accrocher. Il existe de nombreuses bibliothèques qui proposent des fonctions pour cela, mais les programmes antivirus ont tendance à ne pas les aimer, ils rendent votre plugin plus gros et ils ne sont vraiment pas nécessaires. Le processus est relativement simple et il vaut la peine d'être compris. Il est possible de hooker n'importe où dans le code assembleur, mais la manière la plus sûre et la plus propre est de hooker uniquement des fonctions.

Hookin d’un appel de fonction non virtuel

Un appel de fonction non virtuel est toute instruction "call <func> " en assembleur. Cette instruction est longue de 5 octets, le premier octet étant 0xE8 et les 4 suivants étant l'adresse relative de la fonction à appeler. Il suffit de modifier ces 4 octets : </func>

 void hook_call(long src_addr, long to_addr)
{
   *(long*)(src_addr+1) = to_addr-(src_addr+5);
}
hook_call(0x00402445, (long)my_function_hook); 

Dans cet exemple, 0x00402445 est l'adresse de l'instruction d'appel :

.text:00402445                 call    sub_5FFCA0

my_function_hook est une fonction qui utilise la même convention d'appel que sub_5FFCA0. La plupart du temps, les conventions d'appel seront __cdecl ou __stdcall, qu'il suffit de spécifier dans le prototype de la fonction :

 __stdcall void my_function_hook();
__cdecl void my_function_hook();

Mais si le compilateur était un studio visuel et que vous voulez accrocher une fonction membre, alors votre fonction doit utiliser la convention d'appel __thiscall. Le problème est que visual studio ne vous permet d'utiliser cette convention que pour une fonction membre, alors comment accrocher une fonction de classe à une fonction statique ? Ma solution est d'utiliser la convention _fastcall calling : c'est à peu près la même chose, sauf qu'elle utilise le registre edx pour le second paramètre. Donc ce que je fais, c'est simplement ajouter un second paramètre factice :

Gob* last_created_gob;
Gob* (__fastcall *gob_constructor_org)(Gob*, int, char*) = (int (__fastcall *)(Gob*, int, char*))0x007A8E80;
Gob* __fastcall gob_constructor_hook(Gob* gob, int edx, char* name)
{
   last_created_gob = gob;
   return gob_cnstructor_org(gob, edx, name);
} 

Ce hook ne fonctionnera pas pour un appel de fonction virtuel car ils n'utilisent pas une adresse relative mais un registre, comme celui-ci :

call    dword ptr [edi+1Ch]

Ils sont donc un peu plus complexes à accrocher. Je vais les garder pour un autre article.

Accrocher une fonction

Ce hook sert à intercepter tous les appels vers une fonction spécifique. Dans ce cas, nous devons modifier le début de la fonction pour qu'elle passe à la nouvelle fonction qui rappelle ensuite la fonction d'origine. En d'autres termes, nous devons modifier cette fonction :

   func_to_hook <+0>:     push   %ebp
   func_to_hook <+1>:     mov    %esp,%ebp
   func_to_hook <+3>:     mov    0x8(%ebp),%eax
   func_to_hook <+6>:     mov    0x40(%eax),%eax
   func_to_hook <+9>:     pop    %ebp
   func_to_hook <+10>:    ret
   ...
   new_function	<+0>:     push   %ebp
   new_function	<+1>:     mov    %esp,%ebp
   new_function	<+4>:     call	 func_to_hook
   new_function	<+9>:     pop    %ebp
   new_function	<+10>:    ret

À cela :

   func_to_hook <+0>:     jmp    new_function
   func_to_hook <+5>:     nop 	;the value of this byte dont matter because the processor will never reach it
   func_to_hook <+6>:     mov    0x40(%eax),%eax
   func_to_hook <+9>:     pop    %ebp
   func_to_hook <+10>:    ret
   ...
   new_function	<+0>:     push   %ebp
   new_function	<+1>:     mov    %esp,%ebp
   new_function	<+4>:     call	 allocated_mem ; call the orignal func_to_hook
   new_function	<+9>:     pop    %ebp
   new_function	<+10>:    ret
   ...
   allocated_mem <+0>:	  push   %ebp
   allocated_mem <+1>:	  mov    %esp,%ebp
   allocated_mem <+3>:	  mov    0x8(%ebp),%eax
   allocated_mem <+6>:	  jmp    func_to_hook

Elle peut être réalisée avec cette fonction :

 void* hook_function(long from, long to, int len)
{
   char* ret_code = (char*)malloc(len+5);
   enable_write((long)ret_code);
   memcpy(ret_code, (char*)from, len);
   *(char*)(ret_code+len) = (char)0xE9; //jmp
   *(long*)(ret_code+len+1) = (from + len) - (long)(ret_code+len+1+sizeof(long));
   enable_write(from);
   *(char*)from = (char)0xE9; //jmp
   *(long*)(from+1) = to - (long)(from+1+sizeof(long));
   return ret_code;
} 

Le paramètre "len" est une quantité d'octets que nous pouvons déplacer de la fonction originale à un espace alloué, afin de le remplacer par une instruction "jmp hook_func". Il doit être au moins égal à 5 car l'instruction jmp est de 5 octets (elle fonctionne de la même manière qu'un appel, sauf que le premier octet est 0xE9). Cette fonction peut être utilisée pour hooker n'importe où, à condition que les instructions qui se trouvent dans la plage du paramètre len (les instructions qui seront déplacées) ne soient pas un appel ou un saut vers une adresse relative, car si vous déplacez un "jump +5", il ne sautera plus à la même adresse. Enfin, si vous n'accrochez pas au début d'une fonction, vous devez vous assurer que les registres ne sont pas modifiés par votre code.

Accrocher une fonction importée

Ce hook a le même résultat que le hook d'une fonction, sauf qu'il s'agit d'une fonction importée (d'une bibliothèque partagée) ; ils n'ont donc pas d'adresse spécifique, leurs adresses sont chargées au moment de l'exécution. C'est ce que j'ai utilisé dans cet exemple : J'accroche sendto qui est une fonction importée de Ws2_32.dll. Vous pouvez obtenir leur adresse de façon dynamique et utiliser le hook ci-dessus, mais vous n'avez pas besoin de vous en préoccuper car vous pouvez simplement modifier la table d'importation pour forcer une fonction importée à être à l'adresse de votre propre fonction.

Dans ce cas, comme la fonction hook_api est un peu plus complexe, je vais vous donner l'exemple suivant :

hook_api(GetProcAddress(GetModuleHandleA("KERNEL32.dll"), "HeapFree"), my_heap_free); 

Vous pouvez trouver le code source de la fonction hook_api dans l'application de démonstration.

Mise en œuvre des fonctionnalités ou des corrections

Dans mon cas, je voulais juste afficher une boîte à messages, donc :

 void (__cdecl *handle_chat_prompt_org)(void*, int);
void __cdecl handle_chat_prompt_hook(char** message, int p2)
{
   MessageBoxA(0, (std::string("You said: ") + *message).c_str(), "", 0);
   return handle_chat_prompt_org(message, p2); //I could prevent the chat message from being displayed in the chat bar by simply removing this line
} 

Mais sérieusement, je vais garder cette partie pour un autre article, car il y a trop d'autres approches que j'aimerais présenter. À part l'exemple suivant (dans lequel l'application utilise un objet comme celui-ci), il n'y a presque aucune limite à ce qu'il est possible d'accomplir en se connectant à une application pour l'étendre :

 class Players
{
   int last_player_id;
   int players_id[0x60];
   int player_count;
};

Et comme les membres de la classe Players (last_player_id, players_id et player_count) sont accessibles un peu partout dans l'application, il est tout simplement impossible d'augmenter le nombre de joueurs (taille des players_id) ... sans reconstruire complètement l'application, donc malheureusement une instance du serveur Neverwinter Nights est bloquée à 96 joueurs. Mais sinon, sérieusement, je crois qu'il n'y a pas de limite à ce que vous pouvez faire, peut-être même plus que ce qui est possible avec le code source. Avec le code source, vous ne pouvez pas accéder aux membres de la classe privée (du moins pas par conception) mais par accrochage, vous pouvez accéder à tout, n'importe où (oui, oui, je sais que c'est une mauvaise pratique... mais si je peux faire revivre un vieux jeu, pourquoi pas ?) Par exemple, j'ai pu partager des informations entre deux objets de jeu complètement différents : un effet visuel et une créature afin d'activer les textures PLT sur les effets visuels.

Partager l’article :