Secure Your VPS From Cryptominers: The Complete Hardening Checklist (2026)
We found a live cryptominer rootkit on a client's VPS — a fake anacron binary, a hijacked systemd service, and a backdoor root account. Here's exactly how we caught it, removed it, and the 15-point hardening checklist we now run on every server.
If you're running client websites, SaaS apps, or APIs on a VPS, a cryptominer is one of the most common — and most quietly destructive — compromises you'll face. It doesn't announce itself with a ransom note. It just sits in the background, stealing your CPU, your bandwidth, and eventually your trust with clients when their app starts timing out for no obvious reason.
We know this because we found one. Live, on a production server, mid-deployment.
The 195% CPU That Shouldn't Have Been There
While provisioning a new server for a client project, we noticed something a routine health check should never show: a single process pinned at 195% CPU — meaning it was saturating more than one core, continuously, since the moment the server booted. The site was still technically "up," but every deploy, every database query, every page load was fighting a process that had no business being there.
Here's the process list that gave it away:
root 2580 195% /var/tmp/1479800324
A few things should immediately raise red flags in output like this:
- It's running from /var/tmp — a world-writable scratch directory. No legitimate application binary lives there.
- The filename is a random number, not a program name. Real software doesn't name its own binary
1479800324. - It's running as root, with no corresponding installed package.
Following the Trail: A Live Rootkit Investigation
Killing the process didn't help — within minutes, a brand-new PID appeared, same binary, same CPU usage, same outbound connection. Something was actively relaunching it. That's the difference between "a stray process" and "an active infection with persistence" — and it's exactly where most server hardening guides stop short of explaining what to actually look for.
1. The fake "anacron"
Tracing the process tree showed its parent was /usr/local/bin/anacron — except a quick check confirmed the anacron package was never even installed on the box. The attacker had dropped a 2.3MB static binary into /usr/local/bin — a directory that takes priority in the system PATH — and named it after a boring, trusted system utility nobody thinks to double-check.
2. A hijacked systemd service
The real discovery was in /etc/systemd/system/smartd.service — normally the disk health-monitoring service. Its actual contents had been silently rewritten:
[Service]
ExecStart=/usr/local/bin/anacron
Nice=-20
Restart=always
RestartSec=3
This is a textbook persistence technique: disguise the payload as a service nobody questions, give it the highest possible CPU scheduling priority (Nice=-20), and set it to auto-restart within 3 seconds of being killed. It's also exactly why our first kill attempt failed — the malware was back before we'd even finished checking the output.
3. A backdoor root account hiding in plain sight
The most serious finding was in /etc/passwd:
pakchoi:x:0:0::/home/pakchoi:/bin/bash
Look closely at those two zeros. That's UID 0, GID 0 — full root privileges, under an innocuous-looking username. It also had its own entry in /etc/sudoers.d/ granting passwordless root, and group membership in sudo, adm, and root. A UID-0 alias account is particularly nasty because standard tools like userdel get confused by it — the kernel can't tell its processes apart from root's own, so even removing it safely requires editing /etc/passwd, /etc/shadow, /etc/group, and /etc/gshadow by hand rather than relying on the normal account-deletion tooling.
4. An active connection to a control server
Finally, a network check confirmed the malware wasn't just burning CPU locally — it had a live outbound TCP connection to a remote IP on a high, non-standard port, consistent with a cryptomining botnet's command-and-control channel.
Put together: this wasn't a stray miner that wandered in. It was a deliberate, multi-layered compromise — fake binary, hijacked service, backdoor account, live C2 link — and it had clearly been cloned from an already-infected base image, since the same indicators showed up consistently across multiple servers provisioned from the same source.
The Fix: Containment, Then a Clean Rebuild
For an infection this deep, "delete the bad file and move on" isn't good enough — a root-level compromise means you can no longer fully trust anything on that box, including files you haven't checked yet. Our response was:
- Kill the active process tree and remove the malicious binary
- Strip the backdoor account from
passwd/shadow/group/gshadowand delete its sudoers entry - Restore the hijacked
systemdservice to its legitimate state (or remove it entirely if it was never a real package) - Treat every credential and API key that ever lived on that box as compromised and rotate them
- Provision a brand-new server rather than fully trusting the cleaned one, and rebuild it hardened from the very first boot
That last point matters more than people want to believe. Cleaning visible artifacts proves you found those artifacts — it doesn't prove there isn't a second, quieter backdoor you haven't found yet. When the stakes are a production app and real client data, a clean rebuild is cheaper than a guess.
The Server Hardening Checklist We Use on Every Box
This is the exact baseline we now apply to every VPS before any application ever touches it — whether it's hosting a Laravel app, a Node.js API, or a Next.js + PostgreSQL stack like ours.
1. Lock Down SSH First
Before anything else touches the server:
- Move SSH off port 22. This alone eliminates the overwhelming majority of automated scanner traffic.
- Disable root login (
PermitRootLogin no) — every login should be traceable to a named human account. - Disable password authentication entirely (
PasswordAuthentication no) — SSH keys only. A strong password can still be phished, brute-forced, or leaked; a private key sitting on your own machine can't be guessed.
Note for modern Ubuntu (22.04+/24.04): SSH is socket-activated by default, so changing the Port directive in sshd_config alone won't actually move the listening port. You also need a systemd override on ssh.socket itself (ListenStream= cleared and reset to the new port) — a detail that trips up a lot of hardening guides written before this became the default.
2. Create a Named Admin Account
Add a non-root user with sudo access, install your SSH key into its authorized_keys, and verify that login works before you disable root access. Locking yourself out of your own server is a real and avoidable failure mode — always test the new access path while the old one is still open as a fallback.
3. Firewall Everything by Default-Deny
ufw default deny incoming
ufw default allow outgoing
ufw allow <your-ssh-port>/tcp
ufw allow 80/tcp
ufw allow 443/tcp
ufw enable
Allow only what the server actually needs to serve. Nothing else should be reachable from the internet at all.
4. Fail2Ban (or CrowdSec) for Brute-Force Protection
Fail2Ban watches your auth logs and temporarily bans IPs after repeated failed login attempts — make sure its sshd jail is pointed at whatever port you actually moved SSH to, not the default. CrowdSec takes this further with community-shared threat intelligence, blocking IPs that are already known to be scanning or attacking other servers, not just yours.
5. Automate Security Updates
apt install unattended-upgrades
dpkg-reconfigure --priority=low unattended-upgrades
Most server compromises exploit a vulnerability that already has a patch available — the gap is just that nobody applied it.
6. Watch CPU Like You Mean It
A cryptominer's whole business model depends on stealing as many CPU cycles as it can without you noticing. htop and a quick ps aux --sort=-%cpu | head should become a reflex, not an afterthought. Names to watch for: xmrig, kinsing, kdevtmpfsi, or — as we found — something disguised as a totally ordinary system service.
7. Run Rootkit and Malware Scanners
apt install chkrootkit rkhunter clamav -y
rkhunter --check
chkrootkit
clamscan -r /
None of these are perfect, and both rkhunter and chkrootkit are known for false positives on perfectly clean systems (flagging package-bundled dotfiles, or systemd-networkd's legitimate use of promiscuous mode, for instance) — but a clean scan is a useful baseline, and a sudden new finding after running fine for months is worth investigating immediately.
8. Never Expose the Docker API
Ports 2375 and 2376 (unauthenticated and TLS Docker APIs) are one of the single biggest sources of cryptojacking infections on cloud VPS instances worldwide — an exposed Docker daemon effectively hands out root-equivalent remote code execution to anyone who finds it. Check with ss -tulpn and make sure neither port is listening on a public interface.
9. Never Expose Database Ports
PostgreSQL (5432), MySQL (3306), Redis (6379), and MongoDB (27017) should bind to localhost only, full stop. An internet-facing, unauthenticated Redis instance in particular is one of the most commonly automated cryptominer entry points there is — it takes attackers seconds to find and minutes to exploit.
10. Put Cloudflare (or similar) in Front of Client Sites
Proxy mode, a Web Application Firewall, bot protection, and rate limiting stop a huge volume of attack traffic before it ever reaches your origin server at all.
11. Scan for Malware Periodically, Not Just Once
A clean server today doesn't guarantee a clean server in three months. Schedule clamscan and rootkit checks to run on a recurring basis, not just during initial setup.
12. Know What "Suspicious" Actually Looks Like
Beyond CPU usage, check for cron jobs you didn't create (crontab -l, /etc/cron.d/), and active connections to known mining-pool ports — 3333, 4444, 5555, 7777, and 14444 show up constantly in cryptojacking traffic.
13. Harden the Application Layer Too
Infrastructure hardening doesn't help if the app itself leaks the keys to the kingdom. For Node.js, Next.js, Laravel, or any stack:
- Keep
.envfiles outside any publicly served directory - Disable directory listing in your web server config
- Keep dependencies patched — run
npm audit/composer auditregularly - Remove test routes, debug endpoints, and
phpinfo()pages before going to production - Never commit secrets to a public (or even private) GitHub repo — use
.gitignoredeliberately, not as an afterthought - Use strong, unique database passwords — not the ones from the install tutorial you followed
14. Monitor, Don't Just Configure and Forget
A simple daily cron that snapshots top output is a good start:
0 8 * * * top -bn1 | head -20 > /tmp/server-status.txt
For anything serving real traffic, pair that with uptime monitoring (UptimeRobot) and a proper metrics dashboard (Netdata) so a spike shows up as an alert, not as a client complaint.
The Minimum Security Stack We Deploy on Every Server
For a typical modern VPS — Next.js or Laravel + Node.js + PostgreSQL + Nginx + PM2 — this is the floor, not the ceiling:
- SSH keys only, root login disabled
- UFW firewall, default-deny
- Fail2Ban or CrowdSec
- Automatic security updates
- Cloudflare proxy + WAF for public sites
- Daily backups
- PostgreSQL bound to localhost, never public
- PM2 process monitoring with auto-restart
- Recurring rootkit and malware scans
This single checklist stops the overwhelming majority of cryptominer infections we see on small-business and SaaS VPS deployments. The infections that get through it are usually the ones where one item on this list quietly got skipped under deadline pressure.
Don't Want to Do This Yourself?
Every server we provision for a client — whether it's a FastAPI backend, a Next.js frontend, or a full SaaS stack — gets this exact hardening pass before a single line of application code touches it, plus ongoing monitoring after launch. If you've inherited a server you didn't set up, or you just want a second pair of eyes on one you did, we offer a focused VPS security audit: we check for exactly the kind of compromise described above, harden what needs hardening, and hand you a clear report of what we found and fixed.