David Rosas — investigación
11 oct 2026·Dridex·Ingeniería inversa

Un payload en 61 trozos y una cabecera PE que se borra sola.

Un megabyte de ejecutable del que solo 100 KB son malware: el resto es ruido aleatorio. El payload viaja partido en 61 fragmentos que solo se descifran una vez reunidos, la cabecera PE se reconstruye campo a campo en memoria y se vuelve a borrar al terminar, y no hay ni una cadena ni un import de verdad. La carpeta decía Zeus; es Dridex.

Muestra 6e05e84c7a993880409d7a0324c10e74 Compilada 3 jul 2019 Familia Dridex Objetivo Windows · PE32 nativo

Lo que es, y lo que no

La muestra es un PE32 nativo de un megabyte que se hace pasar por una DLL de DirectX: lleva la información de versión robada de un proyecto legítimo de Delphi, con el nombre d3dx81ab.dll y la cadena «DirectX 8.1 D3DX8 DLL for JEDI projects». La fecha de compilación dice 2002. Es falsa; el payload que esconde se compiló en julio de 2019.

Dentro hay un cargador de Dridex, el linaje que desciende de Cridex y Bugat. No es un troyano bancario completo: es la pieza que prepara el terreno. Despliega por DLL side-loading, eleva privilegios abusando de binarios del sistema que se auto-elevan, se inyecta en otros procesos y habla con una red de pares. Los módulos bancarios vienen después y no están aquí.

La carpeta decía Zeus, y no lo es. La forma más rápida de descartarlo fue por ausencia: ZeuS existe para robar credenciales de banca mediante form-grabbing, enganchando el navegador. Probé 21 nombres de API característicos de ZeuS contra los 115 que esta muestra resuelve, y no aparece ninguno. Sin InternetReadFile ni HttpQueryInfoA para los web injects, sin TranslateMessage para el teclado, sin GetClipboardData. Un ZeuS sin nada de eso no es un ZeuS.

Revisión del 11 de octubre de 2026. Cuando escribí esto no tenía un ZeuS real con el que contrastar. Ahora sí, y la lista estaba a medias: de los 21 nombres, 15 están en un ZeuS 2.0.8.9 auténtico y seis no. Esos seis no discriminaban nada, porque dan «ausente» también sobre un ZeuS de verdad. Dos mecanismos estaban mal atribuidos: el ZeuS real no usa GetAsyncKeyState para el teclado sino que engancha TranslateMessage, y para Firefox no toca NSS_Init ni PK11SDR_Decrypt sino nspr4.dll — una capa más abajo, el socket antes de TLS, que es una capacidad distinta. La conclusión no cambia: esto es Dridex, y lo prueban el autoElevate, el side-loading, la arquitectura entre pares y la fecha. Pero el razonamiento citaba marcadores que el propio ZeuS no tiene.

La atribución a Dridex viene de los ficheros que deja caer: dos DLL de 716 y 708 KB, etiquetadas trojan.dridex/cridex, llamadas WINSTA.dll y VERSION.dll. Esos nombres no son casuales: son objetivos de DLL side-loading. Y encajan con lo que ya había leído en el binario antes de consultar nada externo.

Lo que encontré en el binarioLa pieza de Dridex que lo explica
<autoElevate>trueElevación de privilegios: binario del sistema que se auto-eleva, con una DLL plantada al lado
winsxs, wow64, *.exe, *.dllBarrido de directorios buscando objetivos de side-loading
Tasks\, NTUSER.DATPersistencia por tarea programada y acceso a la colmena de registro
Sin keylogger, sin NSS, sin web injectsEl cargador va separado de los módulos bancarios
No hay ninguna URL de C2 embebidaDridex funciona por pares, no contra un servidor fijo

La cadena de cuatro etapas

0 · d3dx81ab.dll stub del crypter · 1.008 KB Reúne 61 fragmentos dispersos y los descifra con RC4, clave de 52 bytes 1 · buffer reunido 942.080 B · tres regiones Relleno, cargador y payload separados por el desplazamiento que el propio stub calcula 2 · cargador reflexivo 9.728 B · un solo import Resta una clave de 32 bytes y reconstruye la cabecera PE campo a campo 3 · el bot 930.816 B · DLL, 432 funciones de los que solo 100 KB son código
Cada salto cambia de técnica. Ninguna de las etapas 2 y 3 llega a tocar el disco.

Las dos últimas etapas solo existen en memoria. El stub las construye, las mapea y salta a ellas; nunca se escriben. Eso importa para la detección: las reglas que las cubren son para volcados de proceso, no para ficheros.

