Comment obtenir les IDT et GDT
sur système multi-processeurs ?
-=¤ Lionel d'Hauenens ¤=-


Quelle surprise la première fois que l'on "titille" l'IDT sur un système multi-processeurs. Le crash que l'on se prend dans les dents est assez violent. Soit... mais ça a au moins le mérite d'exposer le problème assez rapidement. ;p
En effet, chaque micro-processeur (cpu) travaille avec son propre jeu de registres et son propre environnement. Dans un environnement multi-processeurs, il est bien sur possible de jouer avec les interruptions sans être embêté. Il faudra juste prendre la peine de sonder ou modifier tous les environnements des cpu présents.

Le but de ce texte est simplement d'exposer 4 techniques qui permettent d'obtenir les pointeurs et/ou les tailles des tables IDT et GDT propres à chaque processeur présent.

Le principe des deux premiers exemples consiste simplement à soutirer nos informations à l'aide des instructions assembleurs sidt et sgdt. Dans une boucle, basée sur le nombre de cpu présent, nos instructions sont exécutées dans un thread que l'on force à fonctionner dans chaque cpu. Ceci permet de récupérer les valeurs des registres idtr et gdtr de chaque cpu et donc nos tables. :)

Le premier exemple expose une routine fonctionnant en mode utilisateur. Alors que le deuxième exemple expose une routine basée sur le même principe mais fonctionnant en mode kernel. L'inconvénient majeur de cette technique est le temps d'exécution. Le passage d'un thread vers un autre cpu demande du temps.

En mode kernel, le niveau de priorité du code exécuté (irql) ne permet pas tout le temps d'utiliser les API Native. Le troisième exemple essaye de palier à ce problème en proposant une méthode basée sur la recherche et le scan de la table HalpProcessorPCR présente dans hal.dll. Cette table contient les pointeurs des objets PCR (Processor Control Region) propre à chaque cpu. Ces objets définissent les environnements courants des cpu. Cette méthode, utilisé par Softice, exécute une recherche mémoire par signature afin de trouver en premier lieu la routine HalInitializeProcessor. Malheureusement, depuis Windows Server 2003 SP1, les signatures changent. Cette méthode a donc ses limites et il devient de plus en plus difficile de développer quelque chose de générique.

Pour palier à ce problème, j'ai développer une quatrième méthode permmettant de ne pas tenir compte du système hôte. Elle est donc (à ce jour) générique. Cette dernière méthode recherche directement les différents KPCR présents en mémoire. La recherche est cette fois basée sur des régles simples découlant directement de l'architecture des systèmes Windows.

User Mode
méthode 1

Cette technique utilise des API documentés et fonctionne en mode utilisateur. Ces caractéristiques la rendent sûre et fiable. Cependant, il est à noter qu'elle ne fonctionne pas dans un environnement de type Machine Virtuelle.

Pour obtenir ce que l'on désire, il nous faut seulement 2 ingrédients.

  1. Trouver combien de processeurs sont présents sur la machine.
    • Cette information peut être obtenue très simplement en piochant dans la structure PEB (Process Environment Bloc) la valeur NumberOfProcessors.
    • Il est aussi possible d'utiliser l'API NtQuerySystemInformation proposé par ntdll.dll (voir SDK).
  2. Ensuite, il faut pouvoir forcer le thread courant à fonctionner uniquement dans un seul cpu.
    • L'API SetThreadAffinityMask permet justement cela. :)

Kernel Mode
méthode 2

PASSIVE_LEVEL (IRQL=0)

Une technique similaire à celle du mode utilisateur est aussi réalisable en mode kernel. Il ne faut pour cela pas avoir peur d'utiliser des API heuuu... plus ou moins documentées. ;)

Toutes les API et data system proposés dans cette technique sont directement exportés par ntoskrnl.exe.

Pour connaître le nombre de cpu présents, il est possible d'utiliser le data KeNumberProcessors. Ce data est global au système. Il est aussi possible d'utiliser les API NtQuerySystemInformation ou ZWQuerySystemInformation (plus ou moins documentés). Le nombre de cpu se trouve dans la structure SYSTEM_BASIC_INFORMATION. Cette structure s'obtient en donnant System_Basic_Information (valeur:0) au paramètre SYSTEM_INFORMATION_CLASS.

