Martin's Blog

Reversing AISURU: From Two Stripped ARM Binaries to a Live C2 Sensor

This experiment was different from my earlier posts. Those started with a claim I already knew how to test; this one started with two stripped ARM binaries and almost nothing else.

I didn’t know AISURU, didn’t know the actors, didn’t know the protocol. But in short, AISURU is a Mirai-derived IoT botnet that spreads by brute-forcing Telnet on embedded Linux devices: routers, IP cameras, modems, the usual. The whole thing rested on one borrowed question from the NoName work: could I connect to a live C2 and watch its target list go by, without ever running the malware? I had no idea whether the answer was yes.

So this was far more AI-led. I started with basic questions and worked outward from the answers, with plenty of trial and error along the way: different prompts, wrong turns, routines mislabelled and re-checked. Most of what fell out ended up in one Python script that joins the C2 and prints the commands it hands down.

Note that this post runs deeper into the technical weeds than the last post, and that is the point. I wanted to show how far the models have come at chewing through genuinely hard problems, so I have left the detail in rather than smoothing it over. Most of what follows is lifted straight from the work log, in the order it happened.

The samples

The two ARMv7 ELF binaries were:

Filename Role Size SHA-256
aisuru_8087_xd.armv7l Stage-1 loader / dropper 74 KB ff454f64c8e6a9808c182eade0291759dd1e987ef78e3e4f78dab8f812ccd404
aisuru_armv7.bin Full bot payload 242 KB 118eb9e81e70d1ba9f2702037b244bea154100ef52685f0b499fa7b8fa375e90

Both are statically linked, static-PIE, stripped ARM EABI5. The loader fetches the bot at runtime; the bot scans, propagates, and runs DDoS attacks on command. The goal I set myself was concrete: get to the point where I could read the live command channel without running either file. This is how that went, in order.

Phase 1: Triage

I drove triage with the binary-analysis skill, a five-phase static workflow (identification, static analysis, security-feature check, behavioral heuristics, synthesis) that never executes the target. The first phase is just stock binutils:

file aisuru_armv7.bin          # ELF 32-bit LSB, ARM EABI5, statically linked, stripped
sha256sum *.armv7l *.bin
readelf -S aisuru_armv7.bin    # section layout
strings -n 6 aisuru_8087_xd.armv7l | wc -l

For the tedious parts the skill ships a helper, binary_analyzer.py, that does per-section entropy, the security-feature audit, and a MITRE-tagged heuristic scan:

SKILL=.claude/skills/binary-analysis/scripts/binary_analyzer.py
python3 $SKILL --entropy    aisuru_8087_xd.armv7l           # per-section Shannon entropy
python3 $SKILL --checksec   aisuru_8087_xd.armv7l           # NX / PIE / canary / RELRO / FORTIFY
python3 $SKILL --heuristics aisuru_armv7.bin \
        --strings strings.txt -o heuristics.json            # persistence / c2 / anti-analysis ...

--checksec returns the usual stripped-IoT-malware profile on both: NX and PIE on, no stack canary, no RELRO. Size-optimised builds, minimal hardening. --heuristics on the bot lights up persistence, c2_communication, and anti_analysis before I had decompiled a single function: useful, but not yet evidence.

The two samples split immediately on string count:

Entropy confirms which:

Section Loader Bot
.text 6.14 6.14
.rodata 6.949 6.39
.data 3.24 (52 B) 1.95 (66 KB)

The loader’s .rodata sits at 6.949, right under the ~7.0 packed threshold, and its .data is 52 bytes. Something in .rodata is encrypted. Two strings survive in both binaries and tie them to one build lineage:

Those two are the whole thread. Everything else unspools from them.

Phase 2: Decompilation

I decompiled both binaries twice, to cross-check:

Tool Output Addressing
IDA Pro 9.3 (idalib + Hex-Rays hexarm) *.ida.dec/ IDA addr == ELF file offset (static-PIE)
Binary Ninja headless (HLIL) *.dec/ BN addr = IDA addr + 0x10000 (PIE rebase)

