🔑 Key Insights
✦ AI·GENThe author's full account of stumbling onto a cryptominer planted on their home server during a routine performance check, written up as a suspicious-process checklist anyone can run. It starts with a number that shouldn't be there: a load average of 10.65 on an idle 16-thread machine. Using top and docker stats the author pulls out a random-named process pinning 10 cores, then confirms it's a miner via the Linux /proc trio (exe pointing at a deleted /tmp file, cmdline, environ tracing it back to a Docker container), and reconstructs the attack chain: a Next.js container bound to 0.0.0.0, exposed past nginx, punched through by CVE-2025-66478 (the perfect-score RCE in Next.js 16.0.6, aka React2Shell). It walks through cleanup (kill needs no sudo, reap the zombie by killing its parent), persistence checks (cron/bashrc/tmp), and root-cause hardening (bind back to 127.0.0.1, upgrade, container limits), and closes with a six-line self-check. The takeaway: the scariest part of being compromised isn't the stolen CPU, it's not knowing at all.
The morning I meant to run a routine health check on my home server, the very first number was already off: a . On a 16-thread machine that normally sits idle, a load stuck at 10.65 means something was eating two-thirds of the CPU, non-stop.
Something was using my machine behind my back. This is the whole story of finding it, killing it, and shutting the door it came through, and I've tried to write it as a checklist you can run yourself: how to tell whether some process is quietly freeloading on your own machine.
Step 1: Who's eating my CPU#
If the load is high, first find who's holding it. From uptime down to naming the culprit, the terminal record from that day looked like this (every number is what actually printed):
$ uptime
03:14 up 12 days, load average: 10.65, 9.90, 7.40
$ top -bn1 -o %CPU | head
PID USER %CPU %MEM COMMAND
2714011 alice 996.0 3.9 XXAkjjBB
525303 alice 429.0 0.0 XXigEEFC
$ docker stats --no-stream
NAME CPU % MEM USAGE
nas-frontend 1017.32% 2.41GiBNot one of those three numbers is right: a 16-thread machine sitting idle yet showing a load of 10.65; a thing called XXAkjjBB, a randomly-cased filename, eating 996% CPU (10 full cores) and 2.4GB of memory; and even a Next.js frontend container, nas-frontend, reading 1017% CPU.
That XXigEEFC is a , yet its CPU column reads 429 — a process that's already dead shouldn't have a CPU number; that's a leftover in the accounting.
A legitimate service isn't named like this. A random-case, meaningless filename is the most typical look of a miner — it's built precisely so you can't pick it out of a process list at a glance.
Step 2: Fingerprint it with the /proc trio#
Before killing a suspicious PID, ask it three questions first: what are you, how were you started, and where did you come from. Linux's /proc/<pid>/ lays all three answers right there, no extra tools needed. The three commands I ran, and what they spat back:
$ ls -la /proc/2714011/exe
lrwxrwxrwx 1 alice alice 0 ... /proc/2714011/exe -> '/tmp/XXAkjjBB (deleted)'
$ cat /proc/2714011/cmdline | tr '\0' ' '
/tmp/XXAkjjBB
$ cat /proc/2714011/environ | tr '\0' '\n'
NODE_VERSION=20.11.0
HOSTNAME=0.0.0.0
PORT=3001
...Each one hands over a piece of the puzzle:
exepoints to : the executable sat in/tmpand had already been deleted from disk while the process kept running. Legitimate services don't do this; it's the textbook "land and self-delete" move.environ'sNODE_VERSION,HOSTNAME=0.0.0.0,PORT=3001don't look like the variables my login shell would carry — they look more like the inside of some Node.js Docker container. This miner was spawned from one of my containers.
Step 3: Pull out the attack chain#
Walk up the parent chain (ps -o ppid= -p <pid>, or htop's tree view) and the whole thing becomes clear:
The parent is next-server, one of my Next.js frontend containers. Once compromised, it spawned sh (to get a shell), base64 (to decode the bundled payload), and finally launched XXAkjjBB to mine. That's why the container showed 1017% CPU in docker stats: the CPU was really being eaten by the miner it forked off.
Step 4: Clean up#
The good news: this miner was running as my ordinary user (not root) — it was spawned from a container started under my identity and inherited my privileges. So killing it needs no sudo:
# kill the miner (your own identity can kill processes running as you)
kill -9 2714011
# the zombie won't die — it's already dead; to reap it, kill its parent
docker stop <compromised container>Watch the load average drop as you go: 10.65 → 8.80 → 6.30, the CPU cooling off. The zombie only gets reaped by init — truly gone — after the parent container is stopped. kill-ing a zombie does nothing; it's already dead. What you deal with is the live parent.
Step 5: Make sure it left no backdoor#
Killing the current process is only first aid. The real danger is persistence: if the attacker planted a "re-download and run" in some startup script or scheduler, it comes back to life a minute after you kill it. The three places a miner most loves to hide — check each one:
# 1. schedulers: cron is the most common respawn channel
crontab -l
cat /etc/crontab
ls -la /etc/cron.d/ /etc/cron.*/
# 2. shell startup files: run automatically on login
cat ~/.bashrc ~/.profile ~/.bash_profile 2>/dev/null | grep -iE 'curl|wget|base64|/tmp|http'
# 3. anything else executable sitting in /tmp
find /tmp -type f -executable -ls 2>/dev/nullWARNING
If you find something suspicious in any of these, the problem is far worse than a single process
A cron entry running curl … | bash every minute means the attacker already has write access to your scheduler; killing processes alone is nowhere near enough. In my case all three were clean (/tmp empty, cron untouched, shell startup files normal), so I could be sure it hadn't dug in and first aid was enough. But this step can't be skipped: declaring "cleaned" without checking persistence is the single most common mistake in incident response.
Root cause: an open door and an expired lock#
After stopping the bleeding, the real question: how did it get in? Two factors stacked on top of each other, neither sufficient alone.
First, that frontend container's port was bound straight to 0.0.0.0. It was supposed to be reachable only through the nginx reverse proxy, but docker-compose said 13001:3001 instead of 127.0.0.1:13001:3001. That difference is fatal:
. The former lets anyone hit "my public IP:13001" and bypass nginx and Cloudflare entirely. When I checked the nginx access log there was no record of this container at all — because the attack traffic never went through nginx; it knocked straight on the exposed container port.
Second, the Next.js in that container was 16.0.6, a version with a known hole. With the door open (exposed port) and the lock broken (an outdated framework), the attacker took a shell with one public exploit, and the rest was the routine business of downloading a miner.
This RCE has a name: CVE-2025-66478 (upstream, in React Server Components, is CVE-2025-55182, nicknamed React2Shell), CVSS a perfect 10.0. Next.js 16.0.0 through 16.0.6 are all affected; the attacker sends one request with a crafted Next-Action header and runs arbitrary code with no credentials at all, fixed only in 16.0.7. And it went public in December 2025 — I only found myself compromised in April 2026. Those months in between were exactly my window of never upgrading: the door open, the lock broken, left there for the taking.
Hardening: check every door on the whole machine#
After patching this hole, the first thing I did was assume "if this door was open, might there be others." I scanned the whole machine's port bindings:
# list every port listening on 0.0.0.0 (exposed)
ss -tlnp | grep '0.0.0.0'
# for Docker, also look at each container's mapping
docker ps --format '{{.Names}}\t{{.Ports}}'It turned up a whole row of services that should have been reverse-proxy-only but were sitting bare on 0.0.0.0. The one that made my spine go cold: a PostgreSQL database open straight to the internet — far worse than mining, the whole database liable to be scooped up at any moment. All pulled back to 127.0.0.1, with only nginx able to get in.
Three things to wrap up:
Any container meant to be "reached only through nginx" gets the 127.0.0.1: prefix. Keep exactly one public entrance, nginx, and every other service goes invisible to the internet. Lowest cost, highest payoff of this whole incident.
Bump the RCE-carrying Next.js from 16.0.6 to the latest stable at the time. Close the door (bind 127.0.0.1) and replace the lock (patch the known hole) — do both, so the next exploit doesn't walk in through the same door.
Add mem_limit and cpus to every container. This doesn't prevent intrusion, but it limits the blast radius: if another container ever gets a miner planted in it, it eats at most its own quota, rather than one process pinning 10 cores and dragging the whole machine down like this time.
A checklist you can run right now#
If you also run an internet-facing machine (a VPS, a home server, even just a home PC with port forwarding open), spend five minutes running through this:
Suspicious-process self-check (run each line)
- Look at load and CPU:
uptimefor the load average,topsorted by CPU. An idle machine with a high load, or a process you don't recognize maxing the CPU, is a red flag. - Suspicious filenames:
ps aux --sort=-%cpu | head— random-case, meaningless COMMAND names are highly suspect. - Fingerprint its executable:
ls -la /proc/<pid>/exe. Pointing at/tmp,/dev/shm, or carrying a(deleted)tag is almost certainly malicious. - See how it started and where from:
cat /proc/<pid>/cmdline | tr '\0' ' 'andcat /proc/<pid>/environ | tr '\0' '\n'— the environment variables tell you which container/service it was spawned from. - Check persistence:
crontab -l,cat /etc/crontab,ls /etc/cron.d/, and whether~/.bashrchas anything likecurl … | bash. - Check the doors:
ss -tlnp | grep 0.0.0.0lists everything listening to the internet; for each, ask "does this really need to be public?" Databases, admin panels, internal APIs almost never do.
The tab from this one: a miner pinning 10 cores, a database bared to the internet, a framework so outdated it had an RCE — all dug up in the same single health check. The fixing wasn't hard. The hard part is knowing to look.
- Next.js CVE-2025-66478 — the official advisory for the RCE that got menextjs.org
- The Linux /proc filesystem — first-hand data for process forensicsman proc
- Docker — the localhost-only port mapping syntaxDocker Docs
- OWASP — server hardening and minimal exposureOWASP
No comments yet
✨ Be the first to comment