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:
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.1Garbage. 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:
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.
455,427Four 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.
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:
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.
size 164,272 B = 164,272 B
bytes that differ 1,130 # 0.7 % of the fileOne 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:
… /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.
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 changeEight 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.
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.
| Binary | The IP, written at position | Does 07 d0 appear? |
|---|---|---|
| x86-64 | 116,337 | yes — and proven instruction by instruction |
| m68k | 154,873 | yes |
| MIPS | 178,228 | yes |
| MIPS little-endian | 180,500 | yes |
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.
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:
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:
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 EINPROGRESSTwenty-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.
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.
| What | How long it lasts | «Disposable»? |
|---|---|---|
| The files posted on the delivery server | hours | yes |
| The campaign that host serves | 2-3 days | yes |
| The delivery host | ≥ 10 days, and still standing | no |
| The command centre | weeks | no — not «days» |
| The bot's code | practically unchanged | no |
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 centre | Window observed | Life |
|---|---|---|
| femboys.chloebulldog.online:44510 (resolved to 45.205.1.36) | June → unreachable in July | 4-6 weeks depending whether you count from the domain or the IP |
| 103.83.87.122:8060 | the build carrying it is from 6 July; the port stops showing by late August | seven weeks or more |
| 176.65.139.206:2000 | since 10 September | ongoing |
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.
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.
| Type | Value |
|---|---|
| C2 — not published before | 176.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 dossier | b4acd1ab65624b694946b1181bba0732bb63c88c51b8334914c26c1805b2e1aa 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 |
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