ES EN
Index

IranBot · Chapter 22

The twin I was missing

A bot of the Mirai school carries the address of whoever gives it orders. I looked for this one's by four different routes and found it by none — and along the way I published a theory of my own that turned out to be false. What finally worked wasn't a better tool: it was realising that somewhere out there sat another binary, compiled from the same code, whose command centre somebody had already published. With both in front of me, the difference between them is one thousand one hundred and thirty bytes.

In the previous chapter I caught the infection and put a name to the family. What was missing was the only thing that really matters about a bot: who it obeys.

A bug of the Mirai school carries the address of its command centre written inside. It doesn't ask for it, doesn't negotiate it, doesn't resolve it from anywhere: it comes factory-fitted, because whoever compiled it wrote it into the code before hitting build. Finding it is usually a matter of looking in the right place.

I looked in four right places. It was in none of them.

01The easy assumption

The first thing you try is the dumbest, because it lands more often than it should: that the server handing out the bug is also the one commanding it. One box for everything — and we already saw in the previous chapter that this operator has no shame about mixing.

Inside a program, an IP can sit two ways: as readable text, or as the four-byte number the system handles when it opens a connection —176.65.139.206 is b0 41 8b ce—. I went for the number, which is the form that does not show up in a strings dump: I looked for it in all four binaries, in the two orders a processor can lay it out in.

Not there. Not in the x86-64, not in the m68k, not in either MIPS.

Second place. The family's public dossier lists the command centres of its previous campaigns — three, plus two delivery servers, though in addresses that's only four: one plays both roles and another never had a number at all. If this build were a lazy recompile of an old one, it might still be carrying one of them. I looked for them in all four architectures, as a number and as text. None.

And while I was at it, the other thing it could be: a domain name, which would be readable text. I went through every string in all four files. Not one domain. Not a single one.

02Not encrypted, not brute force either

If it isn't in plain sight, the logical thought is that it's hidden. And the classic hiding place in this school is an XOR: mix each byte with a key, which is the cheapest thing there is and enough to keep the address out of a strings dump. In chapter 4 I pulled the key out of a Mirai like that, and it was a single byte.

I tried all 255 possible keys, one by one, looking in each result for anything shaped like an IP or a domain. Two produced something, and neither survived a closer look:

the «results»
key 0x6f →  2.3.2.1 · 4.3.2.1 · 4.34.3.2 · 42.3.2.1 · 74.3.2.1
key 0xee →  1.1.1.1

Garbage. Numbers that come up by chance when you decrypt code with the wrong key. One of them was even a 1.1.1.1, the most convincing-looking of the lot and just as empty — a Cloudflare public DNS that turns up by accident. I also tried the joke keys that go round this world —DEADBEEF, BEEFDEAD— and nothing.

Fourth attempt, and the clumsiest of all. It occurred to me to walk all four binaries end to end looking for any four-byte sequence that could be read as a plausible address, and keep only the ones appearing in all four at once: if the command centre is in every build, it has to be in that intersection.

Thousands of candidates.

I refined. In binaries like these it's common to find the IP and the port close together —sometimes even side by side, inside the same structure the code uses to open the connection—, so I filtered for that combination and was left with a short and very promising list. I went to look at what was actually at each of those positions:

the candidates, up close
32.37.115.13    →   is actually the text   " %s\r"
49.46.48.13     →                          "1.0\r"
47.115.104.10   →                          "/sh\n"
62.32.27.91     →                          "> " + ESC + "["

They weren't addresses. They were ordinary chunks of text that, read as numbers, look like addresses. The method is no good for this binary, and it goes on the record so I don't fall for it again: if you look for patterns in two hundred thousand bytes, you find patterns.

Out of four attempts I salvaged nothing. Four routes, and none of them led anywhere.

03And I got the 8098 wrong

By this point I'd written something in my notes that turned out to be false, and I'd rather tell it than delete it.

