# Peer Review — VPS Compromise Audit (2026-07-29)

**Reviewer:** meta (Claude Code, `claude` user, non-root)
**Reviewing:** `compromise-audit-2026-07-29.md` by Alfred
**Verdict:** Findings confirmed. Remediation ~70% complete. Two items wrong, one actively dangerous. Credential exposure materially underweighted.

---

## 0. Correction to my own earlier assessment

Before reviewing Alfred's work, I told Christian there was **no compromise**. I was wrong.

I ran my investigation at ~22:00 UTC. Alfred killed the miner at **21:58 UTC**. I searched a system that had been cleaned two minutes earlier, found nothing, and constructed a confident false-positive theory (`ps aux | grep xmrig` self-match) to explain Alfred's report away. The self-match is real and I did reproduce it — but it was not what happened.

**Process failure to avoid repeating:** ask what has already been remediated *before* concluding from absence of evidence. "I find nothing" and "nothing was there" are different claims. Alfred's evidence (PID 2299169, wallet `46DttL6Ccs…`, pool `147.189.175.184:8443`, two 8.3 MB binaries, 9 days runtime) was concrete and I should have asked for it first.

---

## 1. What Alfred got right

Independently verified by me:

| Claim | Status |
|---|---|
| Miner process dead | Confirmed |
| `/var/www/html/wp-admin/xmrig` deleted | Confirmed gone |
| `/home/claude/wp-env/` deleted | Confirmed gone |
| No cron persistence | Confirmed independently (user + all `/etc/cron.*`) |
| No systemd persistence | Confirmed (system + user units) |
| No `.bashrc`/`.profile` injection | Confirmed |
| No `/etc/ld.so.preload` rootkit | Confirmed |
| No `authorized_keys` backdoor for `claude` | Confirmed (file does not exist) |
| No deleted-but-running binaries | Confirmed |
| No processes running from temp dirs | Confirmed |
| `PasswordAuthentication no` | Confirmed via `sshd -T` |
| wp-env as likely vector | Plausible and well-argued |

Two independent audits reaching the same "no persistent backdoor" conclusion is meaningful corroboration. The core incident handling was sound.

---

## 2. Problems found

### 2.1 — CRITICAL: the detection cron will kill production bots

`/usr/local/bin/detect-miners.sh`, running as **root every 5 minutes**, does:

```bash
suspicious=$(ps aux | grep -iE "xmrig|stratum|cryptonight|monero|minerd|ccminer|ethminer|tsm|kdevtmpfsi|kinsing")
echo "$suspicious" | awk '{print $2}' | xargs -r kill -9
```

It matches **command-line text**, not miner identity. Demonstrated with a decoy process:

```
WOULD KILL -9 → pid=2555564 user=claude :: node server.js --note about-monero-incident
```

Anything whose command line mentions the incident is SIGKILLed as root. That includes:

- Any Claude bot session discussing this compromise. **My own process matched this pattern earlier today**, when the prompt contained "xmrig".
- Alfred's own session while writing the audit.
- Any `grep`, `tail`, or editor touching either audit file.
- `tsm` is a 3-character bare substring — reckless in a kill rule.

It has not fired yet by luck alone. A false `kill -9` on a bot mid-task costs far more than five extra minutes of mining. **Alert, never auto-kill on cmdline text.**

### 2.2 — CRITICAL: the CUPS fix silently failed

Alfred masked the **deb** units (`cups`, `cups-browsed`). The actual listener is the **snap** (`snap.cups.cupsd.service`, snap `cups` rev 1229). Current state:

```
LISTEN 0 4096  0.0.0.0:631  0.0.0.0:*
LISTEN 0 4096     [::]:631     [::]:*
```

Alfred's own verification step reads "`ss -tlnp | grep 631` → Should return nothing." It returns two lines. Reported as done without re-running the check.

### 2.3 — "No credential theft was found" is unfalsifiable, and the evidence was destroyed

Remediation included `journalctl --vacuum-time=2d` and deleting rotated logs older than 7 days. **The miner ran for 9 days.** The entire intrusion window's logs are gone.

Consequences: we can no longer determine the initial access vector with confidence, whether data was exfiltrated, or whether anything else ran during those 9 days. The report's "no credential theft" line should read "cannot be determined."

