Cryptominer Injection via Supply Chain

Cryptominer Injection via Supply Chain

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:

  1. Build a trojanized image with a simulated cryptominer (safe for lab use)
  2. Run it in Docker and observe the behavior (CPU spike, outbound connections)
  3. Detect it with eBPF tracing, Falco rules, and process monitoring
  4. Prevent it with image scanning (Trivy/Grype), admission control, and seccomp

Every example uses a simulated miner (a CPU stress tool) — no actual mining software is used.


How Supply Chain Cryptojacking Works

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.


Prerequisites

# Just Docker for all examples
docker --version

Part 1: Build the Trojanized Image

We'll create a legitimate-looking nginx image that secretly runs a CPU stress tool in the background — simulating a cryptominer.

The legitimate image

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.

The trojanized image

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

Verify it works as nginx

# The web server works perfectly — nothing suspicious
curl -s http://localhost:8081 | head -5
# <!DOCTYPE html>...  (normal nginx page)

Observe the miner

# 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.


Part 2: Stealthier Techniques

Real-world cryptominers use several evasion techniques. Let's build a more realistic version.

Process name masquerading

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.

Rate-limited mining

#!/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.

Build the stealth version

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

Part 3: Detect with eBPF and Process Monitoring

Monitor CPU-intensive processes

# 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'

Detect with strace — outbound connections

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 with bpftrace — process masquerading

# 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.

Detect with Falco

- 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]

Part 4: Prevent with Image Scanning

Scan with Trivy

# 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.

Scan with Grype from an SBOM

# 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

Custom policy: flag unexpected packages

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

Admission control: block unsigned images

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/"

Part 5: Runtime Resource Limits

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.

Monitor for resource limit saturation

# 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']}\")
"

Defense Summary

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

Lab Cleanup

docker stop trojan-nginx stealth-nginx 2>/dev/null
docker rmi nginx-trojan nginx-stealth 2>/dev/null
rm -rf ~/cryptominer-lab

Next Steps