ES EN
Index

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.

A word before we startWhat follows is told exactly as I lived it, in the order I lived it. After publishing it I found something out that doesn't change a single fact you are about to read, but does change who deserves part of the credit. I am not telling it here because it would spoil the path: it is all in the postscript at the end. If you are the sort who would rather know first, jump there and come back.

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:

strings · trinity (extract)
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.

trinity · what the decompiler returns
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.

And this is why the hash is no use hereThe numbers that drive that loop get re-rolled on every build, and that has a measurable consequence. I took two trinity binaries captured on different dates, of exactly the same size — 239,388 bytes each — and compared them byte by byte: they differ across 89 % of them (212,862 out of 239,388). They look like two different programs.

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:

trinity · the engine, reconstructed
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:

endat · the first bytes
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:

reproducing the header
srandom(1);   // the default seed — the one nobody touches
for(i=0;i<127;i++) putchar('a' + random()%26);

> nwlrbbmqbhcdarzowkkyhiddqscdxrjmowfrxsjybld...   // letter for letter
Three locks, and the key left inThink about it for a second. This man went to the trouble of armouring his worm with three layers of obfuscation that defeat Ghidra and angr… and then heads his file with the default random sequence of the C library, the one that comes out of any Hello World, because he never touched the seed. It's exactly his pattern: the test certificate in chapter 8, the com.example.test in chapter 10. The path of least effort, applied to malware.

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.

Where the line is, and why this time I move itThe house rule still stands: nothing runs on the honeypot. It's a server with a public IP, and switching on an ADB worm there could mean infecting other people's devices — whether or not the address it goes out from is mine.

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:

the trace, at the key moment
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:

what was missing
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 here

It 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:

what came out of endat
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.

xig · what it had written inside
// Monero wallet (95 characters, starts with 4)
44XT4KvmobTQfeWa6PCQF5RDosr2MLWm43AsaE3o5iNRXXTfDbYk2VPHTVedTQHZyfXNzMn8YYF2466d3FSDT7gJS8gdHAr

// and the two places it calls
139.99.9.133:5555
78.46.89.102:7777
Careful with the second walletA second Monero address appears in the same binary. It's tempting to publish that one too, and it would be a mistake: it's XMRig's own donation wallet, which ships with the program — two of its domains sit right next to it, confirming as much. It isn't the attacker's. It's the kind of detail that, published badly, gets copied by the next person and then nobody stops it. Knock on wood.

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:

/proc/net/tcp, inside the cage
0200630A:B87A  8509638B:15B3  02
                     ↑            ↑
              139.99.9.133      :5555     state 02 = trying to connect

There 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.

What I won't be able to tell youWith a wallet in hand, the logical next step is to look up how much it has earned: most mining pools publish per-address statistics, and that's where the money shows. I checked the four big ones. It appears in none of them.

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)

TypeValue
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 wallet44XT4KvmobTQfeWa6PCQF5RDosr2MLWm43AsaE3o5iNRXXTfDbYk2VPHTVedTQHZyfXNzMn8YYF2466d3FSDT7gJS8gdHAr
Active pool139.99.9.133 : 5555 (OVH SAS, Singapore) — no prior reports
Fallback pool78.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
Persistencereplaces /system/bin/debuggerd and debuggerd64 (original becomes debuggerd64_real)
Rivals it uninstallscom.google.time.timer · com.android.good.miner · com.google.test.test
PropagationADB · port 5555 · blind scanning, no C2 (get-state → push → install → am start)
ArmourOLLVM: flattening + opaque predicates + split addresses
endat signature127 bytes = libc random() with seed 1 (the factory default)
IP that brought it103.221.140.29 (China Unicom) — infected device, not a hub
NOT an indicatorThe address 48edfHu7V9Z84Yzz…, which also appears inside the miner, is XMRig's donation wallet and ships with the original program. Don't report it: it isn't the attacker's.
Half this table has expiredThe campaign is still alive and has changed its names. If you came here to copy indicators, start with this:

What I publishedWhat lands now
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

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:

Qi'anxin appendix · 30 September 2020
矿池   (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.

What this takes away from this chapter, and what it givesIt takes away the credit for the find. I did not discover the wallet and the pools: Qi'anxin published them in 2020 and I arrived six years late, on my own and without knowing. What is mine is having confirmed them from my own honeypot, and having checked that they are still alive today.

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
logtrinitymain module: drops the others and starts the mining
bdatendatthe container
tv.apkufo.apkthe CoinHive APK (already useless)
rigxigthe miner
rtsh.shrtsh.shthe rooting script — he did not even bother renaming it
nohupnohupsame
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.

It isn't even a straight lineLook again at Qi'anxin's 2020 column and compare it with what lands this month: bdat, rig, tv.apk. They are exactly the names from six years ago. They haven't invented anything new: they have gone back.

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