Discipline for next time: **snapshot first, clean second.** Hetzner snapshot costs ~€0.01/GB/month and preserves the answer.

### 2.4 — The container-vs-host reasoning is inverted, and it decides the blast radius

The report rules out container escape because the miner "ran as claude user on host, not inside container."

That inference does not hold. In Docker **without user namespaces**, a process running as UID 1000 inside a container appears as `claude` in host `ps`. So "host ps shows claude" is equally consistent with:

- **(a)** attacker had host code execution as `claude` → every secret below was readable, or
- **(b)** attacker only had in-container execution → blast radius limited to the container.

Which one occurred was never established, and `/var/www/html` has since been deleted so the bind-mount question can't be settled. **Must assume (a).**

### 2.5 — Docker still running; UFW bypass hazard is structural and unfixed

Docker publishes ports by writing directly to the iptables `DOCKER-USER`/nat chains, **bypassing UFW rules entirely**. The wp-env containers are gone, but the mechanism that let them be publicly reachable despite a firewall is untouched. The next `docker run -p` re-exposes a port silently.

### 2.6 — Factual error that explains the 9-day blindness

Report header says "1 vCPU"; actual is **4** (`nproc: 4`). This matters: 371% CPU across 4 cores produces load ≈1.5, which *looks normal*. That is precisely why the miner survived 9 days — and why I dismissed it too. Load average was the wrong instrument; per-process `%CPU` was the right one.

---

## 3. Credential blast radius (the report's biggest gap)

Alfred rates "rotate `claude` password" as High and does not mention rotating **any API key**. A principal with `claude`-level read access for 9 days could read:

| Location | Contents |
|---|---|
| `ui-v2/.env.local` | 25 secrets — **Stripe LIVE key**, Anthropic key, **Fly API token**, **Supabase service-role key**, `AUTH_SECRET`, Resend, Telnyx |
| `shared/secrets.env` | 23 secrets |
| `claude-bots/bots/*/.env` | **8 Telegram bot tokens**, **7 Claude Code OAuth tokens**, MailBridge API key, ClearPost webhook secret, `ADMIN_SECRET` |
| `~/.ssh/id_ed25519` | GitHub deploy key (`christpet/mission-control`) |

Severity of individual items:

- `AUTH_SECRET` → forge any MultiplyMe user session
- `FLY_API_TOKEN` → full control of `multiplyme-agents`, all user machines
- Supabase service-role key → bypasses all row-level security
- `STRIPE_SECRET_KEY` (live) → financial
- `CLAUDE_CODE_OAUTH_TOKEN` → bill against Christian's Anthropic account

Standard IR practice: treat everything readable by a compromised principal as compromised. You do not get to assume the attacker was polite.

**Counter-argument, weighed honestly:** coin-miner kits are typically opportunistic — they mine and move on, and many never touch the filesystem beyond their own drop directory. Credential harvesting is increasingly bundled, though, and these files were plaintext and trivially readable. For Stripe-live and Fly, the expected cost of *not* rotating dominates the cost of rotating. Tiered rotation is the proportionate response, not a full panic.

---

## 4. True next steps

### P0 — now

1. **Neuter the detection cron before it kills a bot.** Replace text-matching with identity-based detection; alert instead of kill. Corrected script in §5.
2. **Rotate high-severity credentials, in this order:** Stripe live → Fly API token → Supabase service-role → `AUTH_SECRET` (forces user re-login; acceptable) → Anthropic → GitHub deploy key. Telegram/Claude-OAuth tokens as a second wave.
3. **Actually close 631:** `snap stop --disable cups` — the snap, not the deb unit. Re-verify with `ss -tlnp | grep 631`.

### P1 — this week

4. **Close the Docker/UFW bypass:** explicit `DOCKER-USER` deny rule, or `{"iptables": false}` in `/etc/docker/daemon.json`.
5. **Bind ports 3000 and 3115 to `127.0.0.1`**; let Caddy front them. Survives a firewall failure.
6. **Alerting that actually reaches Christian.** A local logfile nobody reads is not monitoring. Route to Telegram. The real failure was 9 days of blindness, not the miner.

### P2 — structural; prevents the next one

