RondoDox Incident Series - Part 2 of 2
| Part | Title | Status |
|---|---|---|
| 1 | I Got Hit by a Botnet-for-Hire: The Full RondoDox Incident | |
| 2 - You are here | RondoDox Botnet Full Malware Analysis, Attribution, and New IOCs | |
| 3 | RondoDox Part 3: How to Actually Counter It (Even When Your VPS Is Already Choking) |
← Previous: Part 1 the incident story, what happened, how it felt, how I cleaned it up, and where I got a few details wrong the first time I wrote it up.
This post is the other half. Less story this time the binaries, the code, the attribution, and the indicators, reconstructed from static analysis of the actual malware pulled off my own server. Where I’m inferring rather than proving something, I’ve said so directly, same as in Part 1.
Before We Start
Part 1 was about what happened to me. This one is about what the malware actually is reverse engineered, attributed, and documented for anyone else who runs into it.
Short version: this was RondoDox, a tracked commercial DDoS-for-hire botnet that also mines Monero as a side hustle on every box it lands on. Everything below comes from directly examining the binaries recovered from my server, cross-referenced against prior public research from Bitsight TRACE and NICTER.
One thing worth saying up front, since Part 1 admitted a few mistakes: everything in this post is static analysis of files that still exist and can be independently re-checked hashes, disassembly, string tables. That’s a different kind of evidence than “what I remember seeing in htop at 6am,” and it’s a lot more reliable. I’m calling out the one place that distinction still matters below.
Part 1 - The Entry Point, Recapped
Covered in full in Part 1 including the correction that I can’t actually prove my firewall was doing anything at the time. The short version, for reference:
Vulnerability: unauthenticated file upload + a Docker volume mount that exposed the entire host filesystem, in an open-source video generation tool, exposed on a high port.
Three compounding issues, not one:
| # | Vulnerability | What |
|---|---|---|
| 1 | Zero authentication | Auth middleware present in the code, but commented out |
| 2 | Host filesystem mount | Container’s working directory is the host’s, writable, as root |
| 3 | Unrestricted file upload | File extension checked, content never was |
The auth-bypass and file-exposure findings here are what I originally reported and got published as GHSA-hhqf-49vx-fv7x (High severity no CVE has been assigned to it). The specific host-filesystem-write path using that same auth gap plus the volume mount to write arbitrary files, rather than just read exposed ones is something I only fully verified after the incident, when I went back and built a live proof-of-concept against the same root cause.
Why it was invisible to normal defenses: Docker inserts its own iptables rules ahead of whatever the host firewall thinks it’s enforcing. Any container published to 0.0.0.0 is reachable directly from the internet no matter what the host firewall’s rule set says. The exploit never touched my reverse proxy and generated no web server logs RondoDox hit the exposed API directly.
The exploit request’s User-Agent - an actual signature you can hunt for in your own logs:
Mozilla/5.0 ([email protected])
That’s not a spoofed browser string. It’s the operator’s own contact address, sitting in plain text in every request their tooling makes. More on that address in Part 5.
Part 2 - The Attack Chain
1. RondoDox masscans the hosting provider's IP block → finds the exposed port
2. GET /docs → Swagger UI reveals every unauthenticated endpoint
3. POST file upload with payload → lands on HOST filesystem as root
4. Downloads a shell script from the distribution C2
5. Script disables defenses, kills competing malware, finds a writable dir
6. Downloads the main dropper, launches it with a spoofed app-name argument
7. Dropper connects to a second C2 server
8. Dropper drops the XMRig miner binary and launches it
9. XMRig mines Monero to the operator's pool
10. Seven persistence mechanisms deployed, files made immutable
11. chattr, crontab, and iptables binaries deleted to blind the defender
12. Botnet C2 can now issue DDoS commands to the host at any time
Same caveat as Part 1: steps 1–3 match this botnet’s documented behavior everywhere else it’s been observed, and the vulnerability they describe is verified but the specific request that hit my server that night didn’t survive in any log I preserved. Everything from step 4 onward, I can trace directly, starting from a root-level command that runs the dropper. That’s the first artifact-backed moment in the whole chain, and it’s where the malware analysis below actually begins.
Step 12 is the reason this matters beyond one compromised VPS. The mining is passive income. The product being sold is DDoS capacity.
Part 3 - Malware Analysis
3.1 The Miner - /bin/softirq
| Property | Value |
|---|---|
| Software | XMRig 6.26.0 (official open-source release) |
| Type | ELF 64-bit, statically linked, stripped |
| Size | 8,350,992 bytes (~8.35 MB) |
| SHA256 | b20f39fc00d242e706b6c30367ad811c676e0575050a4ec2f30104b696944b49 |
| Name | Chosen to mimic the kernel thread [ksoftirqd] |
| Permissions | world-writable/executable, plus the immutable + append-only attribute set |
The important finding here: the binary itself is unmodified, official XMRig. No embedded IPs, no backdoor, no custom code. All the malicious behavior which pool, which credentials, which flags gets injected purely through the command line at launch. That’s deliberate: it means the binary itself looks completely clean to any hash-based detection system that only flags known-bad samples. XMRig isn’t malware by itself. Only the way it’s launched is.
One honest caveat, carried over from Part 1: I don’t have a machine-captured process listing showing this miner actually running with its full command-line arguments by the time I ran a proper capture, I’d already killed it by hand, working from a glance at my own terminal. The pool addresses and credentials below are consistent with the persistence mechanisms I did capture cleanly (they reference the same launch arguments), so I’m confident in them, but I want to be clear they rest on that one secondhand observation and not on an independently reproducible artifact.
3.2 The Dropper - /etc/rondo/rondo
| Property | Value |
|---|---|
| Size | 141,720 bytes |
| Type | ELF 64-bit, statically linked, stripped, no embedded build ID |
| SHA256 | 062713105b7034e6c764f2148e247a3b61d3340ce6d025ae7c869260d24323ff |
| Language | Likely Rust or Zig no Go runtime table found, ruling out Go |
| Strings | Only 391 printable strings of 5+ characters survive in the raw binary — everything meaningful is encrypted |
Every string in this binary IPs, commands, error messages, everything is encrypted. Pulling it apart, cross-referenced against NICTER’s published analysis of the same family, shows a five-stage scheme:
1. XOR with a 64-byte key
2. Bit rotation, left by 5
3. String reversal (two passes)
4. XOR with a second, 32-byte key
5. Caesar cipher substitution
Once decrypted, the capability list becomes clear.
DDoS arsenal - 18 attack vectors:
.udp .sudp .udpraw .syn .tcp .tcp2 .tcpbypass
.handshake .psh .tcplegit .http .icmp .gre
.spoof .raw .stun .dtls
Game server targets - the “product” being sold to DDoS-for-hire customers:
valve, roblox, fortnite, dayz, fivem, raknet,
discord, minecraft, csgo, wireguard, samp
Anti-analysis - kills 20+ tools on sight, most of them the exact tools a defender would reach for first:
htop, pstree, lsof, netstat, wireshark, tshark,
tcpdump, gdb, strace, ltrace, valgrind, ufw
Self-destruct capability, presumably a last resort if it detects active analysis:
rm -rf /* --no-preserve-root
shutdown, poweroff, halt, reboot
Download fallback chain - tried in order until one works, which says a lot about the range of stripped-down IoT and embedded targets this thing expects to land on:
wget → curl → tftp → ftpget → busybox wget → toybox wget → python3
Runtime behavior, confirmed from process and log data: the dropper runs itself with a .persisted suffix argument on reboot, spawns a family of helper processes under a name chosen to blend into a normal ps aux listing, and pulls down shared-library files for its mining math into /tmp/ under randomized, dot-prefixed filenames — the kind of naming that skips right past a casual ls.
3.3 The Fake crontab - A Detail I Almost Missed
While going through the persistence artifacts, I found a small setgid binary sitting near the cron tooling. My first instinct was to write it off as some kind of watchdog or helper process and move on.
It isn’t one. Cross-referencing it against the system’s own cron logs, this binary was the thing actually performing crontab-style list operations — at the exact moments the real /usr/bin/crontab had already been deleted. It’s a drop-in replacement: the attacker deleted the legitimate tool and substituted their own, so they could keep managing persistence entries without needing anything from the original package.
I don’t have a clean explanation for one detail: its file timestamp says it was placed on this system months before the incident, not on the night of the attack. That could be a backdated timestamp, or it could mean this box had something on it earlier than June 13th that never got caught. I’m leaving it as an open question rather than guessing at an answer I can’t back up.
3.4 Persistence - Seven Redundant Layers
Layer 1: systemd service unit
Layer 2: SysV init script + runlevel symlink
Layer 3: /etc/crontab (@reboot)
Layer 4: /etc/cron.d/ entry (@reboot)
Layer 5: /var/spool/cron/root (@reboot)
Layer 6: /var/spool/cron/crontabs/root (@reboot)
Layer 7: /etc/rc.local (startup)
All seven were deployed within roughly a minute of each other, during the attacker’s second visit not the initial break-in. Alongside them: every malware file got chattr +ia (immutable + append-only), and the tools needed to inspect or undo any of it chattr, crontab, iptables were deleted right after use.
Part 4 - Attribution: Confirming This Was RondoDox
This is the part I want to walk through properly instead of just dropping a comparison table, because the process of confirming it was more interesting than the result.
I had a handful of concrete, unique values pulled directly from the binaries and persistence entries: a mining pool address, a username/password pair, a specific process name, and a naming convention on the launch arguments. None of those are things a random opportunistic script would reuse they’re operator-specific. So the question was simple: does anything else, anywhere, show the same values?
Cross-referencing against Bitsight TRACE and NICTER’s published RondoDox research, most of it lined up exactly:
| Indicator | My server | Published research |
|---|---|---|
| Miner process name | /bin/softirq |
softirq.x86_64 |
| Pool IP | 45.94.31.89:443 |
45.94.31.89:443 |
| Credentials | react / 3cthDeQ5 |
react / 3cthDeQ5 |
| Launcher name | rondo |
rondo.x86_64 |
| Argument naming convention | react.x86_64 |
<vulnerability>.<architecture> |
The one value that didn’t line up cleanly: the XMRig build version on my server (6.26.0) is newer than the version referenced in the research I was comparing against (6.24.0). Worth being upfront about that rather than smoothing it over a version bump between reports is completely normal for an actively maintained campaign updating its payloads over time, but it’s a real discrepancy, not a perfect match, and I’d rather flag it than claim the indicators lined up “exactly” when one of them didn’t.
The naming convention is the piece that actually convinced me this wasn’t a coincidence. react.x86_64 isn’t a random string it decodes as <exploited-vulnerability>.<cpu-architecture>: react referring to the app that got exploited, x86_64 the target’s CPU. That’s internal bookkeeping. It’s how an operator running hundreds of unrelated compromises keeps track of which exploit got them into which box, and it’s exactly the kind of convention researchers use to cluster otherwise-unrelated incidents back to a single campaign. Seeing my own server’s argument match that pattern, against a public report describing infrastructure I’d never looked at before finding this, was the moment attribution stopped feeling like a guess.
Scale, per published tracking:
- 174 vulnerabilities exploited (148 mapped to known CVEs)
- 18 CPU architectures targeted
- 32 known infrastructure IPs
- Peak observed rate: ~15,000 exploitation attempts/day
- Active since May 2025
Business model: commercial DDoS-for-hire, with Monero mining running in the background on every box as passive revenue between paying jobs.
Part 5 - New IOCs (Not Previously Published)
These four weren’t in any prior public writeup on RondoDox I could find submitted to threat-intel trackers as part of this research.
| Type | Value | Notes |
|---|---|---|
| NEW - C2 IP | 204.10.194.134 |
Malware distribution server, hosts an operator contact page |
| NEW - Operator email | [email protected] |
Found on the C2 page and embedded directly in the exploit’s User-Agent |
| NEW - Scanner IP | 95.214.52.198 |
Confirmed RondoDox scanner, probed this server twice before the successful exploit |
| NEW - Exploit vector | The MPT vulnerability / GHSA-hhqf-49vx-fv7x (no CVE assigned) | Not previously linked to RondoDox activity |
Previously known, confirmed present here:
| Type | Value |
|---|---|
| Mining pool 1 | 45.125.66.100:444 (TLS) |
| Mining pool 2 | 45.94.31.89:443 (TLS) |
| Mining credentials | react / 3cthDeQ5 |
| Brute-force IP (unrelated, failed) | 185.231.180.156 |
| Cover domain | welstanbapparel.com → resolves to 45.125.66.100 |
| Miner SHA256 | b20f39fc00d242e706b6c30367ad811c676e0575050a4ec2f30104b696944b49 |
| Dropper SHA256 | 062713105b7034e6c764f2148e247a3b61d3340ce6d025ae7c869260d24323ff |
A separate, unrelated SSH brute-force attempt against root from 185.231.180.156 ran for about 90 minutes the same night every attempt failed. It’s included here for completeness, but it isn’t part of the RondoDox chain; it’s just background internet noise that happened to overlap in time.
Part 6 - Infrastructure Map
| Component | IP | Notes |
|---|---|---|
| C2 server 1 | 45.125.66.100 |
Mining pool 1 |
| C2 server 2 | 45.94.31.89 |
Mining pool 2 |
| Distribution server | 204.10.194.134 |
New hosts an operator contact page |
| Scanner | 95.214.52.198 |
New confirmed scanning IP |
| Cover domain | welstanbapparel.com |
Resolves to C2 server 1 |
Part 7 - Why RondoDox Succeeded Where Others Failed
My server got probed constantly in the weeks before this this wasn’t the first attempt, just the first successful one:
| Attacker | Method | Result |
|---|---|---|
| A known IoT botnet | CGI shell injection | Blocked |
| Generic port scanner | NSE probe scan | Blocked |
| Kubernetes scanner | kubectl scan | Blocked |
| Generic web vuln scanner | Broad app scan | Backend unreachable |
| RondoDox | Direct hit on the exposed container port, firewall bypass | Success |
The pattern: everything that failed was aimed at the parts of the server that were actually reachable through my reverse proxy the parts I’d at least thought about securing. RondoDox succeeded because it went straight for a container port that bypassed the firewall layer entirely, whether or not that layer was even doing its job that night. Every other attacker was knocking on the front door. This one found a door I didn’t know I’d left unlocked.
Part 8 - Remediation Applied
| Item | Status |
|---|---|
| Miner binary removed | |
| Dropper directory removed | |
| All 7 persistence mechanisms removed | |
| Planted SSH key removed | |
| Vulnerable containers removed | |
| Exposed ports closed | |
| Firewall correctly enforced (Docker’s rule insertion accounted for) | |
| C2 IPs blocked at the firewall | |
chattr restored |
|
crontab restored |
|
| SSH keys rotated |
Cleanup method for files locked with the immutable flag, once chattr itself was gone full walkthrough of hitting this wall and getting past it is in Part 1:
import ctypes, os
libc = ctypes.CDLL('libc.so.6', use_errno=True)
FS_IOC_GETFLAGS = 0x80086601
FS_IOC_SETFLAGS = 0x40086602
FS_IMMUTABLE_FL = 0x10
FS_APPEND_FL = 0x20
fd = os.open(b'/bin/softirq', os.O_RDONLY)
flags = ctypes.c_int(0)
libc.ioctl(fd, FS_IOC_GETFLAGS, ctypes.byref(flags))
flags.value &= ~(FS_IMMUTABLE_FL | FS_APPEND_FL)
libc.ioctl(fd, FS_IOC_SETFLAGS, ctypes.byref(flags))
os.close(fd)
Part 9 - Submitting These IOCs
If you want to help get these blocked more broadly, this is where they’re going:
- ThreatFox (
threatfox.abuse.ch) - all IPs and hashes - MalwareBazaar (
bazaar.abuse.ch) - the dropper binary, taggedRondoDox - AbuseIPDB - the new scanner IP, distribution server, and the unrelated brute-force IP
- NICTER - the new C2 IP and the exploit vector
- Bitsight TRACE - the new infrastructure findings
What We Learned
RondoDox - a commercial DDoS-for-hire botnet, active since mid-2025, that also runs Monero mining as passive revenue on every infected host. Not opportunistic malware an operation with a bookkeeping convention, a contact page, and paying customers.
Living-off-clean-binaries - the miner itself is unmodified, official-looking XMRig. All malicious intent lives in how it’s launched, not in the binary. Hash-based detection alone won’t catch this.
String encryption in droppers - a five-stage XOR/rotation/Caesar scheme is enough to defeat casual inspection, but not enough to survive someone willing to actually reverse the algorithm.
Naming conventions as attribution - launch arguments like react.x86_64 aren’t random. They’re internal bookkeeping (<exploited-vulnerability>.<architecture>) that researchers use to cluster unrelated compromises back to the same campaign.
Tool deletion doesn’t remove the underlying syscall - deleting chattr, crontab, or iptables blinds someone relying on those specific tools. It doesn’t block the kernel API underneath them.
One open question I still can’t answer: whether the timestamp on the fake crontab binary means this box was touched months before the incident I actually caught. I don’t have evidence either way, and I’d rather leave it unresolved than manufacture a tidy explanation.
GHSA-hhqf-49vx-fv7x - unauthenticated file upload plus a host filesystem mount, in the video-generation tool I’d deployed to build a PoC for. No CVE has been assigned to it. If you’re running anything with a similar Docker volume pattern, check it.
References
- Bitsight TRACE: RondoDox Botnet Malware Analysis
- Bitsight TRACE: RondoDox Botnet Infrastructure Analysis
- NICTER: RondoDox Campaign Report
- FortiGuard Labs: RondoDox Unveiled
- GitHub Advisory:
GHSA-hhqf-49vx-fv7x(no CVE assigned) - MalwareBazaar:
b20f39fc00d242e706b6c30367ad811c676e0575050a4ec2f30104b696944b49
← Previous: Part 1 - the incident story, start to finish, corrections included.
That’s the full RondoDox writeup. If you’re running any exposed Docker containers, check what your firewall actually thinks is closed versus what’s really reachable from the internet - that gap is exactly how this one got in.