Etant non documentée, il est difficile de dire à quel irql la routine KeSetAffinityThread peut être exécutée. Il est donc raisonnable de s'imposer l'irql le plus bas (PASSIVE_LEVEL) pour assurer son exécution. Ceci dit, il est sûrement possible de l'exécuter 1 ou 2 crans plus haut. c'est à vous de voir... ;p
Quoi qu'il en soit, cette API ne peut pas être exécuté à tous les niveaux de priorité. Cela pose donc le problème de la récupération des tables idt et gdt à un niveau de priorité élevé. La dernière technique proposera une solution à ce problème.

En regardant la routine KeSetAffinityThread de plus près, nous pouvons voir qu'elle mène tout droit au crash dans 2 cas :

  1. Si le mask d'affinité du process (KPROCESS->Affinity) ne permet pas de valider le processeur demandé.
  2. Si le mask passé est égale à 0.

Il faudra donc faire correctement ces vérifications avant d'utiliser KeSetAffinityThread.

Cette procédure fonctionne très bien dans la routine d'initialisation d'un driver puisque celle-ci est exécutée en PASSIVE_LEVEL.

Kernel Mode
méthode 3

ALL_LEVEL (IRQL=0 à 31)

Cette dernière technique permet d'obtenir ce que l'on désire sans dépendre de l'irql courant. Elle est directement inspirée de celle utilisée par Softice pour soutirer les informations dont il a besoin. C'est celle que je préfère :)

Dans un système NT, la couche la plus basse se nomme HAL. C'est la couche d'abstraction matérielle. Cette couche communique directement avec le matériel (hardware). Il n'y a donc rien d'étonnant à trouver les routines d'initialisation des cpu à cet endroit :)

Chaque CPU est définie par un objet nommé PCR (Processor Control Region). C'est dans cette région que l'on trouve tout l'environnement courant du cpu. Et c'est donc dans cette région que l'on trouve les tables IDT et GDT propres au cpu. :)

lkd> dt _kpcr
nt!_KPCR
+0x000 NtTib : _NT_TIB
+0x01c SelfPcr : Ptr32 _KPCR
+0x020 Prcb : Ptr32 _KPRCB
+0x024 Irql : UChar
+0x028 IRR : Uint4B
+0x02c IrrActive : Uint4B
+0x030 IDR : Uint4B
+0x034 KdVersionBlock : Ptr32 Void
+0x038 IDT : Ptr32 _KIDTENTRY
+0x03c GDT : Ptr32 _KGDTENTRY

+0x040 TSS : Ptr32 _KTSS
+0x044 MajorVersion : Uint2B
+0x046 MinorVersion : Uint2B
+0x048 SetMember : Uint4B
+0x04c StallScaleFactor : Uint4B
+0x050 DebugActive : UChar
+0x051 Number : UChar
+0x052 Spare0 : UChar
+0x053 SecondLevelCacheAssociativity : UChar
+0x054 VdmAlert : Uint4B
+0x058 KernelReserved : [14] Uint4B
+0x090 SecondLevelCacheSize : Uint4B
+0x094 HalReserved : [16] Uint4B
+0x0d4 InterruptMode : Uint4B
+0x0d8 Spare1 : UChar
+0x0dc KernelReserved2 : [17] Uint4B
+0x120 PrcbData : _KPRCB

Lorsque les objets PCR sont créés, ntoskrnl appelle la routine HalInitializeProcessor afin d'initialiser chaque cpu et permettre à tout ce beau monde de fonctionner ensemble. Cette routine se trouve dans hal.dll. C'est HalInitializeProcessor qui a la charge de stocker les pointeurs de tous les objets PCR. Pour cela, elle a à sa disposition la table nommée HalpProcessorPCR. Sympa la table ! :)
Si nous avons accès à cette table, nous pouvons aisément faire notre récolte :)
Malheureusement cette table n'est pas exportée. Pour l'identifier clairement il nous faut donc les symbols de hal.dll.
Sans les symbols, impossible d'identifier le pointeur de la table HaLpPr0cESs0rPcR ! Hein ? Quoi ?

