ES EN
Índice

Trinity · Capítulo 13

Tres cerraduras y la llave puesta

En el capítulo 10 cacé el minero, pero no el bicho que lo reparte: aquello lo conté prestado, con el análisis de otros. El cebo, que es rencoroso, me lo trajo entero — blindado tres veces, con un blob que se resistió a todo lo que sé hacer. Hasta que dejé de intentar leerlo.

Odio dejar las cosas a medias, y el capítulo 10 me dejó una. Cacé el minero de Trinity —el APK com.ufo.miner, el fósil que sigue llamando a un Coinhive muerto— pero fui honesto: el binario que de verdad viaja y contagia, el que se llama trinity a secas, no llegó a mi cebo. Lo conté de segunda mano. Me quedé con la espina.

Pues el cebo es rencoroso. Tres semanas después, ya de madrugada, alguien me empujó por el cable de depuración tres ficheros de golpe. Y uno de ellos era, por fin, el que faltaba.

Un aviso antes de empezarLo que viene está contado tal como lo viví, en el orden en que lo viví. Después de publicarlo descubrí una cosa que no cambia ni un dato de los que vas a leer, pero sí cambia a quién hay que dar parte del mérito. No la cuento aquí porque estropearía el camino: está entera en la posdata del final. Si eres de los que prefieren saberlo antes, salta ahí y vuelve.

01El que faltaba

Los tres cayeron en el mismo minuto, por ADB: dos ELF de ARM y un blob que file ni se molesta en clasificar (data, a secas). Un strings sobre el segundo binario me quitó la duda en la primera línea:

strings · trinity (extracto)
com.ufo.miner
com.ufo.miner/com.example.test.MainActivity
/data/local/tmp/trinity
/data/local/tmp/ufo.apk    /data/local/tmp/xig    /data/local/tmp/endat
adb -s %s:5555 get-state
adb -s %s:5555 install %s
adb -s %s:5555 push %s %s
adb -s %s:5555 shell "am start -n %s"
adb -s %s:5555 shell "rm -rf /data/local/tmp/*"

Ahí está el kit entero, en claro: el mismo paquete y la misma actividad del capítulo 10 (com.example.test, el proyecto que nadie renombró), el plano de /data/local/tmp, y el juego completo de comandos adb con los que un aparato infectado contagia al siguiente. No es de oídas. Lo tengo delante.

02Y entonces se defendió

Con las ganas que le tenía, lo cargué en Ghidra esperando leerlo de un tirón. Y no se dejó. Lo que salió no era código: era una maraña de while(true) anidados y números mágicos sin pies ni cabeza.