Both runs were driven by dedicated skills (/decompile-idapro, /decompile-binaryninja; ref .claude/skills) that run each tool headless and emit one .c file per non-thunk function.

For this target IDA’s ARM output was the cleaner of the two: it resolves syscall numbers, names ARM intrinsics (__ROR4__, __mcr, __dmb), and infers better types. The loader’s watchdog loop is the sharpest example:

// IDA: sub_F9F4 (syscall numbers resolved: 248 = futex, 1 = exit)
void __noreturn sub_F9F4(...) {
    for ( i = 248; ; i = 1 )
        linux_eabi_syscall(i, a1, ...);
}

// Binary Ninja: same code, semantics collapsed
void sub_1f9f4() {
    while (true) syscall();
}

That futex/exit alternation, plus a forked child, is a self-respawning watchdog: the parent re-execs the loader whenever the child dies. It keeps the infection resident across crashes.

One caution I’ll repeat because it bit me later: the first decompiler pass mislabelled several functions: it called sub_18888 a ChaCha20 block when it is __udivsi3. Verify a suspected crypto routine against what it actually does. Don’t trust an auto-generated label.

Phase 3: Two ciphers, not one

The loader carries two independent ciphers, and conflating them is the classic AISURU trap:

Cipher Key Constant Purpose
ChaCha20 (standard, 20-round) 32-byte session key expand 32-byte k C2 network traffic
RC4-variant + 32-bit LFSR PJbiNbbeasddDfsc n/a Config string table + C2 key-wrapping

PJbiNbbeasddDfsc is not the ChaCha20 key. It keys a Mirai-style locked string table, the reason the loader has almost no strings.

Breaking the config cipher

Read off the IDA decompilation, the string-table cipher is RC4’s i/j walk plus a 32-bit LFSR (feedback polynomial 0xD800A4), a non-standard S-box init, and a final output whitening of rol8(v,3) ^ (v >> 4). It is symmetric, so one routine both locks and unlocks. Ported to Python (decrypt_config.py):

KEY = b"PJbiNbbeasddDfsc"

def keystream(length):
    """sub_43F4 PRGA: produce `length` keystream bytes."""
    S = ksa()                 # 256-byte S-box, custom init
    i = j = c = 0
    lfsr = 1
    out = []
    for _ in range(length):
        i = (i + 1) & 0xFF
        si = S[i]
        lfsr = ((lfsr << 1) | (lfsr >> 31)) & 0xFFFFFFFF      # ROL32 by 1
        j = (j + si) & 0xFF
        c = (c + S[(j + i) & 0xFF]) & 0xFF
        S[i], S[j] = S[j], S[i]
        if lfsr & 1:
            lfsr ^= 0xD800A4                                  # LFSR feedback
        inner = (S[(c + j) & 0xFF] + S[(j + i) & 0xFF]) & 0xFF
        v = (S[inner] ^ (lfsr & 0xFF)) & 0xFF
        out.append((((v << 3) | (v >> 5)) ^ (v >> 4)) & 0xFF)  # rol8(v,3)^(v>>4)
    return out

def decrypt(blob):
    ks = keystream(len(blob))
    return bytes(b ^ ks[i] for i, b in enumerate(blob))

Each unlock restarts the state (i = j = c = 0, lfsr = 1), so the keystream is fixed and depends only on the hard-coded key. Point it at the loader’s config blob and all 38 populated entries fall out:

$ .venv/bin/python3 decrypt_config.py
idx  len  decrypted
  0   29  System upgrade in progress...        (argv[0] masquerade)
  1   17  stun.l.google.com                    (public-IP discovery)
  2   11  hardload.su                          (C2 domain)
  3   15  rep.hardload.su                      (C2 reporting subdomain)
  4   60  telnetd,udhcpc,inetd,ntpclient,...   (process kill-list)
 13   36  VMware,VirtualBox,KVM,Microsoft,QEMU (VM/sandbox evasion)
 14   32  tcpdump,wireshark,tshark,dumpcap     (anti-analysis)
 21   24  /proc/self/oom_score_adj             (OOM self-protection)
 31   38  /tmp/,/var/tmp/,/root/,/dev/shm/,... (persistence drop paths)
 ...

