Malicious debugger ! (3/3)

Wed 03 October 2007 by alex

Windows contient des fonctions de debuggage offrant des possibilités équivalentes à ptrace sous Linux. Contrairement aux systèmes UNIX, plusieurs fonctions sont disponibles, et ont des noms assez explicites. Alors que les possibilités de debuggage sous UNIX sont regroupés dans une seule fonction, Windows en propose 17. En plus de cela, une bibliothèque supplémentaire, DbgHelp, permet de manipuler les symboles de debuggage s'ils sont présents dans l'exécutable ou dans un fichier annexe. Cette bibliothèque est utilisée généralement à des fins classiques : elle permet de debugger plus facilement ses programmes ou ceux dont les symboles sont disponibles. Les fonctions de cette bibliothèque n'étant pas (ou peu) utilisable d'un point d'un point de vue malicieux, elles ne seront pas traitées ici.

Comment ça marche?

  • le processus s'attache à un processus existant, via DebugActiveProcess, ou lance un processus en s'octroyant les droits de debug avec CreateProcess.
  • le processus effectue les opérations désirées : lecture / écriture de la mémoire avec ReadProcessMemory ou WriteProcessMemory, lecture / écriture des registres avec GetThreadContext et SetThreadContext, et attente des notifications avec WaitForDebugEvent.
  • le processus se détache avec DebugActiveProcessStop, ou termine sans se détacher.

Un processus peut débugger un autre processus uniquement s'il a tous les droits sur celui-ci. Un processus qui a le privilège SE_DEBUG_NAME a le droit de debugger n'importe quel processus. Il l'obtient en appelant AdjustTokenPrivileges. S'il peut s'attacher, la structure du code du debugger est toujours la même : celui-ci se met en attente d'une notification du processus. Quand un événement se produit, le système bloque tous les threads du processus et envoie la notification au debugger, qui traite l'événement et reprend l'exécution du programme. Les événements peuvent être entre autres :

  • la création d'un processus. C'est le premier message reçu par le debugger lorsqu'il est attaché à un processus ;
  • la levée d'une exception. La plupart des types d'exceptions utilisées pour réellement analyser les problèmes d'un programme (division par zéro, etc.) ne sont pas intéressants ici : on se concentre plutôt sur les points d'arrêt quand on veut s'arrêter à une adresse particulière, ou sur les exceptions single step quand on cherche à tracer l'exécution d'une routine ;
  • la destruction d'un processus, quand le processus débuggé termine.

Le code d'un debugger très simple serait :

int BasicDebugger(DWORD dwProcessId)
{
    DEBUG_EVENT db;
    BOOL bDebug = TRUE;
    DWORD dwContinueStatus = DBG_CONTINUE;

    // S'attache à un autre processus
    DebugActiveProcess(dwProcessId)

    // Boucle d'attente des événements
    while (WaitForDebugEvent(&db, INFINITE) && bDebug)
    {
        static HANDLE hThread = NULL;
        static HANDLE hProcess = NULL;

        switch (db.dwDebugEventCode)
        {
        case CREATE_PROCESS_DEBUG_EVENT:
            // Création d'un processus. On sauvegarde le handle du processus
            // et celui du thread principal.
            hThread = db.u.CreateProcessInfo.hThread;
            hProcess = db.u.CreateProcessInfo.hProcess;
            ...
            break;

        case EXCEPTION_DEBUG_EVENT:
            if(db.u.Exception.ExceptionRecord.ExceptionCode == EXCEPTION_BREAKPOINT)
            {
                // Routine à exécuter lors d'un point d'arrêt logiciel
            }
            ...
            break;
        }
        ContinueDebugEvent(db.dwProcessId, db.dwThreadId, dwContinueStatus);
    }
    DebugActiveProcessStop(dwProcessId);
}

Injection mémoire

L'injection de code sous Windows peut se faire de la même manière que sous UNIX. Néanmoins, il existe une méthode plus simple : au lieu d'injecter une portion decode, on injecte un thread dans le processus. Cette méthode est utilisée par de nombreux codes malicieux (vers, virus, etc.) et est donc détectée par la majoritédes antivirus du marché. Elle n'utilise pas de fonctions de débogage; mais pourquoi faire comme sous UNIX si on peut faire plus simple?

Un thread peut être lancé dans un autre processus via CreateRemoteThread. Il faut spécifier l'adresse de départ du thread, qui doit se situer dans l'espaced'adressage du processus. On doit donc tout d'abord copier la fonction à exécuter dans le processus, avec WriteProcessMemory. Vient ensuite un problème : l'appel de fonctions externes. Comment appeler des fonctions externes (API Win32, entre autres)? Si ces fonctions sont présentes dans la table d'imports du processus, on parcourt la table d'imports du processus en mémoire, et on récupère l'adresse des fonctions. Cette méthode est un peu ardue. Le plus simple est delier dynamiquement les fonctions : on charge une DLL avec LoadLibrary et on récupère l'adresse d'une de ses fonctions avec GetProcAddress. On estalors en mesure appeler n'importe quelle fonction exportée par une bibliothèque.

