On 19 August I noticed a load average above 4 on a box that should have been idle. Top showed a process called XXCcPPke burning 380% CPU as the ubuntu user, running out of /tmp. The binary had already been deleted from disk while it was still executing. That is not a crash. That is someone who has done this before.
My first theory was that somebody had got in over SSH. I spent twenty minutes on that theory. It was wrong — root's crontab, authorized_keys and the systemd units were all clean, and nothing in the auth log was interesting.
The parent process was next-server, inside a container. Alongside it sat 390 defunct processes, every single one parented by that same PID. Zombies accumulate when a parent never reaps its children, and 390 of them means the same code path had run 390 times. Working backwards from the timestamps, the endpoint had been exploited repeatedly for about three weeks before I looked.
The application contained no way to execute anything
This is the part that cost me the most time. I grepped the whole application for child_process, exec, spawn. Nothing. It is a static brochure site with one route handler. There is no code in it that could have launched a miner.
The vulnerability was CVE-2025-55182 — a deserialization flaw in the React Server Components protocol, unauthenticated, CVSS 10.0. The attacker did not find a hole in the application. They sent a payload that the framework deserialized into the running server process, and then they had a process. The tell in the logs was an error object carrying a digest field together with a status/signal/output/pid shape, which is what an execSync failure looks like when it crosses the RSC boundary.
So: if you are looking at something like this, do not go hunting through your own code for the exec call. It is not there. Check the framework version first.
The finding that outlived the incident
While cleaning up I wanted to confirm what was actually exposed. I had configured ufw months earlier to allow 22, 80 and 443, and ufw status agreed with me.
# what I believed
$ ufw status
22/tcp ALLOW Anywhere
80/tcp ALLOW Anywhere
443/tcp ALLOW Anywhere
# what was true
$ iptables -t nat -L DOCKER -n
DNAT tcp 0.0.0.0/0 ... to:172.17.0.3:3000
DNAT tcp 0.0.0.0/0 ... to:172.17.0.4:3001
DNAT tcp 0.0.0.0/0 ... to:172.17.0.5:3002
... seven ports, all reachable from the internetBoth outputs are correct. They describe different things.
Docker publishes a port by writing a DNAT rule into nat/PREROUTING. The destination address is rewritten before routing happens, so the packet is then evaluated in the FORWARD chain on its way to the container. ufw's rules live in INPUT. A packet destined for a container never passes through the chain you configured. Your firewall is not being bypassed by a bug — it is simply not on that path.
Every one of those seven ports already had a reverse-proxy route in front of it. Publishing them on 0.0.0.0 bought nothing and cost everything. The fix was one character per line in seven deploy scripts:
- -p 3000:3000
+ -p 127.0.0.1:3000:3000Don't test this from your laptop
My instinct was to check exposure by trying to connect from outside. That instinct produced a false all-clear: the network I was on blocks non-standard outbound ports, so a port ufw explicitly allowed also returned nothing. A failed connection told me about my coffee shop, not about my server.
The packet filter tables are the authority. Read them on the host.
What they were actually after
The 623 commands in the logs were not interested in the box. They swept for ~/.claude/.credentials.json, printenv ANTHROPIC_AUTH_TOKEN, .env at four different paths, and which nvidia-smi. It was an automated campaign hunting AI credentials and GPUs, and the shell comments it left behind read like an agent narrating itself.
They got nothing. The container's environment held PATH, NODE_VERSION, PORT and HOSTNAME. No .env in the image, no credentials directory, running as an unprivileged user, and the databases were loopback-bound so unreachable from a bridge network. That was luck dressed up as design in about equal measure.
One more thing, because it is the actual bug
The site was pinned to an exact framework version. It had never received a patch, because exact pins never do. Two sibling sites using a caret range had drifted up to a fixed version on their own, without anyone deciding anything.
But loosening the range is not sufficient either, and this is the bit people miss: the deploy script runs npm ci, and the lockfile pins the exact version regardless of what the range says. A rebuild reinstalls precisely the same vulnerable code. The lockfile has to be updated deliberately.

Written by Manmohan Singh, who builds the systems that move the money. About.
The build log, by mail
New notes, when I publish them.
One mail per note: what broke, the theory I held before I understood it, and the rule I kept afterwards. Irregular on purpose — I write these when something happens, not to a schedule, and I would rather send you nothing than send you filler.
Also in the log
Thirty per cent of our volume stopped earning money
Yield was falling and every instinct in the room said send more. We sent less of what paid, and yield rose about eighty per cent.
The ad you cannot change after you send it
An image URL inside a delivered email is immutable, and Google caches it. Both facts are fixed. The ad still had to change on every open.