That table answers the question triage left open: the loader’s C2 is the domain hardload.su, with a rep. check-in subdomain, resolved at runtime, which is why no IP was ever visible statically. It also lays out the anti-analysis posture in plaintext: hypervisor-name detection, packet-capture-tool detection, OOM-killer self-protection (oom_score_adj = -1000). Nothing subtle once you have the key.

A DNS covert channel

hardload.su publishes one TXT record: k4MwZw==. It is not a verification token. It is an obfuscated fallback C2 address:

  1. base64-decode → 93 83 30 67
  2. XOR with 0xCAFEBABE59 7d 8a d9
  3. read as dotted-quad IPv4 → 89.125.138.217
$ .venv/bin/python3 decode_txt.py
hardload.su  TXT "k4MwZw=="  ->  89.125.138.217

The apex hides behind Cloudflare, but this TXT-encoded fallback points straight at a real host, and the operator can rotate it by editing one DNS record, no recompile. That host is where the sensor eventually connects.

Phase 4: The C2 protocol

With the bot decompiled, the command channel was next. Transport is TCP port 8001. Every message is three segments:

header    8 bytes
body      <bodylen> bytes   random padding
payload   <payloadlen>      actual content (may be empty)

The 8-byte header:

Off Size Field
0 1 message type
1 1 bodylen
2 2 payloadlen (big-endian)
4 4 MAC (big-endian)

Once the session is keyed, header and payload are ChaCha20-encrypted (the random body padding is not). The handshake is a five-state machine:

State Event Action Encrypted?
0 connect send type 0 (registration, empty) no
1 recv type 1 RC4-unwrap ChaCha20 key+nonce, install, send type 2 no
2 recv type 2 send type 3 (telemetry) yes
3 recv type 3 store a dword yes
4 recv type 4/5/6 dispatch command (steady state) yes

And the command vocabulary:

Type Direction Meaning
0 bot→C2 registration
1 C2→bot session-key provisioning
2 both handshake ack
3 bot→C2 telemetry / sysinfo
4 C2→bot keepalive (bot echoes it)
5 C2→bot kill / self-update
6 C2→bot attack command

Here is the design mistake that makes everything else possible: the bot ships no static ChaCha20 key. The C2 provisions a per-session key in the type-1 message, wrapped with the same RC4-variant cipher I had already broken. Recover that one message and the whole session is readable. The type-1 payload is four length-prefixed fields:

def parse_type1(payload):
    """Type-1 key provisioning: RC4-unwrap key + nonce, verify each MAC."""
    o = 0
    klen = int.from_bytes(payload[o:o+4], "big"); o += 4
    key_enc = payload[o:o+klen]; o += klen
    keymac = int.from_bytes(payload[o:o+4], "big"); o += 4
    nlen = int.from_bytes(payload[o:o+4], "big"); o += 4
    nonce_enc = payload[o:o+nlen]; o += nlen
    noncemac = int.from_bytes(payload[o:o+4], "big"); o += 4
    key = rc4_unwrap(key_enc)      # decrypt_config.decrypt, key PJbiNbbeasddDfsc
    nonce = rc4_unwrap(nonce_enc)
    return {
        "key": key, "nonce": nonce,
        "key_ok":   len(key) == 32 and c2_mac(key) == keymac,
        "nonce_ok": len(nonce) == 12 and c2_mac(nonce) == noncemac,
    }

The MAC, by emulation

Every message also carries a 4-byte MAC: a custom 64-bit MurmurHash-flavoured mixing hash over the plaintext payload. It is inlined into several bot functions and never appears as a clean routine, so hand-porting it meant risking a subtle bug in the 64-bit carry arithmetic: the kind that passes nine test vectors and fails the tenth.

So I didn’t port it. The region is pure ALU work (no syscalls, no library calls), so I run the bot’s own MAC code under Unicorn. Nothing from the malware actually runs; about 120 arithmetic instructions execute in an isolated VM with scratch memory mapped around them.

# c2_mac.py: emulate .text 0xBCE4..0xBE98 of aisuru_armv7.bin
_MAC_START, _MAC_END = 0xBCE4, 0xBE9C

