ES EN
Índice

XorDDoS · Capítulo 2

Abriendo XorDDoS con Ghidra

En el Capítulo 1 el C2 se quedó cifrado dentro del binario. Aquí lo abro con Ghidra y sale entero — pero de paso aparece todo lo demás que este bicho lleva dentro: cómo se disfraza de proceso del sistema, cómo mata a la competencia usando el mismo rastro que lo delata a él, y cómo miente sobre su propia dirección cuando su central se lo ordena.

En el Capítulo 1 quedó un cabo suelto: el bot lleva su C2 escondido dentro del binario, cifrado con una tabla XOR, y con los métodos rápidos no cayó. Hoy lo abro con Ghidra — y sale entero.

Recuperar una configuración oculta para sacar IOCs y poder defenderse no tiene nada que ver con reconstruir el arma: la parte de DDoS ni se toca. Lo que busco es a quién llama el bicho. Pero al abrirlo, buscando eso, apareció todo lo demás que lleva dentro — y resultó ser mucho más interesante que una lista de dominios.

01El binario en la mesa

Cargué la muestra en Ghidra (en headless, sin interfaz; la GUI la abrí después solo para la captura de más abajo) y dejé que la auto-analizara. La pista para encontrar el descifrador salió de un simple strings sobre el binario: entre la basura aparecían, repetidas, la cadena BB2FA36AAA9541F0 y una tabla hexadecimal 0123456789ABCDEF. Basta pedirle a Ghidra quién referencia esos datos para dar con la rutina que los usa: FUN_00015d00, corta, con un bucle y operaciones XOR. Ese es nuestro hombre.

02La rutina de descifrado

Resumen para quien no quiera bajar al fango: es un XOR de clave repetida de 16 bytes, y la clave estaba a la vista todo el rato — el propio relleno de ceros de la config la delataba en las strings. Con eso, descifrar es una línea. El destripe, para quien lo quiera:

La rutina en Ghidra, byte a bytedesensamblado

Así queda el corazón de FUN_00015d00 en el decompilador (recortado a lo esencial):

ghidra · FUN_00015d00 (decompilado)
do {
    *pbVar4      = *pbVar4      ^ (&DAT_00011070)[uVar5     & 0xf];
    pbVar4[1]    = pbVar4[1]    ^ (&DAT_00011070)[uVar5 + 1 & 0xf];
    pbVar4[2]    = pbVar4[2]    ^ (&DAT_00011070)[uVar5 + 2 & 0xf];
    pbVar4[3]    = pbVar4[3]    ^ (&DAT_00011070)[uVar5 + 3 & 0xf];
    uVar5  = uVar5  + 4;
    pbVar4 = pbVar4 + 4;
} while (param_2 != uVar5);   // param_2 = longitud
Ghidra mostrando la rutina de descifrado de XorDDoS: ensamblador x86 a la izquierda, código C reconstruido a la derecha
La rutina, en Ghidra. A la izquierda el ensamblador x86 tal cual está en el binario; a la derecha, el mismo código traducido a C. Ahí están los ^ *pbVar3 del bucle de XOR, el DAT_00011070 que apunta a la clave, y las constantes 0x4136334146324242 y 0x3046313435394141 — que leídas como bytes ASCII son, letra por letra, BB2FA36AAA9541F0. La clave estaba a la vista: texto ASCII tal cual, solo que el desensamblado enseña cada inmediato de 8 bytes del revés, en orden little-endian.

Se lee de un vistazo: XOR de cada byte con una clave indexada por posición & 0xf — una clave de 16 bytes que se repite, guardada en DAT_00011070.

El "ajá" que me tenía despistadoLa clave resultó ser los caracteres ASCII de "BB2FA36AAA9541F0" (bytes 42 42 32 46 41 33 36 41…), no los bytes hexadecimales decodificados. Y por eso el relleno cantaba: la config lleva ceros de padding, y cero XOR clave = clave. Por eso BB2FA36AAA9541F0 aparecía repetido en las strings — era el padding delatando la clave.

03La configuración al desnudo

