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.
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:
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.
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 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:
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:
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:
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
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.
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:
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:
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 rindeQuerí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:
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.
// 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
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:
0200630A:B87A 8509638B:15B3 02
↑ ↑
139.99.9.133 :5555 estado 02 = intentando conectarAhí 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.
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)
| Tipo | Valor |
|---|---|
| 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 Monero | 44XT4KvmobTQfeWa6PCQF5RDosr2MLWm43AsaE3o5iNRXXTfDbYk2VPHTVedTQHZyfXNzMn8YYF2466d3FSDT7gJS8gdHAr |
| Pool activo | 139.99.9.133 : 5555 (OVH SAS, Singapur) — sin denuncias previas |
| Pool de reserva | 78.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 |
| Persistencia | sustituye /system/bin/debuggerd y debuggerd64 (el original pasa a debuggerd64_real) |
| Rivales que desinstala | com.google.time.timer · com.android.good.miner · com.google.test.test |
| Propagación | ADB · puerto 5555 · escaneo ciego, sin C2 (get-state → push → install → am start) |
| Blindaje | OLLVM: aplanamiento + predicados opacos + direcciones partidas |
| Firma de endat | 127 bytes = random() de la libc con semilla 1 (la de fábrica) |
| IP que lo trajo | 103.221.140.29 (China Unicom) — aparato infectado, no central |
| Lo que publiqué | Lo que cae ahora |
|---|---|
| com.ufo.miner | com.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:
矿池 (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.
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 |
|---|---|---|
| log | trinity | módulo principal: suelta los demás y arranca el minado |
| bdat | endat | el contenedor |
| tv.apk | ufo.apk | el APK de CoinHive (ya inservible) |
| rig | xig | el minero |
| rtsh.sh | rtsh.sh | el script de arraigo — ni se molestó en renombrarlo |
| nohup | nohup | igual |
| 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.
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