Oulaaaaa..... pas si vite ! Il faudrait peut être voir aussi comment l'on peut interpréter "identifier clairement". Personnellement, j'ai tendance à être plutôt large... :p

Commençons déjà par une petite recherche sur notre table HalpProcessorPCR :

8001907C   _HalpProcessorPCR dd 20h dup(0)   ; DATA XREF: HalInitializeProcessor(x,x)+1Dw
      ; HalpResetAllProcessors():loc_80017F39r

Cette table a une capacité de 32 pointeurs d'objet PCR. Ceci illustre tout simplement le fait que les systèmes NT supportent jusqu'à 32 CPU maximum (20h).

En regardant les infos de droite, nous pouvons voir que seulement deux endroits font référence à la table : dans les routines HalInitializeProcessor et HalpResetAllProcessors.

La routine HalpResetAllProcessors n'est accessible qu'avec les symbols.... Bon, on est pas plus avancé... Par contre HalInitializeProcessor est bel et bien PUBLIC et exportée par hal.dll. C'est mieux déjà :)

Maintenant regardons de plus prés cette fameuse routine sous les principaux systèmes NT :


Windows 2000 - Sp4


  ; ¦¦¦¦¦¦¦¦¦¦¦¦¦¦¦ S U B R O U T I N E ¦¦¦¦¦¦¦¦¦¦¦¦¦¦¦¦¦¦¦¦¦¦¦¦¦¦¦¦¦¦¦¦¦¦¦¦¦¦¦
; __stdcall HalInitializeProcessor(x,x)
 

 

public _HalInitializeProcessor@8
_HalInitializeProcessor@8 proc near

arg_0 = byte ptr 4
arg_4 = dword ptr 8
 
80011FC4    
80011FCE
80011FD3
80011FD8
80011FDE
80011FE5
80011FEF
80011FF7
80011FF9
.
.
.
C7 05 30 F0 DF FF FF FF+  
0F B6 44 24 04
A2 94 F0 DF FF
8B 0D 1C F0 DF FF
89 0C 85 C8 7F 01 80
C7 05 4C F0 DF FF 64 00+
F0 0F AB 05 20 9D 01 80
8B D0
A1 58 8B 01 80
.
.
.
mov dword ptr ds:0FFDFF030h, 0FFFFFFFFh
movzx eax, [esp+arg_0]
mov ds:0FFDFF094h, al
mov ecx, ds:0FFDFF01Ch
mov ds:_HalpProcessorPCR[eax*4], ecx
mov dword ptr ds:0FFDFF04Ch, 64h
lock bts ds:_HalpActiveProcessors, eax
mov edx, eax
mov eax, ds:_HalpDefaultInterruptAffinity
.
.
.

Windows XP - Sp2


  ; ¦¦¦¦¦¦¦¦¦¦¦¦¦¦¦ S U B R O U T I N E ¦¦¦¦¦¦¦¦¦¦¦¦¦¦¦¦¦¦¦¦¦¦¦¦¦¦¦¦¦¦¦¦¦¦¦¦¦¦¦
; __stdcall HalInitializeProcessor(x,x)
    public _HalInitializeProcessor@8
_HalInitializeProcessor@8 proc near

arg_0 = byte ptr 4
arg_4 = dword ptr 8
 
80011760
8001176B
80011770
80011776
8001177D
80011784
8001178F
80011797
80011799
.
.
.
64 C7 05 30 00 00 00 FF+
0F B6 44 24 04
64 A2 94 00 00 00
64 8B 0D 1C 00 00 00
89 0C 85 7C 90 01 80
64 C7 05 4C 00 00 00 64+
F0 0F AB 05 00 32 02 80
8B D0
A1 6C DA 01 80
.
.
.
mov large dword ptr fs:30h, 0FFFFFFFFh
movzx eax, [esp+arg_0]
mov large fs:94h, al
mov ecx, large fs:1Ch
mov ds:_HalpProcessorPCR[eax*4], ecx
mov large dword ptr fs:4Ch, 64h
lock bts ds:_HalpActiveProcessors, eax
mov edx, eax
mov eax, ds:_HalpDefaultInterruptAffinity
.
.
.

