
Técnica de ejecución/inyección de código mediante la manipulación de la estructura de módulos DLL en el PEB
Ejecución de código sigilosa mediante la modificación del EntryPoint de los módulos cargados en tiempo de ejecución.
Los procesos de Windows tienen varios módulos cargados en tiempo de ejecución. Cada uno de estos módulos tiene una función DllMain() definida, que se invocará en la creación/destrucción de procesos o subprocesos (cuatro escenarios posibles).
Para llamar correctamente a esas funciones durante la vida del proceso, las funciones del cargador de Windows (ntdll!Ldrp*) consultan una lista de entradas que contienen parámetros clave (incluido el campo EntryPoint) para cada módulo.
Al sobrescribir este EntryPoint de una DLL, conseguimos que la ejecución de código se redirija a un lugar de nuestra elección.
Esto puede utilizarse tanto como primitiva de ejecución de código como para el proxying de API, es decir, para ejecutar ciertas APIs con una pila de llamadas no sospechosa, ya que serán invocadas por funciones legítimas de Windows.
También puede utilizarse para desencadenar la ejecución en un proceso remoto, siempre que el atacante tenga la capacidad de leer y escribir memoria en el proceso objetivo. De forma similar a la Threadless Injection, esto permite ejecutar código en un proceso sin invocar las API clásicas relacionadas con la ejecución (CreateRemoteThread, QueueUserAPC).
La carga/descarga de módulos dentro de un proceso de Windows es un tema complejo que presenta muchos desafíos, potencial de inestabilidad, condiciones de carrera y fallos. Un obstáculo bien conocido relacionado con la ejecución de código como parte de una función DllMain(), por ejemplo, radica en que hay un Loader Lock activo y en que nos encontramos en un subproceso que no se ha configurado por completo, o que está en proceso de terminación.
Por lo tanto, he intentado documentar adecuadamente qué es posible y qué no. Por ejemplo, aunque la mayoría de las llamadas API habituales pueden realizarse, ejecutar un beacon completo conlleva ciertos requisitos de estar en un proceso separado, para evitar interbloqueos causados por las funciones utilizadas en wininet.dll o winhttp.dll.
Cada proceso mantiene una lista de estructuras _LDR_DATA_TABLE_ENTRY en tiempo de ejecución. Estas estructuras contienen muchos detalles relevantes de la DLL, como su EntryPoint (que sobrescribiremos), su nombre, ciertos hashes, marcas de tiempo, varias banderas, etc. Algunas de estas estructuras están documentadas y otras no.
Estas pueden visualizarse mediante este comando de WinDbg:
dt nt!_LDR_DATA_TABLE_ENTRY 0xdeadbeef
0:006> dt nt!_LDR_DATA_TABLE_ENTRY 0x18d1c4838c0
ntdll!_LDR_DATA_TABLE_ENTRY
+0x000 InLoadOrderLinks : _LIST_ENTRY [ 0x0000018d`1c485de0 - 0x0000018d`1c4832b0 ]
+0x010 InMemoryOrderLinks : _LIST_ENTRY [ 0x0000018d`1c485df0 - 0x0000018d`1c4832c0 ]
+0x020 InInitializationOrderLinks : _LIST_ENTRY [ 0x0000018d`1c4832d0 - 0x0000018d`1c482c80 ]
+0x030 DllBase : 0x00007ffe`e87e0000 Void
+0x038 EntryPoint : 0x00007ffe`e8838d00 Void
+0x040 SizeOfImage : 0x2fe000
+0x048 FullDllName : _UNICODE_STRING "C:\WINDOWS\System32\KERNELBASE.dll"
+0x058 BaseDllName : _UNICODE_STRING "KERNELBASE.dll"
+0x068 FlagGroup : [4] "???"
+0x068 Flags : 0x8a2cc
+0x068 PackagedBinary : 0y0
+0x068 MarkedForRemoval : 0y0
+0x068 ImageDll : 0y1
(...)
+0x0e0 MappingInfoIndexNode : _RTL_BALANCED_NODE
+0x0f8 OriginalBase : 0x00007ffe`e87e0000
+0x100 LoadTime : _LARGE_INTEGER 0x01db5f86`2fa735fc
+0x108 BaseNameHashValue : 0x235bec4
+0x10c LoadReason : 0 ( LoadReasonStaticDependency )
+0x110 ImplicitPathOptions : 0x4000
+0x114 ReferenceCount : 1
+0x118 DependentLoadFlags : 0x800
+0x11c SigningLevel : 0 ''
Tenga en cuenta la bandera DontCallForThreads. Como su nombre indica, si esa bandera está establecida, el sistema operativo NO llamará al DllMain() de ese módulo para eventos de subproceso (es decir, DLL_THREAD_ATTACH o DLL_THREAD_DETACH).
Al crear una DLL, se debe seguir la siguiente plantilla para trabajar en conjunto con las funciones del cargador del sistema operativo:
BOOL WINAPI DllMain(
HINSTANCE hinstDLL, // handle to DLL module
DWORD fdwReason, // reason for calling function
LPVOID lpvReserved ) // reserved
{
// Perform actions based on the reason for calling.
switch( fdwReason )
{
case DLL_PROCESS_ATTACH:
// Initialize once for each new process.
// Return FALSE to fail DLL load.
break;
case DLL_THREAD_ATTACH:
// Do thread-specific initialization.
break;
case DLL_THREAD_DETACH:
// Do thread-specific cleanup.
break;
case DLL_PROCESS_DETACH:
if (lpvReserved != nullptr)
{
break; // do not do cleanup if process termination scenario
}
// Perform any necessary cleanup.
break;
}
return TRUE; // Successful DLL_PROCESS_ATTACH.
}
Como se ha descrito anteriormente, la técnica sobrescribe temporalmente el EntryPoint de una DLL para redirigir la ejecución. Dado que no tenemos control más allá de la redirección de la ejecución, hay que hacer algunos arreglos en paralelo para manejar qué queremos ejecutar, con qué argumentos y cómo recuperar el valor de retorno.
Esto se logra definiendo una estructura DATA_T en el heap, de modo que permanezca accesible a lo largo de los distintos pasos.
Esa estructura se define de la siguiente manera:
typedef struct _DATA_T {
// LDR structures manipulation
ULONG_PTR runner; // malicious entry point to execute
ULONG_PTR bakOriginalBase; // backup of overwritten OriginalBase
ULONG_PTR bakEntryPoint; // backup of overwritten EntryPoint
HANDLE event; // event signalling that the Runner has executed
// function call
ULONG_PTR ret; // return value
DWORD createThread; // run this API call in a new thread (required for wininet/winhttp)
ULONG_PTR function; // Windows API to call
DWORD dwArgs; // number of args
ULONG_PTR args[MAX_ARGS]; // array of args
} DATA_T, * PDATA_T;
Para configurar una ejecución de API, hay que preparar estos campos. El valor ret es el que recogerá el valor de retorno tras la ejecución. El event se utiliza para la sincronización, para señalar que la ejecución ha terminado. Todos los demás campos son entradas que definen qué API llamar (function), con qué argumentos (dwArgs y args[]), la dirección de la función Runner() a la que se redirige la ejecución, y copias de respaldo de las entradas DLL originales sobrescritas (bakOriginalBase y bakEntryPoint).
El campo createThread debe establecerse a 1 para aquellas funciones API complejas que no se ejecutan bien en un entorno DllMain() (esto incluye muchas librerías wininet y winhttp).
Aquí hay un ejemplo de configuración de una llamada a MessageBoxA() como se ve en el PoC:
pDataT->dwArgs = 4;
pDataT->runner = (ULONG_PTR)Runner;
pDataT->function = (ULONG_PTR)MessageBoxA;
pDataT->args[0] = (ULONG_PTR)0;
pDataT->args[1] = (ULONG_PTR)"Hello";
pDataT->args[2] = (ULONG_PTR)"LDRSHUFFLE";
pDataT->args[3] = (ULONG_PTR)MB_OKCANCEL;
pDataT->event = CreateEventA(NULL, FALSE, FALSE, "ExecEvt");
La función UpdateLdr() es la responsable de realizar la modificación adecuada en la _LDR_DATA_TABLE_ENTRY del módulo objetivo.
RestoreLdr() restaurará esos cambios en una etapa posterior (invocada por Runner()).
Estas funciones localizan esencialmente el PEB y recorren las estructuras de módulos para identificar la DLL correcta y sus campos. En los archivos de cabecera, reutilizo definiciones utilizadas por Batsec en su DarkLoadLibrary y animo a los lectores a revisar este proyecto y el blogpost asociado de MDSec para beneficiarse del excelente trabajo que ha realizado sobre los entresijos de la carga de módulos en Windows.
Nota: este PoC carga una DLL sacrificial (SACRIFICIAL_DLL_NAME) y realiza esos cambios sobre esa DLL. Sin embargo, es perfectamente factible modificar una DLL ya cargada. De hecho, este es el enfoque utilizado para la inyección entre procesos. Por razones de estabilidad, recomendaría evitar tocar DLL importantes como ntdll o kernel32, que también tienden a ser más examinadas por las soluciones de seguridad.
En la creación o destrucción de un subproceso, la ejecución se redirige a Runner(), que actúa como un DllMain() falso para el módulo. Esta función entonces:
PDATA_T)RestoreLdr() a su estado originalDllMain() (esencialmente haciendo de proxy de la llamada DLL normal).En este punto, se ha realizado la ejecución "normal" del sistema operativo. A continuación continúa con nuestras cargas útiles:
DATA_T. Si esta API ha sido marcada para ejecutarse en un nuevo subproceso (createThread = 1), esta llamada se realizará en un nuevo subproceso.pDataT->event) para que nuestro código principal sepa que la llamada se ha realizado.Cuando Windows termina invocando nuestro EntryPoint falso (que es la dirección de la función Runner()), la pila de llamadas se ve de la siguiente manera:

