
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.