Replico el algoritmo (leer la clave, XOR con índice pos % 16) y lo aplico sobre los datos de config. Sale limpio:

config descifrada
# servidores C2 (con failover), puerto 1529
telemetry-pipe.sh:1529 | api-metadata-v6.is:1529 | sys-kernel-update.to:1529

# URL de configuración / actualización
https://api-metadata-v6.is/config.rar

Tres dominios de C2 en el puerto 1529, separados por | como lista de reserva, más una URL para bajar su configuración. Ese era el secreto que el cifrado guardaba.

04El disfraz de los dominios

Fíjate en los nombres — no son casualidad. Están elegidos para parecer infraestructura legítima:

  • telemetry-pipe.sh — suena a "tubería de telemetría", y el TLD .sh (Santa Elena) hace que parezca un nombre de script de shell. Doble disfraz.
  • api-metadata-v6.is — parece un endpoint de API interno de lo más normal.
  • sys-kernel-update.to — huele a "actualizaciones del sistema", justo lo que un admin no mira dos veces.

Es esconderse a plena vista: en un log de conexiones, estos nombres no levantan la ceja. Y los TLDs de países pequeños (.sh, .is, .to) se eligen por disponibilidad y menos escrutinio. Es el mismo instinto —disfrazarse de sistema— que le veremos aplicar a sí mismo cuando corre en una máquina; volveremos a él.

05¿Sigue viva la infraestructura?

OSINT pasivo (resolución DNS + bases de datos de terceros; nunca toco los servidores): de los tres dominios, dos están dormidos —no resuelven— y el tercero, sys-kernel-update.to, apunta a 141.98.11.51: HostBaltic (AS209605, Lituania), hosting tolerante al abuso. Dos dormidos y uno activo es lo normal en una lista de C2 con reservas: no todos se encienden a la vez. Shodan ve esa IP como un servidor Windows; el puerto real del C2, el 1529, no sale porque es poco común y no se escanea de rutina.

Un patrón que dice muchoCada pieza vive en un hosting offshore distinto: el loader entró desde un proveedor, el binario se sirvió desde otro, y el C2 vive en un tercero. Reparten la infraestructura en tres casas de abuso para que tumbar una no lo tire todo. Cutre en la ejecución, pero con cierta idea de resiliencia.

Un detalle del binario que encaja aquí: lleva dos servidores DNS de Google escritos dentro, 8.8.8.8 y 8.8.4.4. En un bicho cuyo C2 va por dominio, tener resolutores propios no es decoración — se asegura de poder resolver aunque el DNS de la máquina falle o esté vigilado.

Y hay un movimiento que la resolución de hoy no enseña, con consecuencia práctica: esa IP no siempre fue esa. El nombre se muda de número dentro de HostBaltic, sin salir del mismo ASN. Ahí está la lección para el catálogo: si publicas la IP a secas, tu indicador caduca y el que lo copie bloquea una dirección vacía. Lo que aguanta es el par dominio + ASN. Cambiar de número dentro del mismo proveedor le cuesta cero; cambiar de proveedor, no.

06Lo encendí en una jaula

Todo lo anterior sale de leer el binario. Pero leer cuenta lo que un programa sabe hacer; encenderlo cuenta lo que hace. Así que lo hice — en una máquina virtual aislada, con la red sin salida a ninguna parte, y revertida a una foto limpia al terminar. En el cebo eso no se hace nunca (tiene IP pública y soltar un gusano contagiaría a terceros); en una jaula sellada, sí. La diferencia es la jaula.

Lo primero: se copia. Una copia va a /usr/lib/libudev.so —ruta de biblioteca del sistema, nombre de biblioteca del sistema—, idéntica byte a byte a la muestra: el mismo SHA-256 de la ficha del Capítulo 1. Otra va a /usr/bin con un nombre de diez letras al azar, y esa ya no tiene el hash que publiqué. Es el mismo truco del nombre de galimatías del primer capítulo, un escalón por encima: entonces rotaba el nombre en disco, aquí rota el fichero entero. El hash que di identifica el fichero que me subieron; al minuto de correr en una máquina de verdad, lo que se ejecuta allí ya tiene otro.

