Rich Gibbs

Capturing a loader is not capturing its next stage

homelab · honeypot · cowrie · botnet · malware-behaviour · session-notes · security

Session notes from 2026-09-09: a live multi-architecture IoT botnet loader was captured on the Cowrie honeypot's telnet listener (port 23). The loader script itself was genuinely downloaded and recorded as a file-download event. But a log search for the C2's binaries path returned no download event at all, so the actual bot binary never arrived. This session's diagnosis of the capture: the fetched script's multi-line contents were logged as a single command-input event rather than run, so the branch selection and the embedded download never happened. Open items left unresolved: a duplicate download event, whether 64-bit ARM mapping to a 32-bit binary is a bug or deliberate, no public threat-intel match, and static triage of the captured script. Closing the gap means a seven-step high-interaction container build in which only the Docker step is already done, and the VPS resize is step one of seven - necessary, not sufficient.

On 2026-09-09 the Cowrie honeypot's telnet listener caught a real one: a live multi-architecture IoT botnet loader, not a scanner poking at the banner and leaving. Stage one was the classic pipe-to-shell idiom — fetch a script over HTTP and run it inline, so nothing ever lands on disk under a filename anyone has to clean up — with a || fallback from wget to curl in case the first one isn't there.

The fetch was real, and that part is worth being clear about with myself. Cowrie's wget genuinely reaches out over the live network; that is intentional, by design, so that real payloads can actually be collected. A cowrie.session.file_download event followed immediately, recording the retrieved script into the downloads directory with a SHA-256. A genuine live payload script, pulled off an active C2.

What the loader does

The fetched script is a full multi-architecture bot loader. Read in order, its behaviour is: detect the CPU architecture with uname -m; run that through a case statement to pick the matching pre-built binary name; download that binary from the C2's binaries path to a hidden dotfile under /tmp; make it world-executable; run it detached so it survives the attacker disconnecting; then delete the loader script itself as anti-forensic cleanup, once the real payload is already running. Its own download step tries wget, then curl, then busybox wget, and gives up if none of them exist — the same downloader-detection pattern as the outer dropper's fallback.

The architecture coverage is the interesting part. It runs from x86_64 servers all the way down through sparc, m68k, sh4, PowerPC, MIPS in both endiannesses, and several ARM generations. That is the standard Mirai-derivative spread — routers, DVRs, industrial and embedded gear, not just conventional servers. I'm recording that as a resemblance, not an identification; nothing here names a family.

One detail I want to still have in six months: 64-bit ARM maps to the 32-bit ARM binary, not to a genuine 64-bit build. Either that's a bug in the loader, or it's a deliberate bet that 32-bit ARM binaries will run fine under a compatibility layer on 64-bit ARM devices. I didn't settle it, and I shouldn't pretend I did.

Nothing matched publicly. Neither the implant's filename nor the C2 address returned hits in threat-intelligence search — checked against public sources, but not cross-referenced against a dedicated feed this session, which is a real limit on that negative result. Two readings, and I'm picking neither: a fresh or unreported operation, or a private custom loader that wasn't built from a widely distributed toolkit.

The check that mattered

The loader's own logic contains a second download — the actual bot binary from the C2's binaries path, not just the delivery script. So the obvious next question: did that fetch fire as well?

I grepped the Cowrie JSON logs for the binaries path. There is no cowrie.session.file_download event for any /bins/ path at all. The only hits were the literal URL string appearing inside the logged text of the script, on its C2-URL assignment line. The actual bot binary was never retrieved.

Why it stopped there, as best this session could tell: Cowrie executed the outer command because that was a literal top-level command string its own wget emulation recognises and genuinely runs. But once the fetched script's multi-line contents came back in as the next input, Cowrie logged them as a single cowrie.command.input event and moved on. It did not parse the case branching, evaluate the architecture variable against the emulated architecture string, work out which binary that branch would have selected, or run the embedded download line as a real command. A multi-line script handed over as one block of text got logged as data, not executed as a program. That's my diagnosis of this capture from this one observation — and it's enough to explain the missing event. Worth noting too: the command-emulation fixes from session 20 all operate at exactly this same layer, and none of them would have changed the outcome here, because the shortfall isn't in any one command.

A captured script is not the same as an executed script.

That's the line to remember. Every command in that loader was logged in full, readable detail — and none of its logic ran. The distinction between "Cowrie saw this text" and "Cowrie did this thing" is easy to blur precisely because the log output looked that complete. I only knew the difference because I went and checked for the second download event and found nothing there. The check was cheap. Next time a capture looks thorough, do it anyway rather than inferring the whole infection from how good the transcript reads.

Still open

  • The duplicate. The same download fired twice, the second flagged as a duplicate. A real shell's || only evaluates the right side when the left side fails, so if Cowrie's wget succeeded, curl should never have run. Whether that's Cowrie running both branches regardless of exit status or the attacker's own tooling re-sending the command, I did not determine. If it's the former, that's a genuine shell-semantics bug in the same family as the session 20 fixes and worth a patch upstream.
  • IOC submission. The C2 and the loader's hash are reasonable candidates for a public IOC feed, once independently cross-checked. Not done this session.
  • Static triage. No strings/file pass over the captured script yet — safe to do, since it's a shell script rather than a binary — to confirm nothing else is tucked inside it, like a base64-encoded secondary payload.

What the resize actually buys

This capture is why I keep circling the high-interaction question, and it's the first time the argument for it is dated and concrete rather than theoretical. Low-interaction stopped at the loader here by construction — not because of a missing feature and not because I'd misconfigured something. A real container backend would have executed the script for real, returned a real architecture string, taken the branch, fired the embedded download and pulled the actual bot binary: a second and probably more valuable sample than the loader I already have.

So: is resizing to the larger paid tier — 4 vCPU and 8 GB, the option confirmed available back in the resize investigation — enough on its own to stand that up?

No. Necessary, not sufficient. The resize solves exactly one part of the build, which is headroom, and configures nothing. The seven-step scope and where it actually stands: the resize itself is still the open decision; Docker is already installed from the session 22 Dionaea work and is reusable here; Cowrie's backend_pool configuration is not started; the hardened decoy image — minimal, no real secrets, no host mounts, ephemeral — isn't built; per-container resource caps aren't configured; container network isolation isn't configured; and testing it against my own session before real traffic reaches it hasn't happened.

That's step one of seven, with step two the only row already done. Docker being there is a genuine head start, but the money-shaped step buys headroom and nothing else.

The one row I don't get to treat as optional hardening is the network isolation: attacker containers reach the internet outbound, with zero path back to the Cowrie host, to Dionaea, or to the homelab. That's a real security control, because a high-interaction container is where actual container-escape risk — however small — first enters this lab's threat model at all.