The server has three open ports: 22, 80 —the Apache handing out the binaries— and a third one, 8098, that didn't fit anywhere. I went to see what it was: 80 answers as Apache, and 8098 answers with the default error page of a server written in Go. A different program, on the same machine.

An unidentified service, in Go, on the attacker's box. I wrote: probably the operator's control panel. And since the command centre was nowhere to be found, I went a step further and wrote that the 8098 was probably the C2.

Both of those were mine. They weren't in the data.

I went to check it the only decent way I could think of: if that port belongs to the operator, it has to be rare. I asked how many machines there were on the internet, the day I looked, with 8098 open.

hosts with port 8098 open
455,427

Four hundred and fifty-five thousand. With nginx, with IIS, Hikvision cameras, Emby and Jellyfin video servers, proxies, you name it. And the exact signature of that Go error, which I thought was distinctive, shows up on 316 machines of perfectly legitimate hosting — Hetzner, Vultr, netcup, including a block of seven consecutive addresses from the same provider.

It's nobody's panel. It's some high port with some Go service on it, and most likely it comes as standard in the VPS image: a monitoring agent, a proxy, anything. I withdraw both claims. It isn't an indicator, it doesn't get published, and it doesn't appear in the table at the end.

Where the mistake came fromFrom a gap. I was missing the C2, I had an unexplained port, and I put them together. After my theory, the port was exactly as unexplained as before.

04The twin I was missing

I was running out of repertoire. So I stopped looking at the binary and went back to reading the family's public documentation — but this time not the report, the boring file next to it: the indicator list, one dry line per sample. One of those lines describes a July build like this:

someone else's dossier · a July sample
iranbot x86_64 self-replicating build (iran.x86_64)
static stripped non-PIE ELF, 164272 bytes
PLAINTEXT hardcoded C2 103.83.87.122:8060  (no domain/DNS/crypto)

Two things at once.

The first: PLAINTEXT. In July, this family's command centre was in plain text. Unencrypted. I'd spent two days hunting an encryption scheme that might not exist.

The second got me out of my chair: 164272 bytes. My binary weighs 164,272 bytes. The same number. To the byte.

Two files compiled from the same code, with the same compiler and the same options, come out the same size; and if the only thing you change is one address for another of the same length, it still comes out the same. On its own that proves nothing —two different programs can weigh the same by chance— but it was the first coincidence worth chasing. And of that build, theirs, the command centre was published.

I had the twin. The sample is on MalwareBazaar, so I pulled it down to the lab. With both files in front of me, the question stops being «where is the C2» and becomes «how do they differ», which is incomparably easier to answer.

theirs against mine · byte by byte
size                  164,272 B  =  164,272 B
bytes that differ          1,130              # 0.7 % of the file

One thousand one hundred and thirty bytes out of one hundred and sixty-four thousand. That isn't a hand-patched file: it's the same code recompiled. And a good part of those differences are one-unit shifts in internal addresses — the clue that something in there grew by exactly one character.

103.83.87.122 has thirteen characters. 176.65.139.206 has fourteen.

I went to the exact position where theirs keeps its command centre, inside its string table:

the same slot, in both files
   … /dev/watchdog0 · /dev/watchdog1 · Not a mirai at all · Death to israel ·

theirs →  103.83.87.122     ← their C2, published
mine   →  176.65.139.206    ← mine

   · stop · !kill · ping · x86_64 · pong %s · !selfrep telnet · off · …

Same table, same position, same neighbours left and right. That address occupies the command centre's slot, not the delivery server's. And the position leaves no doubt about which field is the C2 in this build, because in the twin delivery had a slot of its own, separate and in a different format: the same address, but with :80 stuck on the end. Two jobs, two slots. The one I had in front of me was the command one.

