A few days ago I got an email I did not expect: an abuse report from AWS Trust & Safety, saying my EC2 instance in my personal AWS account had been caught scanning other hosts on the internet.
My first thought was the obvious one someone hacked my box.
What I actually found was more interesting (and honestly, kind of embarrassing): a single line in a package.json file was all it took.
Here's the full story of how it happened, how I tracked it down, and what actually fixed it.
The abuse report
The email included a forwarded report from a third party network. They'd logged multiple completed TCP handshakes from my instance's public IP, hitting a handful of destination IPs on ports like 9200 (commonly Elasticsearch), 443, and 80.
The "completed handshake" detail matters — it rules out IP spoofing. Both sides have to respond for a handshake to finish, so this wasn't some rando forging my IP. My instance really was reaching out and probing other hosts.
Time to figure out why.
First theory: someone broke in
Outbound scanning traffic screams "compromised box," so I started where anyone would: how did they get in?
SSH brute force?
Nope. SSH was key only, and every entry in auth.log was Accepted publickey from IPs I recognized. No password attempts, no unfamiliar sources.
Accepted publickey for ubuntu from <trusted-ip> port 57322 ssh2: RSA SHA256:...
An exposed service?
Everything else was completely unreachable from the internet.
The security group sat in front of all of it. Dead end.
So: no brute force, no exposed service. Whatever this was, it didn't come in through the front door.
Finding the malware
A quick find / -mtime -2 (files modified in the last 2 days) turned up something ugly, sitting right in my frontend app's directory:
scanner_linux
xmrig.tar.gz
scanner_deployed.log
exploited.log
failed.log
monitor.log
data.log
xmrig is a legitimate (but heavily abused) Monero mining tool. scanner_linux was a binary I'd never seen before — and ps aux confirmed it was actively running, as root, chewing through CPU:
root 285679 27.7% CPU ./scanner_linux -t 1000
There it was. That process was almost certainly what generated the traffic AWS flagged.
Both files were owned by root, written within about a minute of each other, at a time that didn't line up with any of my own SSH sessions. Whatever dropped them had root level filesystem access but never touched SSH to get it.
Following the trail outward
If nothing got in from the outside, something on the inside must have reached out. I checked the security group's outbound rule:
Outbound: All traffic → 0.0.0.0/0
The actual root cause
Digging through the frontend app's package.json, one line jumped out immediately:
"dependencies": {
"zod": "latest",
"child_process": "latest",
"aws-amplify": "latest",
"axios": "latest"
}
child_process is not a real npm package. It's a built-in Node.js core module, no install required, ever.
There is zero legitimate reason for it to show up as an external dependency.
But somebody had published a package under that exact name on the public npm registry. This is a known attack pattern: squat on a name that looks like a trusted standard-library module, and wait for someone (a developer copy-pasting a snippet, or these days, an AI coding assistant hallucinating a dependency) to install it by mistake.
These packages almost always ship a postinstall script, which npm runs automatically the second npm install finishes — silently, with whatever permissions the install process has. In my case, that was root, inside a container with a bind-mounted host directory. Every rebuild of that container had been quietly re-running it.
The fix: remove the child_process line from package.json, wipe node_modules and the lockfile, and rebuild from scratch with docker compose up -d --build — the --build flag matters, since a plain restart just resumes the already-infected image.
What I'm taking away from this
A locked-down security group only stops external attackers. It does nothing once malicious code is already executing locally with your own privileges.
Wide-open outbound rules are underrated as a risk. 0.0.0.0/0 outbound is a common default, but it's exactly what let a tiny postinstall script fetch a cryptominer. Restricting egress would've stopped this cold even after the bad package installed.
Any dependency with the same name as a Node built-in is an instant red flag. child_process, fs, http, etc. should never appear in your dependencies.
Stop pinning things to "latest". Half my dependency list was unpinned, which makes it way harder to spot when something new and unwanted sneaks in.
npm audit and a manual skim of package.json should be routine, not just a post incident activity.
Top comments (0)