A 30mm fan screaming on a Raspberry Pi in the corner of my office was the only alert that worked during a four-day cryptojacking incident. Nothing else fired. Not the host. Not the network. Not a single one of the dashboards I pay for. The fan.
Inventory every internet-reachable service this week. If you cannot name the version and the exposure path, move it behind a zero-trust broker today or take it off the internet. Score every service on the Forgotten Services Audit before close of business; anything below 7 gets fixed or killed.
At a glance
- Host: Raspberry Pi 4, hostname
bats, isolated VLAN, no shared credentials with anything that matters. - Vector: unpatched Next.js 15.3.1 prototype on the public internet via a forgotten CNAME, behind nginx, run by pm2.
- CVE: CVE-2025-55182 (React2Shell). CVSS 10.0. Unauthenticated RCE via prototype pollution in the React Server Components Flight deserializer. Patched in Next.js 15.3.6.
- Payload: XMRig Monero miner. Random eight-character name in
/tmp, unlinked from disk after exec, reparented toinit. - Window: Tuesday morning 10am to Friday night 11pm. About 80 hours.
- Detection: the cooling fan. Nothing else.
- Blast radius: contained. Confirmed by three forensic passes. No lateral movement, no persistence beyond the killed process, no credentials exposed.
- What this article is: incident postmortem, plus the operational discipline you should run against your own forgotten services before yours becomes the next data point.
Friday night, late. I was deep in something unrelated and started hearing a faint whine I couldn't place. I traced it across the room to bats, an old Raspberry Pi 4 I had not logged into in months. The little 30mm cooler I'd forgotten was even on the board was redlining. SSH in, load average 7.4 on a four-core box, every core pinned at 100 percent, and the offender was a single process with an eight-character random name eating the entire machine.
It took me about ninety seconds to confirm what I already knew in my gut. It took me longer to admit the lesson, which is the part I'm writing about. The Pi did not get owned because of anything clever. It got owned because I left an internet-exposed service running on a version I never patched, and I forgot the service existed. That is the whole story. The internet has been doing this to abandoned boxes since the 90s. The only thing that has changed is the payload.
The Forensics Took Ninety Seconds
I poked at /proc on the PID and the indicators stacked up immediately:
$ ls -la /proc/$PID/exe
... /proc/$PID/exe -> /tmp/vJLAYsf0 (deleted)
$ ls -la /proc/$PID/cwd
... /proc/$PID/cwd -> /tmp
$ cat /proc/$PID/status | grep PPid
PPid: 1Three tells, all classical. The binary had unlinked itself from disk after starting, so the executable still ran but nothing on the filesystem matched. The working directory was /tmp, where legitimate services do not live. The parent PID was 1, meaning the original launching process had already exited and the miner had been reparented to init. That is a process that wanted to be hard to attribute and hard to clean up.
kill -9. Load dropped under 1 in about thirty seconds. The fan wound down. Silence.
One regret. The right move before kill -9 on an unknown process is cp /proc/$PID/exe /root/sample.bin first. The kernel keeps the deleted binary mapped while the process is running, and a copy out of /proc recovers it intact. I did not. The binary went with the process. I had every indicator and no sample.
Then the gut-check question: how long. The sar archive answered it. Load had been sitting at about 0.2 baseline for weeks. Then on Tuesday morning around 10am it jumped to 7.5 in one sample and stayed there for eighty hours, until I happened to walk by the corner of the room on a Friday night. The Pi had been heating my office on somebody else's behalf for four days, paid for in my electric bill and somebody else's Monero wallet, and the only thing on this network that noticed was a cooling fan I forgot existed.
The auth log told the same story, if I had been reading it. Paired sudo true PWD=/tmp attempts every few hours since Tuesday morning, including at 3am and 6am, definitively automated. Every probe failed because the unprivileged account has a password and the miner did not have it. But the attempts themselves were the smoking gun: a script running as a normal user, probing for passwordless sudo, every couple of hours, for four days, with nobody home.
atop archives showed the binary had been spawned inside the pm2 service tree of an old Next.js 15.3.1 prototype I had thrown on the Pi months ago to test an idea, never decommissioned, and left running behind nginx on a CNAME I had wired up at the time and then forgot about. The original parent was a Node worker that I, personally, put on the public internet and then mentally filed away as "I'll get back to it." The miner also inherited pm2's environment variables from its parent process. PM2_USAGE=CLI, pm_out_log_path=...next-app..., PORT=3000. It carried receipts identifying its own parent. That is how the parent-cgroup attribution stuck even though the pm2 god log itself showed no spawn of any /tmp binary; the env trail survived in /proc/$PID/environ after the launching Node worker exited.
The first theory was wrong, and I will name it because the lesson matters. A legitimate SSH session from my own Windows box happened to land a few minutes before I noticed the fan, and for an embarrassing thirty minutes I was investigating that login as the entry point. Then sar showed load had been pinned at 7.5 since Tuesday morning, not since the last hour, and the SSH session was a coincidence. First theories during an active investigation are almost always wrong. Let the archives correct you before you commit to a story.
The harder part was not the kill. The harder part was convincing myself that the kill was enough. I spent the next several hours triple- and quadruple-checking that the attacker had not jumped from bats into anything else on the network and had not persisted on bats itself in a way I had missed. find / -newer /var/log/sar/sa$(date +%d -d "last Tuesday") -type f for anything modified since Tuesday morning. systemctl list-unit-files --state=enabled against a known-good baseline for any new service. crontab -l for every user on the box, /etc/cron.* end to end, at -l, the systemd timer list, ~/.bashrc and friends for shell-init backdoors. ss -tunap against the connection table for anything I did not put there. ARP table and the upstream router's connection log for outbound flows to anything bats had no business talking to. The trust graph from bats outward: which SSH keys were in authorized_keys, which hosts trusted that fingerprint, which tokens or credentials had ever been written to disk on that host. Nothing. The miner was unprivileged, sandboxed by virtue of the unprivileged Node user, and isolated by the VLAN. But "nothing" is not a thing you accept on the first pass when you have a confirmed RCE. You accept it on the third pass after you have looked everywhere a competent attacker would have gone. You can never fully prove the negative on a compromised box. You can stack enough evidence that you sleep, then you decommission the host anyway if it matters.
The fan was long since quiet by the time I finished. Everything it had been screaming about was still sitting in /proc and the sar archive, waiting to be read. The same signals will be on your hosts if a scanner found you. Pull them before you kill anything.
If you want to check your own boxes
None of these require new tooling. Most return zero in seconds and let you stop worrying.
# Any process whose binary has been unlinked from disk while running?
# (XMRig variant signature: random name in /tmp, "(deleted)" on exe)
for p in /proc/[0-9]*; do
exe=$(readlink "$p/exe" 2>/dev/null)
case "$exe" in *"(deleted)"*) echo "$p -> $exe" ;; esac
done
# Anything currently running out of /tmp?
ls -la /proc/[0-9]*/cwd 2>/dev/null | grep -F /tmp
# Sustained load anomaly since last week (RandomX pegs every core)?
sar -q | awk 'NR>2 && $4+0 > 4 {print}' # adjust core count
# Automated sudo probes from an unprivileged user, every few hours?
grep -E 'sudo.*PWD=/tmp|authentication failure' /var/log/auth.log \
| awk '{print $1,$2,$3}' | sort | uniq -c | sort -rn | head
# Persistence drops the React2Shell campaign favored:
systemctl list-unit-files --state=enabled | grep -iE 'system-update|update-service'
ls -la /etc/cron.* /var/spool/cron/ 2>/dev/null
for u in $(cut -d: -f1 /etc/passwd); do crontab -u "$u" -l 2>/dev/null; done
# Inherited pm2 environment is the strongest attribution signal —
# if the parent was a Node app and the child is in /tmp, that's your story:
cat /proc/$SUSPECT_PID/environ | tr '\0' '\n' | grep -E '^PM2_|pm_out_log'Three rules from this incident, in case you actually find one:
- Preserve the binary before you kill it.
cp /proc/$PID/exe /root/sample.binfirst. The deleted file is still mapped while the process is alive. Lose the process, lose the sample. - Pull
/proc/$PID/environbefore you kill it. That env trail is how you attribute the parent after pm2's own logs go silent. - Rotate keys before you finish refining your theory. Fifteen minutes of unnecessary churn is cheaper than one missed lateral-movement path.
The CVE: React2Shell
I checked the Next.js version. 15.3.1. Then I opened the disclosure tab, because by Friday night this was already a known story.
CVE-2025-55182, dubbed "React2Shell," was disclosed on December 3, 2025. CVSS 10.0. Unauthenticated remote code execution via prototype pollution in the React Server Components "Flight" protocol deserializer. Next.js initially tracked it as CVE-2025-66478; that was later rejected as a duplicate because the root cause is the same library bug. It affects default configurations of any Next.js App Router deployment on vulnerable React versions, patched in Next.js 15.3.6 and later. The exploit is a single unauthenticated HTTP POST. No credentials. No SSH. No interesting tradecraft.
The disclosure-to-mass-exploitation gap was hours. Huntress logged the first observed exploitation on December 4. Google's Threat Intelligence Group started watching XMRig drops on December 5. The Shadowserver Foundation reported over 165,000 vulnerable IPs and 644,000 vulnerable domains by December 8. By the time my Pi got picked up on a Tuesday morning, the scanners had been at it for weeks.
The payload chain was commodity. A shell script downloads stock XMRig from GitHub. It writes to /tmp, unlinks itself from disk after exec, configures attacker-controlled mining pools, and on most variants drops a systemd unit named something like system-update-service so it survives reboot. Some variants kill competing miners. Some don't bother. Multiple groups picked it up within hours of public PoC.
The Economics Were Insulting
Here is the part that I cannot stop thinking about. Let me put numbers on it.
A Raspberry Pi 4 mines Monero at roughly 100 hashes per second across four ARM Cortex-A72 cores. Current network difficulty and XMR price work out to fractions of a cent per day of mined value on a Pi at full tilt. Four days of cryptojacking on bats produced something on the order of one cent of Monero for whoever was on the other end of that pool address.
The scanner that found me was free. Mass internet scanning on the cheap end runs at single-digit dollars per day for a full IPv4 sweep on the right rental services. The exploit was free; the PoC was published. The miner binary was free; it lives on GitHub. The electricity to run the operation was mine, not theirs. The whole campaign is unit-economically rational only because the marginal cost of one more victim is zero. Nobody picked my Pi. A scanner indexed the internet, my CNAME answered, the vulnerable endpoint responded, the payload landed. I was not targeted. I was statistics.
That's the part that should bother every operator reading this. You will never be important enough to be targeted by a careful adversary, and you do not need to be. The economics of commodity exploitation guarantee that any vulnerable service that answers a TCP handshake will eventually be found, and the marginal cost of trying is so close to zero that the only filter on the attacker side is "did the box respond at all." This is not 2003 anymore where Slammer needed a worm to propagate. It is a continuous, ambient, well-capitalized scanning operation, and your forgotten prototype is one of its data points.
The Playbook Hasn't Changed Since 1988
The Morris worm exploited a buffer overflow in fingerd in November 1988 and took down a meaningful fraction of the early internet in a weekend. Code Red hit IIS in July 2001 and infected hundreds of thousands of hosts in fourteen hours. SQL Slammer was a 376-byte UDP packet against MS SQL Server in January 2003 and reached saturation in ten minutes. Shellshock chewed through bash CGI handlers in 2014. Log4Shell chewed through Java in 2021. React2Shell chewed through Next.js in December 2025.
The pattern is identical every time. A bug ships in a library that is deeply embedded in default configurations. The disclosure is responsible, the patch is released, the CVE is published. Within hours, mass scanning begins. Within days, the long tail of operators who forgot they were running the vulnerable service starts feeding the scanner. The payload du jour is whatever monetizes cheapest right now. In 2001 it was defacement and DDoS bot armies. In 2017 it was ransomware. In 2025 it is XMRig and a Monero address.
The thing that has actually changed in the last two decades is the time-to-exploit number. Mandiant's M-Trends 2026 report put it bluntly: in 2025 the average time between disclosure and observed exploitation was negative one day. VulnCheck's own 2026 numbers show nearly 29 percent of known-exploited vulnerabilities in 2025 were exploited on or before their CVE publication date. Scanners are pre-positioned. PoCs leak. Researchers race vendors. By the time the patch lands in a release notes file, the internet is already converting unpatched instances into Monero.
I have run servers on the public internet since the T1 era. I had a basement data center before AWS existed. In thirty years I have never been hit by anything I would call clever. Every operational incident I can remember, on my hosts or on hosts I was paid to look at, was a forgotten service that nobody patched because nobody was looking at it. The most dangerous service on your network is the one you forgot you were running. Everything else gets patched because somebody is paying attention to it.
This Is a Much Bigger Story Than One Pi
Next.js powers a meaningful chunk of the public web. Intersect that footprint with the patched-vs-unpatched curve any large CVE traces over its first ninety days, and the long tail is staggering. Shadowserver's 165,000 vulnerable IPs on day five is a snapshot, not a final number. Shadowserver's daily scans showed the vulnerable population dropping fast in the first ten days as anyone with active monitoring shipped the patch. Then the curve flattened. The remaining hosts are the ones that nobody is watching. Prototypes. Demos. Internal tools that got a CNAME and then a job change. Founder side projects with a long-dead Vercel-equivalent on a forgotten Pi.
Multiply this across the year's CVEs and you start to see the actual problem. The internet is not getting more secure on the long tail. It is getting more abandoned. Engineers move on. Companies pivot. The infrastructure their prototypes ran on sits there answering TCP handshakes, accumulating CVEs the way a parked car accumulates rust. Every framework you ever bolted in is another CVE you have agreed to babysit, a point I have made about the broader cost of accumulated layers in different terms. Every quarter another critical RCE drops in another deeply-embedded library, the responsible-disclosure clock starts, and the same forgotten services get added to the same scanners' target lists.
The defensive answer most operators reach for, the one that actually works at scale, is to refuse to expose origin servers to the public internet at all. This is the same instinct that makes distributed architectures expensive to operate: every network surface you create is a surface someone else can probe. Run everything behind a reverse proxy you control. Put authenticated tools behind a zero-trust broker. Use Cloudflare Tunnel, Tailscale, AWS PrivateLink, whatever your stack supports. The principle is the same: the origin should never answer a packet from an IP the broker did not vouch for. A vulnerable Next.js running behind a Cloudflare Tunnel with Access in front of it is not internet-reachable, so the scanner that found my Pi could not have found it. The CNAME would resolve to the edge. The edge would refuse the unauthenticated POST. The origin never sees the exploit. The blast radius collapses to whatever the edge lets through, which on a properly configured tunnel is whatever you explicitly chose. That is the architecture you want for anything you might forget about, which is to say all of it.
The Forgotten Services Audit
This is the artifact I should have had on my wall already. Score every internet-reachable service you operate on these five dimensions. Anything that scores 7 or lower goes off the internet today, not this sprint.
| Dimension | 0 — Bad | 1 — Tolerable | 2 — Good |
|---|---|---|---|
| Inventory | Nobody knows it exists | One person remembers | In a current asset inventory |
| Patch posture | Frozen at deploy version | Manual quarterly updates | Automated security updates on |
| Exposure | Direct origin on public IP | Behind a CDN, no auth | Behind zero-trust broker (Tunnel + Access, Tailscale, VPN) |
| Egress | Outbound allowed by default | DNS + HTTPS allowed | Deny-by-default outbound |
| Monitoring | None ("the fan was loud") | Host metrics, nobody reads them | CPU / egress / process anomaly alerting |
Scoring: 9 or 10 means the service is fine to leave alone. 7 or 8 means schedule the fix this week. 6 or below means decommission, air-gap, or move behind a tunnel before close of business. My Pi scored a 0. Inventory was zero, patch posture was zero, exposure was zero, egress was zero, monitoring was a screaming fan.
One honest caveat on the egress row. I captured seven minutes of post-kill traffic on the way out, looking for the pool address. Zero stratum protocol, zero known mining ports, zero connections to any listed pool. Either the miner phoned home over DoH on port 443, batched its work in long intervals, or slept between submissions. Passive egress monitoring would not have caught this without protocol-aware inspection. Deny-by-default egress would have, which is why that column scores a 2 only for an actively enforcing firewall, not for the dashboard you log into once a quarter.
One last operational note for any active incident: rotate keys first, refine the theory later. I rotated the SSH key across every host that trusted that fingerprint, even though SSH was not the vector and I knew it was not the vector. The cost of an unnecessary key rotation is fifteen minutes. The cost of a vector you ruled out wrongly is everything you had keys to.
When This Bites You Less Hard
I'm not saying every prototype needs production hardening. Some genuinely don't. The exception cases are worth naming honestly:
- The box has no path to anything that matters.
batssits on its own VLAN with no shared credentials and no route into anything I care about. The blast radius was one Raspberry Pi worth of electricity and pride. If this had been a host with production keys on it, this article would be a postmortem and not a story. - The service is genuinely not reachable. Local-only, link-local, behind a real firewall with default-deny inbound. Most home-lab projects qualify, until someone adds a CNAME "just to test something" and forgets.
- The service is behind a zero-trust broker. If the only way to reach the origin is through Cloudflare Access, Tailscale ACLs, or an equivalent identity-aware proxy, scanners are not in the conversation. They cannot establish a session. They are denied at the edge.
For most operators with old prototypes scattered across old hosts, none of these exceptions apply. The honest answer is to move toward "nothing answers the public internet that doesn't have to," and to make that the default in your deploy tooling rather than something you remember to do.
The Bottom Line
You will never be important enough to be carefully targeted, and you do not need to be. The scanners that found my Pi on a Tuesday morning will find your forgotten prototype on a Tuesday morning. The marginal cost of trying you is zero, the marginal payoff is a cent of Monero, and the math works fine for them because the operation pays nothing per attempt.
The fix is not heroics. It is inventory, patch automation, and a hard rule that nothing answers the public internet directly that doesn't have to. Edge brokers exist now and they are cheap. Use them. If you cannot tell me the name and version and exposure of every service you operate that takes TCP from the internet, the conversation is not "should I move them behind a tunnel," it is "which one is mining Monero for someone right now."
My Pi gets to keep its job. It earned a story. Your forgotten prototype probably has not earned the same indulgence.
"You will never be important enough to be carefully targeted, and you do not need to be. The scanners that found my Pi on a Tuesday morning will find your forgotten prototype on a Tuesday morning."
Sources
- CVE-2025-55182 (React2Shell): Remote Code Execution in React Server Components and Next.js — Primary technical disclosure analysis of the prototype pollution vulnerability in the RSC Flight protocol deserializer.
- Multiple Threat Actors Exploit React2Shell (CVE-2025-55182) — Google Threat Intelligence Group observations of in-the-wild exploitation beginning December 5, 2025, including XMRig cryptomining campaigns.
- React2Shell Exploitation Delivers Crypto Miners and New Malware Across Multiple Sectors — Reporting on the rapid commodification of the React2Shell exploit by multiple threat actor groups within hours of public PoC.
- VulnCheck State of Exploitation 2026 — Annual exploitation telemetry report documenting that 28.96% of known-exploited vulnerabilities in 2025 were exploited on or before their CVE publication date.
Audit Your Forgotten Services
Cataloging every internet-exposed service you operate is unglamorous and load-bearing. I help engineering teams build the inventory, decommission what does not need to be public, and put the rest behind a real broker.
Get an infrastructure audit