def c2_mac(payload: bytes) -> int:
    """Return the 32-bit C2 MAC of `payload` by emulating the bot's code."""
    mu = Uc(UC_ARCH_ARM, UC_MODE_ARM)
    mu.mem_map(0, _IMG_SIZE)
    mu.mem_write(0, _CODE)                      # the bot binary, mapped at its file offset
    mu.mem_map(_STACK, 0x10000)
    mu.mem_map(_BUF, max(0x1000, (len(payload) + 0xFFF) & ~0xFFF))
    mu.mem_write(_BUF, payload)
    mu.reg_write(UC_ARM_REG_SP, _STACK + 0x8000)
    mu.reg_write(UC_ARM_REG_R5, len(payload))   # payload length
    mu.reg_write(UC_ARM_REG_R6, _BUF)           # payload pointer
    mu.emu_start(_MAC_START, _MAC_END, count=2_000_000)
    return mu.reg_read(UC_ARM_REG_R3) & 0xFFFFFFFF

This is exact by construction (it is the malware’s algorithm) and it checks out against 12 ground-truth vectors in the module’s self-test. One quirk drops out of the emulation: mac(b"") is 0xaf70ac84, but the protocol writes MAC = 0 for an empty payload. Knowing that special case is exactly what lets the sensor’s empty registration and ack messages pass verification later. Small detail, load-bearing.

Phase 5: Threat intelligence: mapping the campaign

Reversing one binary tells you what that binary does. It doesn’t tell you how big the campaign is or where it lives. For that I leaned on two platforms: REDS (RationalEdge, sample and code intelligence) for the family, and Censys for the C2 infrastructure. Both pivots start from artefacts the reverse already produced: the hashes, the expand 3te k marker, and the C2 IP 89.125.138.217.

Sample intelligence: REDS

Driven by the reds-malware-platform skill (a redscli wrapper). Both samples went in for decompilation and code-similarity analysis, and the marker string from Phase 1 turned out to be a precision IOC:

redscli strings samples --string 'expand 3te k'   # -> exactly 2 samples: our loader + bot
redscli search -q 'ipv4="15.204.230.147"'         # -> 30 samples on the old cluster C2
Pivot Result
expand 3te k string 2 samples (ours), the mangled ChaCha20 constant is unique to this May-2026 build
C2 IP 15.204.230.147 30 samples, 10 known cluster members + 20 new
C2 IP 220.158.234.23 2 samples, a PowerPC bot and an x86-64 XMRig cryptojacker

The 30 samples on the primary C2 span at least two architectures (the original cluster was ARM; a new one I examined is x86-64) and more than one payload: one new x86-64 sample is an SSH-scanning botnet rather than our Telnet ARM bot, and the 220.158.234.23 pivot drags in an XMRig miner. So that IP is shared, cross-payload infrastructure, not a clean AISURU C2.

I’ll be blunt about the limits of this. The 20 new samples are linked by shared C2 IP alone, not the code similarity that defined the original cluster, and none of them carry the expand 3te k marker: they are an earlier, un-obfuscated generation, not this build. The interesting part isn’t the count, it’s the arc: a plaintext single-binary bot in January became a two-stage loader with an encrypted config by May. The operator hardened.

Infrastructure intelligence: Censys

I pivoted the C2 addresses in Censys through the censys-platform skill (a cscli wrapper). Host lookups of all four:

cscli host get 89.125.138.217 --pretty
C2 IP Role ASN / network Live services
89.125.138.217 TXT-fallback C2 (the sensor’s target) AS26383 Baxet Group 22 (OpenSSH 8.9p1), 8080 (Go net/http)
212.192.12.168 rep.hardload.su check-in AS26383 Baxet Group 22 only
15.204.230.147 Jan-2026 cluster C2 AS16276 OVH none (rotated/sinkholed)
220.158.234.23 cross-payload IOC AS38623 Viettel Cambodia none (residential)

