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

Una clave que es la tabla de reubicaciones y un hilo que arranca donde no debe.

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.

Qué fue ZeuS, y qué dicen las fuentes

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 no hay nada que desempaquetar

«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 clave es la tabla de reubicaciones

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.

Cabecera PE NumberOfSections > 2 sección [2] = .reloc RVA 0x25000 · 5.738 B Config en claro URL del C2 · clave RC4 Blob cifrado, 812 B dentro de .text, en 0x402d88 XOR byte a byte El mismo blob, SIN descifrar, es además la clave de un RC4 que protege los contenedores. Dos papeles, un dato.
La tabla de reubicaciones existe en cualquier PE, parece ruido, nadie la mira, y no cuesta un solo byte de clave.

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.

Un contenedor que se demuestra solo

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.

Un hilo que arranca donde no debe

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.

Proceso nuevo, suspendido su hilo inicial espera en RtlUserThreadStart Copia reflectiva de la imagen RWX de SizeOfImage · reubicaciones reales ctx.Eax = entrada del bot un registro, antes de que el hilo exista Arranca en el bot, no en el programa Y devuelve el control sin guardarlo Se comió la dirección de arranque, así que tiene que devolverla. En vez de guardarla, la RECALCULA de la cabecera PE del anfitrión y salta ahí. El programa arranca normal. Nadie nota nada.
El hilo lo crea el sistema operativo. El bot solo cambia dónde empieza, y luego le devuelve el control al programa legítimo.

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.

Mutex que parecen objetos COM

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.

Una llave por programa

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.

ProgramaDe dónde leeQué hace con la contraseña
Windows Mail / Live MailRegistro + account{*}.oeaccountHexadecimal, luego DPAPI con la entropía leída del registro
FlashFXPRegistro → sites.datXOR cíclico con clave fija, más una resta con decremento condicionado
Total CommanderRegistro → wcx_ftp.iniCuatro pasadas con un generador congruencial
WinSCPRegistroXOR con 0x5c, cabecera de longitud y relleno
SmartFTPXML de favoritosXOR con una clave de 122 palabras construida en la pila
FAR managerRegistrok = (b[0] ^ b[1]) | 0x50, luego XOR
FTP Commanderftplist.txtXOR con 0x19
WS_FTP, FileZilla, CoreFTPRegistro, INI, XMLNada: 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.

SOCKS5 y un escritorio que no ves

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.

Procedencia: lo que el binario no dice

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 origenQué hay aquí
Recursos (VERSIONINFO, LANGID)No existe la sección .rsrc
Rich header (versión exacta del compilador)Borrada
Cadenas en el idioma del autorLas 230 descifradas son ASCII inglés, sin un byte >127
Constantes de página de código cirílicaNinguna 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.

Calidad como pieza de software

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 hechoDó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 enganchaFIXME 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ó antesBOT NOT CRYPTED!, un mensaje de depuración que se quedó dentro
SOCKS5 con BIND, UDP ASSOCIATE, dominios e IPv6Un 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 redEl VNC no tiene autenticación: tipo de seguridad «ninguna»
Carga PE reflectiva con reubicaciones reales, y parchea su base en la copia remotaSin 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 vezCó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 incluidoCurrentversion 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 elevarHextile 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.

Detección

Lo que de verdad vale aquí son los artefactos de memoria y de comportamiento, porque sobreviven a cualquier recompilación del kit:

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.

Lo que queda, y si vale un dinámico

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.

Fuentes del contexto público