EtapaQué esTamañoMD5
0Stub del crypter1.032.1926e05e84c7a993880409d7a0324c10e74
1Buffer reunido y descifrado942.08071b2680ce618e0ba3e25baaa03fa8f79
2Cargador reflexivo de PE9.72862b66560da6ef356a564ff9bc87bb3f4
3El bot930.816e2554b59145e67c7d1ca6f2da742910e

El payload en 61 trozos

La primera etapa no guarda el payload en un bloque. Lo tiene repartido en 61 fragmentos esparcidos entre dos secciones con nombres basura, aei y 9B|PsbB+, y un trozo más escondido en .data. Una tabla de 61 entradas, con paso de 160 bytes, dice de dónde sale cada uno y cuánto mide.

en el fichero, disperso .data · 1 aei · 16 9B|PsbB+ · 44 tabla de 61 entradas, paso 0xa0 concatenados en memoria 942.080 bytes, todavía cifrados RC4, una sola pasada, clave de 52 B en claro El orden lo fija la tabla: sin ella, la clave no sirve de nada.
Primero reunir, después descifrar. Separar las dos operaciones es lo que rompe el carving.

El detalle que convierte esto en una defensa y no en una curiosidad es el orden. RC4 es un cifrado de flujo: la clave genera un chorro continuo que depende de cuántos bytes lleves consumidos. Al cifrar el buffer ya concatenado, cada fragmento queda cifrado con una porción distinta del chorro, y esa porción depende de la suma de las longitudes de todos los anteriores. Aunque tengas la clave, un fragmento suelto no se descifra: hace falta la tabla entera para saber en qué posición del chorro empieza.

Los campos de longitud rematan la idea. Cada entrada guarda un lo y un hi, y la longitud es la resta. Las bases son aleatorias y distintas en cada entrada —la primera arranca en 0xa86afbb2— así que los números no significan nada por separado. Solo la diferencia tiene sentido.

La cabecera que se borra sola

Las etapas 2 y 3 están guardadas sin una cabecera PE válida. No es que esté cifrada: le faltan campos concretos, y la lista es quirúrgica.

en disco en memoria ya mapeada e_magic e_lfanew firma PE Machine NumberOfSections SizeOfOptHeader Magic repone «MZ» 0xc8 «PE\0\0» 0x14c · i386 6 0xe0 0x10b · PE32 borra «MZ» 0xc8 00 00 00 00 00 00 00 00 borrados en el fichero válida, unos milisegundos la firma, otra vez a cero Un escáner que recorra la memoria del proceso no encuentra un ejecutable. Y el carving estático sobre el fichero tampoco, porque los campos no están.
Los campos que faltan son exactamente los primeros que comprueba cualquier parser de PE.

Fíjate en qué campos son: e_magic, e_lfanew, la firma, Machine, NumberOfSections, SizeOfOptionalHeader y Magic. Es la lista exacta de lo primero que mira un parser para decidir «esto es un PE». Todo lo demás —SectionAlignment, FileAlignment, ImageBase, la tabla de secciones entera— sigue intacto.

Esa asimetría es lo que permite reconstruirlos sin ejecutar nada, y por dos caminos independientes que se confirman entre sí. El primero es leer los valores directamente del cargador, que los escribe como constantes:

*(uint16*)img          = 0x5a4d;    // «MZ»
*(uint32*)(img+0x3c)   = 0xc8;      // e_lfanew
*(uint32*)(img+0xc8)   = 'PE\0\0';
*(uint16*)(img+0xcc)   = 0x14c;     // Machine = i386
*(uint16*)(img+0xce)   = 6;         // NumberOfSections
*(uint32*)(img+0xf0)   = 0x65ac;    // AddressOfEntryPoint

El segundo es derivarlos de lo que sobrevive. Si la tabla de secciones está en el desplazamiento 0x1b8 de la imagen, entonces e_lfanew tiene que valer 0x1b8 − 0x18 − 0xe0 = 0xc0, porque esos son los tamaños fijos de la firma, la cabecera COFF y la cabecera opcional. Y hay una tercera comprobación que cierra el asunto: el SizeOfImage que se lee de la cabecera reconstruida vale 0xe8000, y es exactamente el tamaño que el cargador pide en su VirtualAlloc. Dos sitios distintos del binario diciendo lo mismo.

830 KB de ruido