Y entonces miré la lista de procesos, y no lo encontré. Estaban crond, /usr/sbin/gdm3, rpc.statd, automount, rpc.idmapd y /sbin/audispd. Seis demonios de sistema de lo más aburrido, de esos por los que la vista resbala. Los seis eran él.

Un proceso arrastra dos nombres: el que el núcleo apunta al arrancarlo, y el que el propio programa se escribe en argv[0] — el que sale en la lista. Y ahora que tengo el binario abierto, sé cómo se pone el falso: se relanza a sí mismo pasándose el nombre a aparentar, y en ese segundo arranque se borra la línea de órdenes y escribe encima.

El mecanismo del disfraz, en el decompiladodesensamblado
ghidra · el disfraz, en tres pasos
nombre = argv[1];                 // se guarda el nombre a aparentar
memset(argv[0], 0, ...);          // borra su propio nombre
memset(argv[1], 0, ...);          // borra el que le pasaron
memset(argv[2], 0, ...);          // borra el tercero
                                  // y escribe el falso encima de argv[0]

Por eso, si te fijas en la memoria del proceso, aparecen restos del tipo /usr/bin/xqaghzmjom [kthreadd] 19882: el bicho llamándose a sí mismo, con el nombre falso en medio y detrás el número del proceso al que quiere parecerse.

En la jaula le vi puestos seis nombres, pero en el código lleva dieciocho, y doce son de un tipo que los seis no incluían — hilos del núcleo, esos nombres entre corchetes:

los dieciocho, en el binario
hilos del núcleo   [kthreadd]  [rcu_gp]  [rcu_par_gp]  [cryptd]  [mld]
                   [scsi_eh_2]  [cpuhp/0]  [mm_percpu_wq]  [ttm_swap]

demonios           crond   automount   rpc.statd   rpc.idmapd   pcscd
                   hald-runner   (sd-pam)   /usr/sbin/gdm3   /sbin/audispd

Y esa distinción tiene consecuencia. Comparar el nombre que dice un proceso con el fichero al que apunta su /proc/<pid>/exe no vale como regla general: un bicho que se cambie los dos nombres pasa por debajo, y alguno hay en esta misma bitácora. Pero contra estos doce sí vale, y sin discusión: un hilo del núcleo de verdad no tiene ejecutable. Si algo dice ser [kthreadd] y su /proc/<pid>/exe apunta a un fichero en /usr/bin, no hay interpretación posible. Contra un crond falso no sirve; contra un corchete falso, es definitivo.

07Se busca donde busca a sus rivales

El mismo binario tiene una rutina para quitarse de encima a la competencia: recorre los procesos de la máquina uno por uno, resuelve el /proc/<pid>/exe de cada uno, lo compara con lo que busca, y mata al que coincide. Es su forma de quedarse la máquina en exclusiva — la misma guerra entre delincuentes que sale en media bitácora, aquí leída en el código, no deducida.

Veinte líneas más arriba, en la misma función, hay otra llamada a /proc/<pid>/exe. Pero esta no es sobre un rival: es sobre sí mismo.

Las dos llamadas, en la misma funcióndesensamblado
ghidra · bot_main
// sobre sí mismo — para saber dónde está su propio binario
sprintf(ruta, "/proc/%d/exe", getpid());
readlink(ruta, ...);

// … veinte líneas más abajo, sobre los demás — para cazarlos
sprintf(ruta, "/proc/%d/exe", pid_ajeno);
readlink(ruta, ...);
kill(pid_ajeno, 9);

Y no es que le apetezca preguntar por sí mismo: es que no le queda otra. Acaba de borrar su propio argv[0] para disfrazarse (lo vimos en la sección anterior), y con eso ha destruido la vía normal que tiene un programa de saber dónde está su fichero. Así que para encontrarse tiene que ir al mismo sitio por el que caza a los demás — y por el que, si te fijas, se le caza a él.

Depende del mismo rastro que lo delata. No es una manera de hablar: son dos llamadas, en la misma función, a veinte líneas. Se esconde borrando lo único que decía quién era, y luego tiene que ir a preguntarlo al mostrador donde cualquiera puede oírlo.

