Trinity · Chapter 13
Three locks, and the key left in
In chapter 10 I caught the miner, but not the thing that hands it out: that part I told on loan, using someone else's analysis. The honeypot holds a grudge, and brought me the whole thing — armoured three times over, with a blob that resisted everything I know how to do. Until I stopped trying to read it.
I hate leaving things half done, and chapter 10 left me one. I caught Trinity's miner —the com.ufo.miner APK, the fossil still calling a dead Coinhive— but I was honest about it: the binary that actually travels and infects, the one simply called trinity, never reached my honeypot. I told that part second-hand. It stuck in my throat.
Well, the honeypot holds a grudge. Three weeks later, well into the small hours, somebody pushed three files at once down the debugging cable. And one of them was, at last, the missing one.
01The missing one
All three landed in the same minute, over ADB: two ARM ELFs and a blob that file doesn't even bother to classify (data, full stop). A strings on the second binary settled it in the first line:
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/*"
There's the whole kit, in the clear: the same package and the same activity as chapter 10 (com.example.test, the project nobody renamed), the map of /data/local/tmp, and the full set of adb commands an infected device uses to infect the next one. Not second-hand. Right in front of me.
02And then it fought back
Given how much I'd wanted this, I loaded it into Ghidra expecting to read it in one sitting. It wouldn't have it. What came out wasn't code: it was a tangle of nested while(true) and magic numbers with no rhyme or reason.
iVar1 = -0x1b614514; while(true){ while(true){ while(true){ if(iVar1 == -0x771841f2){ ... iVar1 = -0x55f9c47e; } if((~((x-1)*x) | 0xfffffffe) == 0xffffffff) ... // this is always true
This has a name: control-flow flattening. Instead of chaining its blocks like a normal program (do A, then B, then C), the thing puts them all inside one loop and uses that variable —iVar1, the magic numbers— to decide in secret which one comes next. The real graph disappears. It's like a connect-the-dots drawing with the numbers rubbed out: every dot is right there in front of you, but the order —the only thing that turns it into a drawing— he kept for himself.
And on top of that, it's padded with opaque predicates: rigged conditions like the one above —(x-1)*x is always even, so the comparison always gives the same answer— put there purely so you can't tell which branch is the real one.
Then I compared their strings: 907 out of 907, identical. The code mutates completely; the string table doesn't budge.
There it is, with a number on it, what this log has been repeating since Chapter 1: a hash identifies a file, not a critter. Against this, the signature that holds is the strings, or the behaviour.
03Three locks
I went at it the blunt way, with the three tools anyone has to hand. And here's the honest part: not one of them opens it alone.
- Ghidra decompiles it flattened and unreadable.
- angr —which doesn't run the binary, but works out its paths mathematically— blows up: 542 states in 509 steps, lost in the fog of opaque predicates before getting anywhere.
- angr's decompiler —which sometimes undoes these tricks by itself— spits it out just as flattened.
And underneath all that there is a third lock, the quietest one: the addresses are split. Every call and every string doesn't point somewhere fixed, but to a piece + another piece + an offset, added up on the fly. That's why neither Ghidra nor angr can draw who calls whom: the graph comes out empty. This is, by some distance, the best protected thing that has ever landed in my honeypot.
04What I did get by reading
Something resisting you doesn't mean walking away empty-handed: flattening gets in the way of reading, not of what the thing does. Cross-referencing the strings that are in the clear with the shape of the functions, the skeleton comes out whole, and confirms first-hand what in chapter 10 I told on loan from Keysight's analysis:
seed_random(); // once, at startup while(true){ ip = random_ip(); // any address on the internet if( is_blacklisted(ip) ) continue; // reserved ranges infect(ip); // adb connect -> push -> install -> am start }
It's a textbook IoT worm: seed the randomness, make up an IP, try port 5555 blind, and if it finds an open Android, push it the whole kit and start it. It has no command server. None — just as chapter 10 said. And it carries a blacklist of ranges in a 1024-bit map: the same fingerprint Mirai made famous. Recognisable lineage, with armour XorDDoS and Mirai never put on.
05The third piece, and the key left in
That left the blob file couldn't read: endat, 334 KB of noise. It starts with 127 lowercase letters, a zero, and from there on, nothing legible:
nwlrbbmqbhcdarzowkkyhiddqscdxrjmowfrxsjybld... // 127 bytes 00 27 32 06 42 a2 27 46 18 fe e9 ac ... // and here the noise starts
That header —nwlrbbmqbhcdarzo…— rang a bell. (Kidding. It rang no bell at all: I pasted it into a search engine, like anyone would.) And it turns out it's one of the most famous strings in the world, the one that shows up in a thousand tutorials. It is, exactly, what the C standard library's random generator spits out when you don't change the default seed. I checked by generating it myself:
srandom(1); // the default seed — the one nobody touches for(i=0;i<127;i++) putchar('a' + random()%26); > nwlrbbmqbhcdarzowkkyhiddqscdxrjmowfrxsjybld... // letter for letter
With the header identified, I went after the rest convinced it would fall. I tried XOR with that same randomness. RC4. ChaCha20. AES in three modes, with keys derived from all of the above. Decompression, just in case. Nothing. Not one legible byte.
And there I sat for a good while, staring at the screen. I had the decryptor right in front of me —it lives inside those very binaries— but the binary wouldn't be read, and the blob wouldn't open without the binary. Chasing my own tail.
06I stopped reading and started watching
The change of mind was this: I don't need to understand how it decrypts. I need the result. And there is someone who knows perfectly well how to decrypt it and will happily do it in front of me: the thing itself.
So I did what I had never done on this blog: I switched it on.
Ahem. Yes, I know: I have spent twelve chapters reading critters without switching them on. Bear with me for three paragraphs, and I'll tell you where the line was (mine) and why I moved it without erasing it.
What I did was set up another machine for this: a virtual machine with no desktop, a snapshot of the disk to roll it back, the network cable unplugged, and the thing running inside an empty network namespace — not one interface, not one route, not one neighbour. And on top of that, emulated: it's an ARM binary and the machine is Intel, so it isn't even really executing; an emulator interprets it, instruction by instruction, and I see every system call it makes.
Watching is not releasing. The difference between the two is the cage.
And here come the three stumbles, because the path wasn't straight and telling it saves time for whoever comes next.
One. I launched it and the trace died at 76 lines, always in the same place. On startup, the thing daemonises itself: it redirects its three output channels to the system's bin. And since my trace went out through one of them, it took my own recording with it. Fixed by sending the log to a file it doesn't control.
Two, and this one cost me half an hour. Look at these two consecutive lines:
2538 clone(...) // it duplicates itself 2538 exit_group(0) // and the PARENT dies right here 2540 setsid() // the CHILD breaks away... and it's the one doing the work
The process you launch dies within two seconds. Everything interesting is done by a child that has broken away. And I, tidy and careful, kept killing the processes «when it finished»… taking out precisely the only one that was working. Every single time.
Three. When I finally let it live, still nothing happened. I went through the trace with a magnifying glass and there it was, a one-line failure:
faccessat("/data/local/tmp/endat", F_OK) = 0 // exists, good
openat("/data/local/tmp/endat", O_RDONLY) = 4 // opens it
openat("/sdcard/33", O_WRONLY|O_CREAT|O_TRUNC) = -1 ENOENT // ...and gives up hereIt wanted to write to /sdcard, which on an Android is the phone's storage. On my Linux machine, that folder doesn't exist. I created it for him. And on the next run, it worked.
07What was inside
I looked at the folder after letting it run for thirty seconds, and there were three files that hadn't been there before:
ufo.apk 46,525 B Android package (APK) rtsh.sh 5,272 B shell script, plain text xig 657,948 B ELF 32-bit ARM, static
endat was never encrypted. It's a container. A self-extracting archive, with its index at the end —that's why the first thing it does is jump to the last three thousand bytes— and its three pieces inside. All the time I spent trying AES and ChaCha, I spent asking the wrong question of a file that had nothing to hide, only something to pack.
And now, the three pieces:
The APK is the one from chapter 10. Not similar: the same, byte for byte, same hash. The fossil that mines for a company that closed in 2019 was travelling inside this. I closed the circle without looking for it.
rtsh.sh is what chapter 10 was missing to be frightening. It doesn't mine: it takes root. It replaces /system/bin/debuggerd —the Android process that collects system crashes— with one of its own, keeping the original under another name just in case, and adjusts the SELinux labels so it passes. It tries three different tools for every operation, in case the phone is old. When it's done, it writes its signature: botbotbot.
And xig. The one that came down the cable weighed 153 KB; this one weighs 658. It isn't the same file: it's what that one was going to fetch. And there's no need to guess what it is, because it says so inside: cryptonight, randomx, stratum, donate. It's XMRig, the Monero miner. The real one, the one that does make money.
08So, where does the money go?
This is the question I'd gone three chapters without being able to answer. And with the miner in hand, it answers itself: a miner has to say where it sends the coins, and that can't be hidden completely.
// Monero wallet (95 characters, starts with 4) 44XT4KvmobTQfeWa6PCQF5RDosr2MLWm43AsaE3o5iNRXXTfDbYk2VPHTVedTQHZyfXNzMn8YYF2466d3FSDT7gJS8gdHAr // and the two places it calls 139.99.9.133:5555 78.46.89.102:7777
I knew which two places it can call. I wanted to know which one it actually calls. And for that you have to let it try — without letting it out.
I built it a fake network: a made-up interface, with an invented address and a gateway that doesn't exist. A Faraday cage and a self-destruct button I did not add, the budget wouldn't stretch. From the inside, the thing sees a normal network. It fires its connection packet, the packet leaves through that interface… and dies there, because there's nothing on the other side. Meanwhile, I read the system's connection list:
0200630A:B87A 8509638B:15B3 02
↑ ↑
139.99.9.133 :5555 state 02 = trying to connectThere it is. Of the two, it uses the first: a server in Singapore. The second, in Germany, is plan B. And the nice part of the method: not a single packet left my machine. The thing thought it was talking to the world and it was talking to a painted wall.
And that makes sense: it doesn't use a public pool, it uses its own. Those two servers are his. Whoever mines on a commercial pool leaves the books open to anyone; this one built his own till, and with it his privacy. So I know where the money goes, but not how much — nor who collects it. A Monero wallet has no name on it.
09Indicators (IOCs)
| Type | Value |
|---|---|
| SHA-256 (trinity) | 76ae6d577ba96b1c3a1de8b21c32a9faf6040f7e78d98269e0469d896c29dc64 |
| SHA-256 (endat) | a1b6223a3ecb37b9f7e4a52909a08d9fd8f8f80aee46466127ea0f078c7f5437 |
| SHA-256 (xig, launcher) | d7188b8c575367e10ea8b36ec7cca067ef6ce6d26ffa8c74b3faa0b14ebb8ff0 |
| SHA-256 (XMRig extracted) | aa85b6b8bcd3d90c2221bd6431733463… · 657,948 B |
| SHA-256 (rtsh.sh extracted) | 426d8adbd84c7a12fedea5e171f6f57d… · 5,272 B |
| Monero wallet | 44XT4KvmobTQfeWa6PCQF5RDosr2MLWm43AsaE3o5iNRXXTfDbYk2VPHTVedTQHZyfXNzMn8YYF2466d3FSDT7gJS8gdHAr |
| Active pool | 139.99.9.133 : 5555 (OVH SAS, Singapore) — no prior reports |
| Fallback pool | 78.46.89.102 : 7777 (Hetzner, Germany) — no prior reports |
| Kit components | /data/local/tmp/{trinity, ufo.apk, xig, endat, rtsh.sh, lock0.txt, botsuinit_1_1.txt} · /sdcard/33 · /sdcard/44 |
| Persistence | replaces /system/bin/debuggerd and debuggerd64 (original becomes debuggerd64_real) |
| Rivals it uninstalls | com.google.time.timer · com.android.good.miner · com.google.test.test |
| Propagation | ADB · port 5555 · blind scanning, no C2 (get-state → push → install → am start) |
| Armour | OLLVM: flattening + opaque predicates + split addresses |
| endat signature | 127 bytes = libc random() with seed 1 (the factory default) |
| IP that brought it | 103.221.140.29 (China Unicom) — infected device, not a hub |
| What I published | What lands now |
|---|---|
| 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 |
And the disguise has improved: com.ufo.miner gave itself away; com.google.home.tv passes for a Google TV app.
The one thing they didn't touch is com.example.test.MainActivity, alongside the adb -s %s:5555 and get-state commands. Chapter 10 laughed at that default name nobody renamed. Eight years and a complete rotation later, it is the only indicator that still works.
And on "still alive", which sounds like filler: between 2 and 10 September, four different trinity binaries and two versions of the container came through the bait, some of them as many as four times. This isn't a fossil asleep in a forgotten device: it's a campaign that iterates.
September's container, incidentally, is the same one from this chapter plus eight bytes: a "DATA" 00 00 01 00 marker slipped in at the 128 KB boundary. Same payload, versioned format. The author maintains his packer even though he doesn't rename his classes — which is, in one line, the whole portrait.
And one last thing about how I caught it, because the mistake is instructive. When it landed, I did the first thing you do: look its hash up in what I already have catalogued. Zero matches. A new sample, in theory.
It wasn't. It was the APK from Chapter 10 under another name — same Coinhive key, the same dex, the same certificate. What had changed was the wrapper, and with the wrapper, the hash. Searching by hash doesn't recognise a repackage: it only recognises the exact file you already saw.
Chapter 10 ended on an image I liked a lot: a phone mining for a company that closed in 2019, working for nobody. It's still true — of the APK.
What I didn't know then is that this fossil travels as a stowaway. That it rides inside a package with a worm armoured three times over, a script that pulls out the system's insides so it never has to leave, and a real miner that does get paid, into a Monero wallet, on a server in Singapore, right now, while you read this.
The poor devil from chapter 10 wasn't working for nobody. It was just the cover story.
10Postscript: I wasn't the first
I published everything above and, shortly after, ran into a Chinese article from 2020. It is signed by the Qi'anxin Virus Response Center —one of China's big security companies— and titled, roughly, «A corner of IoT attacks: the unextinguished AdbMiner operation».
I went straight to the appendix. And there was everything I had dug out by hand, published six years earlier:
矿池 (mining pools) 139.99.9.133:5555 78.46.89.102:7777 钱包地址 (wallets) 门罗币 (Monero): 44XT4KvmobTQfeWa6PCQF5RDosr2MLWm43AsaE3o5iNRXXTfDbYk2VPHTVedTQHZyfXNzMn8YYF2466d3FSDT7gJS8gdHAr CoinHive: fwW95bBFO91OKUsz1VhlMEQwxmDBz7XE
Both pools, exact. The Monero wallet, exact. And the CoinHive key… is the one from chapter 10. The very same I pulled out of that five-kilobyte APK.
And it gives me something I could not prove alone. Notice that the CoinHive key and the Monero wallet sit in the same list, back in 2020. Chapter 10 and this one —the fossil that earns nothing and the miner that does get paid— are the same business. I deduced it by opening the container; they already had it side by side six years earlier. Two different roads, the same conclusion.
Their module table, moreover, is mine under different names:
| Qi'anxin (2020) | My sample (2026) | What it is |
|---|---|---|
| log | trinity | main module: drops the others and starts the mining |
| bdat | endat | the container |
| tv.apk | ufo.apk | the CoinHive APK (already useless) |
| rig | xig | the miner |
| rtsh.sh | rtsh.sh | the rooting script — he did not even bother renaming it |
| nohup | nohup | same |
| droidbot | (the engine I reconstructed) | the spreading worm |
Six years of «evolution» that amount to changing the first letter of three files. bdat became endat, rig became xig, log became trinity. The other two did not even get that. Same design, same wallet, same two servers. The path of least effort again, now measured in years.
So "six years of evolution" falls short, and the joke turns itself around: anyone who had Qi'anxin's 2020 indicators on file would catch this month's variant. Anyone with mine from August wouldn't.
Back in 2020, looking at Shodan, Qi'anxin reckoned there were close to ten thousand Android devices exposed to this. It started in 2018 aimed at TV set-top boxes and ended up turning up in electric-car charging posts. And at some point, to scan faster, somebody grafted Mirai's scanning module onto it.
So the answer to «where does the money go?» has a second half I could not give on my own: to the same wallet and the same two servers since at least September 2020. Six years. Documented, public, and in full detail from day one.
And there it still is, collecting.
That is what I really take away from this postscript, and it is not what I expected: the problem was never that nobody had found it. It was found and published six years ago. The problem is that finding it does not switch it off.
To be continued — the honeypot is still on. And this time I'm leaving a splinter in on purpose: I know where the money goes, but not how much — nor who is on the other end. 🍯
Comments