trinity · lo que devuelve el decompilador
iVar1 = -0x1b614514;
while(true){ while(true){ while(true){
  if(iVar1 == -0x771841f2){ ... iVar1 = -0x55f9c47e; }
  if((~((x-1)*x) | 0xfffffffe) == 0xffffffff) ...   // esto siempre es cierto

Esto tiene nombre: aplanamiento del flujo de control. El bicho, en vez de encadenar sus bloques como un programa normal (haz A, luego B, luego C), los mete todos dentro de un mismo bucle y usa esa variable —iVar1, los números mágicos— para decidir en secreto cuál va después. El grafo de verdad desaparece. Es como un dibujo de unir los puntos al que le han borrado los números: los puntos están todos delante de ti, pero el orden —que es lo único que convierte eso en un dibujo— se lo ha quedado él.

Y por si fuera poco, lo rellena de predicados opacos: condiciones tramposas como esa de arriba —(x-1)*x siempre es par, así que la comparación siempre da lo mismo— puestas solo para que no sepas qué rama es la buena.

Y por eso el hash no sirve aquíLos números que gobiernan ese bucle se re-sortean en cada compilación, y eso tiene una consecuencia que se puede medir. Cogí dos trinity capturados en fechas distintas, del mismo tamaño exacto — 239.388 bytes los dos — y los comparé byte a byte: difieren en el 89 % (212.862 de 239.388). Parecen dos programas distintos.

Y luego comparé sus cadenas de texto: 907 de 907, idénticas. El código muta entero; la tabla de cadenas no se mueve.

Ahí está, con número, lo que el blog lleva repitiendo desde el Capítulo 1: el hash identifica un fichero, no un bicho. Contra esto, la firma que aguanta son las cadenas o el comportamiento.

03Tres cerraduras

Lo intenté por las bravas, con las tres herramientas que uno tiene a mano. Y aquí va la parte honesta: ninguna lo abre sola.

  • Ghidra lo decompila aplanado e ilegible.
  • angr —que no ejecuta el binario, sino que va resolviendo sus caminos con matemáticas— explota: 542 estados en 509 pasos, perdido en la niebla de los predicados opacos antes de llegar a ninguna parte.
  • El decompilador de angr —que a veces deshace estos trucos solo— lo escupe igual de aplanado.

Y debajo de todo eso hay una tercera cerradura, la más silenciosa: las direcciones están partidas. Cada llamada y cada texto no van a un sitio fijo, sino a un trozo + otro trozo + un desplazamiento, sumados al vuelo. Por eso ni Ghidra ni angr consiguen dibujar quién llama a quién: el grafo sale vacío. Este es, con diferencia, el bicho mejor protegido que ha caído en mi cebo.

04Lo que sí saqué leyendo

Que se te resista no significa irse de vacío: el aplanamiento estorba la lectura, no lo que el bicho hace. Cruzando los textos que sí están en claro con la forma de las funciones, el esqueleto sale entero, y confirma de primera mano lo que en el 10 conté prestado del análisis de Keysight:

trinity · el motor, reconstruido
siembra_azar();                 // una vez, al arrancar
while(true){
    ip = ip_al_azar();           // una dirección de internet cualquiera
    if( esta_en_la_lista_negra(ip) ) continue;   // rangos reservados
    contagiar(ip);              // adb connect -> push -> install -> am start
}

Es un gusano de IoT de manual: siembra el azar, se inventa una IP, prueba el puerto 5555 a ciegas, y si encuentra un Android abierto le empuja el kit entero y lo arranca. No tiene servidor de mando. Ninguno — igual que decía el capítulo 10. Y lleva una lista negra de rangos en un mapa de bits de 1024 posiciones: la misma huella que popularizó Mirai. Estirpe reconocible, con una armadura que XorDDoS y Mirai nunca se pusieron.

05El tercer trozo, y la llave puesta

Quedaba el blob que file no sabía leer: endat, 334 KB de ruido. Empieza con 127 letras minúsculas, un cero, y a partir de ahí, nada legible:

endat · los primeros bytes
nwlrbbmqbhcdarzowkkyhiddqscdxrjmowfrxsjybld...   // 127 bytes
00 27 32 06 42 a2 27 46 18 fe e9 ac ...          // y aquí empieza el ruido

Esa cabecera —nwlrbbmqbhcdarzo…— me sonaba de algo. (Es coña. No me sonaba de nada: la pegué en el buscador, como haría cualquiera.) Y resulta que es una de las cadenas más famosas del mundo, la que sale en mil tutoriales. Es, exactamente, lo que escupe el generador de azar de la librería estándar de C cuando no le cambias la semilla por defecto. Lo comprobé generándola yo:

reproduciendo la cabecera
srandom(1);   // la semilla por defecto — la que nadie toca
for(i=0;i<127;i++) putchar('a' + random()%26);

> nwlrbbmqbhcdarzowkkyhiddqscdxrjmowfrxsjybld...   // letra por letra igual
Tres cerraduras, y la llave puestaPiénsalo un segundo. Este señor se ha molestado en blindar su gusano con tres capas de ofuscación que revientan a Ghidra y a angr… y luego encabeza su fichero con la secuencia aleatoria por defecto de la libc, la que sale en cualquier Hola Mundo, porque no tocó la semilla. Es exactamente su patrón: el certificado de pruebas del capítulo 8, el com.example.test del 10. La ley del mínimo esfuerzo, aplicada al malware.

Con la cabecera identificada, me lancé a por el resto convencido de que caería. Probé XOR con ese mismo azar. RC4. ChaCha20. AES en tres modos, con claves derivadas de todo lo anterior. Descompresión, por si acaso. Nada. Ni un byte legible.

Y ahí me quedé un rato largo, mirando la pantalla. Tenía el descifrador delante —vive dentro de esos mismos binarios— pero el binario no se dejaba leer, y el blob no se dejaba abrir sin el binario. La pescadilla que se muerde la cola.

06Dejé de leer y me puse a mirar

El cambio de idea fue este: yo no necesito entender cómo lo descifra. Necesito el resultado. Y hay alguien que sabe descifrarlo perfectamente y estará encantado de hacerlo delante de mí: el propio bicho.

Así que hice lo que no había hecho nunca en esta bitácora: lo encendí.

Ejem. Sí, ya sé: llevo doce capítulos leyendo bichos sin encenderlos. Aguántame tres párrafos, que te explico dónde estaba la línea (la mía) y por qué la he movido sin borrarla.

Dónde está la línea, y por qué esta vez la muevoLa regla de la casa sigue en pie: en el honeypot no se ejecuta nada. Es un servidor con IP pública, y encender ahí un gusano de ADB podría suponer contagiar aparatos ajenos — sea mía o no la dirección desde la que salga.

Lo que hice fue montar otra máquina para esto: una máquina virtual sin escritorio, con una foto del disco para revertirla, el cable de red desenchufado, y el bicho corriendo dentro de un espacio de red vacío — ni una interfaz, ni una ruta, ni un vecino. Y encima, emulado: es un binario de ARM y la máquina es Intel, así que ni siquiera se ejecuta de verdad; lo interpreta un emulador, instrucción a instrucción, y yo veo cada llamada al sistema que hace.

Observar no es soltar. La diferencia entre las dos cosas es la jaula.

Y aquí van los tres tropiezos, porque el camino no fue recto y contarlo ahorra tiempo a quien venga detrás.

Uno. Lo lancé y la traza moría a las 76 líneas, siempre en el mismo sitio. El bicho, al arrancar, se demoniza: redirige sus tres canales de salida a la papelera del sistema. Y como mi traza salía por uno de ellos, se llevaba por delante mi propia grabación. Se arregla mandando el registro a un fichero que él no controla.

Dos, y este me tuvo media hora. Fíjate en estas dos líneas seguidas:

la traza, en el momento clave
2538 clone(...)          // se duplica
2538 exit_group(0)       // y el PADRE se muere aquí
2540 setsid()            // el HIJO se independiza... y es quien trabaja

El proceso que lanzas muere a los dos segundos. Todo lo interesante lo hace un hijo que se ha independizado. Y yo, ordenado y precavido, mataba los procesos «al terminar»… cargándome justo al único que estaba trabajando. Cada vez.

Tres. Cuando por fin lo dejé vivir, seguía sin pasar nada. Miré la traza con lupa y ahí estaba, un fallo de una línea:

lo que faltaba
faccessat("/data/local/tmp/endat", F_OK)              = 0    // existe, bien
openat("/data/local/tmp/endat", O_RDONLY)             = 4    // lo abre
openat("/sdcard/33", O_WRONLY|O_CREAT|O_TRUNC)  = -1  ENOENT  // ...y aquí se rinde

Quería escribir en /sdcard, que en un Android es la memoria del teléfono. En mi máquina Linux, esa carpeta no existe. Se lo creé. Y a la siguiente, funcionó.

07Lo que había dentro

Miré la carpeta después de dejarlo correr treinta segundos, y había tres ficheros que antes no estaban:

lo que salió de endat
ufo.apk     46.525 B   Android package (APK)
rtsh.sh      5.272 B   script de shell, texto plano
xig        657.948 B   ELF 32-bit ARM, estático

`endat` no estaba cifrado. Es un contenedor. Un archivo autoextraíble, con su índice al final —por eso lo primero que hace es saltar a los últimos tres mil bytes— y sus tres piezas dentro. Todo el tiempo que pasé probando AES y ChaCha lo pasé haciéndole la pregunta equivocada a un fichero que no tenía nada que ocultar, solo que empaquetar.

Y ahora, las tres piezas:

El APK es el del capítulo 10. No parecido: el mismo, byte a byte, mismo hash. El fósil que mina para una empresa que cerró en 2019 viajaba dentro de esto. Cerré el círculo sin buscarlo.

`rtsh.sh` es lo que le faltaba al capítulo 10 para dar miedo. No mina: echa raíces. Sustituye /system/bin/debuggerd —el proceso de Android que recoge los fallos del sistema— por uno suyo, guardando el original con otro nombre por si acaso, y ajusta los permisos de SELinux para que cuele. Prueba tres herramientas distintas para cada operación, por si el teléfono es viejo. Cuando termina, escribe su firma: botbotbot.

Y `xig`. El que llegó por el cable pesaba 153 KB; este pesa 658. No es el mismo fichero: es lo que aquel iba a buscar. Y no hace falta adivinar qué es, porque lo lleva escrito dentro: cryptonight, randomx, stratum, donate. Es XMRig, el minero de Monero. El de verdad, el que sí gana dinero.

08Entonces, ¿a dónde va el dinero?

Esta es la pregunta que llevaba tres capítulos sin poder responder. Y con el minero en la mano, se responde sola: un minero necesita decir a dónde manda las monedas, y eso no se puede esconder del todo.

xig · lo que llevaba escrito
// cartera de Monero (95 caracteres, empieza por 4)
44XT4KvmobTQfeWa6PCQF5RDosr2MLWm43AsaE3o5iNRXXTfDbYk2VPHTVedTQHZyfXNzMn8YYF2466d3FSDT7gJS8gdHAr

// y los dos sitios a los que llama
139.99.9.133:5555
78.46.89.102:7777
Cuidado con la segunda carteraEn el mismo binario aparece una segunda dirección de Monero. Es tentador publicarla también, y sería un error: es la cartera de donaciones del propio XMRig, que viene de serie en el programa —lo confirman dos dominios suyos que están al lado—. No es del atacante. Es la clase de detalle que, si lo publicas mal, lo copia el siguiente y ya no lo para nadie. Toco madera.

Sabía a qué dos sitios puede llamar. Quería saber a cuál llama de verdad. Y para eso hay que dejarle intentarlo — sin dejarle salir.

Le monté una red de mentira: una interfaz falsa, con una dirección inventada y una puerta de enlace que no existe. Jaula de Faraday y botón de autodestrucción no le puse, que no me llegaba el presupuesto. Desde dentro, el bicho ve una red normal. Lanza su paquete de conexión, el paquete sale por esa interfaz… y muere ahí, porque al otro lado no hay nada. Mientras tanto, yo leo la lista de conexiones del sistema:

/proc/net/tcp, dentro de la jaula
0200630A:B87A  8509638B:15B3  02
                     ↑            ↑
              139.99.9.133      :5555     estado 02 = intentando conectar

Ahí lo tienes. De los dos, usa el primero: un servidor en Singapur. El segundo, en Alemania, es el plan B. Y lo bonito del método: de mi máquina no salió un solo paquete. El bicho creyó que hablaba con el mundo y estaba hablando con una pared pintada.

Lo que no voy a poder contarteCon una cartera en la mano, lo lógico sería mirar cuánto ha ganado: casi todos los pools de minería publican las estadísticas de cada dirección, y ahí se ve el dinero. Consulté los cuatro grandes. No aparece en ninguno.

Y tiene sentido: no usa un pool público, usa el suyo. Esos dos servidores son de él. Quien mina en un pool comercial deja las cuentas a la vista de cualquiera; este se montó su propia caja registradora, y con ella su privacidad. Así que sé a dónde va el dinero, pero no cuánto — ni quién lo recoge. Una cartera de Monero no tiene nombre.

09Indicadores (IOCs)

TipoValor
SHA-256 (trinity)76ae6d577ba96b1c3a1de8b21c32a9faf6040f7e78d98269e0469d896c29dc64
SHA-256 (endat)a1b6223a3ecb37b9f7e4a52909a08d9fd8f8f80aee46466127ea0f078c7f5437
SHA-256 (xig, lanzador)d7188b8c575367e10ea8b36ec7cca067ef6ce6d26ffa8c74b3faa0b14ebb8ff0
SHA-256 (XMRig extraído)aa85b6b8bcd3d90c2221bd6431733463… · 657.948 B
SHA-256 (rtsh.sh extraído)426d8adbd84c7a12fedea5e171f6f57d… · 5.272 B
Cartera Monero44XT4KvmobTQfeWa6PCQF5RDosr2MLWm43AsaE3o5iNRXXTfDbYk2VPHTVedTQHZyfXNzMn8YYF2466d3FSDT7gJS8gdHAr
Pool activo139.99.9.133 : 5555 (OVH SAS, Singapur) — sin denuncias previas
Pool de reserva78.46.89.102 : 7777 (Hetzner, Alemania) — sin denuncias previas
Componentes del kit/data/local/tmp/{trinity, ufo.apk, xig, endat, rtsh.sh, lock0.txt, botsuinit_1_1.txt} · /sdcard/33 · /sdcard/44
Persistenciasustituye /system/bin/debuggerd y debuggerd64 (el original pasa a debuggerd64_real)
Rivales que desinstalacom.google.time.timer · com.android.good.miner · com.google.test.test
PropagaciónADB · puerto 5555 · escaneo ciego, sin C2 (get-state → push → install → am start)
BlindajeOLLVM: aplanamiento + predicados opacos + direcciones partidas
Firma de endat127 bytes = random() de la libc con semilla 1 (la de fábrica)
IP que lo trajo103.221.140.29 (China Unicom) — aparato infectado, no central
NO es un indicadorLa dirección 48edfHu7V9Z84Yzz…, que también aparece dentro del minero, es la cartera de donaciones de XMRig y viene en el programa original. No la reportes: no es del atacante.
Media tabla ha caducadoLa campaña sigue viva y ha cambiado de nombres. Si has llegado aquí a copiar indicadores, empieza por esto:

Lo que publiquéLo que cae ahora
com.ufo.minercom.google.home.tv
/data/local/tmp/trinity/data/local/tmp/m7m
/data/local/tmp/endat/data/local/tmp/bdat
/data/local/tmp/xig/data/local/tmp/rig
/data/local/tmp/ufo.apk/data/local/tmp/tv.apk
/data/local/tmp/lock0.txt/data/local/tmp/lk.txt

Y el disfraz ha mejorado: com.ufo.miner cantaba; com.google.home.tv pasa por una app de Google TV.

Lo único que no han tocado es com.example.test.MainActivity, junto a las órdenes adb -s %s:5555 y get-state. El Capítulo 10 se reía de ese nombre por defecto que nadie renombró. Ocho años y una rotación completa después, es el único indicador que sigue funcionando.

Y sobre lo de «sigue viva», que suena a coletilla: entre el 2 y el 10 de septiembre han caído por el cebo cuatro binarios trinity distintos y dos versiones del contenedor, algunas de ellas hasta cuatro veces. No es un fósil dormido en un aparato olvidado: es una campaña que itera.

El contenedor de septiembre, por cierto, es el mismo de este capítulo más ocho bytes: un marcador "DATA" 00 00 01 00 metido en la frontera de los 128 KB. Misma carga, formato versionado. El autor mantiene su empaquetador aunque no renombre sus clases — que es, en una frase, todo el retrato.

Y una última cosa sobre cómo lo pillé, porque el error es instructivo. Cuando cayó, hice lo primero que se hace: buscar su hash en lo que ya tengo catalogado. Cero coincidencias. Muestra nueva, en teoría.

No lo era. Era el APK del Capítulo 10 con otro nombre — misma clave de Coinhive, el mismo dex, el mismo certificado. Lo que había cambiado era el envoltorio, y con el envoltorio el hash. Buscar por hash no reconoce un reempaquetado: solo reconoce el fichero exacto que ya viste.

El capítulo 10 terminaba con una imagen que me gustó mucho: un teléfono minando para una empresa que cerró en 2019, trabajando para nadie. Sigue siendo cierta — del APK.

Lo que no sabía entonces es que ese fósil viaja de polizón. Que va dentro de un paquete con un gusano que se blinda tres veces, un script que le arranca las tripas al sistema para no salir nunca, y un minero de verdad que sí cobra, en una cartera de Monero, en un servidor de Singapur, ahora mismo, mientras lees esto.

El pobre diablo del capítulo 10 no trabajaba para nadie. Solo era la tapadera.

10Posdata: no fui el primero

Publiqué todo lo anterior y, poco después, me topé con un artículo chino de 2020. Lo firma el Centro de Respuesta ante Virus de Qi'anxin —una de las grandes empresas de seguridad de China— y se titula, más o menos, «Un rincón de los ataques a IoT: la inextinguible operación AdbMiner».

Fui directo al apéndice. Y ahí estaba todo lo que yo había sacado a pulso, publicado seis años antes:

apéndice de Qi'anxin · 30 de septiembre de 2020
矿池   (pools de minado)
  139.99.9.133:5555
  78.46.89.102:7777

钱包地址   (carteras)
  门罗币 (Monero):  44XT4KvmobTQfeWa6PCQF5RDosr2MLWm43AsaE3o5iNRXXTfDbYk2VPHTVedTQHZyfXNzMn8YYF2466d3FSDT7gJS8gdHAr
  CoinHive:        fwW95bBFO91OKUsz1VhlMEQwxmDBz7XE

Los dos pools, exactos. La cartera de Monero, exacta. Y la clave de CoinHive… es la del capítulo 10. La misma que saqué de aquel APK de cinco kilobytes.

Lo que esto le quita a este capítulo, y lo que le daLe quita el mérito del hallazgo. La cartera y los pools no los descubrí yo: los publicó Qi'anxin en 2020 y yo llegué seis años tarde, por mi cuenta y sin saberlo. Lo que sí es mío es haberlo confirmado desde mi propio cebo, y haber comprobado que siguen vivos hoy.

Y le da algo que yo solo no podía demostrar. Fíjate en que la clave de CoinHive y la cartera de Monero están en la misma lista, en 2020. El capítulo 10 y este —el fósil que no gana nada y el minero que sí cobra— son el mismo negocio. Yo lo deduje abriendo el contenedor; ellos ya lo tenían junto seis años antes. Dos caminos distintos, la misma conclusión.

Su tabla de módulos, además, es la mía con otros nombres:

Qi'anxin (2020)Mi muestra (2026)Qué es
logtrinitymódulo principal: suelta los demás y arranca el minado
bdatendatel contenedor
tv.apkufo.apkel APK de CoinHive (ya inservible)
rigxigel minero
rtsh.shrtsh.shel script de arraigo — ni se molestó en renombrarlo
nohupnohupigual
droidbot(el motor que reconstruí)el gusano de propagación

Seis años de «evolución» que consisten en cambiarle la primera letra a tres ficheros. bdat pasó a endat, rig a xig, log a trinity. Y a los otros dos ni eso. Mismo diseño, misma cartera, mismos dos servidores. La ley del mínimo esfuerzo otra vez, ahora medida en años.

Ni siquiera es una línea rectaVuelve a mirar la columna de Qi'anxin de 2020 y compárala con lo que cae este mes: bdat, rig, tv.apk. Son exactamente los nombres de hace seis años. No han inventado nada nuevo: han vuelto atrás.

Así que aquello de «seis años de evolución» se queda corto, y el chiste se le da la vuelta solo: quien tuviera fichados los indicadores de Qi'anxin de 2020 cazaría la variante de este mes. Quien tenga los míos de agosto, no.

Qi'anxin calculaba en 2020, mirando Shodan, que había cerca de diez mil aparatos Android expuestos a esto. Empezó en 2018 apuntando a descodificadores de televisión y acabó apareciendo en postes de carga de coches eléctricos. Y para escanear más rápido, en algún momento le injertaron el módulo de escaneo de Mirai.

Así que la respuesta a «¿a dónde va el dinero?» tiene una segunda parte que yo solo no podía dar: a la misma cartera y a los mismos dos servidores desde septiembre de 2020, por lo menos. Seis años. Documentado, público, y con todo detalle desde el primer día.

Y ahí sigue, cobrando.

Eso es lo que de verdad me llevo de esta posdata, y no es lo que esperaba: el problema no era que nadie lo hubiera descubierto. Estaba descubierto y publicado hace seis años. El problema es que descubrirlo no lo apaga.

Continuará — el cebo sigue encendido. Y esta vez me dejo una espina a propósito: sé a dónde va el dinero, pero no cuánto — ni quién está al otro lado. 🍯

Comentarios