08Y miente sobre de dónde viene

Hasta aquí, un bicho que se esconde. Pero hace algo más, y es lo que lo separa de un flooder cualquiera: falsifica su dirección de origen. El cebo lo ha grabado haciéndolo — una ráfaga corta, un par de segundos, unas sesenta conexiones cuyo remite no era el suyo. Direcciones vecinas, y la distancia con la real no era ruido:

la distancia entre el origen falso y el real, en orden de llegada
−1     +1        −3     +3        −7     +7        −15    +15
−31    +31       −63    +63       −127   +127      −255   +255
−511   +511      −1023  +1023     −2047  +2047     …
                                            … doblando cada paso, hasta el final del rango

Uno menos, uno más. Tres menos, tres más. Cada paso el doble, y siempre simétrico. Eso no lo escupe un generador al azar: lo cuenta alguien. Y fui al binario a ver quién.

Está — y no es una sospecha del tráfico: es código, y aparece en dos sitios del programa. Pero lo interesante es cómo está montado:

  • Es opcional, y no lo decide el bicho. Hay una casilla en su config; si no está puesta, usa su dirección real. La rellena el centro de mando.
  • Va acotada. El operador no le dice «miente»: le da un rango, y el bicho se mueve dentro.
  • No hay azar. La dirección falsa sale de una cuenta, no de un generador — por eso la ráfaga son pasos regulares y no ruido.

Eso encaja con lo que grabó el cebo hasta en el orden: el bicho habla primero con su central usando su dirección verdadera, y después empieza a mentir. No estaba tanteando nada por su cuenta: recibió la orden y la ejecutó.

La cabecera y el cálculo, byte a bytedesensamblado

Para falsificar el origen hay que pedirle al núcleo que te deje escribir la cabecera del paquete tú mismo. Son dos gestos, y los dos están en el binario: un socket crudo y la opción IP_HDRINCL. Con eso activo, el sistema ya no rellena tu dirección: la pones tú.

ghidra · el paquete, montado a mano
// socket crudo + "la cabecera va incluida"
fd = socket(AF_INET, SOCK_RAW, IPPROTO_RAW);
setsockopt(fd, IPPROTO_IP, IP_HDRINCL, "1", 2);   // ← le pasa el TEXTO "1", no un entero

// y escribe la cabecera IP byte a byte:
h[0]      = 0x45;            // versión 4, longitud de cabecera 5
h[2..3]   = htons(72);       // longitud total
h[8]      = 128;             // TTL
h[9]      = 17;              // protocolo = UDP
h[12..15] = origen_falso;    // ← aquí va la mentira
h[16..19] = destino;

Ese "1" es una chapuza que funciona por los pelos: le pasa dos bytes de texto donde el núcleo espera un entero. El núcleo lee algo distinto de cero y enciende la opción igual.

Y el cálculo del origen falso, que es lo que hace la ráfaga regular:

ghidra · de dónde sale la dirección
if (cfg.spoof == 1) {                       // ← el interruptor del C2
    tam    = cfg.rango_fin - cfg.rango_base + 1;
    origen = cfg.rango_base + (X % tam);     // X = valor de una tabla, por contador
} else {
    origen = mi_ip_real;
}

Base y fin son dos campos que manda el operador. X sale de una tabla indexada por un contador — determinista, sin rand(). El mecanismo explica que la ráfaga sean pasos que doblan; reproducir el patrón exacto haría falta la tabla y el rango que el C2 mandó aquella vez.

Y lo que más te sirve si defiendes no es el molde, es la firma en la red: una ráfaga corta —decenas de paquetes en dos segundos, no miles— desde direcciones vecinas a la real que van doblando la distancia; y, sobre todo, direcciones de tu propio espacio llegando desde fuera, que es justo lo que un filtro de entrada bien puesto debería tirar siempre. La capacidad es del bicho; la detección es tuya.