El PoC proporcionado contiene un ejemplo que invoca MessageBoxA().
También contiene una demostración de una descarga HTTP usando wininet. Defina la variable HTTP para habilitar ese código.

Los principios descritos anteriormente se resumen en leer y escribir en el espacio de memoria del proceso, con el fin de provocar la ejecución de código en un punto arbitrario del futuro.
Con algunos ajustes, estas operaciones de lectura y escritura pueden aplicarse a un proceso remoto para sobrescribir el EntryPoint de una de sus DLL.
Un requisito previo es la capacidad de leer y escribir en el espacio de memoria del proceso, es decir:
OpenProcess(PROCESS_VM_READ, FALSE, dwPid) y OpenProcess(PROCESS_VM_WRITE | PROCESS_VM_OPERATION, FALSE, PID)
En el PoC hay un proyecto adicional, llamado LdrInject, que demuestra cómo realizar estos pasos. En resumen, hace lo siguiente:
en ReadPEB(), recorre la lista _LDR_DATA_TABLE_ENTRY en el proceso objetivo para identificar una DLL adecuada a sobrescribir. Tenga en cuenta que esta DLL debe tener DontCallForThreads == 0 porque queremos que Windows invoque el EntryPoint de esa DLL en la creación de subprocesos. Tampoco elegimos las primeras DLL de la lista, ya que tienden a estar más examinadas por los productos de seguridad (ntdll.dll, kernel32.dll...).
los detalles de esa DLL se almacenan en una estructura de datos PEBINJ_DATA.
el shellcode (en este caso un beacon) se escribe en el espacio del proceso remoto con InjectShellcodeToRemoteProcess()
dos llamadas a WriteProcessMemory() sobrescriben el EntryPoint de la DLL y lo respaldan en OriginalBase para que pueda restaurarse más tarde.
En ese momento, el siguiente evento DLL_THREAD_ATTACH o DLL_THREAD_DETACH dará como resultado la invocación del shellcode. Esto conlleva ciertas limitaciones y advertencias en el contexto de la ejecución de un beacon, que se detallan en la siguiente sección.
Esta técnica da como resultado la ejecución de un shellcode en una situación muy específica. El Loader Lock está activo (ya que el sistema operativo cree que está en proceso de cargar/descargar una DLL); un subproceso se está creando o destruyendo; y, en términos generales, existe potencial de problemas de sincronización de subprocesos, interbloqueos, etc.
Durante las pruebas, se han observado dos desafíos:
ejecutar un beacon típico de Cobalt Strike provocaría un interbloqueo al usar APIs en wininet.dll o winhttp.dll.
ejecutarse en la destrucción de un subproceso causa problemas de estabilidad, ya que nos encontramos en un subproceso que está en proceso de ser destruido.
Para aumentar la estabilidad, tenemos que:
asegurarnos de que el beacon se ejecutará en un nuevo subproceso. Por lo tanto, el UDRL hará CreateThread antes de invocar el punto de entrada habitual de la DLL reflectiva de Cobalt Strike.
ejecutarse solo en un subproceso que se está creando y no en uno que está muriendo. Para ello, nos aseguramos de que cuando el sistema operativo llame al EntryPoint, el motivo invocado sea fdwReason == DLL_THREAD_ATTACH:
winApi.CreateThread(NULL, 4096, (LPTHREAD_START_ROUTINE)&runner, (LPVOID)&ct_data, 0, &dwThId);
en lugar de lo habitual
((DLLMAIN)entryPoint)((HINSTANCE)loaderStart, 4, NULL);
ULONG_PTR __cdecl ReflectiveLoader(HINSTANCE hinstDLL, DWORD fdwReason, LPVOID lpvReserved) {
// only run for a Thread creation event
if (fdwReason != DLL_THREAD_ATTACH) {
return TRUE;
}
...
}
Estos dos pasos adicionales se han incorporado en una demo para un UDRL.
VirtualAlloc
VirtualProtect
CreateThread
Sleep
MessageBoxA
InternetOpenW (necesita ejecutarse con createThread = 1)
InternetOpenUrlA (necesita ejecutarse con createThread = 1)