Crypto Miner in Docker: The Postgres Port I Forgot to Close

A 381% CPU spike on my VPS was a crypto miner in a Docker container — not where I first looked. The three-day misdiagnosis, root cause, and fix.

On a Friday afternoon my VPS ground to a halt. OpenClaw — the AI agent that runs my infrastructure — stopped responding. When I finally got a terminal, top showed a process chewing through 381% CPU and 2.4 GB of RAM. The process was /tmp/mysql, running as user ollama.

My first instinct was right, and also completely wrong.

The wrong fix that looked right

I assumed the obvious: a brute-forced SSH login. The signs were all there — PermitRootLogin yes, password authentication enabled, and 30+ failed SSH attempts every hour. I killed the miner, rotated the root password, disabled password auth, and installed fail2ban with 24-hour bans plus a recidive jail for repeat offenders.

It worked. For 18 hours. Then the miner came back.

I killed it again. It came back again — four times total. Each time it respawned under the ollama user, so each time I assumed the Ollama service itself was compromised. I checked the systemd unit, /usr/share/ollama/, the ollama user’s crontab, its shell init files — everything clean. I restarted Ollama. The miner kept coming back.

The breakthrough: the process tree doesn’t lie

What finally cracked it was process-tree analysis. Next to the miner I found two init processes that didn’t belong to any systemd unit. When I checked their root filesystem:

ls /proc/680266/root/tmp/

I found the stash: mysql, kinsing, init, libsystem.so.

Those weren’t host processes. They were running inside a Docker container — specifically simstudio-db-1, the PostgreSQL container for a self-hosted app called SimStudio.

The reason the miner kept showing up as the ollama user is the part that fooled me for three days: the container’s process UID collides with the host’s ollama account. Docker runs container processes as whatever UID the image declares, and when that UID matches a host user, ps reports the host name. Process ownership is meaningless when containers share a UID namespace with the host — the process said “ollama”, but it was a database container the whole time.

The actual root cause

The miner was the Kinsing malware — a cryptojacking worm that scans the internet for exposed databases and Docker APIs. It got in through a port I’d forgotten about: PostgreSQL 5432, exposed through Docker’s proxy with no password on the postgres user.

The image was pgvector/pgvector:pg17. Kinsing found the open port, dropped four files into the container’s /tmp:

  • /tmp/mysql — the XMRig miner (named to look like a database binary)
  • /tmp/kinsing — the loader/backdoor
  • /tmp/init — a fake init process for persistence
  • /tmp/libsystem.so — a malicious shared library

It even left a ransom database named readme_to_recover.

The cleanup

docker exec simstudio-db-1 rm -f /tmp/mysql /tmp/kinsing /tmp/init /tmp/libsystem.so
docker restart simstudio-db-1
ps aux | grep -E "mysql|kinsing" | grep -v grep   # empty = clean

Resolution took under three minutes once I found the real source. Finding it took three days.

What I changed so it can’t happen again

  • ufw now denies 5432 outright. Postgres is only ever reached over the internal Docker network.
  • A strong password on the postgres user.
  • A daily container security scan that greps every running container for the Kinsing file names and removes them (cron, 2 AM).
  • fail2ban + rkhunter + unattended upgrades, and SSH locked to key-only.

The three lessons I’ll actually remember

  1. Process ownership is meaningless with containers. A process running as “ollama” was a Postgres container. UID collisions make ps lie.
  2. Docker isolation is not security. A container on the default bridge with an exposed port is a server with an exposed port.
  3. A monitoring alert is worth more than a clean top. Slack + email alerts caught the respawn twice — the miner never ran longer than six hours before I knew.

Filed under: Cybersecurity