Plongeon dans les appels systèmes Windows
28 mars 2009 – 12:53Mon stage m’a donné l’occasion d’analyser en détails comment un appel système est réalisé sous Windows. Si vous vous demandez comment un programme utilisateur (ring 3) fait pour appeler une fonction s’exécutant en mode noyau (ring 0), alors cet article est pour vous. J’y explique toutes les étapes de la chaîne en partant du début (l’appel de fonction dans un programme quelconque) pour arriver au code de l’API en mode noyau. Pour lire cet article, des bases d’assembleur sont indispensables.
Privilèges des processeurs x86
Avant d’aborder le sujet, il est nécessaire de bien comprendre une subtilité des processeurs x86 : les niveaux de privilèges. Un processeur est censé exécuter du code machine produit par un compilateur / assembleur. Le code qui s’exécute à un instant t a accès à certains privilèges qui dépendent du niveau de privilège dans lequel se trouve le processeur. Ce niveau, aussi appelé ring, ou anneau, est un entier codé sur deux bits. Il peut donc prendre 4 valeurs : 0, 1, 2, et 3. Plus ce nombre est petit, plus les privilèges sont élevés ; plus il est grand, plus ils sont restreints. Le ring 0 est appelé mode superviseur, ou kernelland,et c’est sous ce mode que tournent 99% des noyaux d’OS (Windows et Linux en font partie). Les programmes utilisateurs s’exécutent quant à eux en ring 3, appelé mode utilisateur ou userland.
Le niveau de privilège courant du processeur est appelé CPL (Current Privilege Level). En interne, le CPL est stocké dans les deux premiers bits des registres CS et SS. La règle générale est la suivante : il n’est pas possible d’exécuter des instructions nécessitant un niveau de privilège inférieur au CPL. De même, il n’est pas directement possible de demander au processeur de changer le CPL vers un niveau inférieur. On peut donc se demander : comment est-ce possible d’appeler une routine du noyau depuis un code utilisateur ? C’est là tout l’objet de cet article…
Un programme d’exemple
Afin d’analyser comment se déroule un appel système, nous allons commencer par coder un programme d’exemple en C.
#include <stdio.h> #include <stdlib.h> #include <windows.h> int main(int argc, char *argv[]) { HANDLE handle; WIN32_FIND_DATA findData; handle = FindFirstFile(".", &findData); printf("Handle = %dn", handle); getchar(); return 0; }
Ce programme appelle l’API Windows FindFirstFile
. Cette fonction prend en paramètre un nom de dossier et retourne un handle vers le premier fichier de ce dossier. Je suis d’accord : ce programme ne sert pas à grand chose, à part appeler une API Windows, mais c’est justement le but.
Tracing en userland
Pour information, j’ai réalisé tous ces tests sur un Windows XP SP3 français, avec Dev-C++ et GCC 3.4.2. Une fois le programme compilé, on peut utiliser un débogeur pour suivre l’exécution du programme. Lançons OllyDbg et observons le main()
du programme :
00401290 /$ 55 PUSH EBP 00401291 |. 89E5 MOV EBP,ESP 00401293 |. 81EC 78010000 SUB ESP,178 00401299 |. 83E4 F0 AND ESP,FFFFFFF0 0040129C |. B8 00000000 MOV EAX,0 004012A1 |. 83C0 0F ADD EAX,0F 004012A4 |. 83C0 0F ADD EAX,0F 004012A7 |. C1E8 04 SHR EAX,4 004012AA |. C1E0 04 SHL EAX,4 004012AD |. 8985 A4FEFFFF MOV DWORD PTR SS:[EBP-15C],EAX 004012B3 |. 8B85 A4FEFFFF MOV EAX,DWORD PTR SS:[EBP-15C] 004012B9 |. E8 82040000 CALL syscalls.00401740 004012BE |. E8 1D010000 CALL syscalls.004013E0 004012C3 |. 8D85 A8FEFFFF LEA EAX,DWORD PTR SS:[EBP-158] ; | 004012C9 |. 894424 04 MOV DWORD PTR SS:[ESP+4],EAX ; | 004012CD |. C70424 0030400>MOV DWORD PTR SS:[ESP],syscalls.00403000 ; |ASCII "fg" 004012D4 |. E8 E7050000 CALL <JMP.&KERNEL32.FindFirstFileA> ; FindFirstFileA 004012D9 |. 83EC 08 SUB ESP,8 004012DC |. 8945 F4 MOV DWORD PTR SS:[EBP-C],EAX ; | 004012DF |. 8B45 F4 MOV EAX,DWORD PTR SS:[EBP-C] ; | 004012E2 |. 894424 04 MOV DWORD PTR SS:[ESP+4],EAX ; | 004012E6 |. C70424 0330400>MOV DWORD PTR SS:[ESP],syscalls.00403003 ; |ASCII "Handle = %d" 004012ED |. E8 4E050000 CALL <JMP.&msvcrt.printf> ; printf 004012F2 |. E8 39050000 CALL <JMP.&msvcrt.getchar> ; [getchar 004012F7 |. B8 00000000 MOV EAX,0 004012FC |. C9 LEAVE 004012FD . C3 RETN
Je rappelle que nous sommes à présent dans un programme utilisateur, donc en userland. Il est facile de le vérifier en regardant la valeur de CS. Chez moi, c'est 0x2B, soit 0b101011. Les deux premiers bits (poids faible) sont bien 0b11, correspondant au ring 3.
L'appel à FindFirstFile
se trouve en 004012D4
, après que les paramètres aient été mis sur la pile. Après avoir placé un breakpoint sur le call (F2), et lancé l'exécution (F9), le programme s'arrête dessus. On fait un step in (F7) pour suivre le call.
004018C0 $-FF25 B8504000 JMP DWORD PTR DS:[<&KERNEL32.FindFirstFileA>]
Nous nous situons à présent dans une zone appelée trampoline. Cette zone fait référence à l’IAT (Import Address Table) de l’exécutable, qui contient les adresses des fonctions importées. Pour faire le parallèle avec Linux, le trampoline est l’équivalent de la section .plt, et l’IAT joue le même rôle que la section .got. Pour plus d’informations sur ces sections je vous conseille de lire cet article.
Le jump fait référence à un pointeur situé en 004050B8, qui contient l’adresse de la fonction FindFirstFileA
. On rappuie sur F7 pour suivre l’appel. On arrive alors dans la section .text de kernel32.dll, qui a été chargée à l’exécution.
7C813869 > 8BFF MOV EDI,EDI 7C81386B 55 PUSH EBP 7C81386C 8BEC MOV EBP,ESP 7C81386E 81EC 6C020000 SUB ESP,26C ... 7C813894 56 PUSH ESI 7C813895 56 PUSH ESI 7C813896 56 PUSH ESI 7C813897 8D8D ACFDFFFF LEA ECX,DWORD PTR SS:[EBP-254] 7C81389D 51 PUSH ECX 7C81389E 56 PUSH ESI 7C81389F FF70 04 PUSH DWORD PTR DS:[EAX+4] 7C8138A2 E8 66B2FFFF CALL kernel32.FindFirstFileExW
On s’aperçoit que cette fonction en appelle une autre, FindFirstFileExW
, toujours dans kernel32. Pourquoi ? Simplement parce que la plupart des fonctions internes de Windows utilisent un encodage Unicode, et non pas ASCII. Dans la convention Windows, les fonctions se terminant par un A gèrent l’ASCII, et celles en W gèrent l’Unicode. En interne, les fonctions ASCII convertissent les paramètres passés en Unicode, et appèlent les fonctions Unicode correspondantes. C’est le call que nous venons de voir. Suivons le.
7C80EB0D > 8BFF MOV EDI,EDI 7C80EB0F 55 PUSH EBP 7C80EB10 8BEC MOV EBP,ESP 7C80EB12 81EC CC020000 SUB ESP,2CC ... 7C80EC66 57 PUSH EDI 7C80EC67 8D85 90FDFFFF LEA EAX,DWORD PTR SS:[EBP-270] 7C80EC6D 89B5 40FDFFFF MOV DWORD PTR SS:[EBP-2C0],ESI 7C80EC73 8B35 1410807C MOV ESI,DWORD PTR DS:[<&ntdll.NtOpenFile>] ; ZwOpenFile 7C80EC79 50 PUSH EAX 7C80EC7A C785 34FDFFFF 18>MOV DWORD PTR SS:[EBP-2CC],18 7C80EC84 FFD6 CALL ESI ... 7C80ED46 FFB5 90FDFFFF PUSH DWORD PTR SS:[EBP-270] 7C80ED4C FF15 2812807C CALL DWORD PTR DS:[<&ntdll.NtQueryDirectoryFile>]
On voit que cette fonction réalise plusieurs appels (ici je n’en n’ai affiché que 2 mais il y en a d’autres) dans ntdll.dll, une autre DLL chargée. Continuons notre traçing en explorant l’appel à ZwOpenFile
.
7C91D580 > B8 74000000 MOV EAX,74
7C91D585 BA 0003FE7F MOV EDX,7FFE0300
7C91D58A FF12 CALL DWORD PTR DS:[EDX] ; ntdll.KiFastSystemCall
7C91D58C C2 1800 RETN 18
Transition vers le mode noyau
Nous sommes à présent dans ntdll.dll, dans la fonction ZwOpenFile
exportée par la dll. Comme vous le voyez, la fonction est très courte. La première instruction place 0×74 dans EAX, qui correspond au numéro de la fonction du noyau qui va être appelée. On a ensuite un appel à une fonction nommée KiFastSystemCall
. C’est elle qui va réaliser le passage en mode noyau, à l’aide des instructions suivantes :
7C91E4F0 > 8BD4 MOV EDX,ESP 7C91E4F2 0F34 SYSENTER 7C91E4F4 > C3 RETN
C’est précisément SYSENTER qui réalise la transition. Mais que fait donc cette instruction ? On ouvre le manuel Intel 2B au chapitre 4.1, instruction SYSENTER. On y apprend que cette instructionpermet d’exécuter des appels systèmes. Historiquement, les appels systèmes étaient exécutés en utilisant les interruptions logicielles, avec l’instruction INT 2E sous Windows (et INT 80 sous Linux). SYENTER étant plus rapide, elle a succédé à l’ancienne méthode, qui reste toutefois disponible pour des raisons de compatibilité.
Contrairement aux interruptions, SYSENTER n’utilise pas de table de pointeurs, mais des registres spéciaux du processeur, appelés MSR (pour Model Specific Register). Ces registres sont assez particuliers ; ils possèdent un numéro et sont accessibles en lecture et écriture via les instructions rdmsr
et wrmsr
. SYSENTER en utilise 3 :
- IA32_SYSENTER_CS (0×174) correspond à la valeur à charger dans CS quand SYSENTER sera appelé
- IA32_SYSENTER_ESP (0×175) sera quant à lui chargé dans ESP
- IA32_SYSENTER_EIP (0×176) sera chargé dans EIP
Ainsi, ces trois registres définissent tout ce qu’il faut pour exécuter un bout de code en mode en mode noyau, puisque CS (et donc ses deux premiers bits, encodant le CPL) sera changé.
Ces registres ne sont pas lisibles en mode utilisateur ; il faut impérativement être en ring 0 pour les lire. Pour cela, utilisons Windbg, le débogueur noyau de Microsoft. Il se télécharge ici, vous trouverez également des informations pour la configuration des symboles sur cette page.
Lançons Windbg en local kernel debugging (Cltrl K puis local). Dans l’invite de commande, tapons :
lkd> rdmsr 174 msr[174] = 00000000`00000008 lkd> rdmsr 176 msr[176] = 00000000`80541520
Nous voyons donc que IA32_SYSENTER_CS = 0×8 (donc avec un niveau de privilège à 0) et IA32_SYSENTER_EIP = 80541520. Au passage, on notera que cette adresse est supérieur à 80000000, c’est à dire en mode noyau, puisque Windows divise l’espace d’adressage de tout processus en 2 : 2 Go pour l’utilisateur (de 00000000 à 7FFFFFFF) et 2 Go pour le noyau (de 80000000 à FFFFFFFF). Regardons ce qui se trouve en 80541520 :
lkd> u 80541520 80541520+100 //desassemble les 100 premiers octets nt!KiFastCallEntry: 80541520 b923000000 mov ecx,23h 80541525 6a30 push 30h 80541527 0fa1 pop fs 80541529 8ed9 mov ds,cx 8054152b 8ec1 mov es,cx ... 80541600 8b3f mov edi,dword ptr [edi] 80541602 8b1c87 mov ebx,dword ptr [edi+eax*4] 80541605 2be1 sub esp,ecx 80541607 c1e902 shr ecx,2 8054160a 8bfc mov edi,esp 8054160c 3b3534215680 cmp esi,dword ptr [nt!MmUserProbeAddress (80562134)] 80541612 0f83a8010000 jae nt!KiSystemCallExit2+0x9f (805417c0) 80541618 f3a5 rep movs dword ptr es:[edi],dword ptr [esi] 8054161a ffd3 call ebx
On voit que la fonction s’appelle KiFastCallEntry
et se trouve dans le module nt qui correspond à ntoskrnl.exe, un des exécutables du noyau.
Après plusieurs vérifications de paramètres, la fonction KiFastCallEntry
charge une table dans EDI (après les « … »). Il s’agit de la SSDT (Service System Dispatch Table), une table très importante du noyau qui a un rôle similaire à la table d’interruptions (IDT). Elle sert à dispatcher les appels systèmes vers la bonne fonction. Le symbole correspondant à la SSDT se nomme KiServiceTable
, et est exporté par le noyau. Voici le début de son contenu :
lkd> dds KiServiceTable 80504450 805a4614 nt!NtAcceptConnectPort 80504454 805f0adc nt!NtAccessCheck 80504458 805f4312 nt!NtAccessCheckAndAuditAlarm 8050445c 805f0b0e nt!NtAccessCheckByType 80504460 805f434c nt!NtAccessCheckByTypeAndAuditAlarm
Souvenez-vous : juste avant le SYSENTER, on a placé le numéro d’appel système dans EAX. Ce registre va être utilisé ici afin de servir d’index dans cette table et de trouver le pointeur de la fonction à appeler. L’instruction suivante multiplie justement EAX par 4 (la taille d’une entrée dans la table) et l’ajoute à l’adresse de début de la table contenu dans EDI. Le résultat est place dans EBX. Et quelques instructions plus loin, on trouve… un call EBX
Faisons le calcul nous même : nous connaissons le numéro d’appel système (0×74) :
lkd> dds KiServiceTable+0x74*4 80504620 8057a182 nt!NtOpenFile
Bingo, voila la fonction NtOpenFile
: c’est elle qui sera exécutée lors du call EBX.
Retour en userland
Notre reversing peut s’arrêter ici. Nous n’allons pas désassembler cette fonction car ce n’est pas le but de l’article. Concernant le retour de l’appel système, on peut, en désassemblant la suite de KiFastCallEntry
, voir que la fonction KiServiceExit
va être appelée. A son tour, elle appelle une des deux fonctions KiSystemCallExit
ou KiSystemCallExit
2. La première revient en mode utilisateur en utilisant l’instruction IRET, et l’autre le fait en utilisant SYSEXIT (c’est celle là qui sera appelée dans notre cas). Cette instruction rebascule en userland et restaure EIP. On se retrouve alors dans ZwOpenFile
, de ntdll.
Conclusion
Au travers de cet article, nous avons tracé l’exécution d’un programme utilisateur afin de comprendre comment il passe en mode noyau afin d’exécuter des instructions privilégiées. Nous avons vu comment fonctionne l’instruction SYSENTER et le rôle de la SSDT.
On peut alors imaginer plusieurs hooks possibles exploitant ce schéma de fonctionnement. Au niveau utilisateur, on peut hooker l’entrée de l’IAT correspondant à la fonction à appeler. En mode noyau, il y a plusieurs possibilités. La première serait de remplacer l’adresse de la fonction à appeler dans la SSDT par une autre fonction qui appelle l’originale et effectue un filtrage en entrée et en sortie. Une autre serait de modifier carrément le registre IA32_SYSENTER_EIP en le faisant pointer sur une autre routine de traitement. L’équivalent pour les vieilles versions de Windows utilisant les interruptions serait de modifier le registre IDTR contenant l’adresse de la table des interruptions, ou bien de hooker l’entrée 2E dans cette table. Enfin, une dernière solution, radicale mais fonctionnelle, serait de faire un hook inline de la fonction pointée, en remplaçant ses premiers octets par un call d’une autre fonction. Comme vous le voyez, les possibilités de hook sont innombrables, les plus « profondes » étant bien souvent les plus indétectables…
Références
- Ivanlef0u’s Blog – SSDT Hooking Reinvented
- Ivanlef0u’s Blog – SYSENTER, stepping into da ring0
- Rootkits – Subverting the Windows Kernel, Greg Hoglund et Jamie Butler
- Intel 64 and IA-32 Architectures Software Developer’s Manual, Volume 2B, Chapitre 4.1, Instruction SYSENTER
- Intel 64 and IA-32 Architectures Software Developer’s Manual, Volume 3A, Chapitre 4.5
- Debugging Tools for Windows
- Debugging Tools and Symbols: Getting Started
8 réponses à “Plongeon dans les appels systèmes Windows”
Good work, clair, as usual
Par JoE le 28 mars 2009
Hello Trance,
Tout d’abord merci pour cette article, tu présentes très bien le passage userland/kerneland, c’est très detaillés, bien expliqué !
Cependant, il y a une petite bourde, tu parles à un moment d’un symbole pointant sur la SSDT, tu parles alors de KiServiceTable !
Ce n’est pas vraiment le symbole pointant sur la SSDT.
Si l’on regarde la structure de la SSDT on peut voir:
typedef struct ServiceDescriptorEntry {
PDWORD ServiceTable;
PDWORD CounterTableBase;
DWORD ServiceLimit;
PBYTE ArgumentTable;
} SSDT;
lkd> dd nt!KeServiceDescriptorTable l 4
8055b6e0 80503960 00000000 0000011c 80503dd4
On repère bien sur le nombre de syscall 0x11c, le pointeur sur le tableau des adresses des fonctions 80503960.
lkd> dds nt!KeServiceDescriptorTable l 4
8055b6e0 80503960 nt!KiServiceTable
8055b6e4 00000000
8055b6e8 0000011c
8055b6ec 80503dd4 nt!KiArgumentTable
On constate juste que KiServiceTable est un symbole pointant sur la table des adresses des fonctions, et non la SSDT !
Voilà, bonne continuation.
Cordialement, 0vercl0k.
Par 0vercl0k le 29 mars 2009
En effet. Pour moi la SSDT c’était justement la table des adresses de fonctions, mais tu as raison. Désolé de cette erreur de vocabulaire
Et merci pour les compliments, ça fait plaisir !
Par Emilien Girault le 29 mars 2009
Les mecs arretez avec vos getchar et comparaison avec zero pour finir une action en C , et tester system(« PAUSE »); des comparaisons plus « scientifique »
en tout cas je comprends mieux trampoline mainant
Par jehv le 12 avril 2009
Euh où as-tu vu une « comparaison avec zéro » ?
Et je ne vois pas en quoi system(« pause ») serait plus « scientifique ». Je n’utilise jamais cette méthode car je la considère comme pas propre (lancer un shell et faire exécuter une commande MS DOS juste pour mettre en pause un programme, vive l’usine à gaz). Enfin tout cela est personnel bien sur
En tout cas ce n’était pas le but de l’article donc désolé si j’ai heurté ta sensibilité !
Par Emilien Girault le 12 avril 2009
Eh eh eh
Aurais-je déteint sur toi Émilien ? Et dire qu’il fut une époque ou tu me considérais comme un maniaque du code…
Par contre, je suis d’accord, getchar n’est pas encore une solution parfaite. Sous linux, le mieux serait encore de se mettre en attente d’un signal quelconque, mais bon, sous windows il faut bien faire avec ce qu’on a sous la main
Par Léo le 12 avril 2009
Yo Trance,
« lkd> u 80541520 80541520+100 //desassemble les 100 premiers octets »
100 est exprimé ici en hexadécimal, donc ça sera 256 premiers octets.
Je sais que ton article commence à dater un peu, mais il reste très utile (la preuve puisque je le continue de le consulter).
Bonne continuation !
Ge0
Par Ge0 le 20 août 2011