Bon, c'est un veil article que j'avais écrit lorsque j'étudiais les buffer overflow sous Windows, il devait traiter des structured exception handler, de leur exploitation dans les débordements de tampon et les nouvelles protections mises au niveau du compilateur (Safe SEH). Je devais finir cet article un jour, mais je pense que ce jour n'arrivera jamais, alors je vais déjà lâcher cette introduction au SEH qui aidera peut être un pauvre internaute perdu sur Google. Attention ça peut donner mal au crane.
- C'est quoi un SEH ?
C'est un gestionnaire d'exception qui permet au programmeur de gérer une exception lui même plutôt que de laisser le programme le faire (ce qui aboutit généralement à un ExitProcess() ). Concrètement ils sont représentés en C par les instructions __try / __except / __finally. Parmi les exceptions on peut distinguer :
- Les exceptions materielles : typiquement un ACCESS_VIOLATION ou DIVISION_BY_ZERO, c'est le style d'exception que l'on rencontre le plus souvent.
- Les exceptions logicielles : c'est le programmeur lui même qui créé ce type d'exception, il les déclenche à l'aide d'une fonction RaiseException() . Un programmeur peut par exemple créer une exception NOT_ENOUGH_MEMORY lorsqu'il n'arrive plus à allouer de mémoire.
Un SEH est responsable d'une portion de code sur laquelle il peut intercepter des exceptions. Dans cette section de code il peut également y avoir d'autre SEH qui gère eux aussi d'autre portion de code.
SEH1[ //code protégé par SEH1 SEH2[ //code protégé par SEH2, SEH1 SEH3[ ... ] ] ]
Donc dans un programme on a pas un SEH mais plusieurs, et à différent niveau, lorsqu'un SEH ne gère pas une exception il la passe au SEH du niveau supérieur. En mémoire ils sont représentés sous forme d'une liste chainée dans la pile (on verra çà plus en détail après).
Que se passe-t-il lorsqu'une exception est déclenchée ?Le système va passer en mode noyau, il va effectuer quelques opérations notamment un dump des registres du processeur ( le contexte ) qu'il va placer sur la pile du thread. Il repasse ensuite en mode utilisateur dans la fonction KiUserExceptionDispatcher() de ntdll.dll.
Si le processus est en cours de débgage le programme va passer la main au débuggeur qui va lui même gérer l'exception. S'il n'y a pas de débuggeur, ou qu'il ne gère pas l'erreur, l'exception va être retransmise au premier SEH, c'est à dire celui qui est le plus proche de là où a été générée l'exception.
Ce gestionnaire va alors regarder s'il est capable de gérer l'exception :- S'il en est capable, le SEH fait alors son traitement, par exemple il peut essayer d'obtenir des informations sur la cause de l'erreur pour générer des informations utiles au débuggage, ou bien il peut choisir d'essayer de corriger l'erreur par modification de variable, des registres... Dans tous les cas il pourra choisir de reprendre l'exécution là où l'erreur à eu lieu ou bien après la portion de code qu'il protège.
- S'il n'est pas capable de la gérer il la passe alors au SEH du niveau du dessus et ainsi de suite jusqu'à atteindre le dernier SEH.
Le comportement du dernier SEH peut dépendre des logiciels que vous avez installés sous Windows, mais sur un Windows par défaut, il va créer une boite de dialogue avec quelques informations sur l'état des registres et faire un appel à ExitProcess() pour quitter l'application. Si vous avez installé un debuggeur, Windows vous proposera de lancer le débuggeur Just In Time...
- Au niveau assembleur
Les SEH sont stockés dans la pile sous forme d'une liste chainée. La structure d'un SEH est de la forme :
_EXCEPTION_REGISTRATION struc prev dd ? handler dd ? _EXCEPTION_REGISTRATION ends
prev représente un pointeur sur le précédent SEH et handler est un pointeur vers la fonction qui va être appelée lorsqu'une exception sera levé.
Le début de cette liste chainée est stocké dans la TEB (Thread Environnement Block) dans le registre de segment fs en fs:[0]. Cela signifie également qu'une liste de SEH est propre à un thread et non à un processus.
fs:[0] pointe toujours vers le dernier SEH installé,c'est le premier qui sera appelé en cas d'exception.
Le premier SEH installé est différencié des autres par son pointeur prev qui a la valeur 0xFFFFFFFF, il est mis en place à la création du processus (dans BaseProcessStart ) et avant l'entrée dans le main()/WinMain() . Si une exception est déclenchée et qu'aucun SEH n'a géré cette exception, alors ce dernier SEH va appeler la fonction UnhandledExceptionFilter() . C'est cette fonction qui créé la boite de dialogue avec les infos sur les registres ou qui propose de lancer le debugeur en dernier recours (Le fameux Just In Time Debugging ) . Il est possible de modifier le comportement de ce seh en appelant la fonction SetUnhandledExceptionFilter() .
Pour mettre en place un SEH en assembleur on peut procéder de cette manière :
; adresse du handler push handler ; adresse de la structure SEH précédente push fs:[0] ; fait pointer fs:[0] vers notre nouveau SEH mov fs:[0],esp ; ici le code protégé par le seh ; ... ;on enléve le SEH pop fs:[0] add esp,4 ret ; notre handler handler: ; ...Une fois dans le handler
Lorsque le handler d'un SEH est appelé, des informations concernant l'exception sont mis en place sur la pile :
ESP + 0x4 | EXCEPTION_RECORD |
ESP + 0x8 | Le SEH |
ESP + 0xC | CONTEXT (cf WinNT.h) |
- Le code de l'exception (ACCESS_VIOLATION, etc.)
- Les flags : par exemple ils permettent de savoir si c'est une exception non continuable, si on est dans l'appel du stack unwinding (2éme appel du handler cf après)...
- L'adresse où a eu lieu l'exception.
C'est à partir de cette structure que le handler va pouvoir déterminer s'il a la capacité de gérer une exception.
La structure CONTEXT permet d'avoir des informations sur l'état des registres lorsque l'exception a eu lieu, c'est cette structure qu'il faut modifier si on veut reprendre l'exécution à un autre endroit en modifiant EIP.
Et enfin un pointeur vers le SEH que l'on avait mis sur la pile pour garder la portion de code où a eu lieu l'exception. Le fait d'avoir un pointeur vers ce SEH signifie que l'on peut construire un SEH personnalisé afin d'y intégrer des informations supplémentaires.
Par exemple, on pourrait avoir besoin d'avoir une adresse pour reprendre l'exécution à un endroit sûre. Voici un exemple de code qui exploite ce système de SEH étendue :
.386 ; force 32 bit code .model flat, stdcall ; memory model & calling convention option casemap :none ; case sensitive include c:masm32includewindows.inc MYSEH STRUCT prev DWORD ? handler DWORD ? safeeip DWORD ? MYSEH ENDS .code start: main PROC assume fs:nothing ; on mets en place un SEH étendu push safeip ; notre champ supplémentaire safeeip push handler ; le handler push fs:[0] ; l'adresse du SEH suivant mov fs:[0],esp ; on installe notre seh xor eax,eax mov [eax],eax ; on génére un ACCESS_VIOLATION jmp endx handler: ; esp == ret eip ; esp + 0x04 == EXCEPTION_RECORD* ; esp + 0x08 == MYSEH* ; esp + 0x0C == CONTEXT ; est ce un ACCESS_VIOLATION ? mov ebx,[esp+04h] cmp (EXCEPTION_RECORD PTR [ebx]).ExceptionCode, 0C0000005h jz AViol ; si ce n'est pas un ACCESS_VIOLATION on donne la main au handler suivant mov eax,ExceptionContinueSearch ret AViol: ; si c'est un ACCESS_VIOLATION on reprend l'execution en myseh.safeeip mov ebx,[esp+08h] ; ecx == MYSEH mov ecx,[esp+0Ch] ; ebx == CONTEXT mov edx,(MYSEH PTR [ebx]).safeeip mov (CONTEXT PTR [ecx]).regEip, edx mov eax, ExceptionContinueExecution ret safeip: endx: ; on enléve le SEH et restaure l'ancien pop fs:[0] add esp,8 ret main endp end start
Le compilateur windows utilise ce système de SEH étendue en réalisant une structure beaucoup plus complexe que le EXCEPTION_REGISTRATION vu au début.
The stack unwindingEn réalité le handler d'un SEH qui ne gère pas une exception doit être appelé une seconde fois. Ce deuxième appel est déclenché par le handler qui a décidé de gérer l'exception, il va reparcourir la liste des SEH depuis le début et exécuter une deuxième fois le handler de chaque SEH qui le précède, en rajoutant le flag EH_UNWINDING au niveau des Exceptions Flag de la structure EXCEPTION_RECORD pour que les handlers puissent différencier les 2 appels.
Ici l'action est bien déclenchée par le handler qui gère l'exception et non par le système, c'est à dire que c'est au programmeur d'implémenter cette fonctionnalité. Cela peut être fait en appelant la fonction RtlUnwind() (cf l'article de J. Gorgon pour plus d'info ) .
Le rôle du stack unwinding est de nettoyer les variables de la portion de code qui a déclenché l'exception, par exemple c'est à ce moment qu'il est utile de fermer les handles, libérer la mémoire... Concrètement ce deuxième appel correspond au bloc __finaly en C.
De plus, si l'exécution reprend à partir d'un SEH de niveau supérieur, alors le stack unwinding est également responsable d'enlever de la liste des SEH tout ceux qui ont été mis après ce SEH (c'est à dire les SEH qui ne porte plus sur le code courant ).
Voilà c'est tout pour aujourd'hui, par la suite nous verrons comment sont implémentés les SEH dans les compilateurs, comment les exploiter lors d'un débordement de tampon et les nouvelles protéctions misent en place par Windows (Safe SEH).
Réferences : http://www.microsoft.com/msj/0197/Exception/Exception.aspx http://msdn.microsoft.com/en-us/library/swezty51(VS.80).aspx