Both active C2 hosts sit in the same ASN, AS26383, Baxet Group, despite different GeoIP countries. They have distinct SSH host keys, so two separate VMs, but an identical HASSH and banner: both stock Ubuntu 22.04, one provisioning template. Don’t over-read it: n=2, and the ASN announces 11,700+ hosts. This is where these two C2s live, not “the AISURU network”.

The 8001 listener never shows up in Censys, and it would be easy to claim it is invisible by design. It isn’t. Censys scanned both hosts days later and does record open-but-bannerless ports (the host lists 111/udp with the empty-string hash), so an open 8001 would appear too. Its absence means 8001 was closed or filtered at scan time: the listener is intermittent, or firewalled to bot IPs. It was up when the sensor hit it on 5/21, down ten days later. That’s also why the operator bothers with a rotatable DNS fallback.

Every generic attempt to enumerate sibling C2s failed. I’m listing them so nobody repeats the work:

Pivot Hits Verdict
Baxet hosts on port 8001 47 the ones I checked run nginx; 8001 is not AISURU
Go-404 8080 banner hash 3.4M globally stock Go net/http, pure noise
stock Ubuntu SSH banner 11,709 in Baxet noise

The C2 has no Censys-fingerprintable surface. Track this layer by ASN and DNS, not service scans. One structural result did fall out, though: the hardload.su booter-panel web cluster, found separately via a shared SSL OK\n body hash, is hosted across AWS, Oracle, Alibaba, DigitalOcean and PFcloud, and none of it is on Baxet. The customer-facing panel and the bot C2 share a brand, not a provider.

The combined picture

Put the two together and AISURU is a multi-architecture, multi-payload operation (ARM and x86-64; DDoS bots and cryptominers) that tightened its operational security between January and May 2026, runs its live check-in on Baxet (AS26383) behind a rotatable DNS-fronted fallback, and keeps its booter panels on mainstream clouds well away from the C2. The expand 3te k marker pins my two samples to the newest, obfuscated generation, and hands defenders an exact-match hunt string for it.

Phase 6: A passive sensor

Now the pieces compose. c2_monitor.py breaks the key wrap with decrypt_config.decrypt, computes MACs with c2_mac, and frames messages with a stock ChaCha20. The session driver is a straight transcription of the state machine:

def session(self):
    self.sock = socket.create_connection((self.ip, self.port), timeout=30)

    # state 0 -> 1 : registration (plaintext, empty payload)
    self._send(0)

    # state 1 -> 2 : receive key provisioning, RC4-unwrap the session key
    msg = self._recv()
    info = parse_type1(msg["payload"])
    self.key, self.nonce = info["key"], info["nonce"]
    log("session key provisioned", key=self.key.hex(), nonce=self.nonce.hex(),
        msg_mac_ok=msg["mac_ok"])

    # state 2 -> 3 : ack, then telemetry (TLV reversed from sub_C310)
    self._send(2); self._recv()
    self._send(3, self.telemetry); self._recv()

    # state 4 : steady state, observe and log, never execute
    log("ENROLLED - monitoring command channel")
    while True:
        self.handle(self._recv())

handle() is deliberately inert. Type-6 attack orders get parsed, logged, and enriched with the victim’s network owner and abuse contact, and then nothing. Type-5 kill/update is logged and ignored. The only thing I send back is the keepalive echo, because that is what keeps the sensor enrolled:

def handle(self, msg):
    t, pl = msg["type"], msg["payload"]
    if t == 4:
        self._send(4)                      # keepalive echo (passive)
        log("keepalive (type 4)")
    elif t == 5:
        log("KILL/UPDATE command (type 5) - observed, NOT executed")
    elif t == 6:
        attack = parse_attack_command(pl)
        log("attack command (type 6)", attack=attack, mac_ok=msg["mac_ok"])
        notify_victims(attack)             # RDAP/whois abuse-contact enrichment

The whole stack self-tests offline, with no network and no malware in sight:

.venv/bin/python3 c2_monitor.py --selftest   # 7 crypto/framing/parser checks
.venv/bin/python3 c2_mac.py                  # MAC oracle: 12 ground-truth vectors

Connecting to the live C2

