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 binario | La pieza de Dridex que lo explica |
|---|---|
<autoElevate>true | Elevación de privilegios: binario del sistema que se auto-eleva, con una DLL plantada al lado |
winsxs, wow64, *.exe, *.dll | Barrido de directorios buscando objetivos de side-loading |
Tasks\, NTUSER.DAT | Persistencia por tarea programada y acceso a la colmena de registro |
| Sin keylogger, sin NSS, sin web injects | El cargador va separado de los módulos bancarios |
| No hay ninguna URL de C2 embebida | Dridex funciona por pares, no contra un servidor fijo |
La cadena de cuatro etapas
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.
| Etapa | Qué es | Tamaño | MD5 |
|---|---|---|---|
| 0 | Stub del crypter | 1.032.192 | 6e05e84c7a993880409d7a0324c10e74 |
| 1 | Buffer reunido y descifrado | 942.080 | 71b2680ce618e0ba3e25baaa03fa8f79 |
| 2 | Cargador reflexivo de PE | 9.728 | 62b66560da6ef356a564ff9bc87bb3f4 |
| 3 | El bot | 930.816 | e2554b59145e67c7d1ca6f2da742910e |
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.
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.
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.
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:
| Biblioteca | Funciones | Para qué |
|---|---|---|
NTDLL | 29 | NtQueueApcThread, NtMapViewOfSection, NtWriteVirtualMemory: inyección por APC y mapeo de secciones |
KERNEL32 | 55 | Procesos, ficheros, mutex con nombre, y conciencia de WOW64 |
ADVAPI32 | 16 | Registro, tokens y SID |
WININET | 4 | InternetConnectW, HttpOpenRequestW: el canal con la red |
USER32, PSAPI, SHLWAPI… | 11 | Tí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 enganchada | Por qué esa |
|---|---|
NtProtectVirtualMemory | Es la que hay que llamar para parchear memoria ajena: enganchándola, ve quién intenta tocar la suya |
NtWriteVirtualMemory | Lo mismo para la escritura |
NtQueueApcThread | Su propia vía de inyección |
NtTerminateProcess | Supervivencia: 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:
| Ancla | Qué detecta | ¿Sobrevive a un rebuild? |
|---|---|---|
| Bytes del código de RC4 y del descifrado de cadenas | El crypter, sea cual sea la clave | Sí |
| Clave RC4 de 52 bytes, clave de 16 bytes, recurso robado | Esta campaña concreta | No |
Los mov que reponen la cabecera PE | El cargador reflexivo | Sí |
La máscara 0x322efb6c y los hashes de DLL derivados | El bot | Mientras no cambien la máscara |
| Los 16 bytes de la tabla de las cuatro funciones enganchadas | El bot | Sí |
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.