Reste encore un problème : comment connaître l'adresse de ces deux fonctions? Ces fonctions sont exportées par kernel32.dll, qui est toujours mappée à la même adresse sur un système, quel que soit le processus. L'adresse des fonctions de kernel32.dll sera donc toujours la même, quel que soit le processus. On passe donc ces adresses au thread sous forme d'arguments. Ces arguments doivent être dans l'espace d'adressage du processus distant. Il faut donc les copier dans une zone de mémoire allouée dans le processus, et tout marche.

Deux petites remarques :

  • les chaînes de caractères utilisées dans le thread sont déclarées comme des tableaux de char ({'a', 'b'...} et pas "ab...") afin d'être stockées dans la pile. Dans le cas contraire elles sont stockées dans la section .data du processus qui va injecter le code, section qui n'est pas lisible par le processus où va être injecté le code ;
  • il faut de plus supprimer les instructions de vérification de la pile (security cookie check), chargées de détecter les vérifications de tampon via deux appels en début et fin de fonction, car celles-ci utilisent également une variable de la section .data, qui n'est pas lisible par le thread.

Vol de mots de passe

Etudions maintenant le cas d'un spyware récupérant les mots de passe entrés par les utilisateurs. Généralement, ceux-ci peuvent décoder ou déchiffrer les motsde passe qui sont enregistrés dans la base de registres ou dans des fichiers de configuration. D'autres applications, comme les archiveurs, ne stockent pas lesmots de passe. Dans le cas des archiveurs, ils sont utilisés pour chiffrer les fichiers compressés, et il devient impossible dans la plupart des cas de retrouver le mot de passe à partir d'une archive chiffrée. Les fonctionnalités de debuggage permettent de récupérer ces mots de passe lorsqu'ils sont tapés par les utilisateurs.

7-Zip est un archiveur offrant une très bonne compression, et permet de chiffrer ses documents avec AES-256. Sans connaître le mot de passe il est impossible de lire les fichiers compressés. Le mot de passe est rentré dans une boite de dialogue. L'application récupère le mot de passe et, après une rapide étude du programme on s'aperçoit que le mot de passe est visible en mémoire quand l'application est à l'adresse 0x401C61 (7-Zip 4.45 Beta). Le mot de passe se trouve alors à l'adresse pointée par edi, au format UNICODE.

