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):
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
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.
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:
# 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.rarTres 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 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
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:
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/audispdY 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
// 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:
−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 rangoUno 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ú.
// 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:
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.
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.
10Indicadores (IOCs)
| Tipo | Valor |
|---|---|
| C2 (puerto 1529) | telemetry-pipe.sh · api-metadata-v6.is · sys-kernel-update.to |
| C2 activo | 141.98.11.51 (HostBaltic, AS209605, LT) |
| Indicador duradero | sys-kernel-update.to + AS209605 — la IP baila dentro del ASN; el dominio y el proveedor, no |
| URL config | https://api-metadata-v6.is/config.rar |
| Cifrado config | XOR · clave 16 bytes ASCII "BB2FA36AAA9541F0" · idx = pos & 0xf |
| Resolutores embebidos | 8.8.8.8 · 8.8.4.4 |
| Copia en biblioteca | /usr/lib/libudev.so — mismo SHA-256 que la muestra |
| Copia en /usr/bin | nombre 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í vale | un 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 spoofing | falsificació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 red | rá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