De los 930 KB del bot, 830 son relleno aleatorio que no lee nadie. La sección .sdata ocupa 811 KB ella sola, y otros 20 KB están repartidos por .rdata. El código real son 90 KB.

930.816 bytes, a escala 90 KB código 4 KB datos 830 KB de relleno aleatorio cero referencias apuntando aquí Entropía 8,0 en todo el bloque. La entropía no distingue relleno de cifrado: los dos son uniformes. Lo que los separa es si algo lo referencia.
El relleno infla la imagen, rompe las heurísticas de tamaño y agota a los emuladores que limitan cuánto escanean.

Distinguir relleno de datos cifrados tiene truco, porque por entropía son idénticos: los dos dan 8,0. Y hay una trampa añadida. Medida en bloques de 256 bytes, esa misma región da 7,15, que parece «no cifrado» hasta que uno recuerda que en 256 extracciones no se pueden observar los 256 valores posibles. Para datos uniformes la entropía esperada es 8 − 255/(2n·ln2), o sea 7,28 con n=256 y 7,82 con n=1024. Medir 7,15 en bloques pequeños es exactamente lo que predice el azar.

Lo que sí resuelve la pregunta son los punteros. La tabla de relocalizaciones del bot tiene 207 entradas, y ninguna apunta a .sdata. Un barrido de valores de 32 bits en el rango de esa sección da 130 coincidencias, pero todas son bytes solapados de otras instrucciones: ninguna va precedida de un opcode que de verdad cargue una dirección. Nadie lee esos 811 KB.

Un binario sin cadenas ni imports

El bot importa cuatro funciones: Sleep, FreeConsole, OutputDebugStringW y poco más. Y de 1.449 cadenas candidatas, ninguna es legible. Todo lo demás lo resuelve por hash CRC32 del nombre, enmascarado con una constante:

hash_guardado = crc32(nombre) ^ 0x322efb6c

Son 171 puntos de llamada, 11 DLL y 115 funciones distintas. Romper ese esquema es lo que convierte 432 funciones opacas en código legible, y una vez roto el perfil se lee solo:

BibliotecaFuncionesPara qué
NTDLL29NtQueueApcThread, NtMapViewOfSection, NtWriteVirtualMemory: inyección por APC y mapeo de secciones
KERNEL3255Procesos, ficheros, mutex con nombre, y conciencia de WOW64
ADVAPI3216Registro, tokens y SID
WININET4InternetConnectW, HttpOpenRequestW: el canal con la red
USER32, PSAPI, SHLWAPI…11Títulos y clases de ventana, enumeración de procesos, sesiones

Dos detalles del esquema que cuesta ver. Los nombres de DLL se hashean en mayúsculas y con extensión (KERNEL32.DLL), mientras que los de función van tal y como se exportan. Y el binario usa dos esquemas a la vez: la máscara solo se aplica al resolvedor de APIs. Cuando compara cadenas sueltas usa CRC32 crudo, sin máscara, y por eso crc32("http") y crc32("https") aparecen tal cual al analizar la URL.

Las pocas cadenas que necesita de verdad las monta carácter a carácter en la pila, para que no existan en el fichero:

push 0x6e ; call añadir    ; 'n'
push 0x74 ; call añadir    ; 't'
push 0x64 ; call añadir    ; 'd'
...                        ; → "ntdll.dll"

Recuperando ese patrón salen 22 cadenas, y las interesantes son pocas pero muy expresivas: <autoElevate>true, winsxs, wow64, Tasks\, NTUSER.DAT, *.exe, *.dll, USERNAME. Esa primera es la que delata la elevación de privilegios: busca binarios del sistema cuyo manifiesto los auto-eleva, para colarse en una elevación legítima.

Engancha justo lo que usa

El bot instala ganchos inline sobre cuatro funciones de ntdll, y la selección es elocuente:

Función enganchadaPor qué esa
NtProtectVirtualMemoryEs la que hay que llamar para parchear memoria ajena: enganchándola, ve quién intenta tocar la suya
NtWriteVirtualMemoryLo mismo para la escritura
NtQueueApcThreadSu propia vía de inyección
NtTerminateProcessSupervivencia: ve venir los intentos de matarlo

Antes de poner los suyos hace algo más interesante: comprueba si ya hay ganchos de otro. Lee el primer byte de cada función y lo compara con 0xE9, el opcode de un salto relativo. Si lo encuentra, es que un producto de seguridad llegó antes. En ese caso monta la cadena "ntdll.dll" letra a letra y vuelve a cargar la biblioteca para recuperar los bytes originales, y solo entonces instala los propios.