On sniffe ce mot de passe à l'aide d'un debugger. La méthode est très basique :

  • on cherche la fenêtre de 7-Zip pour récupérer le pid du processus ;
  • on s'attache à 7-zip ;
  • on pose un point d'arrêt à l'adresse 0x401C61 et on attend que l'utilisateur entre un mot de passe ;
  • une fois le mot de passe entré, le debugger prend la main. On lit la valeur de edi, puis le tableau d'octets stockés à cette adresse : c'est le mot de passe ;
  • on restaure le code original (avant la pose du point d'arrêt) et on reprend l'exécution du programme comme si de rien n'était.

Il est à noter que depuis Windows XP, le debugger peut se détacher d'un processus sans fermer celui-ci, via DebugSetProcessKillOnExit(). Notre programme espion pourra donc se fermer une fois que le mot de passe aura été enregistré.

Se protéger

Les protections contre le reverse engineering sont beaucoup plus développées sous Windows que sous Linux. Les codes anti-debug sont donc extrêmement nombreux. L'API Windows contient une fonction déterminant si le processus qui l'appelle est débuggé ou pas. Avec Windows XP SP1 est apparue une autre fonction qui fait la même chose sur n'importe quel processus. Les prototypes de ces fonctions sont :

BOOL IsDebuggerPresent(void);
BOOL CheckRemoteDebuggerPresent(HANDLE hProcess, PBOOL pbDebuggerPresent);

Sous Windows, les informations sur chaque processus sont contenues dans le PEB (Process Environment Block). Cette structure contient un octet, BeingDebugged. IsDebuggerPresent renvoie simplement la valeur de cet octet. Le code de cette fonction est :

; BOOL IsDebuggerPresent(void)
    public __stdcall IsDebuggerPresent()
__stdcall IsDebuggerPresent() proc near
    mov     eax, large fs:18h
    mov     eax, [eax+30h]        ; Récupère l'adresse du PEB
    movzx   eax, byte ptr [eax+2] ; eax <-- peb->BeingDebugged
    retn
__stdcall IsDebuggerPresent() endp

Cette fonction est une base pour détecter si un processus est débuggé ou pas. Si elle est souvent utilisée par les développeurs soucieux de se protéger, elle est également très connue des attaquants. Pour éviter qu'un attaquant détecte un appel à cette procédure, on l'émule nous même avant le code à protéger :

int _tmain(int argc, _TCHAR* argv[])
{
    PPEB ppeb = NULL;
    __asm
    {
        mov eax, fs:[18h]
        mov eax, [eax+30h]
        mov ppeb, eax
    }
    if (ppeb->BeingDebugged)
    {
        _tcprintf(TEXT("Process is debugged. Exiting\n"));
        return 0;
    }
    // Code à exécuter s'il n'y a pas de debugger
    return 0;
}

Ce code fonctionne si l'attaquant pose un point d'arrêt sur IsDebuggerPresent(), mais pas s'il modifie directement la valeur de l'octet BeingDebugged dans le PEB en la mettant à zéro. On peut pour se protéger mettre BeingDebugged à une valeur quelconque. Les outils automatiques permettant la furtivité des debuggers feront que IsDebuggerPresent renverra 0 au lieu de notre valeur, et on pourra ainsi détecter si le processus est débuggé ou pas. Cette méthode, pourtant toute simple, détecte la plupart des outils publics existants. Le code suivant illustre ce mécanisme :

#define BEING_DEBUGGED 0x67

int _tmain(int argc, _TCHAR* argv[])
{
    PPEB ppeb = NULL;

    // Premier test si aucun outil de furtivité n'est présent
    if(IsDebuggerPresent() != 0)
    {
        _tcprintf(TEXT("Process is debugged. Exiting\n"));
        return 0;
    }
    __asm
    {
        mov eax, fs:[18h]
        mov eax, [eax+30h]
        mov ppeb, eax
    }
    ppeb->BeingDebugged = BEING_DEBUGGED;

    // Renvoie 0 au lieu de BEING_DEBUGGED si un outil modifie le PEB
    if(IsDebuggerPresent() != BEING_DEBUGGED)
    {
        _tcprintf(TEXT("Anti-IsDebuggerPresent detected. Exiting\n"));
        return 0;
    }
    // Code à exécuter
    return 0;
}

On peut également empêcher un debugger de s'attacher à notre processus. Comme sur les systèmes UNIX, un processus ne peut être débuggé que par un seul autreprocessus. Pourquoi ne pas créer un processus qui se debugge lui-même? On imaginer un exécutable fonctionnant de deux façons :

  • il fonctionne "normalement" s'il est débuggé.
  • il lance le même exécutable s'il n'est pas debuggé. Il agit alors comme un debugger et communique avec le processus auquel il est attaché (en modifiant ses données, en posant des points d'arrêts pour le rediriger, etc.). Ses fonctionnalités "normales" ne sont pas utilisées.

Il y a une forte interaction entre le processus servant de debugger et leprocessus fils. Il devient difficile pour un attaquant, s'il arrive à s'attacherau processus, de simuler ces interactions. Il doit d'abord comprendre lefonctionnement du processus quand il fonctionne comme un debugger.

Comment détecter si le processus attaché est bien le bon? Si un processus est débuggé, il ne peut pas savoir par qui. En utilisant juste IsDebuggerPresent(), tout debugger pourra s'attacher au processus, alors qu'on veut bloquer cela. On peut utiliser un mécanisme d'exclusion mutuelle. Pour cela on crée un objet mutex dont le processus "fils" héritera lors de sa création. On teste la présence de ce mutex : s'il n'existe pas, on le crée et on lance un nouveau processus auquel on s'attache, et s'il existe, on sait que le processus est déjà debuggé et il s'exécute normalement. Il n'y a pas d'interaction entre le debugger et l'autre processus ici, mais elles peuvent être facilement rajoutées. Toutes ces méthodes dérouteront uniquement un attaquant débutant. Le reverse engineering sous Windows est bien plus développé que sous UNIX; en conséquence les contre-mesures le sont également. Il est très difficile d'empêcher l'utilisation d'un debugger, en particulier en ring 3 où les fonctionnalités sont relativement restreintes. Les fonctions contre le debuggage doivent être couplées à d'autres méthodes (chiffrement, obfuscation, etc.) pour être efficaces. Seules, elles ne font que retarder un attaquant pendant, dans la majorité des cas, très peu de temps.

Conclusion

Nous avons vu différentes applications malicieuses de debuggage, sous Linux grâce au tout puissant appel système ptrace et sous Windows avec l'API standard : modification du comportement d'un processus, injection de code, etc. Ces exemples ne sont qu'une parcelle de ce qui peut être fait, la seule limite venant de l'imagination des programmeurs.

Un debugger permet de contrôler l'exécution complète d'un processus. Son utilité première, la correction de bogues, peut être détournée pour en faire un très bonoutil de contrôle des processus, quels qu'il soient. Les applications présentées ici ne sont pas exploitables dans un environnement relativement sûr : si leur principe est valable, les méthodes qu'elles utilisent sont trop communes pour ne pas être détectées par un antivirus ou un HIDS correct, qui surveillent en permanence de nombreuses fonctions critiques utilisées dans les exemples.

Étant donné les possibilités d'un debugger une fois qu'il est attaché à un processus, il paraît intéressant d'essayer de se protéger contre cela. Malheureusement, dans la pratique les tentatives aboutissent très rarement. Les méthodes utilisées, une fois analysées, sont très souvent publiées puis intégrées dans des outils qui permettent de "cacher" les debuggers. A moins d'une véritable réflexion, envisageant tous les angles d'attaque au moment du développement d'une protection, et surtout un couplage avec d'autres types de protection, une succession d'anti-debug ne sera que très peu pénalisante pour un attaquant.