Next.js React2Shell — Reverse Engineering the Post-Exploitation Malware
Thirteen malware samples, picked from what a set of Next.js honeypots and a reactive telescope
caught over four months, all delivered through the same unauthenticated RCE. A Windows dropper loads a signed driver
into ring 0 so the miner can write MSR_MISC_FEATURE_CONTROL and switch off four
CPU prefetchers, which the miner says RandomX runs faster without. A Linux bot forks a pair and has them watch each other through ptrace. One seizes
the other and exits to free the tracer slot; interfere with a half and its twin answers.
1. Background
React2Shell is an unauthenticated RCE in the React Server Components Flight protocol. The reply decoder walks attacker-controlled reference paths without restricting property reads to own properties, so a lookup reaches an inherited member and a crafted payload is interpreted as JavaScript server side — a prototype-pollution path, filed under CWE-502. The fix adds own-property guards where the reference path is walked and again in the module-exports lookup of each bundler client config — the published ones being react-server-dom-webpack, react-server-dom-parcel and react-server-dom-turbopack. The bug was disclosed as CVE-2025-55182 against React's Server Components renderers, at CVSS 10.0. A separate advisory covered the Next.js side under the same CVE.
Lachlan Davidson reported it to Meta on 2025-11-29. React's security advisory records the issue confirmed the next day, a fix developed on 2025-12-01, and patches released on 2025-12-03. Remediation meant an application-level dependency bump — React and the build dependencies around it — which could mean clearing compatibility constraints first. For a Next.js application the bump reaches the framework too, since Next.js carries its own copy of the renderer, and patched releases were on npm the same day.
The samples came from two of the three sensors behind the paper: a reactive telescope and a set of Next.js honeypots interactive enough to answer like a real vulnerable app, the third being a large passive telescope. The reactive telescope is address space carrying no legitimate service. It completes the handshake, so the request body arrives — the passive one never answers, so nothing on it gets past the SYN.
In a Next.js application the exploit arrives on the Next-Action header, which is what routes a server-function call. The bug itself is in React's decoder, which is why React's advisory covers applications serving Server Components generally; in Next.js the route to it is that header. The study identifies scanning and exploitation attempts alike by that header, so everything described here was aimed at the App Router path that header addresses.
Between them they recorded 13,253,566 such flows from 2025-12-04 to 2026-03-31 — 7,666 source IPs, 1,076 autonomous systems, and 111 countries. The reactive telescope ran from 2025-12-01 — the honeypots from 2025-12-11.
Grouped by the indicators inside the payloads, at least 95% fall into 93 clusters. Source IPs are deliberately left out of that signature set. One campaign can rotate through a vast number of them. Where a payload did more than probe, it was typically a shell one-liner that fetched something bigger — I reversed the binaries that arrived once those requests succeeded.
That clustering works on payload indicators, by design. The binaries were a separate pass. Far more of them arrived than I could open one at a time, and most were the same build recompiled.
I hashed them with TLSH and grouped by similarity — which collapses near-identical builds into one entry and leaves the genuinely different ones standing on their own. TLSH measures structural similarity, not family membership. The grouping was my own triage, separate from the paper's clustering, and no family placement below depends on it.
That bought a shortlist: one member of each group to reverse properly, sparing me the same thing 20 times, and the rest of the time spent on the outliers.
That work became the malware section of Investigating Next.js React2Shell Exploitation, at IMC '26, where five of them are written up in detail. The thirteen below are a wider selection from the same pass — the ones that recurred, and the ones that did something odd. The reverse engineering is my contribution to a six-author study; the clustering methodology, the sensor design, and the measurement analysis are my co-authors' work.
2. Cryptominers
Seven of the thirteen belong to mining operations — miners, the droppers that deliver them, and one auxiliary component. Four are XMRig, a fifth is Monero tooling. The Go loader stages a miner, and BITOK is the outlier.
That ratio describes the samples I chose to open, not the traffic. Bot payloads outnumbered mining roughly five to one by request volume — mining leads on cluster count. The paper's conclusion puts that balance in motion: botnet and infostealer activity is most popular in the first months, and cryptomining becomes the most prevalent abuse later.
XMRig dropper, Windows (cccc.exe)
This is the only Windows sample of the thirteen, and the only one that ships a kernel component. It was detonated and traced in a VM, like most of the Linux samples here. The dropper installs WR64.sys — a legitimate signed driver better known as WinRing0, shipped inside hardware monitoring tools. The dropper then exploits the driver to obtain kernelspace code execution. The access is spent narrowly: rdmsr and wrmsr against a single register. The PDB path already named the driver, but I walked its IOCTL handlers in a decompiler rather than take that on trust — an attacker can ship a modified build under an honest name.
The driver carries a valid Microsoft signature and runs in ring 0, and it validated when I looked at it. The chain runs through a Microsoft cross-certificate over the issuing CA, and under that an object-signing certificate issued in 2007 with a year to run. A timestamp countersignature keeps the signature good past that expiry, and the exception for end-entity certificates issued before 29 July 2015 that chain to a supported cross-signed CA means the kernel-mode driver-signing policy still accepts it. Secure Boot, HVCI and the blocklist are separate gates, and each of them does stop it. What stops the load on a current Windows with that policy enforced is the blocklist in section 5.
Monero's proof-of-work is RandomX, which walks a 2 MiB scratchpad at addresses that fall out of register values as the program runs. XMRig ships a driver-backed MSR mod that turns the hardware prefetchers off for it. The miner's own documentation gives the reasoning: speculative fills against that access pattern are wasted work, and the prefetching that helps is already explicit in the code. The gain the miner claims for it is vendor-reported, not something I measured. On mainstream Intel Core and Xeon parts it writes one register:
0x1a4 MSR_MISC_FEATURE_CONTROL (Intel SDM Vol. 4)
bit 0 L2 hardware prefetcher 1 = disable
bit 1 L2 adjacent cache line 1 = disable
bit 2 DCU prefetcher 1 = disable
bit 3 DCU IP prefetcher 1 = disable
wrmsr 0x1a4, 0xf ← all four off
The bit assignment is model-specific. Knights Landing defines two bits and reserves the rest, and its two are the reverse of the mainstream assignment — bit 0 is the DCU prefetcher there, bit 1 the L2, and 0xf would be writing reserved bits. The two Atom generations the manual covers for this register reserve bit 1 and everything from bit 3 up. Recent parts call the register MSR_PREFETCH_CONTROL at the same address and extend the field upward: 12th and 13th Gen Core add bit 5 for the AMP prefetcher with bit 4 still reserved, on the performance cores only — the efficiency cores have no entry for this register at all — and 4th and 5th Gen Xeon Scalable match them. Core Ultra defines bit 4 as well, so the field runs to bit 5 there.
Three details sit behind obfuscation. The .rdata section has an entropy near 8.0, so plain strings returns nothing usable, and separately the text has to come out of stack-built fragments — FLOSS recovers what a straight dump misses. Scanning the image for MZ turns up 24 embedded PE headers, bytes 3 and 4 of each altered on purpose. That breaks a scanner validating more of the header than the two-byte magic; its own PE loader reads only the magic and the header offset, so the corruption costs nothing.
XMRig has no Intel family switch. It sets the MSR mod on the vendor string alone and writes the same 0xf everywhere, so on a Xeon Phi it disables the same two through swapped bits and puts two more into reserved space, and on a hybrid part's efficiency core the register is undocumented altogether. Before it mines, the sample profiles the host through trusted Windows components, and network traffic stays quiet while it does. One of the processes it drops runs that reconnaissance and then opens no connection at all, which is not a finished implant but a stage waiting for the next one. Persistence is an elevated scheduled task triggered at logon. A kernel-level process-start watcher of mine caught three taskkill processes spawned in the same second, which a task manager polling on a timer would have missed entirely.
The dropper registers the driver as a kernel service under a version-suffixed WinRing0 service name. The BYOVD approach, the CPU tuning, the naming convention and the network behavior line up closely with what has been publicly documented for SteelFox, which section 5 comes back to.
BITOK miner (*.png)
The paper reports it as a previously unknown type of miner targeting a different cryptocurrency, with profit-switching across 40 or more algorithms. The one it settled on in my traces was yespower. The hashrate goes to a private BitokPool node acting as C2 — the pool the sample is built around.
The file arrives UPX-packed at 3.2 MB, unpacks to 7.3 MB, and runs from memory. Its dropper serves per-architecture payloads — the file that lands already matches the host. The paper counts four layers of persistence, among them systemd services, crontab entries, and .bashrc modifications.
The process renames itself through prctl(PR_SET_NAME), which changes the comm — what a bare ps -e and top show — and leaves the full argv listing alone. The pool is resolved over DNS-over-HTTPS, keeping the lookup out of the local resolver's log, though the connection to a known endpoint and its SNI are still on the wire. The session it opens afterward is not protected at all: a TCP conversation on port 3032 in the capture summary is Stratum, and the payload is JSON in the clear, with the wallet, the coin and the pool name all readable off the wire. It also reads the DMI identifiers under /sys/class/dmi/ — the same sandbox check the Go loader compiles in, in a sample from an unrelated lineage.
Then it runs itself twice, 120 mining threads between the two, a fixed count, and a second copy alive if the first is killed.
Fileless XMRig loader
This is a UPX-packed binary, and the sequence below is what its stub does at run time rather than anything the author added. One program header maps 0x1000 bytes from the file and asks for 0x9881e0 in memory; the stub then tears down exactly that reservation and writes the unpacked image back over it:
memfd_create("upX") /* first anonymous file */
addr = mmap(..., PROT_EXEC)
read /proc/self/exe /* its own on-disk path */
munmap(addr, 9994720) /* 0x9881e0 — the same number */
memfd_create("upx") /* second anonymous file */
mmap(addr, ..., MAP_FIXED) /* written over the hole */
Those two figures are one figure: 0x9881e0 is 9,994,720, and the munmap length matches the reservation to the byte. None of that is the sample's invention, and neither is the pair of anonymous files either side of it: recent UPX stubs create both, capital X and all.
Nothing re-executes the anonymous file, so the process keeps its on-disk exe link and a hunt across /proc/*/exe comes back empty. The memfd is in /proc/*/maps and /proc/*/fd from the moment it is mapped, with no exec involved.
The shell script that delivers it stages into /var/tmp, launches the binary in the background, sleeps two seconds, and removes it — then removes itself through an EXIT trap, so the process outlives its file by design.
Fileless describes the unpack and nothing else. The filesystem diff afterward holds a three-megabyte UPX-packed executable under /home, a second file under root's, and an edited crontab.
Instances of it all reach for one loopback TCP port. The port is a constant in the image — it is already in memory a second in, before the first bind.
It re-executes itself, so a host ends up with several, and three of the traces tell the whole story. One bound the port and listened, and exited 2. Another bound the same port and its listen failed; that one exited 0. Nothing in the traces fixes which of the two came first. A third never attempted a bind at all, and its trace file was still growing eleven minutes after the others had closed; it exited 0.
Mining runs over Stratum on port 80. I captured that exchange on my own analysis host, not on the study's sensors. The capture holds two pools, not one. A share goes out at four seconds to the first, with no login to that pool anywhere in the capture, and the login lands on the second three seconds later. The miner cannot submit on a connection it did not log in on — the first flow was open before the capture started. The login is 1,483 bytes against 184 for the share, and it carries a benchmark naming all 33 algorithms the build supports, so the pool knows what the machine will accept before it hands out a single job. Eight of the 33 never produced a hashrate on the host.
The address in the login field is 95 characters and starts with 8 — a Monero subaddress, where a plain address would start with 4. The operator is tagging something. The identification field next to that benchmark claims Firefox 47 on Windows 7, while the adjacent password field says linux. The pool reads the second and ignores the first.
Between the second and sixth second of the syscall trace it walks over 940 system directories.
Four path buffers left in the heap afterward point at a packer-identification tool's signature database and a JavaScript deobfuscator, both of which ship on REMnux, which is where I ran it. The walk in the previous paragraph is enough to explain them; nothing in the image itself references an analysis tool.
Not one of those traces had a route out. Every resolver attempt came back ENETUNREACH, and the mining traffic comes from a separate run. Those two views do not overlap, which is why no flow above is attributed to a process.
XMRig miner delivered as a .pdf
A miner arrived under a .pdf extension, which defeats a filter classifying on filename or declared MIME type — and it unpacks and runs from memory the same way the loader above does.
Go loader
It clears the ground for a miner. Built with Go 1.25.3, statically linked at 3.8 MB.
The singleton lock goes down first — an abstract-namespace Unix socket with no filesystem entry, which the kernel releases on the last reference and scopes to one network namespace — the lock holds inside a namespace, not across the host. A trace settles the ordering: the sample reaches for the lock before the virtualization check runs at all, on a host it then refuses to infect.
systemd-detect-virt answered "vmware" at 1.4 seconds, the check aborted the run at 1.6, and the process exited clean at 2.0 — the tracer hunt still fired on the way out. The rest of this entry is disassembly.
The disassembly holds this: a competitor sweep across /proc/*/exe, laid out in section 4; and the same function writes two bytes into /proc/sys/vm/nr_hugepages — 0x30 0x0a, the digit zero and a newline. The loader is handing the displaced miner's reservation back to the kernel before the replacement asks for its own, and it reads the transparent-hugepage size first to know what one page costs.
Four DNS resolvers are compiled in. The lookup never consults /etc/resolv.conf. Three of those four are the familiar public ones. The fourth is a digit-duplicated form of a well-known public resolver that does not appear in the list — close enough to read past in a configuration file.
A further function reads the miner's JSON configuration, rewrites the pool entry and writes the file back before launch.
The install directory is chosen the hard way: a marker file written at each candidate, then a one-line script run from it. That probes for noexec — a hardened /tmp gets skipped before the install commits to it.
The candidates are tried in a fixed order: /tmp, /var/tmp, /dev/shm, then ~/.config and ~/.local, the last two being the fallback for a host where all three of the first are mounted noexec — and persistence is then written to four places at once: cron, systemd units, init scripts, and a user autostart entry, with two more scripts alongside, one for keepalive and one for persistence.
XMRig dropper, three-copy shell script
Three near-identical copies arrived, differing only in the C2 address they fetch the miner from. Nothing ties them to the script that stages the fileless loader above, and nothing rules it out. The retrieval chain assumes a stripped host:
wget → curl → /dev/tcp /* not POSIX, not a builtin, just
special-cased in redirection */
If neither wget nor curl exists, the script opens the socket from the shell itself. That hop is not portable. The script has a #!/bin/sh line, and on a busybox host sh is ash.
Bash honors it in POSIX mode and when invoked as sh, and ksh93 has it too. Neither dash nor busybox ash does. On the stripped embedded hosts the chain is written for, the fallback is the one that breaks, and it is also the noisiest indicator in the script: it breaks loudly, printing the address and port it could not open to stderr.
Linux trojan, suspected Monero node component
A second sample arrived under the same .png extension convention. What it carries points somewhere other than BITOK. The file is an ELF with a deliberately corrupted section header table, which breaks static analysis tools while the kernel still runs the file.
Its strings hold Monero-specific identifiers including view_tag, a transaction scanning primitive, along with daemon RPC endpoint names, which reads as an auxiliary component for a live Monero node: an inference from strings, and the sample never ran far enough to confirm it.
3. DDoS botnets and backdoors
Six of the thirteen install a bot. Three are Mirai derivatives, and what separates them is narrow: one hides its command channel, one carries the widest watchdog path list, and one is a straight cross-compile for an architecture these targets still run.
RondoDox (rondo.x86_64)
RondoDox is a DDoS-capable IoT bot with remote shell support that hands the operator full control of the device, and alongside that it runs local-network reconnaissance — discovery across SSDP, Spotify Connect, and ARP, mapping nearby devices and exposed services. A shell-script dropper stages the binary and launches it.
Its startup is conventional for the family apart from the seize — seize its own child, daemonize, set the signal dispositions, rename its own file, and unlink it:
rt_sigaction(SIGCHLD, handler) /* installed before any fork */
fork()
ptrace(PTRACE_SEIZE, child) /* parent seizes the child,
then exits and releases
the tracer slot */
/* --- child from here --- */
setsid() /* new session, no tty */
write "-1000" → /proc/self/oom_score_adj /* while stdio is still open */
close(0,1,2); open("/dev/null") → dup2 onto 0, 1, 2
9 signals → SIG_IGN, SIGTRAP first /* see below: inert as anti-debug */
rename(self, "/tmp/<random>")
unlink("/tmp/<random>") /* inode lives while mapped */
fork() /* survivor seizes the new child */
The SIGCHLD handler goes in before the first fork. The -1000 to oom_score_adj is written while stdio still points at the real descriptors, so a failure there would still be visible. Lowering it past the process minimum needs CAP_SYS_RESOURCE: the write only lands if the sample is running as root. The first signal set to SIG_IGN is SIGTRAP. Under a tracer the signal is reported to the tracer before the disposition applies, and the kernel rewrites SIG_IGN back to SIG_DFL for synchronous faults, so a bare int3 still kills a process that tried to ignore it. An installed handler would still run, and a tracer could still swallow the signal.
My first run, off a shared-folder mount, stopped after a single fork; copied to /tmp it ran the whole startup sequence. Which step gives way is not recoverable from the trace: the child reads the parent's status file, finds it gone, and stops there.
Its SSDP probe asks for the DIAL service type under a Windows Chromium user-agent: a stripped Linux ELF sending a Windows browser's token.
Mirai variant, CDN-routed
This one is UPX-packed, version 3.94, and carries its C2 domains XOR-encoded under a single-byte key — recoverable by brute force in the time it takes to write the loop. Only the primary domain is fronted, four CNAMEs deep, ending in a large CDN's anycast space. The final address is shared with everything else behind that edge.
The chain is not static either. That resolution chain I chased down myself; it is not in the paper. Two queries half a minute apart returned different final hops, numbered siblings in the same zone. The TTLs fall along the chain — 148 seconds at the top, 20 at the last.
The two fallbacks skip the chain. Both resolve directly to the same four addresses as each other, in shuffled order: one host pool behind two names.
The same query batch asked all three names for ANY. They answer with a single HINFO record reading RFC8482: the minimal response a large provider returns.
Both fallbacks answered TXT, and neither answer was a command: two records each, byte-identical across the two domains, on a 60-second TTL, with promotional copy for a cryptocurrency project.
One line of it names RondoDox to deny any connection with it.
The packed file identifies as x86-64. The image inside it is 32-bit i386, so anything reading the architecture off the header before unpacking files it wrongly. UPX picks its packer, and with it the stub it emits, by matching both the class and the machine field of its input, and refuses an inconsistent pair outright. The two cannot have disagreed at pack time, so the header was edited afterward.
The libc is identifiable without symbols. The image carries a strerror table that stops at Wrong medium type, where the mainstream library runs on for eight more entries. The SunRPC strings narrow it further: uClibc-ng deleted its RPC tree in 2017, which puts the image at uClibc proper or a uClibc-ng old enough to still carry it. uClibc has not been the usual choice for a new embedded cross-build for years, which dates the toolchain rather than the campaign. They sit in the image while the sample never calls into RPC — one of them is __get_myaddress: ioctl (get interface configuration), sitting there while the sample walks the interfaces itself. That string is a better fingerprint than it looks. glibc dropped it in 2004, when the routine was rewritten onto getifaddrs, so a binary still carrying it has not been glibc for twenty years. uClibc kept the older text until uClibc-ng deleted the RPC tree, and musl never had SunRPC at all.
The sample walks those interfaces through int 0x80, and so does everything else on the wire: connect, recv and send are hand-written stubs. That is ordinary for a statically linked 32-bit binary, and it hides nothing from strace, from seccomp or from a tracepoint — the static link had already taken libc hooking off the table.
The interface walk is four ioctl codes on the socket it already holds — SIOCGIFCONF for the list, then SIOCGIFNAME, SIOCGIFADDR and SIOCGIFMTU per entry, stepping 0x20 bytes at a time, which is sizeof(struct ifreq) on 32-bit. Name, address, and MTU. No SIOCGIFHWADDR, so no MAC: the profile identifies the network the host is attached to.
The disassembly shows a receive loop that sends nothing first: the bot connects, then waits and only answers. A buffer opening with 85 8f 8a 8a triggers a host report; anything else returns before a single value is collected.
What goes back out has a different header, 99 22 11 and a literal R. That header precedes a twelve-field pipe-delimited template — three strings, seven longs, two ints — filled from uname(), sysinfo(), and that interface walk. Four bytes each way, and no byte in common. Detonated with no reachable C2, the sample skips the profiling code entirely.
A second channel lies behind that one. The strings hold /etc/resolv.conf and nameserver, and a 32-character alphabet running a through w then 0 through 8. That is not base32, and legality is not what separates them, since base32's alphabet is DNS-legal too. DNS labels are case-insensitive — an uppercase alphabet loses half its symbol space on the wire, which is why tunnelling tools reshape it to lowercase.
The traced run skipped resolv.conf entirely: a datagram socket, a connect to a hardcoded public resolver on port 53, and nothing more. That is a connectivity check; the channel itself was never seen to open.
The attack side is one string, and it is the only evidence in this binary of what the bot attacks. Under key 24 sits TSource Engine Query, the server query the upstream family's flood module sends straight at the target, unspoofed, on udp/27015.
It came out of the decoder in two pieces, and the break is not in the binary. The letter g encodes to 0x7f under that key — the one non-printable byte in the run — and strings cut the literal in half before anything was decoded. Across printable ASCII g is the only character that maps to a non-printable byte under that key, and the domains have no g in them.
Mirai variant, watchdog
The unpacked binary holds seven hardcoded drop paths under /tmp and /var. Upstream's bot hardcodes nothing of the sort; it is upstream's separate loader binary that enumerates writable directories at run time, reading them out of /proc/mounts and probing each with a marker file before appending /dev unconditionally. Two families' names sit in that one list: five paths built from a known Mirai-derived family's name, two from a different one entirely. That points to copying between variants.
That is a string, carried in the binary at no cost. The behavior further down is code: the permission sweep in section 4.
The wrapper does not match what it wraps. The file is named for x86-64 and the packed header says the same, while the binary that comes out is 32-bit i386. The banner claims UPX 3.94 — the same packer and version as the CDN-routed variant, and the two carry the same strerror table ending at the same message. Two samples from different build lineages were built against the same libc on the same kind of toolchain. Its stub is i386 under the same x86-64 header, so the same reasoning applies — edited after packing too.
Ten architecture suffixes are in the string table, four of them ARM. Upstream's cross-compile table is ten targets with three ARM variants — here a fourth ARM target and an x86-64 one sit where two of the non-ARM targets used to be.
The resolver is compiled in — /etc/resolv.conf is never consulted — and it doubles as the connectivity check. A gateway sinkhole is never reached — the sandbox has to answer on that address itself.
Its retry is not on a fixed timer either. In my faked-network capture it resolves, takes a TCP RST from the C2 port, and retries after ten seconds, then twenty, then forty. The first three attempts land inside a minute; the fourth does not.
The attack side never fired, but it is in the binary. Beside the HTTP request template sit five more: an LDAP search filter, an SLP service type, the mDNS _services._dns-sd._udp.local enumeration name, a STUN tag, and a TeamSpeak query probe. All five are one payload each, and four of them name services with a measured amplification factor behind them, and TeamSpeak is the one with none — its handshake has answered at one to one or better since server 3.0.8. The list needs a command to fire, and no capture ever showed one.
Its SSDP probe goes to the limited broadcast address. The specification provides for the multicast group and for unicast to a known device, and broadcast is neither. Its user-agent is a Google Chrome build string where RondoDox sends a Chromium one. The browser and the version both differ. The shape and the DIAL service type are the same. A browser sends a product token in its discovery probe too, but to the multicast group or to a device it already knows. Here the token is paired with a broadcast destination.
The command channel is six bytes to a fixed port — 00 00 00 01 00 33, where the first four are the original Mirai greeting kept intact. Upstream sends five, the fifth being a length byte for an optional identifier; this build sends six and puts 0x33 in the last, uninterpreted; against a listener of mine that answered with a plausible acknowledgment the backoff did not apply, and the bot sent its six bytes, dropped, and reconnected three times from three ports in a row.
Connecting to the C2 port draws a reply before anything is sent, and what answers is not the bot protocol. That connection was mine, and nothing went out beyond the bot's own six bytes. The port answers with something else — an alternate-screen escape, a telnet negotiation, an xterm title set to a four-character name, then Username: in bold white. The first two are the upstream admin handler verbatim; what differs is that the title is set before any login and the prompt is in English. That is a telnet login banner, drawn once from the same port the bots beacon to.
No debugger is named anywhere in the binary. The single /proc read in the traces is the sample reading its own status file and calling getpid() — the same file RondoDox reads, but the only field literal this binary carries is Name:, and a check for a second copy of itself rather than for a tracer.
The sample attempts to open the watchdog device nodes and the watchdog daemon's binary and configuration for writing. The repair tooling goes the same way. Section 4 covers both.
Mirai variant, MIPS
This is a standard bot cross-compiled for MIPS, which consumer routers still run even as ARM takes the segment. It renames itself through prctl(PR_SET_NAME), which changes the comm and leaves argv alone. The hardware watchdog goes the same way, minus the daemon paths: the automatic reboot is what it goes after. Nothing else about it is unusual.
Tox DDoS bot (test1)
The bot takes its commands over the Tox peer-to-peer network — so there is no VPS to take down and no domain in the operator's name. Taking it down is a different job: c-toxcore ships no default bootstrap list, so a build has to carry its own bootstrap entries, and the implant's Tox identity cannot be rotated without reissuing it.
The sample also supports deployment of a secondary payload alongside its DDoS commands — which indicates a wider operational scope than a single-purpose bot.
Perl IRC backdoor (notrooter.pl)
The script gives full remote control over an IRC channel. It executes shell commands on demand and pulls in an external module for scanning. The evasion is two moves, and the paper's less common obfuscation method is both of them together.
The script chdirs first, into a directory named .. under /dev/shm — dot, dot, space, which renders as the parent-directory entry in anything that does not quote it. Then $0 is overwritten with node padded out with nulls, which fits a host compromised through a Node application.
The C2 is IRC on port 80. The protocol does not need that port: it is there because egress filtering usually lets it through. Five names are set to IGNORE on the way up, and one of them is PS, which is not a signal at all. Perl takes the assignment and discards it. Whoever wrote the line was thinking of the ps command.
One line in it does more than the evasion. The channel it joins is chosen by $< == 0 — root shells and unprivileged shells land in separate channels.
The IRC C2, the process masquerade and the external scanning module line up closely with the SSHStalker botnet, which the paper ties to the Outlaw / Dota group; the report that named SSHStalker declines that link itself.
5. Two CVEs, one driver
This section is about the public reporting on SteelFox, not about the paper, which names no CVE for the driver and rests its comparison on behaviour alone. In that reporting two CVEs are routinely cited together for WinRing0, and the second does not name WinRing0 anywhere in its record. Running the pairing down is mine.
CVE-2020-14979 names it directly:
The WinRing0.sys and WinRing0x64.sys drivers 1.2.0 in EVGA Precision X1
through 1.0.6 allow local users, including low integrity processes, to
read and write to arbitrary memory locations. This allows any user to
gain NT AUTHORITY\SYSTEM privileges by mapping \Device\PhysicalMemory
into the calling process.
CVE-2021-41285 names a different product from a different vendor:
Ballistix MOD Utility through 2.0.2.5 is vulnerable to privilege
escalation in the MODAPI.sys driver component. The vulnerability
is triggered by sending a specific IOCTL request that allows
low-privileged users to directly interact with physical memory via
the MmMapIoSpace function call ...
Reading only those two records, the pairing looks like an error. The reason it is not lies in the original advisory. Paolo Stagno, who reported the Ballistix issue, found that MODAPI.sys is WinRing0x64.sys shipped under a Micron filename — down to a matching 40-hex digest, and therefore the same primitives.
What gets lost is the explanation. Kaspersky's SteelFox writeup cites both CVEs against the driver without repeating Stagno's finding, and the pairing has been copied from there since. Anyone reading the two records alone sees a Micron overclocking utility and no connection to WinRing0.
ID_DENY_WINRING* rules plus a separate ID_FILEATTRIB_WINRING0 rule, and Defender detections were widely reported from March 2025. The driver is also often described as an EDR-killer — which overstates what it provides. It exposes no termination IOCTL — killing an agent through it means building the capability out of raw memory access. The families that kill endpoint agents from the kernel reach for drivers that already carry a termination IOCTL, zam64 and aswArPot among them. WinRing0 carries none. None of this reaches the paper, which carries neither CVE and rests its SteelFox comparison on behavioral overlap — the BYOVD approach, the CPU tuning, the naming convention, the network behavior.
6. What this does not show
All of this comes out of four months of traffic against a single vulnerability, seen by two sensors: the samples above are what reached a reactive telescope and a set of honeypots advertising a vulnerable Next.js application, from 2025-12-04 to 2026-03-31. The work marked as outside the study was run on my own analysis host, past those two sensors and past what the paper reports.
That selects for opportunistic mass exploitation — targeted activity may well have been missed. The paper also finds that the honeypots and the reactive telescope see different source IPs, and part of what reached one sensor would never have reached the other.
The aggregate is flatter than the individual samples suggest:
flows share clusters
Vulnerability scanning 6,598,927 49.79% 27
Botnet / Backdoor 5,218,456 39.37% 21
Cryptominer 1,068,705 8.06% 39
Unclassified 294,545 2.22% —
Credential theft 72,933 0.55% 6
---------- --------
13,253,566 93
Half of the flows are scanning — payloads that check for the vulnerability or fingerprint the host without installing anything, a category the paper counts as not harmful by itself. That describes flows and not actors. The paper finds that over 95% of the credential-theft IPs are also seen performing multiple types of vulnerability scan, and that, for those campaigns, the scanning and the exploitation come from the same infrastructure. No ransomware appeared in any classified cluster, and nothing I opened was ransomware.
How much of the anti-analysis the operators wrote themselves is not something the binaries answer, and for the Mirai derivatives most of it is inherited — the watchdog handling reached this campaign through later variants rather than the 2016 source, and it predates the campaign by years.
The pair — one half seizing the other with ptrace, the watch running on TracerPid — is the only one of these that makes the kill automatic and continuous.
The full study — clustering methodology, payload evolution across the four months, and two-phase exploitation analysis — is in the paper. The authors are Martin Mladenov, Max van der Horst, Alexandru Hossu, Rok Štular, Harm Griffioen, and Georgios Smaragdakis, IMC '26, Karlsruhe. The honeypot is open source at DelftThreatIntelligenceLab/next-honeypot.