And here comes the part that stings. I'd had that address in front of me since day one. It's the only IP written in plain text inside the binary and it came out in the very first strings dump, the one in the previous chapter. I dismissed it without a second thought —«sure, it's the server it downloaded from, it carries it to spread»—. And it is, it's that too. But it's also its command centre, and the string doesn't say that: the slot it occupies does. I spent two days hunting a hidden number while the answer sat there in letters, between a joke and a slogan. And that's when you see why the structure hunt in section 2 couldn't work: I was looking for an IP and a port together, and here the IP isn't a number, it's text — and the port isn't even nearby.

The port was still missing, and that one really is a number inside an instruction. The twin solves that too: both binaries set up the connection with the same instruction, at the same position in the file.

the instruction that sets the port
theirs →  66 c7 84 24 72 1f 00 00   1f 7c      # = port 8060
mine   →  66 c7 84 24 72 1f 00 00   07 d0      # = port 2000
          └──── identical byte for byte ────┘   └──┬──┘
                                        only these two change

Eight identical bytes —the instruction, the register, the stack slot it writes to— and two that aren't. Theirs read 8060. Mine, 2000.

176.65.139.206:2000. In plain text, unencrypted, no domain, nothing. The IP is catalogued out there as a command centre — on other ports, which is a story of its own. The 2000 nobody has published: it isn't in the family's dossier, nor on MalwareBazaar, nor in the indicator catalogues I've been able to check.

Why this worksDifferential analysis is nothing but subtraction. If you have two versions of the same program and you know what one of them contains, everything that doesn't change stops mattering and you're left looking only at what does. Here it cut a file of one hundred and sixty-four thousand bytes down to one thousand one hundred and thirty, and inside those was what I was after. What I needed wasn't a better tool: it was the other file.

05The other three architectures

The differential I could only run on the x86-64, because it's the only one with a published twin. The other three have to be checked another way, and here it's time to separate what I know from what I assume.

What I know: all four carry 176.65.139.206 written in them, each at whatever position its build gives it. And in all four the byte pair 07 d0 appears, which is 2000.

BinaryThe IP, written at positionDoes 07 d0 appear?
x86-64116,337yes — and proven instruction by instruction
m68k154,873yes
MIPS178,228yes
MIPS little-endian180,500yes

What I did not know: whether in those three those two bytes are really the port. Any two bytes turn up by chance in a two-hundred-thousand-byte file, and proving it would mean disassembling three more architectures. That was all I had when I built the table, and it wasn't enough to claim it.

So I checked it the direct way: I switched all four on and watched where they called.

Where the line is, and why I'm moving itThis doesn't happen on the honeypot — that's a machine with a public IP. I did it in a cage: isolated, with no route anywhere, an unprivileged user, and reverted when I was done. Not one packet left for the world. Watching something caged isn't letting it loose; the difference is the cage. (The same line I moved in chapters 13 and 15.)

The first thing it writes to the screen on starting up is the two lines that had let me name it: Not a mirai at all and Death to israel. And after that, all four to the same place:

strace · the 64-bit one, setting up the call (trimmed: the «[pid 908]» prefix removed from each line)
socket(AF_INET, SOCK_STREAM, IPPROTO_IP) = 6
setsockopt(6, SOL_TCP, TCP_NODELAY, [1], 4) = 0
setsockopt(6, SOL_SOCKET, SO_KEEPALIVE, [1], 4) = 0
connect(6, {sa_family=AF_INET, sin_port=htons(2000), sin_addr=inet_addr("176.65.139.206")}, 16) = -1 EINPROGRESS (Operation now in progress)

There's the whole call. It opens the socket; asks for TCP_NODELAY, which means «send me the packets as you get them, don't wait to bundle a few»; asks for SO_KEEPALIVE, which means «don't drop the line on me even if we go quiet for a while» — the two things anyone expecting short, occasional orders would ask for. And it dials. That EINPROGRESS at the end means «working on it»: in the cage there's no line, so there it stays, and it tries again in a loop.

And the other three, exactly the same: the same two setsockopt calls before every connection, without a single exception. As for where they call, all four to the same place:

the four samples · the connection only
x86-64  connect(6, … htons(2000) … inet_addr("176.65.139.206")) = -1 EINPROGRESS
m68k    connect(3, … htons(2000) … inet_addr("176.65.139.206")) = -1 EINPROGRESS
mips    connect(3, … htons(2000) … inet_addr("176.65.139.206")) = -1 EINPROGRESS
mipsel  connect(3, … htons(2000) … inet_addr("176.65.139.206")) = -1 EINPROGRESS

Twenty-five connections across the four samples, in windows of twenty-five to thirty seconds each, and a single destination. It's no longer «very likely»: it's what they do.

06Depends which clock you look at

With the twin in front of me I can measure something that's usually only guessed at: how much these people change between one campaign and the next.

The code, almost nothing. One thousand one hundred and thirty bytes out of one hundred and sixty-four thousand, and of those, the vast majority are internal addresses shifted along. Instructions with a real change: four. One is the port —from 8060 to 2000— and the other three are pointers that in July aimed at a second address and now aim at the only one left. Because the July build carried two inside: one for command and another, with its port stuck on, for delivery. The September one has deleted the second.

And there's a neat way to verify that without arguing at all: the internal addresses before the deleted slot shift by +1, and those after it by −16. One is the character the IP grew by; sixteen is the size of the slot that vanished, minus that one. The arithmetic works out exactly. You don't have to take my word: it falls out on its own.

With that in hand I can go back to something this family's dossier says — the report that let me name it. Because the Nokia Deepfield dossier —the one from the previous chapter, the one with the markers— isn't titled «IranBot». It's titled Cattle, not pets. And its thesis is that this is a disposable operation — build cheap, burn fast, move on.

And a small confession, before going onWhen I measured the obfuscation and saw it going backwards —the old builds encrypted their configuration, mine carries it in plain text— I wrote it up as a finding of my own. It isn't: it's on the first page of that report, which I had open. Checking whether someone has already told it comes before claiming it, and here I skipped that with the document right in front of me.

So what follows isn't contradicting anyone: it's measuring it myself and seeing where it matches and where it doesn't. And the first thing measuring turns up is that there isn't one clock. There are five, and they don't say the same thing.

WhatHow long it lasts«Disposable»?
The files posted on the delivery serverhoursyes
The campaign that host serves2-3 daysyes
The delivery host≥ 10 days, and still standingno
The command centreweeksno — not «days»
The bot's codepractically unchangedno

What comes and goes at speed is the merchandise: the files last hours, the campaign lasts days. What doesn't move is everything else — the box is still standing, command holds for weeks, and the program is July's. And on the fourth clock I agree with them: their report speaks of «a new C2 every few weeks», and that's exactly what comes out when you count the dates.

Command centreWindow observedLife
femboys.chloebulldog.online:44510
(resolved to 45.205.1.36)
June → unreachable in July4-6 weeks
depending whether you count from the domain or the IP
103.83.87.122:8060the build carrying it is from 6 July; the port stops showing by late Augustseven weeks or more
176.65.139.206:2000since 10 Septemberongoing

Weeks, not days — and the ranges are wide on purpose, because the start dates aren't when the operator put the server up but when somebody first saw it, which is not the same thing.

There's something else I wasn't expecting: the servers the dossier gives as down are still switched on and still collecting abuse reports. One is at 686 and another at 871, both with a report from yesterday. Careful with what that means, though: one of them today sits with another company, in another country, serving something else. It could be the same owner holding on, or it could be that the provider resold the address and the reports belong to the new tenant. I don't know, and the previous chapter warns about exactly this: in these ranges the data rotates fast.

Where the version of the characterisation that reached me does fall down is on a part that is not in the report: that they modify the code to throw analysts off. Deepfield doesn't say that —if anything it says the opposite— and my two files certainly don't:

  • The diff is 0.7 %, and it's explained entirely by the address change.
  • They keep the two strings that give them away most. Not a mirai at all and Death to israel are a gift to anyone writing detection rules — the first thing anyone wanting to hide would strip out. They're still there.
  • And there are the two loader typos from the previous chapter, one of which writes off an entire architecture.