El canal de red tiene otro detalle que merece mirarse. Al abrir la conexión llama a InternetSetOptionW con INTERNET_OPTION_SECURITY_FLAGS y el valor 0x3380, que desglosa limpio en las cinco validaciones de TLS: revocación, autoridad desconocida, uso incorrecto, nombre que no coincide y fecha caducada. Todas desactivadas. Puede hablar con cualquier servidor que presente cualquier certificado.

Por qué no hay C2

Busqué una URL o un dominio embebido y no hay ninguno. La función que abre la conexión recibe el host ya parseado de otra que se lo pasa, y el constructor de cadenas carácter a carácter solo produce el esquema, https. Durante un rato pareció un callejón sin salida.

No lo es: es cómo funciona la familia. Dridex no habla con un servidor fijo, sino con una red de máquinas comprometidas que hacen de proxy. No hay un dominio que quemar porque no hay un dominio. La telemetría pública de la muestra lo enseña bien: decenas de direcciones IP sin relación entre sí, repartidas por medio mundo y casi todas sin detecciones. Eso no es una infraestructura de mando; es una lista de pares.

Es un buen recordatorio de que la ausencia de un indicador puede ser el indicador. «No encuentro el C2» y «esta familia no tiene un C2 que encontrar» se parecen mucho desde dentro del desensamblador, y solo la segunda es una conclusión.

Detección

Siete reglas YARA en capas, de genérica a específica. La distinción que importa es qué sobrevive a que el actor recompile:

AnclaQué detecta¿Sobrevive a un rebuild?
Bytes del código de RC4 y del descifrado de cadenasEl crypter, sea cual sea la claveSí
Clave RC4 de 52 bytes, clave de 16 bytes, recurso robadoEsta campaña concretaNo
Los mov que reponen la cabecera PEEl cargador reflexivoSí
La máscara 0x322efb6c y los hashes de DLL derivadosEl botMientras no cambien la máscara
Los 16 bytes de la tabla de las cuatro funciones enganchadasEl botSí

Las reglas dan positivo en las cuatro etapas, cada una por la que le corresponde. Como controles negativos pasé 399 PE nativos benignos, 8 binarios .NET de otra familia, 205 ejecutables de macOS, texto, dos megabytes de datos aleatorios y —el más útil de todos— la misma muestra antes de quitarle la última capa de cifrado. Cero detecciones en todos. Ese último confirma que las reglas enganchan en contenido real y no en una coincidencia de forma.

El corpus negativo es pequeño y poco variado, así que el riesgo de falso positivo sobre un parque real está estimado, no medido. La regla más expuesta es la heurística de secciones —dos secciones grandes de alta entropía y una tabla de imports ridícula—, que cualquier empaquetador comercial puede disparar. No la desplegaría sola.

Como indicadores de host, lo más sólido son los nombres de las DLL plantadas: WINSTA.dll y VERSION.dll apareciendo junto a un ejecutable del sistema copiado fuera de System32. Un binario legítimo de Windows corriendo desde un directorio escribible, con una DLL de esos nombres al lado, es la firma del side-loading.

Lo que no pude cerrar

Nada se ejecutó. Toda la cadena se desempaquetó estáticamente, y eso tiene un límite claro: las cuatro etapas son PE estructuralmente válidos y los algoritmos salen del propio código que los usa, así que esa parte es evidencia fuerte. Pero la inyección, la tarea programada, la elevación por autoElevate y el tráfico de red los leí en el código, no los vi ocurrir. La distinción importa.

La atribución es inferencia, no lectura. Que sea Dridex se apoya en la etiqueta de los ficheros que deja caer, en los nombres de side-loading y en que las técnicas que encontré por mi cuenta encajan con la familia. Es una inferencia bien soportada, pero no es lo mismo que haber leído «Dridex» dentro del binario. Lo verificado es el mecanismo; la familia es la mejor explicación que tengo.

Y me equivoqué por el camino de una forma que vale la pena contar. Busqué el hash en un buscador web, no obtuve nada y escribí «no indexado públicamente». Era falso: la muestra llevaba en las bases públicas desde julio de 2019. Los buscadores generales no indexan ese tipo de fichas. Lo correcto era «no consultado», que es una afirmación mucho más débil y mucho más honesta.

Quedan sin resolver 14 de los 115 hashes de función, por no tener sus nombres en el diccionario, y el formato de un registro marcado con la constante 0x534b5c36 que aparece dos veces en los datos y se compara en el código. Ahí es donde seguiría tirando.