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):

console
$ 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.41GiB

Not 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:

console
$ 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:

  • exe points to : the executable sat in /tmp and 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's NODE_VERSION, HOSTNAME=0.0.0.0, PORT=3001 don'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:

bash
# 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:

bash
# 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/null

WARNING

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:

bash
# 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:

Bind every reverse-proxy-only service back to 127.0.0.1

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.

Upgrade the outdated framework

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.

Put a resource ceiling on every container

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)
  1. Look at load and CPU: uptime for the load average, top sorted by CPU. An idle machine with a high load, or a process you don't recognize maxing the CPU, is a red flag.
  2. Suspicious filenames: ps aux --sort=-%cpu | head — random-case, meaningless COMMAND names are highly suspect.
  3. Fingerprint its executable: ls -la /proc/<pid>/exe. Pointing at /tmp, /dev/shm, or carrying a (deleted) tag is almost certainly malicious.
  4. See how it started and where from: cat /proc/<pid>/cmdline | tr '\0' ' ' and cat /proc/<pid>/environ | tr '\0' '\n' — the environment variables tell you which container/service it was spawned from.
  5. Check persistence: crontab -l, cat /etc/crontab, ls /etc/cron.d/, and whether ~/.bashrc has anything like curl … | bash.
  6. Check the doors: ss -tlnp | grep 0.0.0.0 lists 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