For this lineage, the word isn't «evasive». It's fast and careless.

A limit that can't be rounded offThat «2-3 days» in the second row is measured for the campaigns on this host, between 31 August and 10 September. That's a closed figure. What I can't say is that the family recompiles every few days: for that I only have the published builds plus mine, and that neither supports nor refutes it. They're two different claims and only one is settled.
And a temptation worth resistingThat same box was handing out, a few days earlier, another campaign with another loader —w.sh— broken in a similar and worse way: eight of its twelve lines download one file and run a different one. It's tempting to put them together and say «look, always the same». You can't. I opened the binary that script hands out and it has not one of IranBot's markers: not the joke, not the slogan, not the orders, not the parameters. Different size, different naming, different campaign tag. It's another family on the same box, and the only thing they share is the server — which is exactly what the previous chapter says doesn't count as a link. Whether it's the same hands, I don't know. That the box has more than one tenant, that much yes.

07What I don't know

This is where the chapter stops, because there's a question I can't answer and I'm not going to pretend otherwise.

Is that command centre still alive?

What I know for certain is that delivery was emptied out the same day: anyone asking for cat.sh gets a 404. But delivery and command are two different services on the same box, and one being empty says absolutely nothing about the other. And the box is still standing: still answering, still collecting abuse reports.

The cage from the previous section doesn't tell me either. In there the bug dials the number, but there's no line: I caged it precisely so there wouldn't be one. I know who it calls; I don't know whether anyone picks up.

I tried the two routes I had, and both stopped halfway:

  • I asked Shodan to rescan the host. It came back «completed» and never got indexed: its record still shows the same three ports as before. And I don't even know whether its scan profile covers 2000 — which means a «doesn't show up» wouldn't have proven anything either.
  • The other search engine of that kind would have done just as well. My quota is exhausted: it replies that there's no balance, not even to look up a single address.

And there's the obvious route, which is to open a connection to 2000 and see whether anyone answers. I'm not going to. Calling a botnet's command port isn't like checking whether a website is up: from the other side it looks a great deal like a new bot registering, and my address would end up written in the logs of whoever runs that machine. For a data point that changes nothing in this chapter, it isn't worth it.

So the honest answer is that I don't know. Delivery is clean; command, unchecked. The box is under watch and, if it shows itself again, we'll know.

08Indicators (IOCs)

The infection ones are in chapter 21. These are the ones from inside.

TypeValue
C2 — not published before176.65.139.206:2000/tcp · in the clear, unencrypted, no domain
The same machine, its roles:22 SSH · :80 delivery (Apache) · :2000 C2 · and origin of the attack
Reference sample used (already published)b1a6dba6636b519d76d7219f6264ac9f1456681c0855baef954fb435d3e25ce5
x86-64, 164,272 B, C2 103.83.87.122:8060
July artefact not listed in its dossierb4acd1ab65624b694946b1181bba0732bb63c88c51b8334914c26c1805b2e1aa
iran.sh4 — an architecture I didn't capture
Family's earlier C2s (context)femboys.chloebulldog.online:44510 (→ 45.205.1.36) · mythickass.onthewifi.com:313 · 103.83.87.122:8060
Persistence (documented in its dossier)/etc/init.d/xs.main · /etc/rc.local
A port that is NOT an indicatorThe 8098 on that same machine isn't in the table, and that's no oversight. I called it the operator's panel myself and withdrew it in section 03: four hundred and fifty-five thousand machines have it open and its signature turns up in legitimate hosting. Publishing it as an indicator would only get someone to block an innocent neighbour.

To be continued — the honeypot's still on, and so is that box. Because while I was chasing the 2000 I kept looking at what else that server had handed out before it got to me, and it turned out this bug wasn't its only tenant. That's no longer the story of a bug, it's the story of an address — and it doesn't fit here. 🍯

Comments