Plongeon dans les appels systèmes Windows

28 mars 2009 – 12:53

Mon 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 KiSystemCallExit2. 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

  1. 8 réponses à “Plongeon dans les appels systèmes Windows”

  2. Good work, clair, as usual ;-)

    Par JoE le 28 mars 2009

  3. 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

  4. 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

  5. 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

  6. 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

  7. 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

  8. 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

  1. 1 Trackback(s)

  2. 29 mars 2009: Ivanlef0u’s Blog » Return of the SMM

Écrire un commentaire