Après avoir chargé le fichier décompressé dans IDA (en laissant les options par défaut), voilà ce que nous voyons à l'EntryPoint :
Avant d'aller plus loin, je vais expliquer la fonction GetApiFromCRC. En effet, toutes les adresses des apis utilisées dans le programme sont résolues dynamiquement juste avant d'être appelées.
Voici la définition de la fonction :
DWORD GetApiFromCRC(DWORD CRC, DWORD Dll);
Le premier argument (CRC) est un hash 32 bits du nom de l'api. Le deuxième argument est l'ImageBase de la dll dans laquelle se trouve l'api ou 0 pour Kernel32.
Voici comment est appelée une api dans le programme :
Je ne vais pas détailler le code de cette fonction car Neitsa a déjà écrit un article en 3 parties expliquant son fonctionnement (Il est identique si ce n'est qu'il travaille sur des Hash au lieu de chaines de caractères).
Pour faciliter l'analyse, j'ai codé un petit programme qui me renvoit le nom de l'api en lui donnant un Hash (GetCRCApiName dans le dossier Sources). J'ai simplement copié le code des fonctions de IDA dans MASM.
Nous avons vu dans le 1er chapitre le fonctionnement des appels des apis. Je ne vais pas recopier le code qui récupère simplement l'ImageBase de NTDLL en appelant LoadLibraryA.
Ensuite, cette valeur est sauvegardée dans le premier DWORD de la pile (Sous NT, à l'EntryPoint d'un programme, [esp+38h] est le 1er DWORD de la pile). Puis le programme teste la présence d'une valeur en 00401000. On verra plus tard que cette valeur correspond à l'EntryPoint du programme infecté. Si cette valeur est nulle, le programme n'est pas dans un fichier infecté et s'exécute donc pour la première fois. Cela ne va pas changer grand chose à la suite. La seule différence est que l'exécution va continuer soit normalement, soit dans un nouveau thread.
Si le virus se trouve dans un fichier infecté, il lance un nouveau Thread pour éviter de ralentir le chargement du programme et ainsi, prévenir l'utilisateur d'un problème. Puis il décrypte l'OEP avant de s'y rendre.
Si le virus ne se trouve pas dans un exe infecté, il appelle simplement la même procédure que pour le Thread.
On sait que cette fonction prend un paramètre, 0 en cas de première exécution ou l'ImageBase de NTDLL si le fichier est infecté. Donc on va commencer par définir les propriétés de la fonction.
On renomme d'abord la fonction en Thread en cliquant sur son adresse (00401051) puis en tapant N. Puis Y pour définir son type. Un message vous demande de préciser un compilateur avant. Ce que l'on fait en allant dans Options --> Compiler..., Là on choisie par exemple Visual C++ puis OK.
On rappuie sur la touche Y (toujours à l'adresse de la fonction), et IDA nous donne la déclaration suivante : int __stdcall Thread(int);. On nomme l'argument pour obtenir int __stdcall Thread(int argNTDLL); et le listing se modifie pour afficher ce nouveau nom.
Examinons le début de cette fonction :
Le 1er DWORD de la pile est récupéré par l'intermédiaire du TEB. Cette valeur est ensuite sauvegardée dans une variable locale. Finalement, si cette valeur est nulle, l'argument passé à la fonction est placé dans cette variable locale.
On a vu précédemment que le programme y plaçait l'ImageBase de NTDLL. Mais l'adresse pointée par [esp+38h] ne correspond au 1er DWORD de la pile que sous système NT. En effet, sous système 9x, la pile est déjà bien remplie avant d'arriver à l'EntryPoint du programme. De plus, cette valeur étant nulle, c'est l'argument qui sera utilisé à la place. Mais on a vu que lors de la première exécution, l'argument est nul. Donc sous systèmes 9x, le programme crashera lorsqu'il voudra utiliser l'ImageBase de NTDLL.
Nous avons ensuite un appel à l'api native NTDLL.ZwSetInformationThread :
Le paramètre ThreadInformationClass nous indique que la fonction va modifier la priorité d'exécution du Thread (3 = ThreadBasePriority) et le paramètre ThreadInformation indique que la priorité sera décrémentée. Ceci toujours dans l'optique de rester discret.
Juste après, nous avons droit à un appel d'une autre api native NTDLL.ZwQueryInformationProcess :
Le paramètre ProcessInformationClass indique que la fonction demande la liste des lecteurs accessibles par le processus (17h = ProcessDeviceMap). Cela signifie que la fonction va remplir une structure de type PROCESS_DEVICEMAP_INFORMATION dont l'adresse est indiquée dans le paramètre ProcessInformation.
Ces 2 fonctions ne sont pas exportées par ntdll.dll sous 98. Donc même si le programme disposait du Handle de NTDLL, les fonctions ne pourraient pas être importées et le virus provoquerait une exception en appelant l'adresse 0.
Cette structure est déclarée en tant que variable locale. Nous allons donc préciser à IDA de quelle structure il s'agit. Sa définition peut être trouvée dans le livre Windows NT/2000 Native API Reference par Gary Nebbett :
Nous allons juste déclarer la partie Query puisque c'est la seule qui nous intéresse.
On ouvre la fenêtre des structures (Alt+F9) puis Inser pour créer une nouvelle structure. Il semble que cette structure ne soit pas définie de base dans IDA. Nous allons donc la créer de toute pièce.
On nomme notre structure PROCESS_DEVICEMAP_INFORMATION puis OK. On obtient ceci :
0000 PROCESS_DEVICEMAP_INFORMATION struc ; (sizeof=0x0)
0000 PROCESS_DEVICEMAP_INFORMATION ends
Le 1er membre est un dword. Pour le créer, il suffit de taper 3 fois sur la touche D (DB -> DW -> DD). Ensuite on le nomme en DriveMap.
Le 2ème membre est un tableau de 32 octets. On crée d'abord un octet avec la touche D puis le tableau avec la touche *. On précise 32 dans le champ Array Size puis OK. Et finalement on renomme le membre DriveType.
0000 PROCESS_DEVICEMAP_INFORMATION struc ; (sizeof=0x24)
0000 DriveMap dd ?
0004 DriveType db 32 dup(?)
0024 PROCESS_DEVICEMAP_INFORMATION ends
On retourne dans la fonction Thread, on affiche la fenêtre des variables locales (Ctrl+K), on clique sur la première qui doit être var_30 et on la définit en tant que structure (Alt+Q). On renomme également la variable en DeviceMapInfo par exemple.
Finalement, le tableau des lecteurs rempli par ZwQueryInformationProcess est parcouru jusqu'à tomber sur un octet nul précisant l'abscence de lecteur. La boucle recherche la présence de lecteurs de type DRIVE_FIXED, correspondant aux disques durs puis pour chacun, appelle la fonction SearchFiles en passant le chemin en paramètre (C:, D: etc).
La variable lpPath a été initialisée au début de la fonction avec la chaine "C:". La boucle commence donc avec le 3ème lecteur. Je n'ai pas identifié le rôle du 2ème paramètre (4) passé à SearchFiles, celui-ci n'étant jamais utilisé dans la fonction.
La boucle ne teste pas tous les lecteurs. Uniquement à partir de C: jusqu'à ce que la lettre ne corresponde à aucun lecteur (DriveType = 0). Cela signifie que si le système possède les lecteurs C:, D: & G:, le dernier ne sera pas scanné.
La fonction SearchFiles est très simple. Je ne vais donc pas la copier ici (Vous trouverez la fonction analysée et commentée dans l'IDB). On a vu qu'elle reçoit une lettre de lecteur en paramètre. Elle va y ajouter "\*" pour lister tous les fichiers présents dans le dossier courant à l'aide d'une boucle utilisant FindFirstFile / FindNextFile (exemple : C:\*). Les fichiers commençant par un '.' sont ignorés.
Pour chaque fichier récupéré, le programme teste ses attributs pour voir si c'est un dossier. Dans ce cas, le nom du dossier est ajouté au chemin courant puis la fonction s'appelle elle-même avec ce nouveau chemin (exemple : C:\WINNT\*).
Si un fichier est trouvé, le programme vérifie que son extension est ".exe" et dans ce cas, appelle la fonction InfectFile (int __stdcall InfectFile(int PathFileName,int cFileName);). Elle prend 2 arguments : le chemin du dossier courant et le nom du fichier.
Pour une meilleure lisibilité et un gain de place, voici un résumé de la structure de la fonction en pseudo C :
D'abord, le chemin du fichier est déterminé à partir des 2 paramètres de la fonction (Path et cFileName), puis le résultat est passé en argument de la fonction OpenAndMapFile. Puis IsValidPE vérifie que le fichier est un PE correct en testant les signatures du DosHeader et du PEHeader. Ensuite, la fonction AddSection ajoute une section (qui va contenir le virus) au fichier et modifie le PEHeader. Finalement, le fichier est démappé puis remappé pour enregistrer les modifications et le virus est copié dans la nouvelle section.
Cette fonction ouvre le fichier en lecture/écriture avec l'api CreateFileA et récupère sa taille avec GetFileSize pour vérifier qu'elle est supérieure à 4096 octets. Si le fichier est plus petit, il n'est pas infecté et la fonction quitte. Finalement, les apis CreateFileMappingA & MapViewOfFile sont utilisées pour mapper son contenu dans l'espace mémoire du processus.
La fonction renvoit 3 valeurs : le handle (CreateFileA) dans eax, le maphandle (CreateFileMappingA) dans ebx et l'adresse de base du fichier (MapViewOfFile) dans ecx.
Cette fonction va préparer le fichier à recevoir le code du virus. D'abord le nombre de section est incrémenté, puis la fonction vérifie le nom de la dernière section. S'il est égal à ".K_N", le fichier est déjà infecté et la fonction quitte. Sinon une nouvelle section est crée avec les valeurs suivantes :
Ensuite, la nouvelle SizeOfImage est enregistrée avec la somme VOffset+VSize de la nouvelle section. Puis le nouvel EntryPoint est également inscrit en ajoutant 4 au VOffset de la section. Finalement, un nombre d'octets ègal à la RSize est alloué avec VirtualAlloc pour être ajoutés à la fin du fichier à l'aide de WriteFile.
Le virus nous donne là sa signature, à savoir que la dernière section doit se nommer ".K_N". On remarquera que le virus s'infectera lui-même puisque sa dernière section se nomme "ZeGun1".
La fonction FreeMappedFile se contente de fermer les Handles précédemment ouverts par la fonction OpenAndMapFile. Le fait de démapper un fichier en mémoire enregistre définitivement les modifications.
Le code qui suit va d'abord crypter l'OEP du fichier infecté puis l'enregistrer au début de la section du virus. Enfin, la fonction va calculer l'adresse de début du code pour copier le virus dans la section du fichier.