ChoJin’s Quarter
Computer Sciences, Cooking, Photography and Filmmaking…
Ring0 under Microsoft Windows 9x

Posted on Saturday 8 July 2006

Pouvoir exécuter du code Ring 0 sous Win9x

1 - Connaissances requises:

  • Assembleur i80x86
  • De bonnes connaissances sur le mode protégé (lien entre sélecteur et le privilège d'exécution)

2 - Utilité

Certains pourraient se demander quelles utilités on peut tirer du Ring 0. Je serais tenté de répondre "tout ce qu'on ne peut pas faire en Ring 3", c'est-� -dire avoir accès aux registres privilégiés (les registres de debug DR0-7, les registres de contrôles CR0-4), aux instructions privilégiées de l'OS, et surtout avoir un accès en lecture et écriture � toute la mémoire sans restriction. Enfin bref il peut y avoir beaucoup de raisons � vouloir passer en Ring 0, � vous de trouver la vôtre, mais il ne faut pas en abuser, il y a souvent un moyen de le faire sans, donc ne passez pas en Ring 0 sous prétexte que vous avez la flemme d'appeler la fonction VirtualProtect pour donner un accès en écriture sur le code d'un process (par exemple).

3 - Exception et Interruption

a - Introduction

Pour pouvoir comprendre l'astuce que l'on va utiliser pour passer en Ring 0 il nous faut tout d'abord comprendre le fonctionnement et la gestion des exceptions et des interruptions. Pour simplifier on va considérer que les exceptions sont la même chose que les interruptions. Quand une erreur intervient (division par 0, accès écriture � une zone de mémoire Read-Only etc. ...) une exception est déclenchée:

  • les Eflags sont empilés sur la pile
  • suivi de CS (un sélecteur vu que l'on est en mode protégé)
  • puis de l'offset de retour
  • et enfin le système réalise un jmp far sur le code de l'exception

Le retour au programme (s'il y a retour) se fait au moyen d'un iret.

Mais comment le système connaît-il l'adresse de l'exception ? Grâce � l'IDT ( Interrupt Descriptor Table). Cette table contient toutes les infos nécessaires au système des 256 Exceptions/interruptions (les exceptions de 0 � 31 sont déj� prédéfinies et non masquables et de 32 � 255 sont les interruptions masquables). Les numéros des exceptions et interruption sont appelées Vecteurs.

Voici la liste des exceptions/interruptions:

N° Vecteur Description
0 Divide Error
1 Debug Exception
2 NMI
3 Break Point
4 Into
5 Bound
6 Invalid Opcode
7 Device Not Available
8 Double Fault
9 Copro Segment Overrun
10 Invalid Task State Segment
11 Segment Not Present
12 Stack Fault
13 General Protection
14 Page Fault
15 Reserved By Intel
16 Floating Point Error
17 Alignment Check
18 Machine Check
19-31 Intel
32-255 Maskable Interrupt

b - IDT

Maintenant revenons � l'IDT. L'IDT est un tableau qui permet d'associer chaque vecteur � un descripteur de procédure. Ce descripteur a une taille de 8 octets, ainsi l'IDT est un tableau dont chaque élément a une taille de 8 octets (et donc pour pointer sur le descripteur du n-ieme vecteur il suffit de sommer � l'adresse de l'IDT le numéro du vecteur*8. La question naturelle de savoir comment récupérer l'adresse cette IDT semble plutôt intéressante :o ).En parcourant la liste des mnémoniques on remarque deux instructions qui se rapportent � l'IDT: LIDT et SIDT (LIDT=load IDT et SIDT=store IDT), LIDT permet de charger dans le registre IDTR du processeur une adresse, tandis que SIDT permet de récupérer l’adresse de l'IDT qui se trouve dans le registre IDTR.LIDT est une instruction privilégiée ainsi une application normale ne peut pas l'utiliser mais l'instruction SIDT est une instruction non protégée donc n'importe quelle application peut récupérer adresse de l'IDT.

Voyons comment fonctionne ces deux instructions: ces deux instructions prennent en argument une adresse qui pointe sur 6 octets. Ces 6 octets ont la signification suivante:

47-16 15-0 Bit
IDT Base Address IDT Limit  

IDT Base Address+IDT Limit donne l'adresse du dernier octet valide, mais c'est surtout IDT Base Address qui nous intéresse. Ainsi si pour récupérer le contenu du registre IDTR on peut procéder comme suit:

  1. ; Dans le code:
  2. sidt fword ptr IDT_Limit
  3.  
  4. ; Dans les data:
  5. IDT_Limit DW 0
  6. IDT_BaseAddress DD 0

Après cette instruction on a donc l'adresse de l'IDT dans la variable IDT_BaseAddress.

c - Descripteurs IDT

Maintenant que l'on sait comment récupérer l'adresse de l'IDT, voyons la structure des descripteurs qui la compose. Il y a 3 sortes de descripteurs: Task Gates, Interrupt Gates, Trap Gates. Voici la structure des 8 octets pour chacun d'eux:

Task Gate:

31-16 15 14-13 12-8 7-0  
Reserved P DPL 00101 Reserved +4
TSS Segment Selector Reserved +0

Interrupt Gate:

31-16 15 14-13 12-8 7-5 4-0  
Offset 31..16 P DPL 01110 000 Reserved +4
Segment Selector Ofsset 0..15 +0

Trap Gate:

31-16 15 14-13 12-8 7-5 4-0  
Offset 31..16 P DPL 01111 000 Reserved +4
Segment Selector Ofsset 0..15 +0

DPL: Descriptor Privilege Level
OFFSET: Offset To Procedure Entry Point
P: Segment Present Bit
RESERVED: Do not use
SELECTOR: Segment Selector For Destination Code Segment

C'est la structure de l'Interrupt Gate qui nous intéresse (remarquez que Trap Gate � la même structure � un code près). On voit que l'offset de l'interruption est découpé en deux parties: les bits 16 � 31 sont dans la partie supérieur des 4 derniers octets du descripteur et les bits 0 � 15 sont dans la partie inférieur des 4 premiers octets du descripteur.

Ainsi si eax contient l'adresse de base de l'IDT et ebx le vecteur de l'interruption, pour récupérer l'offset de l'adresse de l'interruption on peut procéder comme suit:

  1. shl ebx,3                         ; index*8 car 8 octets par Descripteur
  2. add eax,ebx                     ; calcul l'adresse du descripteur
  3. mov esi,dword ptr [eax+4] ; récupère les 16 bits supérieurs de l'offset
  4. mov si,word ptr [eax]        ; puis les 16 bits inférieurs

Ainsi maintenant esi contient l'offset de l'interruption (et comme Windows est en model flat - i.e. que les sélecteurs ont 0 comme base- on peut considérer que esi contient l'adresse de l'interruption).

4 - Passage au Ring 0

Bon c'est bien beau tout ça, mais c'est quoi le rapport avec le passage en ring 0 ? ;) La patience est une vertu ...

a - Analyse de l'IDT de Windows 9x

Pour analyser l'IDT de Windows 9x je vais utiliser le debuger de NuMega, � savoir SoftIce/W.C'est vraiment le meilleur debuger que l'on peut trouver sous Windows 9x et NT, et c'est un outil essentiel pour tous les bidouilleurs. Vous trouverez un link dans la section Link (sans blague ? :o ) ) sur la page de NuMega.

Je lance la commande IDT et j'obtiens ça:

  1. IDTBase=800A8000 Limit=02FF
  2. Int            Type          Sel:Offset              Attributes    Symbol/Owner
  3. 000           IntG32        0028:C0001350      DPL=0 P      VMM(01)+0350
  4. 001           IntG32        0028:C0001360      DPL=3 P      VMM(01)+0360
  5. 002           IntG32        0028:C00046E0      DPL=3 P      Simulate_IO+02A0
  6. 003           IntG32        0028:C0001370      DPL=3 P      VMM(01)+0370
  7. 004           IntG32        0028:C0001380      DPL=3 P      VMM(01)+0380
  8. 005           IntG32        0028:C0001390      DPL=3 P      VMM(01)+0390
  9. 006           IntG32        0028:C00013A0      DPL=0 P      VMM(01)+03A0
  10. 007           IntG32        0028:C00013B0      DPL=0 P      VMM(01)+03B0
  11. 008           TaskG        0068:00000000       DPL=0 P
  12. 009           IntG32        0028:C00013C0      DPL=0 P      VMM(01)+03C0
  13. ...

Cette commande nous donne de précieuses informations. Tout d'abord on voit l'adresse de l'IDT : 800A8000. Demandons � SoftIce/W des infos sur cette mémoire, on lance la commande: "page 800A8000", et on obtient: (avec la commande query on peut avoir d'autres informations)

  1. Linear           Physical        Attributes          Type
  2. 800A8000      0092D000      P D A U RW       Private

Ce qui est intéressant ici c'est les Attributes. Vous voyez le "RW" ? :) Bienvenu dans le monde de Windows 9x, cette table est en Read and Write ! Cela veut dire que l'on peut la modifier � notre guise(on se demande pourquoi Intel s'est fait chier � protéger l'instruction LIDT justement pour empêcher qu'une application s'amuse avec l'IDT ...) On verra plus tard ce que l'on peut tirer du RW de cette table.

Continuons notre exploration en nous intéressant cette fois ci au vecteur 0, on voit que ce vecteur est du type Interrupt Gate (ce qui nous permet de connaître la structure du descripteur de ce vecteur) puis on voit l'adresse de la routine. Relançons la commande page sur cette adresse: "page C0001350", et on obtient:

  1. Linear           Physical        Attributes          Type
  2. C0001350      00110350      P D A U RW       Private

Le Monde magique de Windows 9x continu puisque l'on voit que la fonction elle-même est placée dans une zone de mémoire qui est Read and Write. Et donc on peut aussi aller modifier le code du vecteur 0.

Pour finir notre analyse, demandons des informations sur le sélecteur "GDT 0028" (GDT permet d'avoir la Global Descriptor Table - i.e. la table qui stock les sélecteurs et leurs propriétés), on obtient:

  1. Sel.          Type        Base          Limit             DPL    Attributes
  2. 0028         Code32    00000000    FFFFFFFF        0        P RE

Ce qui est surtout important ici, c'est le DPL du sélecteur, on voit qu'il est en 0, cela veut dire que quand l'interruption 0 est déclenchée (division par zéro) le code de l'interruption se retrouve en CPL 0.

Résumons: L'IDT sous Windows 9x se trouve dans une zone mémoire accessible en écriture, l'interruption 0 (division par zéro) a son code aussi en accès écriture, et enfin le sélecteur correspondant a un CPL 0.

b - Ring 0 Premier Méthode

Maintenant que nous avons toutes les informations que l'on a besoin, attaquons-nous � la première méthode pour passer en CPL 0.

La première approche est la suivante: puisque l'IDT est accessible en écriture rien ne nous empêche de modifier l'adresse d'un des vecteurs et le faire pointer sur une fonction � nous. Pour cela il ne faut pas modifier le sélecteur de l'adresse puisque c'est justement lui qui permet au code d'entre en CPL 0 par contre il faut modifier l'offset de l'interruption et la faire pointer sur une fonction � nous. Bon je ne donne pas de code d'exemple car c'est assez trivial. Pensez juste � restaurer l'offset du vecteur modifier une fois le code Ring 0 exécuté. Un autre point important: comme toute interruption, votre fonction ne doit pas avoir de registre modifié � la sortie de la fonction et doit se terminer par un iretd au lieu d'un simple ret. Si vous avez quelques problèmes avec cette méthode n'hésitez pas � me mailer.

c - Ring 0 Deuxieme Methode

Une deuxième méthode pour passer en Ring 0 (je ne suis l'inventeur d'aucune des deux, et je ne sais pas trop qui en premier les a inventé). Je préfère nettement cette méthode pour des raisons que je vous expliquerai après.

Cette fois ci au lieu de modifier "l'entry point" du vecteur d'interruption, on va modifier directement le code de la fonction (vous vous souvenez que le code est aussi en writable ? :o ) ).Pourquoi modifier directement le code ? L'explication sera plus claire après avoir donné le code d'exemple:

  1. sidt fword ptr IDT 
  2. mov eax , dword ptr [IDT+2]        ; récupère la base address de l'IDT
  3. mov esi , dword ptr [eax+4]
  4. mov si , word ptr [eax]                ; récupère l'offset du premier vecteur d'interruption
  5. mov eax , dword ptr [esi]             ; récupère les 4 premiers octets du code
  6. mov Save1 , eax                         ; et les sauvegarde
  7. mov eax , dword ptr [esi+4]         ; les 4 prochains
  8. mov Save2 , eax                         ; et les sauvegardes aussi
  9. mov CurrentSelector , cs              ; sauvegarde le sélecteur courrant de code
  10. mov IntAddress , esi                    ; sauvegarde l'adresse de l'int
  11. mov dword ptr [esi] , 530E5858h   ; on écrit un nouveau code dans l'int
  12. mov byte ptr [esi+4] , 0CFh          ; écrit du code encore
  13. lea ebx , Ring0Code                     ; on charge dans eax l'adresse de la suite de notre programme
  14. xor eax , eax
  15. div eax                                      ; on divise par 0 comme ça cela éclanche l'exception 0 et donc notre code
  16. Ring0Code:
  17.                                                 ; Ici on est en CPL 0
  18.                                                 ;...
  19.  
  20. mov esi , IntAddress                    ; normalement esi n'a pas été modifié mais bon ...
  21. mov dword ptr [esi] , 53515858h   ; on écrit du code encore
  22. mov byte ptr [esi+4] , 0CFh          ; du code ...
  23. xor ecx , ecx
  24. mov cx , CurrentSelector              ; on met l'ancien select or dans cx
  25. lea ebx , Ring3Back                     ; on charge l'adresse de retour
  26. xor eax , eax
  27. div eax                                      ; et on déclenche l'int 0
  28. Ring3Back:
  29.                                                 ; Ici on est de nouveau en CPL 3
  30.  
  31. mov esi , IntAddress
  32. mov eax , Save1                         ; on restaure les 4 premiers octets de l'int 0
  33. mov dword ptr [esi] , eax
  34. mov eax , Save2                         ; et les 4 suivants
  35. mov dword ptr [esi+4] , eax
  36. ;...
  37. ; Voila c'et fini! :)

Bon les explications maintenant, en fait c'est très simple, je récupère l'adresse de l'IDT, puis l'adresse de la première exception (divide by 0), puis je sauvegarde les 8 premiers octets du code puisque je vais en modifier 5 (c'est plus simple d'en sauvegarder 8 :o ) ). Tout se passe donc dans ces octets mystérieux que je place � la place du code: 58585153CFh (n'oubliez pas que DWORD sont inversés en mémoire donc dans le premier mov, les octets sont � l'envers). voici la signification des ces octets:

  1. pop eax; pop the return offset
  2. pop eax; pop the return selector
  3. push cs; push the current selector to the stack
  4. push ebx; push ebx to the stack
  5. iretd; return

Alors je dépile tout d'abord de la pile l'offset de retour et le sélecteur de retour qui a été placé sur la pile lors du déclenchement de l'int. Puis je replace sur la pile le sélecteur courant (dans cette exemple c'est 0028, i.e. un sélecteur en CPL 0) et ebx (souvenez vous que ebx est initialisé avant la division par zéro sur l'adresse de Ring0Code) comme ça j'ai maintenant sur la pile une nouvelle adresse de retour mais surtout un autre sélecteur qui lui est en CPL 0, ainsi le iretd va récupérer cette adresse et donc initialiser CS avec le sélecteur CPL 0 et nous voici donc � nouveau dans notre code mais cette fois si en CPL 0:

Vous pouvez faire tout ce que vous voulez ! Essayez par exemple de lire le registre DR7 (mov eax,DR7).

Pour le retour c'est presque pareil, mais cette fois il faut que sur la pile on place l'ancien Sélecteur (qui était un sélecteur CPL 3) pour cela le code est légèrement différent:

  1. pop eax;
  2. pop eax;
  3. push ecx;
  4. push ebx;
  5. iretd;

ebx contient toujours l'adresse de retour, mais ecx contient le sélecteur original. Voila après on termine en restaurant le code original de l'int 0.

Pourquoi je préfère cette méthode ? Tout simplement parce que la lecture est plus linéaire (pas besoin de faire une fonction � part pour la partie Ring0 etc...) et il est facile de faire une macro avec. Enfin tout ceci n'est qu'une question de goût.

5 - Conclusion

Et voil� c'est fini, j'espère que cela vous a appris quelque chose, si certaines choses restent obscures ou s'il y a des erreurs n'hésitez pas � laisser un commentaire.

6 - Références

Share this story using:These icons link to social bookmarking sites where readers can share and discover new web pages.

Pages: 1 2 3

No comments have been added to this post yet.

Leave a comment

(required)

(required)


Information for comment users
Line and paragraph breaks are implemented automatically. Your e-mail address is never displayed. Please consider what you're posting.

Use the buttons below to customise your comment.


Comment moderation is in use. Please do not submit your comment twice -- it will appear shortly.

RSS feed for comments on this post | TrackBack URI