Because the protocol is fully reversed and the key wrap is broken, the sensor can do something a packet capture can’t: join the real botnet’s command channel as a passive node and decrypt every order as it arrives. It connects outbound, completes the handshake, RC4-unwraps the per-session ChaCha20 key the C2 hands it, verifies each MAC, and reads the plaintext of every command, writing each line to c2_events.jsonl and executing none of them.

On 2026-05-21 I ran it against the live C2 for about 1h40m, from isolated infrastructure. The handshake log:

{"ts":"2026-05-21T11:43:20Z","msg":"connecting","c2":"89.125.138.217:8001"}
{"ts":"2026-05-21T11:43:20Z","msg":"sent registration (type 0)"}
{"ts":"2026-05-21T11:43:20Z","msg":"session key provisioned",
 "key":"c2d7847b5ddc40b895a14377cca7dd9067a4046d4028e3081835ae2bca354fd0",
 "nonce":"029e5cb17d8f17e3c2a0c276","msg_mac_ok":true}
{"ts":"2026-05-21T11:43:20Z","msg":"handshake step 2","got_type":2}
{"ts":"2026-05-21T11:43:21Z","msg":"sent telemetry (type 3)","bytes":28}
{"ts":"2026-05-21T11:43:21Z","msg":"ENROLLED - monitoring command channel"}

The session key the C2 provisioned, c2d7847b...354fd0, is exactly what PJbiNbbeasddDfsc unwrapped, and the MAC verified. Enrolled. Seconds later the first attack order arrived, fully decrypted:

{
  "ts": "2026-05-21T11:43:27Z",
  "msg": "attack command (type 6)",
  "attack": {
    "flags": 1252071,
    "vector": 0,
    "duration_s": 60,
    "targets": ["79.153.152.58/32"],
    "options": [
      { "key": 0, "data": "0043" },
      { "key": 2, "data": "0578" }
    ]
  },
  "mac_ok": true
}

Over 69 minutes the sensor logged 36 DDoS commands against 10 distinct victims, every one MAC-verified, plus 202 keepalives. The commands were uniform: 60-second, single-/32 attacks on vector 0; option key0 tracked a destination port, key2 was a constant 0x0578 (1400, probably packet size). The victim list, decoded straight from the log:

Hits Target Port Network owner (country)
14 210.2.84.8 53 QTSC-VN (VN)
5 79.153.224.235 67 Telefónica de España (ES)
4 191.95.135.26 67 Colombia Móvil / Tigo (CO)
4 178.237.233.230 67 MásOrange (ES)
3 139.74.17.179 67 Valmet Oyj / Elisa (FI)
2 178.237.236.52 67 MásOrange (ES)
1 79.153.152.58 67 Telefónica de España (ES)
1 190.14.243.226 67 Media Commerce Partners (CO)
1 102.208.130.111 80 Ericsson NAT pool (CI)
1 83.172.96.61 62962 CITYNET / Lidnet (SE)

For each target, notify_victims() resolved the RIR abuse contact over RDAP (with a whois fallback) and wrote it back into the same log. That turns a decrypted command stream into per-victim threat intel you can actually send somewhere. 89.125.138.217 is a confirmed-active AISURU C2 issuing live DDoS orders at a global victim set, and c2_events.jsonl is a timestamped, attributable record of its targeting, produced without running the malware or joining a single attack.

Takeaways

The IOCs worth keeping: domains hardload.su / rep.hardload.su; the check-in host 212.192.12.168 and the TXT-record fallback 89.125.138.217, both on AS26383 Baxet Group; and expand 3te k as an exact-match hunt string for the May-2026 build.

One last word, since this is where I started. The NoName-style idea, join a live C2 and watch its target list without running the malware, held up: I went from two stripped binaries I knew nothing about to a passive sensor logging real DDoS orders, and most of the path there was AI-led. Basic questions, answers, the occasional wrong turn, then a script. AI-led reversing and threat intel works, and it is really powerful, not as a replacement for knowing what the bytes do, but as a way to get there far faster than I could alone.

#Malware-Analysis #Reverse Engineering #Iot #Mirai #Botnet #Arm #C2 #Threat-Intelligence #Censys