AFFICHER CET ARTICLE EN MODE PAGE ENTIERE
SOMMAIRE
Dans un précédent papier sorti chez DHS, on s'est aventuré dans le territoire de l'API hooking. Dans ce petit article, je vous propose de continuer la ballade avec un exemple concret d'utilisation. Rien de tel qu'un peu d'action pour se faire la main sur une technique !
Le programme en démonstration est un moniteur d'accès au réseau. Les applications demandant l'accès au net devront montrer patte blanche sous peine de se prendre la porte dans la figure. L'exemple est facile à suivre, mais permet de voir comment l'API hooking peut rendre de fiers services. Ca n'est pas réservé aux rootkits, certains effets peuvent avoir pour but de se montrer, et non pas juste de cacher les choses !
Je vais commencer par un bref rappel technique sur les DLLs, afin que même un lecteur dont les yeux ne seraient jamais tombés sur un article de hooking ne soit pas trop paumé. Je ne vais pas entrer dans les détails, pas de code, pas de diagramme : c'est programmé en "français++".
Ensuite je vais aborder l'ossature générale d'un programme de ce type, à savoir ce qu'il faut pour avoir la main sur tout le système et s'assurer qu'un programme lancé ultérieurement ou une DLL chargée dynamiquement ne puisse pas esquiver l'application. Puis je discuterais, toujours en français++ des hooks mis en place dans le programme, et enfin des évolutions possibles voire même souhaitables.
Un programme n'est pas seul dans l'univers de Windows. Ni seul ni indépendant, car pour réaliser bon nombre d'actions, ce petit assisté se contente de faire appel à des fonctions partagées mises à la disposition de tous. Ces fonctions, ce sont les APIs, contenues dans les DLLs. Un programme en mémoire fait donc apparaître dans son espace d'adressage les DLLs dont il va avoir besoin, et exécuter les APIs voulues. On dirait une bonne vieille invocation de démon pour nous prêter main forte. Finalement, la programmation système, c'est parfois bien proche de Diablo !!
Il y a un programme, et il y a les APIs. D'une exécution à l'autre, les DLLs peuvent ne pas être chargées au même emplacement mémoire. L'adresse de chaque API est donc susceptible de bouger. Lorsqu'un programme décide de charger dynamiquement une DLL, afin d'accéder à une API, il fait appel à LoadLibrary et GetProcAddress. Ainsi il a toutes les cartes en main : DLL chargée et adresse de début du code de API localisée.
Mais d'où sortent LoadLibrary et GetProcAddress ? Ce sont des APIs comme les autres, alors elles sont susceptibles de flotter dans l'espace d'adressage pourtant. Comment le programme connaît-il à coup sur les adresses de ces deux fonctions-là ?
Certaines APIs de DLLs sont liées au moment de la compilation. Le programme n'a pas à les charger, ni à rechercher l'adresse des APIs qu'il va utiliser. Le code source dit au compilateur qu'on va avoir besoin des APIs a, b, c, d et e de la DLL F, et ce dernier l'inscrit dans l'EXE. Le chargement des DLLs et la localisation des APIs est alors pris en charge par le Loader d'Images de Windows. Sympa, le Loader ! Il charge le programme, voit les DLLs requises, les charge à leur tour, et enfin renseigne une structure, l'IAT, avec l'adresse en mémoire des APIs. Et le loader charge toujours certaines DLLs système comme Kernel32.dll, la DLL de fonctions système dans l'userland.
Lorsque le programme appelle LoadLibrary, il saute en fait dans l'IAT et non pas dans l'API. C'est de l'IAT qu'il rebondit dans le code de l'API. On parle de code trampoline. Par contre si le programme charge dynamiquement la DLL a pour avoir l'adresse de l'API b, alors le programme doit sauter directement dans b. Bon bien sûr, il y a des petits malins qui ne veulent jamais faire comme les autres. Alors oui, il est possible de faire une espèce d'IAT utilisateur maintenue par le programme lui-même. Aucun empêchement à cela.
Le hook d'API consiste à hooker une API. Sisi c'est vrai, pas de bobard ! Pour gagner le contrôle, après ce rappel, on voit que plusieurs possibilités existent :
* parcourir tout le code du programme, et contrôler son saut dans l'IAT. Cette méthode est, disons-le, plutôt bidon. Si le programme appelle 10 fois LoadLibrary dans son code, c'est 10 interceptions qu'il faut mettre en place. En plus, le temps de parcourir tout le code du programme pénalise l'exécution du programme. Définitivement, on laisse tomber. Bien sûr, il y a des petits malins...
* repérer l'entrée de l'IAT qui fait rebondir vers l'API et contrôler ce saut : IAT Hooking. Cette méthode est sympa, rapide et facile. Même si le programme appelle 10 fois LoadLibrary, une seule interception gère sans problème le hook. Par contre, cette méthode n'est pas, mais alors vraiment pas très discrète. Pour une fonction donnée, il suffit de comparer l'adresse dans le trampoline de l'IAT et l'adresse renvoyée par GetProcAddress pour détecter l'embrouille. De plus, cette méthode ne hook que les APIs appartenant à des DLLs liées à la compilation. Si le programme charge une DLL lui-même avec LoadLibrary, puis en trouve une API avec GetProcAddress, alors il est impossible de le hooker.
* repérer l'adresse de l'API en mémoire et l'y intercepter : API Patching. Ahhh voilà une méthode qu'elle est bonne ! Même une API provenant d'une DLL chargée dynamiquement par le programme se fera prendre. Par contre cette méthode nécessite quelques précautions. Comme on va placer notre interception par dessus le code "légal" de l'API, il faut s'assurer de sauvegarder les instructions du code légal sinon l'API plantera. Il est bon aussi de placer le hook non pas tout de suite au début, mais après plusieurs octets. En effet, certains logiciels antivirus et autre moniteurs de comportement sonnent une alarme lorsque le corps d'une API commence par un saut. Mais à l'inverse, plus on s'éloigne du début de l'API plus on risque de se trouver non plus dans le préambule de l'API mais par exemple après un label ou dans une boucle. Dans ce cas là le détour va s'exécuter plus d'une fois par appel à l'API légale. Or comme notre détour se doit de lancer l'API d'origine après son exécution, on arrive à une situation où l'API appelle le détour qui rappelle l'API qui rappelle le détour qui ... ad infinitam. Et là tout d'un coup, ça marche moins bien...
Cette méthode, l'API patching, reste la plus efficace au niveau userland. Par contre, si on veut l'implémenter de manière plus fine, il faut alors bien connaître le code de l'API ciblée pour placer le détour le plus loin possible du début pour ne pas se faire détecter tout en évitant de se placer après un retour de boucle. Un bon débugger, il n'y a que ça de vrai !
On doit déjà pouvoir charger la DLL dans l'espace d'adressage de tous les programmes en cours. Forcément, si on n'a pas le contrôle sur tout le système, la surveillance surveille moins bien. Il nous faut donc un loader, et des fonctions d'injection ciblée, de récupération des processus, d'injection globale. Pour se dépatouiller dans les processus, il vaut mieux avoir sous le coude quelques fonctions utiles comme passer d'un nom de processus à son PID et inversement.
Pour que le hook soit durable dans le temps, il faut aussi surveiller la création de processus. Sinon, on a bien le contrôle sur l'ensemble du système, mais seulement à un instant t. Il y a donc des détours à mettre en place sur quelques apis de Kernel32. CreateProcess en fait partie. En creusant dans kernel32, on trouve plusieurs apis homologues comme CreateProcessA, CreateProcessW. En examinant CreateProcessA sous la loupe d'un débugger, on voit que cette API est juste là pour renvoyer sur CreateProcessW. Si vous tentez l'expérience, vous verrez d'ailleurs que l'explorateur Windows utilise directement CreateProcessW. Si vous ne hookez que CreateProcessA, alors vous passez à côté du jackpot ! Par contre, CreateProcessW travaille avec des chaînes de caractère en unicode. Pour rendre le travail plus facile, une fonction de conversion de l'unicode vers l'ascii est nécessaire.
La DLL de notre application ne doit pas pouvoir être dégagé comme ça de l'espace d'adressage d'un programme, non mais ! C'est qui le patron ? Il faut donc surveiller ça et du coup, un hook de FreeLibrary est impératif. De même, il faut surveiller Loadlibrary. En effet, si un programme ne lie pas la DLL winsock lors de la compilation, il faut pouvoir s'y attacher lorsqu'elle sera chargée dynamiquement !
Hooker demande d'avoir le privilège débug, on aura donc une fonction rapide pour ça. Et qui dit hooker dit aussi nettoyer derrière soi, donc il faudra une fonction de déhook. Pour une gestion facile des DLLs ciblées, une routine globale de hook et déhook de toutes les apis d'une même DLL sera réalisée. Ca évitera de tout faire en bordel...
Le squelette nécessaire comporte déjà un bon nombre de fonctions. Dans le projet, celles-ci se trouvent dans deux fichiers source : hookutils pour les utilitaires, et kernel32 pour les hooks nécessaires.
Privilège débug
Injection d'un processus
Injection globale
Hook d'une api
Déhook d'une api
Conversion pid vers nom
Conversion nom vers pid
Conversion unicode vers ascii
Hook de CreateProcessW
Hook de FreeLibrary
Hook de LoadLibrary
Hook général de kernel32
Déhook général de kernel32
Je vais détailler chaque fonction. Sur le site de DHS, vous trouverez les sources du projet pour VC++. Vous y trouverez des choses que je ne vais pas coller ici, comme par exemple les variables globales pour les apis hookées, les includes pour gérer les références faites d'une source à l'autre etc. Mais le principal est là, de quoi tout saisir en tout cas.
Privilège débug :
int WINAPI EnableDebugPriv (void) _if (!OpenProcessToken (GetCurrentProcess (),TOKEN_ADJUST_PRIVILEGES,&hToken)) _if (!LookupPrivilegeValue (NULL, SE_DEBUG_NAME,&newPrivs.Privileges[0].Luid)) _newPrivs.Privileges[0].Attributes = SE_PRIVILEGE_ENABLED; _return 1; |
Injection d'un processus :
int WINAPI injector (DWORD le_pid, char* dll_name) _//Ouvre le processus _//Alloue de l'espace pour le nom de la dll _//Copie le nom de la DLL _/* BOOL FlushInstructionCache( _//Lance l'exécution _return 1; |
Injection globale :
_/* Snapshot de tous les processus */ _if(hProcessSnap == INVALID_HANDLE_VALUE) __pe32.dwSize = sizeof(PROCESSENTRY32); |
Hook d'une API :
int WINAPI initialise_hook (char* nom_dll, char* nom_fonction, DWORD new_handler, char** backup) _// Initialise backup s'il ne l'est pas _// Si la DLL n'est pas chargée, CASSOS _// Localise l'API _// Si l'API est introuvable, CASSOS _//Teste si l'API commence par un detour _// Mesure la taille de l'API à archiver _// Initialise le tampon _// Copie ce qu'il faut dedans _// Calcul de @FROM : juste apres le jmp _// Jmp relatif sur DWORD : @TO - @FROM _// Oter la protection du handler _//Si echec : cassos _// Ecrase le handler _// Remettre l'ancienne protection _return TRUE; |
Déhook d'une api :
int WINAPI enleve_hook (char* nom_dll, char* nom_fonction, char** backup) // Mesure la taille de l'API à archiver // Oter la protection du handler // Restaurer le handler // Remettre l'ancienne protection free(*backup); |
Conversion pid vers nom :
char* WINAPI GetNameByPID (DWORD ProcID) int WINAPI enleve_hook (char* nom_dll, char* nom_fonction, char** backup) // Mesure la taille de l'API à archiver // Oter la protection du handler // Restaurer le handler // Remettre l'ancienne protection free(*backup);
if(hProcessSnap == INVALID_HANDLE_VALUE) // Retourne le PID trouve |
Conversion nom vers pid :
DWORD WINAPI GetPIDByName (TCHAR *szProcName) if(hProcessSnap == INVALID_HANDLE_VALUE) while(th32ProcessID) pe32.dwSize = sizeof(PROCESSENTRY32); |
Conversion unicode vers ascii :
int wide_to_ascii (char* wide_name, char* buffer, int buffer_size) { return WideCharToMultiByte(CP_ACP, 0, (const unsigned short *) wide_name , -1, buffer, buffer_size, NULL, NULL); } |
Hook de CreateProcessW :
BOOL _stdcall NewCreateProcessInternalW (DWORD unknown1, LPCTSTR lpApplicationName,LPTSTR lpCommandLine,LPSECURITY_ATTRIBUTES lpProcessAttributes,LPSECURITY_ATTRIBUTES lpThreadAttributes,BOOL bInheritHandles,DWORD dwCreationFlags,LPVOID lpEnvironment,LPCTSTR lpCurrentDirectory,LPSTARTUPINFO lpStartupInfo,LPPROCESS_INFORMATION lpProcessInformation, DWORD unknown2) // Récupère la ligne de commande qui va etre lancée call backup_api_CreateProcessInternalW //Affiche la chaine de debug |
Hook de FreeLibrary :
BOOL _stdcall NewFreeLibrary (HMODULE hModule) // Si tentative de virer le rootkit : ne le vire pas |
Hook de LoadLibrary :
HMODULE _stdcall NewLoadLibrary (LPCTSTR lpFileName) _snprintf( (char*)debug_string,2048, "% 20s - appel à LoadLibraryA -> %s\n",GetNameByPID(GetCurrentProcessId()), lpFileName); // Ancien appel // Si on a chargné des DLLs à hooker, ACTION ! if (stricmp(lpFileName,"wininet.dll") == 0) |
Hook général de kernel32
int WINAPI hook_kernel32() // Hook de loadlibrary if (backup_api_loadlibrary)
if (backup_api_freelibrary) // Hook de CreateProcessInternalW |
Déhook général de kernel32 :
int WINAPI free_kernel32 () if (backup_api_loadlibrary) if (backup_api_CreateProcessInternalW) // Fin |
Si on a en tête les séquences de fonctions lors d'échanges via winsock, on voit que les bons candidats semblent être connect et listen. En effet en amont on a socket, bind, des fonctions de préparation qui ne mettent pas directement en jeu le réseau. Et en aval on a send, recv, des fonctions qui entrent en jeu lorsqu'une connexion a déjà eu lieu.
Les nouvelles fonctions pour connect et listen sont basées sur le même modèle. Je n'ai donc implémenté que celles concernant connect. Hé oui, l'article est interactif : charge à toi, ô lecteur, de t'amuser à coder le détour de listen. Lorsqu'on arrive dans le détour, un messagebox est affiché, demandant à l'utilisateur si le programme Untel a le droit d'accéder au réseau au moyen de l'API bidule. Si oui, une variable est mise à jour pour qu'à l'avenir le détour s'en souvienne et ne pose plus la question, puis l'API d'origine est appellée. Si non, l'API d'origine n'est même pas invoquée, on renvoie directement une erreur.
Attention, il faut penser exhaustif !! Connect c'est bien joli mais il existe WSAConnect, à qui il faut faire subir le même traitement. Pour trouver ces APIS relevant d'une même fonction, il vous faut utiliser par exemple depends.exe, fourni avec visual studio, ou bien regarder sur le site de MSDN. Pour chaque API, vous trouverez des liens vers celles qui ont un rapport avec.
Vous pouvez également débugger l'API. Ainsi pour LoadLibrary, on trouve LoadLibraryA et LoadLibraryW. Un coup de débuggage montre que LoadLibraryA invoque en fait LoadLibraryW. Du coup, il n'y a besoin de hooker que la version unicode. Mais ça n'est pas le cas pour connect et WSAConnect donc il faut bien en faire deux.
On trouvera dans le source les hooks dans le fichier ws2_32.
Importer les fonctions utiles de ws2_32 :
int importe_fx () { // Chope les fx qu'on utilisera HMODULE adresse_ws2_32; if ((adresse_ws2_32 = GetModuleHandle("ws2_32.dll")) == 0) return FALSE; if ((import_send = (type_send) GetProcAddress(adresse_ws2_32,"send")) == 0) return FALSE; if ((import_recv = (type_recv) GetProcAddress(adresse_ws2_32,"recv")) == 0) return FALSE; if ((import_closesocket = (type_closesocket) GetProcAddress(adresse_ws2_32,"closesocket")) == 0) return FALSE; if ((import_wsasetlasterror = (type_wsasetlasterror) GetProcAddress(adresse_ws2_32,"WSASetLastError")) == 0) return FALSE; if ((import_wsagetlasterror = (type_wsagetlasterror) GetProcAddress(adresse_ws2_32,"WSAGetLastError")) == 0) return FALSE; return TRUE; } |
Handler de WSAConnect :
int _stdcall NewWSAConnect (SOCKET s, const struct sockaddr* name, int namelen, LPWSABUF lpCallerData, LPWSABUF lpCalleeData, LPQOS lpSQOS, LPQOS lpGQOS) _snprintf((char*)debug_string,2048, "% 20s - appel à WSAConnect\n",GetNameByPID(GetCurrentProcessId())); char chaine_autorisation[1024]; if (!ALLOW_OUTBOUND) if ( demande_autorisation (chaine_autorisation)) return_val = SOCKET_ERROR; __asm |
Handler de Connect :
int _stdcall Newconnect (SOCKET s, const struct sockaddr* name, int namelen) _snprintf((char*)debug_string,2048, "% 20s - appel à connect\n",GetNameByPID(GetCurrentProcessId())); if (!ALLOW_OUTBOUND) if ( demande_autorisation (chaine_autorisation)) ALLOW_OUTBOUND = true; return_val = SOCKET_ERROR; return return_val; |
Hook global de ws2_32 :
int WINAPI hook_ws2_32 () if (backup_api_WSAConnect) if (backup_api_WSAConnect) |
Déhook global de ws2_32 :
int WINAPI free_ws2_32 () if (backup_api_WSAConnect) if (backup_api_connect) // Fin |
En testant l'application, on peut voir que les résultats sont là. Messenger, FireFox, j'en passe et des meilleurs. Tout le monde demande maintenant gentiment la permission de sortir. Tout le monde ou presque. Internet Explorer reste récalcitrant et fait le mur pour aller gambader dans la nature. Hmmm c'est inacceptable. Si faire berner par IE, c'est le comble. Pourtant il accède bel et bien au net, sinon il ne chargerait pas ses pages web. Mais il ne passe pas par l'interface socket.
Un outil utile est Developer Playground, qui liste les processus actifs, les DLLs qu'ils ont de chargées, et permet de tracer les appels à leurs apis. Il en ressort qu'Internet Explorer utilise la bibliothèque de haut niveau wininet.dll. Et cette wininet travaille comme une grande, elle ne s'appuie pas sur ws2_32 pour accéder au réseau. Voilà enfin la fuite !
Sur le site de msdn, on peut trouver des explications sur le fonctionnement de wininet, notamment les séquences de fonctions qui permettent d'aller titiller un site web ou un serveur http. Il y a des fonctions communes, et la plus intéressante de notre point de vue est IntenetConnect. Ni trop en amont, ni trop en aval. Et en examinant la dll de plus près, on remarque que InternetConnect se décline en A et en W, ascii et wide. Et cette fois ci, les deux versions sont indépendantes. In va falloir implémenter un hook pour ces deux là. Le principe en est exactement le même que pour connect : demander la permission, relayer ou non la demande à l'ancienne API, et si besoin mémoriser le fait que l'utilisateur accepte que le programme aille sur le net via wininet.
En testant, effectivement désormais IE ne fait plus son petit malin en agissant derrière notre dos.
Handler de InternetConnectW :
int _stdcall NewInternetConnectW (HINTERNET hInternet,LPCTSTR lpszServerName,INTERNET_PORT nServerPort,LPCTSTR lpszUsername,LPCTSTR lpszPassword,DWORD dwService,DWORD dwFlags,DWORD_PTR dwContext) _snprintf((char*)debug_string,2048, "% 20s - appel à InternetConnectW\n",GetNameByPID(GetCurrentProcessId())); sprintf(chaine_autorisation,"Autoriser %s à accéder réseau (InternetConnectW) ?",GetNameByPID(GetCurrentProcessId())); if (!ALLOW_WININET) if ( demande_autorisation (chaine_autorisation)) return_val = NULL; __asm } |
Handler de InternetConnectA :
int _stdcall NewInternetConnectA (HINTERNET hInternet,LPCTSTR lpszServerName,INTERNET_PORT nServerPort,LPCTSTR lpszUsername,LPCTSTR lpszPassword,DWORD dwService,DWORD dwFlags, DWORD_PTR dwContext) _snprintf((char*)debug_string,2048, "% 20s - appel à InternetConnectA\n",GetNameByPID(GetCurrentProcessId())); sprintf(chaine_autorisation,"Autoriser %s à accéder réseau (InternetConnectA) ?",GetNameByPID(GetCurrentProcessId())); if (!ALLOW_WININET) if ( demande_autorisation (chaine_autorisation)) __asm |
Hook global de wininet :
int WINAPI hook_wininet () // Hook de InternetConnectW if (backup_api_InternetConnectW) // Hook de InternetConnectA if (backup_api_InternetConnectA) |
Dehook global de wininet :
int WINAPI free_wininet () _snprintf((char*)debug_string,2048, "% 20s - Wininet.dll libération\n",GetNameByPID(GetCurrentProcessId())); if (backup_api_InternetConnectW) if (backup_api_InternetConnectA) // Fin |
Maintenant que tout ca est en place, voici le code du DLLMAIN :
BOOL APIENTRY DllMain (HANDLE hModule, DWORD reason_for_call, LPVOID lpReserved) _snprintf((char*)debug_string,2048, "% 20s - chargement du rootkit\n",GetNameByPID(GetCurrentProcessId())); hook_kernel32(); // PARTIE 2 : RESTAURATION DE L'ESPACE D'ADRESSAGE free_wininet(); end_debug_file(); default: |
L'application, dans sa version actuelle, manque de la petite couche de vernis qui transforme un exemple en programme utilisable. Principalement, un peu de centralisation. Actuellement, chaque programme est totalement indépendant. Si vous lancez firefox et acceptez au premier popup, vous êtes tranquille. Mais si vous le fermez et le relancez, alors le popup va revenir. L'application n'enregistre nulle part les listes de programmes amis.
Mais la gestion centralisée implique que par exemple l'utilisateur puisse shooter le processus Monitor.exe. Alors il y a moyen de faire une version centralisée mais flottante. L'idée m'a bien plu mais j'ai manqué de temps pour l'implémenter. Je vais en parler un peu tout de même.
Un processus se retrouve promu au rang de processus central. Il ouvre un named pipe en écoute. Les autres process communiquent avec lui pour les demandes d'autorisation, et l'écriture de fichier de log / debug. Si à un moment donné le fichier principal est fermé, alors le premier processus qui le détecte prend le relais. Pour le détecter ca n'est pas bien dur : la connexion sur le named pipe échouera.
Bref, avec cette idée en tête, j'ai commencé à rassembler dans le fichier source serveur les fonctions de débug et de demande d'autorisation. Mais ca n'est pas finalisé pour le moment. Encore un coup du document interactif, ou du syndrome démerde-toi...
Et bien pour finir, bon test ! Vous trouverez les sources ainsi qu'une version compilée dans la partie [ Annexes ].
BY TOLWIN
Copyright © 2005 ARENHACK - DHS