Windows Server 2003 Entreprise SP0


 
  ; ¦¦¦¦¦¦¦¦¦¦¦¦¦¦¦ S U B R O U T I N E ¦¦¦¦¦¦¦¦¦¦¦¦¦¦¦¦¦¦¦¦¦¦¦¦¦¦¦¦¦¦¦¦¦¦¦¦¦¦¦
; __stdcall HalInitializeProcessor(x,x)
    public _HalInitializeProcessor@8
_HalInitializeProcessor@8 proc near

arg_0= byte ptr 4
arg_4= dword ptr 8
 
80012F10
80012F1B
80012F20
80012F26
80012F2D
80012F34
80012F3F
80012F47
80012F49
.
.
.
64 C7 05 30 00 00 00 FF+
0F B6 44 24 04
64 A2 94 00 00 00
64 8B 0D 1C 00 00 00
89 0C 85 80 A0 01 80
64 C7 05 4C 00 00 00 64+
F0 0F AB 05 20 4D 02 80
8B D0
A1 BC EC 01 80
.
.
.
mov large dword ptr fs:30h, 0FFFFFFFFh
movzx eax, [esp+arg_0]
mov large fs:94h, al
mov ecx, large fs:1Ch
mov ds:_HalpProcessorPCR[eax*4], ecx
mov large dword ptr fs:4Ch, 64h
lock bts ds:_HalpActiveProcessors, eax
mov edx, eax
mov eax, ds:_HalpDefaultInterruptAffinity
.
.
.

Nous pouvons voir quelques différences entre win2k et XP/Server 2003. Les adresses du PCR courant sont en brut pour win2k. Alors que sous XP et Server 2003, le PCR courant est manipulé en passant par le segment fs.

Mais il faut surtout relever que l'opcode utilisé pour remplir la table garde la même signature pour les trois systèmes.
signature : 0x89,0x0C,0x85 + p_HalpProcessorPCR

Voilà :) Tout est là !

Il n'y a plus qu'à faire ses courses tranquillement. Pas besoin d'API, aucune dépendance à l'irql courant, rapide et ouverte...

Elle est bien sur limitée aux systèmes étudiés pour ce texte et demande donc quelques modifications pour pouvoir fonctionner sur d'autres versions de windows. Car depuis Windows Server 2003 SP1 les signatures changent.

Kernel Mode
méthode 4 (générique)

ALL_LEVEL (IRQL=0 à 31)

Cette méthode n'utilise pas d'API et pas de structure en brut. Elle travaille sur une gestion du scan de la mémoire qui évite les fautes de pages. Elle n'est donc pas dépendante de l'IRQL courant.

C'est pour l'instant la méthode la plus générique que j'ai pu trouver. Le principe est simple : scanner la mémoire du noyau à la recherche des KPCR.

Pour cela, quelques règles simples sont utilisées pour retrouver sans ambiguïté tous les KPCR :

  • Les KPCR sont toujours alignés sur une page mémoire. Ceci permet un scan quasi instantané de toute la mémoire du noyau.
  • Chaque KPCR a un pointeur vers lui-même dans son champ SelfPcr.
  • Chaque KPCR donne (dans son champ GDT) un pointeur vers la table GDT propre au processeur qu'il représente. Le sélecteur de segment 0x30 dans la GDT définie le segment FS qui n'est rien d'autre que le KPCR lui-même. En d'autres mots, en mode kernel, le descripteur de segment du sélecteur de segment 0x30 a comme base mémoire le KPCR du processeur représenté.

Cette méthode est vraiment générique car, contrairement à d’autres méthodes, les offsets des champs de la structure KPCR ne sont pas utilisés en brut. Ils sont déduits en mémoire. Tout ça en fait une méthode que je pense assez efficace.

That's all folks !

Lionel d'Hauenens
 

 
Labo - Association Loi 1901 - 04200 PEIPIN - France

Désengagement : Laboskopia se désengage de toute responsabilité quand à l’utilisation ou le mauvais usage qui peut être fait des informations contenues dans son site. Les renseignements contenus dans la publication des Bulletins peuvent changer et évoluer. L'utilisation de ces renseignements constitue l'acceptation des conditions. Toutes utilisations de ces informations se fait au risque de l'utilisateur.