ZeuS 2.0.8.9 no esconde su configuración detrás de una clave guardada en
algún sitio: la cifra contra su propia tabla de reubicaciones, que existe en cualquier PE y que
nadie mira. Y para inyectarse en otros procesos no llama a CreateRemoteThread:
reescribe un registro del contexto antes de que el hilo exista, y deja que Windows lo
cree donde él diga. Esta muestra se compiló un mes antes de que el código fuente de esa misma
versión apareciera en internet.
Conviene empezar por el contexto, porque es lo que da sentido a todo lo demás. ZeuS fue el troyano bancario que definió la categoría: un kit que se vendía, con panel de control, que robaba credenciales de banca interceptando el navegador desde dentro.
Lo que está documentado públicamente sobre esta versión concreta, con las fuentes al lado:
Y aquí el dato que aporta esta muestra: se compiló el 14 de abril de 2011. Si vale la fecha de iDefense, es de las mismas semanas que la filtración; si vale la de la prensa, la precede en un mes.
Lo que las fuentes no dicen. Sobre la autoría, lo verificable es que Evgeniy
Bogachev fue imputado dos veces en Estados Unidos —como lucky12345 en Nebraska en
agosto de 2012, y con su nombre real en el Distrito Oeste de Pensilvania en mayo de 2014, con 14
cargos— acusado de actuar como administrador de la botnet Gameover Zeus, con una
denuncia aparte que lo vincula a una variante anterior llamada Jabber Zeus. Entre sus alias figura
«Slavik». El FBI estima más de un millón de infecciones y pérdidas superiores a 100 millones de
dólares.
Lo que las fuentes consultadas no establecen es que escribiera el ZeuS original. Lo ligan a dos variantes posteriores. Decir que es el autor de esta muestra sería ir más allá de lo verificado, así que no lo digo.
«Esta vez» porque la muestra anterior que salió de la misma carpeta era un cargador de Dridex etiquetado como Zeus, con 830 KB de relleno aleatorio y el payload partido en 61 trozos. Esta es lo contrario.
141 KB, tres secciones con nombres normales, 347 imports repartidos en 13 DLLs, entropía 6,70 y
ni un bloque uniforme. Nada de eso parece malware empaquetado, y no lo es: es el bot entero, salida
directa del constructor del kit, sin ningún crypter encima. Lleva hasta la cadena de aviso que el
propio bot muestra cuando detecta que no se le ha aplicado uno: BOT NOT CRYPTED!
La IAT enorme engaña. Un montón de funciones de interfaz gráfica
—DefMDIChildProcA/W, DefFrameProcA/W, GetUpdateRgn,
SaveDC— parecen relleno para disimular. Son lo contrario: son exactamente las
56 APIs que el bot engancha, importadas para poder llamar a la original después de
parchearla.
Así que el trabajo no estaba en el envoltorio sino dentro. Y lo primero fue el silencio:
strings devuelve 718 líneas de basura y 24 cadenas sueltas. Las de verdad son 230, y
viven en una tabla donde cada cadena lleva su propia clave:
BYTE clave; BYTE relleno; WORD longitud; DWORD puntero
plano[i] = cifrado[i] ^ clave ^ i
El índice entra en el cálculo, así que dos letras iguales no producen el mismo byte y un XOR de
un byte sobre todo el binario no encuentra nada. Roto eso, aparece el inventario completo de
capacidades: los 25 comandos del servidor de control, los diez clientes de FTP que saquea, los de
correo, las rutas de registro y hasta un bloque de user_pref(...) que desactiva cinco
avisos de seguridad de Firefox.
La configuración estática —la que lleva la dirección del servidor de control y la clave maestra de la botnet— son 812 bytes cifrados dentro del binario. Lo interesante es que la clave no está almacenada en ninguna parte.
El código la localiza en ejecución: lee su propia cabecera PE, comprueba que haya al menos tres secciones, va a la tercera y usa su contenido como clave XOR.
Por qué funciona tan bien: las reubicaciones están en todos los ejecutables, su contenido parece aleatorio, y a nadie se le ocurre buscar una clave ahí. Si un analista reconstruye el binario o le quita la tabla, la configuración deja de descifrarse.
Y hay un detalle que encaja con la pieza siguiente: cuando el bot se copia a otro proceso, lo
rebasa aplicando las reubicaciones de verdad. El contenido de .reloc no se
reubica a sí mismo, así que sigue siendo idéntico tras el rebase y la clave sobrevive. Las
dos partes están diseñadas juntas.
Dentro de esos 812 bytes: la URL de configuración del servidor de control, tres pares de temporizadores, un nombre de botnet que el operador dejó vacío, y una clave RC4 maestra ya expandida —una permutación completa de 0 a 255, 258 bytes— que cifra tanto la configuración dinámica que descarga como los informes que sube.
Pegados al final del ejecutable, fuera de la imagen que se carga en memoria, hay 512 bytes de ruido. El bot los encuentra mapeando su propio fichero y recorriéndolo byte a byte, descifrando solo cuatro bytes en cada posición hasta que aparece una firma.
+0x000 DWORD 'DAVE'
+0x004 DWORD crc32(+0x08 .. +0x200)
+0x008 WORD longitud de la carga
+0x00a carga + relleno ALEATORIO hasta 512
todo el bloque bajo RC4, firma incluida
Ese CRC32 es lo que convierte el descifrado en prueba y no en conjetura. Acertar una firma de cuatro bytes ya es uno entre cuatro mil millones; que además cuadren 32 bits de suma de verificación sobre los 504 restantes cierra la pregunta. Buscar la firma cifrada por todo el fichero dio exactamente un acierto y ningún falso positivo.
La carga son doce bytes. Y resultaron ser la pieza que faltaba en otro sitio: una clave XOR de
cuatro bytes y dos longitudes, 692 y 505. Esos números son exactamente el tamaño de dos
funciones del instalador que están cifradas en el disco y que el bot descifra en
ejecución entre dos cambios de permisos de memoria. Con la clave correcta, las dos empiezan en
55 8b ec 83 e4 f8 81 ec: un prólogo de compilador de manual. Con los otros dos números,
basura.
Es decir: el bot lleva dos funciones que no existen como código hasta que se ejecuta, y la llave viaja escondida dentro del contenedor cifrado del final del fichero. Un desensamblador estático ve 1.197 bytes de ruido y no les asigna función.
Aquí es donde ZeuS se separa de casi todo lo que vino después. Para meterse en otros procesos no
usa shellcode, ni CreateRemoteThread en su camino principal, ni un solo byte de código
ajeno al propio ejecutable. Se inyecta a sí mismo, entero, y deja que el sistema operativo
cree el hilo.
Engancha la creación de procesos —NtCreateUserProcess en Vista y posteriores,
NtCreateThread en XP— y en el manejador hace esto:
GetThreadContext(hilo, ctx) ; ContextFlags = 0x10003
if (ctx.Eip != RtlUserThreadStart) abortar
ctx.Eax = base_remota + entrada_del_bot ; el offset 0xb0 del CONTEXT
SetThreadContext(hilo, ctx) ; ContextFlags = 0x10002
En x86, un hilo nuevo arranca en RtlUserThreadStart y el núcleo le pasa la dirección
de inicio en el registro Eax. Reescribiendo Eax en el
contexto antes de que el hilo exista, el bot cambia dónde empieza un hilo que crea el
propio Windows. No hay creación de hilo remoto que registrar. Y al escribir solo los registros
enteros, no toca el Eip.
La comprobación de que Eip siga valiendo RtlUserThreadStart es la que
garantiza que solo secuestra el hilo inicial de un proceso recién creado: ahí, y
solo ahí, la dirección de arranque del hilo es el punto de entrada de la imagen, y por eso se puede
recalcular. Para procesos que ya están corriendo usa un segundo punto de entrada que solo inicializa
y retorna, porque es un hilo extra y no hay nada que devolver.
Dos detalles más que redondean el diseño. Al copiarse al proceso remoto, parchea su
propia base dentro de la copia, que es lo que mantiene vivo el cifrado por
.reloc. Y duplica en el destino el handle de un evento que funciona como interruptor
general: 60 comprobaciones repartidas por el código consultan ese evento, así que al señalarlo los
56 ganchos pasan al original de golpe en todos los procesos a la vez. Eso es el
comando de desinstalación.
La muestra no tiene ni un nombre de mutex, evento o fichero fijo. Los deriva, y el resultado tiene forma de GUID:
GUID g = GUID del volumen del directorio de Windows
g.Data1 ^= semilla ; una por objeto
g.Data2 ^= LOWORD(semilla)
g.Data3 ^= HIWORD(semilla)
for i in 0..7: g.Data4[i] ^= bytes(crc32(SID del usuario))[i % 4]
rc4(&g, 16, clave_maestra)
nombre = ["" | "Local\" | "Global\"] + {GUID}
Un defensor que busque nombres de mutex sospechosos ve Local\{DD02E4CD-5600-...} y
lo da por objeto COM legítimo. Es la mejor evasión de la muestra y no cuesta nada.
Y tiene una debilidad aprovechable. El estado RC4 se copia de nuevo en cada llamada, así que el flujo de clave de esos 16 bytes es siempre el mismo; y la semilla solo toca los primeros ocho bytes. Consecuencia: en un equipo dado, todos los nombres de objeto de ZeuS comparten los últimos ocho bytes del GUID y difieren solo en los primeros.
Eso da una detección que no necesita saber nada del equipo ni de la botnet: buscar dos objetos con
nombre {GUID}, Local\{GUID} o Global\{GUID} cuyos dos últimos
grupos coincidan y cuyos tres primeros difieran. En COM legítimo eso no pasa.
Lo que más dice del esfuerzo invertido no es ninguna técnica concreta, sino la variedad: ZeuS lleva embebido el formato de secreto de cada programa que saquea.
| Programa | De dónde lee | Qué hace con la contraseña |
|---|---|---|
| Windows Mail / Live Mail | Registro + account{*}.oeaccount | Hexadecimal, luego DPAPI con la entropía leída del registro |
| FlashFXP | Registro → sites.dat | XOR cíclico con clave fija, más una resta con decremento condicionado |
| Total Commander | Registro → wcx_ftp.ini | Cuatro pasadas con un generador congruencial |
| WinSCP | Registro | XOR con 0x5c, cabecera de longitud y relleno |
| SmartFTP | XML de favoritos | XOR con una clave de 122 palabras construida en la pila |
| FAR manager | Registro | k = (b[0] ^ b[1]) | 0x50, luego XOR |
| FTP Commander | ftplist.txt | XOR con 0x19 |
| WS_FTP, FileZilla, CoreFTP | Registro, INI, XML | Nada: texto claro |
Dos de esos merecen un comentario. La «resta» de FlashFXP lleva un decremento condicionado al
acarreo: quien lo reimplemente como una resta módulo 256 limpia obtiene basura en la mitad de los
bytes. Y el generador de Total Commander multiplica por 0x08088405, que es el
multiplicador del Random() de Delphi — y Total Commander está escrito en Delphi. ZeuS no
inventa un cifrado: reimplementa el del programa víctima, generador aleatorio
incluido.
La clave de SmartFTP es el detalle más elegante del bloque: 244 bytes que nunca existen
como bloque contiguo en el fichero, porque se montan con 122 instrucciones encadenadas.
Ni strings ni una regla de bytes la encuentran.
Y el robo de formularios no se limita a mirar: reporta el POST completo y
después borra esos campos del POST antes de reenviarlo. El banco nunca
ve los campos robados; el operador sí. Esa es la diferencia entre un sniffer y ZeuS.
La función más grande del binario, 2.257 bytes, es un servidor SOCKS5 completo:
negociación de método, los tres tipos de dirección —IPv4, nombre de dominio e IPv6— y los tres
comandos, incluidos BIND y UDP ASSOCIATE. Con pila dual de verdad: prefiere
la familia del socket entrante y deja la otra como alternativa.
Eso es mucho más de lo que suele llevar el malware, que se queda en CONNECT sobre
IPv4. Y en un troyano bancario no es un túnel de conveniencia: es lo que derrota la
detección de fraude por IP. Una transferencia iniciada a través del proxy de la propia
máquina de la víctima sale de su IP, de su proveedor y de su ciudad. Para el banco es
indistinguible del cliente legítimo.
La otra mitad de la misma idea es un servidor VNC sobre un escritorio oculto. En vez de enrutar tráfico, el operador conduce el navegador de la víctima en un escritorio que ella no ve. Habla RFB 3.3 de verdad, con los seis mensajes de cliente, y codifica en Raw y Hextile —sin compresión— reduciendo la profundidad de color a lo que pida el cliente para ahorrar banda.
Encaja con los ganchos de una forma que da gusto leer: 24 de los 56 están gobernados por un bit
que solo se activa en el escritorio oculto, y lo que hacen es redirigir BeginPaint y
GetDC a un lienzo en memoria del propio bot. Las aplicaciones dibujan en un
sitio invisible y el VNC lee de ahí. La víctima no ve nada porque nunca se pinta en su
pantalla.
Y el nombre que el servidor anuncia en el saludo RFB es, literalmente, FIXME.
Son dos preguntas distintas y conviene no mezclarlas: quién escribió el kit y quién operaba esta build. De la primera habla el contexto público. De la segunda no hay nada publicado, y los indicios apuntan a alguien modesto: nombre de botnet vacío, servidor de control en un alojamiento gratuito, y sin crypter encima.
Del binario, lo que llama la atención es lo que falta:
| Dónde suele haber una pista de origen | Qué hay aquí |
|---|---|
Recursos (VERSIONINFO, LANGID) | No existe la sección .rsrc |
| Rich header (versión exacta del compilador) | Borrada |
| Cadenas en el idioma del autor | Las 230 descifradas son ASCII inglés, sin un byte >127 |
| Constantes de página de código cirílica | Ninguna aparición |
La ausencia es información: el constructor del kit limpia las huellas de origen a propósito. Lo
más cerca que hay de un rastro humano son dos cadenas en inglés, FIXME y
BOT NOT CRYPTED!
Una prueba que parecía una prueba. Un barrido buscando texto cirílico en CP1251
dio 883 coincidencias. Parecía un hallazgo hasta que lo pasé por controles: 256 KB de datos
aleatorios dan 751, y openssl da 2.390. Todo byte alto decodifica a cirílico en esa
página de código, así que la medida no distingue nada. Sin el control, eso se publica como «hay
texto ruso en el binario».
Y cuando sí aparecen artefactos de idioma hay que tratarlos con pinzas: son triviales de plantar y se usan como desorientación.
Merece la pena juzgarlo como lo que es —un producto que se vendía— porque explica por qué dominó su nicho durante años y también dónde se le ven las costuras.
| Lo que está bien hecho | Dónde se le ven las costuras |
|---|---|
| Un desensamblador de longitudes para no cortar una instrucción por la mitad al salvar el prólogo que engancha | FIXME como nombre de escritorio VNC, viajando por la red |
| Detecta ganchos previos: comprueba si el prólogo ya empieza por un salto, o sea si otro bot o un EDR llegó antes | BOT NOT CRYPTED!, un mensaje de depuración que se quedó dentro |
SOCKS5 con BIND, UDP ASSOCIATE, dominios e IPv6 | Un bug verificado: el gancho de WSASend no avanza el puntero y solo mira el primer buffer |
| Integridad en su propio formato: CRC32 en el contenedor, MD5 en los mensajes de red | El VNC no tiene autenticación: tipo de seguridad «ninguna» |
| Carga PE reflectiva con reubicaciones reales, y parchea su base en la copia remota | Sin compresión en el VNC, y solo dos de los seis tipos de codificación |
| Un único evento apaga 56 ganchos en todos los procesos a la vez | Códigos de error SOCKS colapsados: cuatro de los nueve se reducen a «fallo genérico» |
| Reimplementa el cifrado de cada programa víctima, generador de Delphi incluido | Currentversion con v minúscula en la ruta del registro |
| Diseño coherente: solo inyecta en procesos del mismo usuario, aborta en integridad baja, no intenta elevar | Hextile triplicado por profundidad de color en vez de parametrizado |
| Sigilo medido: marcas de tiempo falseadas en fichero y carpeta, pero atributos normales — una carpeta oculta llamaría más la atención |
El veredicto: ingeniería de sistemas seria con descuido de producto. Los mecanismos nucleares están resueltos con soltura y con detalles que solo pone quien ha sufrido el problema antes. Y conviven con un marcador de trabajo pendiente visible por la red y un bucle mal escrito.
La lectura más probable es que no fuese un producto pulido sino un kit que se vendía: lo que daba ventaja competitiva —robar más, ocultarse mejor, inyectar donde otros no llegaban— está cuidado. Lo que no se veía en la demostración, no.
Lo que de verdad vale aquí son los artefactos de memoria y de comportamiento, porque sobreviven a cualquier recompilación del kit:
MZ/PE dentro, en un proceso que no mapeó ese fichero. Más una segunda
región RWX pequeña con los trampolines, de 35 bytes por gancho.GetThreadContext y SetThreadContext con banderas
0x10003 y luego 0x10002 sobre el hilo inicial de un proceso recién
creado.NtCreateUserProcess y LdrLoadDll parcheados con un salto en un proceso
de usuario.{GUID} cuyos últimos ocho bytes coinciden.FIXME.Run que se reescribe cada 200 milisegundos: borrarlo con el bot vivo no
sirve de nada.Las reglas estáticas que escribí anclan las primitivas —el descifrador de cadenas, el truco de
.reloc, el contenedor, el secuestro del contexto, el SOCKS5 y el bucle que descifra las
funciones cifradas— y no el build concreto. Pasan sobre la muestra y no disparan en nueve
controles negativos, entre ellos otra familia de malware nativo, datos aleatorios y binarios
legítimos.
La limitación: no están probadas contra un corpus de ejecutables benignos de Windows, así que el riesgo de falso positivo está estimado por construcción y no medido. Eso cambia cómo se despliegan.
Todo el análisis es estático: nada se ejecutó. De las 450 funciones que el desensamblador reconoce, quedan clasificadas todas menos quince — 917 bytes, el 1,1% del código. El bloque mayor de lo que queda no son capacidades desconocidas sino funciones de utilidad de subsistemas ya documentados.
Lo que no se puede alcanzar desde aquí, y no es un fallo del método: el contenido de la configuración dinámica —las reglas de inyección, las páginas falsas, la dirección donde se suben los datos robados— vive en un servidor que lleva catorce años muerto. Ejecutar la muestra no lo recupera; haría falta el fichero. Con la clave RC4 extraída, cualquiera que lo tenga puede descifrarlo.
¿Merece la pena un análisis dinámico? Sí, pero estrecho. Para validar los indicadores —los nombres de objeto predichos, la forma de la ruta de instalación, el descifrado de tráfico real— porque un indicador equivocado es peor que un mecanismo mal explicado. No para entender el mecanismo central: el secuestro del contexto no se puede validar en un equipo ARM, porque bajo emulación x86 el contexto que el malware manipula es el emulado. Conviene decirlo en vez de dejar la impresión de que falta un paso que ahí es imposible.
Y queda un cabo sin nombre, que preferí dejar abierto antes que rellenar: hay un bit de configuración que, cuando está activo, guarda en el registro de arranque del hilo un valor que no es una dirección válida —el hilo arrancaría en basura— y que en otro sitio dispara un cierre de sesión forzado. Encajaría con el comando de destrucción o con el de desconectar al usuario. Con los dos fragmentos que hay no se puede determinar, así que no le pongo etiqueta.