All notes

19 September 2026 · 5 min read

Your firewall is lying to you

ufw said three ports were open. Seven were. Both statements were true at the same time.

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 internet

Both 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:3000

Don'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.

Manmohan Singh, headshot

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.

Your address, nothing else. Not sold, not shared, and never used to sell you anything you did not ask about. Reply “stop” to any of them and I take you off.

If this sounds like your stack, say so.

A teardown is five days and a fixed fee, and you keep the findings whether or not we go further.

Get in touch