Una honestidad de métodoDos cosas que no conviene mezclar: el comportamiento —la ráfaga— lo vi en una captura del cebo; la capacidad —que está escrito en el programa— la leo en el binario que tengo abierto. Son de la misma familia, no te garantizo que del mismo fichero al byte. Digo que esto lo lleva dentro XorDDoS y que encaja con lo grabado, no que haya desensamblado el paquete exacto que salió por el cable.

09Lo que una jaula no te cuenta

Un detalle que me hizo pensar, y que va sin adorno porque es la lección honesta del capítulo. Cuando encendí el bicho en la jaula, vi el disfraz, vi las copias, vi la persistencia. De la falsificación de origen no vi nada. Y no porque se escondiera:

  • Miré los procesos, no la red. Monté la observación para responder «¿qué se instala y con qué nombre?», y a eso contestó entero. La otra pregunta ni la hice.
  • Y aunque la hubiera hecho, no habría visto nada. La jaula no tiene salida — para eso es una jaula. Así que el bicho nunca llegó a hablar con su central, nunca recibió la casilla del spoofing puesta, y nunca ejecutó esa rama del código. La capacidad estaba dormida por diseño.

Una detonación solo cuenta aquello para lo que la montaste, y en una red sin salida hay ramas enteras del programa que no se pisan. La mentira sobre el origen no la enseñó la jaula; la enseñó el código, y la confirmó el cebo cuando el bicho tuvo una central de verdad al otro lado. Ninguna de las dos vías, sola, la habría contado entera.

Y las herramientas tampocoPasé este binario por un clasificador automático de capacidades y no vio el socket crudo. En un binario estático y sin símbolos, que una herramienta no encuentre algo no prueba que no esté.

10Indicadores (IOCs)

TipoValor
C2 (puerto 1529)telemetry-pipe.sh · api-metadata-v6.is · sys-kernel-update.to
C2 activo141.98.11.51 (HostBaltic, AS209605, LT)
Indicador duraderosys-kernel-update.to + AS209605 — la IP baila dentro del ASN; el dominio y el proveedor, no
URL confighttps://api-metadata-v6.is/config.rar
Cifrado configXOR · clave 16 bytes ASCII "BB2FA36AAA9541F0" · idx = pos & 0xf
Resolutores embebidos8.8.8.8 · 8.8.4.4
Copia en biblioteca/usr/lib/libudev.so — mismo SHA-256 que la muestra
Copia en /usr/binnombre de diez letras al azar — SHA-256 distinto al de la muestra
Procesos suplantados (18)[kthreadd] · [rcu_gp] · [rcu_par_gp] · [cryptd] · [mld] · [scsi_eh_2] · [cpuhp/0] · [mm_percpu_wq] · [ttm_swap] · crond · automount · rpc.statd · rpc.idmapd · pcscd · hald-runner · (sd-pam) · /usr/sbin/gdm3 · /sbin/audispd
La comprobación que sí valeun proceso con nombre entre corchetes cuyo /proc/<pid>/exe resuelve a un fichero — un hilo del núcleo auténtico no tiene ejecutable. Solo para corchetes
Capacidad de spoofingfalsificación de la IP de origen — opcional, la activa el C2, acotada a un rango del operador y calculada sin azar
Cómo se ve en la redráfaga corta (decenas de paquetes en dos segundos) desde direcciones vecinas a la real en pasos simétricos que doblan · y direcciones de tu propio espacio llegando desde fuera (un filtro de entrada debería tirarlas)

Y así se cierra el cabo suelto del Capítulo 1 — el C2 estaba ahí, cifrado, y ahora está sobre la mesa — y de paso el bicho entero: cómo se esconde, cómo caza, y cómo miente. Contado sin recortes: la capacidad, cómo funciona y cómo se detecta. Lo que no se reparte no es el análisis, es el arma — el binario y el exploit del vector de entrada; eso se queda fuera. El resto está aquí.

Continuará — el cebo sigue encendido. Me queda una cosa por leer de este bicho: cuando le mandan falsificar, le dan un rango. El rango decide a quién le echan la culpa. Eso no está en el binario — se lo dice el C2 por el cable, y esa conversación todavía no la sé leer. 🍯

Comentarios