7. **Get secrets out of plaintext on disk** — `systemd LoadCredential`, or sops/age encrypted at rest. Highest-leverage change available: shrinks the blast radius of any future foothold from "everything" to "nothing."
8. **Never run dev/untrusted containers with published ports on this host.** wp-env exposed WordPress on `0.0.0.0:8889` and MariaDB on `0.0.0.0:32775`. Bind test stacks to localhost, or run them somewhere disposable.
9. **Snapshot before remediation.** Keeps root-cause and exfiltration questions answerable.

---

## 5. Corrected detection script

Detects on **executable identity and behaviour**, never on command-line text. Alerts; does not kill.

```bash
#!/bin/bash
# /usr/local/bin/detect-miners.sh
# Detects miners by executable identity + behaviour. Never matches command-line
# text (that kills bots discussing incidents). Alerts to Telegram; never kills.

LOG=/var/log/miner-detection.log
TG_TOKEN=$(grep -h '^TELEGRAM_BOT_TOKEN=' /home/claude/mission-control/claude-bots/bots/meta/.env 2>/dev/null | cut -d= -f2)
TG_CHAT=6827258224
findings=""

note() { findings="${findings}\n- $1"; }

for pid in $(ls /proc 2>/dev/null | grep -E '^[0-9]+$'); do
  exe=$(readlink "/proc/$pid/exe" 2>/dev/null) || continue
  [ -z "$exe" ] && continue
  comm=$(cat "/proc/$pid/comm" 2>/dev/null)

  # (a) binary executing from a world-writable temp dir — near-zero legit cases
  case "$exe" in
    /tmp/*|/dev/shm/*|/var/tmp/*) note "pid=$pid exe=$exe (running from temp dir)" ;;
  esac

  # (b) deleted-but-running binary — classic miner hiding
  case "$exe" in
    *"(deleted)") note "pid=$pid exe=$exe (deleted binary still running)" ;;
  esac

  # (c) known miner binary NAMES (comm, not cmdline — cmdline is attacker/user text)
  case "$comm" in
    xmrig|minerd|ccminer|ethminer|cpuminer|kdevtmpfsi|kinsing|xmrigDaemon)
      note "pid=$pid comm=$comm exe=$exe (known miner binary name)" ;;
  esac
done

# (d) sustained high CPU — the signal that was missed for 9 days.
#     371% CPU on 4 cores = load 1.5, which looks fine. Watch %CPU, not load.
while read -r pid pcpu exe_comm; do
  # integer compare, strip decimal
  if [ "${pcpu%%.*}" -ge 150 ] 2>/dev/null; then
    exe=$(readlink "/proc/$pid/exe" 2>/dev/null)
    case "$exe" in
      */node|*/claude|*/npm|*/next-server|*/esbuild|*/dockerd|*/containerd|*/tsc) ;;  # known heavy
      *) note "pid=$pid %cpu=$pcpu exe=$exe (sustained high CPU, unrecognised binary)" ;;
    esac
  fi
done < <(ps -eo pid=,pcpu=,comm= --sort=-pcpu 2>/dev/null | head -8)

# (e) outbound connection to a common mining-pool port
pools=$(ss -tnp state established 2>/dev/null \
  | awk '$4 ~ /:(3333|4444|5555|7777|8443|9999|14444|45700)$/ {print $0}')
[ -n "$pools" ] && note "outbound to mining-pool port: $pools"

if [ -n "$findings" ]; then
  printf '%s: FINDINGS%b\n' "$(date -Iseconds)" "$findings" >> "$LOG"
  if [ -n "$TG_TOKEN" ]; then
    curl -s -m 10 "https://api.telegram.org/bot${TG_TOKEN}/sendMessage" \
      --data-urlencode "chat_id=${TG_CHAT}" \
      --data-urlencode "text=$(printf '🚨 VPS miner-detector findings:%b' "$findings" | head -c 3500)" \
      > /dev/null
  fi
fi
```

Note on (e): port `8443` is in the list because that is the pool this attacker used — but 8443 is also a legitimate HTTPS-alt port, so expect occasional benign hits. It alerts rather than kills, so the cost of a false positive is one Telegram message.

---

## 6. What actually failed, in one line

A dev WordPress container published to `0.0.0.0` on a box with no firewall, holding 50+ plaintext production secrets, with no per-process CPU monitoring and no alerting that reaches a human. The miner was the symptom. Those four things are the disease.
