morphit/docs/audit/2026-05-attack-tree.md

169 lines
9.3 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# Morphit attack tree — drain the relay's BLURT balance
The relay is the only Morphit-controlled wallet on chain. Indexer + frontend
hold no funds. So "drain Morphit" reduces to "drain the relay." This document
walks the attack tree against that goal.
```
GOAL: drain the relay's BLURT balance
├── A1. Make many account_create requests succeed
│ (relay pays ~100 BLURT/account in chain fees)
│ │
│ ├── A1.1. Bypass per-IP rate limits
│ │ ├── A1.1.1. Distribute across many IPs (botnet)
│ │ │ └── BLOCKED at: global daily ceiling (default 50/day)
│ │ ├── A1.1.2. Forge X-Forwarded-For
│ │ │ └── BLOCKED at: clientIp() loopback-peer-only trust
│ │ ├── A1.1.3. Send malformed XFF to bypass bucketing
│ │ │ └── BLOCKED at: parseXff length cap + leading-comma fix (C2)
│ │ └── A1.1.4. Abuse IPv6 address-space breadth
│ │ └── PARTIALLY MITIGATED: per-/64 buckets would help
│ │ (currently bucket per full IP); covered by global
│ │ ceiling. Note in REVISIT-LIST.
│ │
│ ├── A1.2. Bypass invite-token gate
│ │ ├── A1.2.1. Forge an invite signature
│ │ │ └── BLOCKED at: HMAC-SHA256 with timingSafeEqual,
│ │ │ secret never leaves server memory
│ │ ├── A1.2.2. Replay a used token
│ │ │ └── BLOCKED at: nonce tracked in consumedNonces map
│ │ ├── A1.2.3. Use someone else's token (different IP)
│ │ │ └── BLOCKED at: ip_hash binding in payload
│ │ └── A1.2.4. Replay a token whose IP has changed (NAT
│ │ transition during user's signup flow)
│ │ └── BLOCKED at: invite_ip_mismatch — user has to
│ │ request a fresh invite. Mild UX cost; only path.
│ │
│ ├── A1.3. Bypass ALTCHA proof-of-work
│ │ ├── A1.3.1. Submit fake solution
│ │ │ └── BLOCKED at: HMAC verification on issued challenge
│ │ ├── A1.3.2. Replay a solved challenge
│ │ │ └── BLOCKED at: usedSalts tracked, single-use
│ │ ├── A1.3.3. Outsource to CAPTCHA-solving farm (~$0.001/solve)
│ │ │ └── Economically unattractive: cost-per-account =
│ │ │ 0.001 (PoW solve) + 100 BLURT fee paid by relay
│ │ │ — but the FOR ATTACKER cost is ALSO the IP/account
│ │ │ to send the request. The relay loses 100 BLURT,
│ │ │ attacker spends near-zero. So this attack DOES
│ │ │ work past the per-IP ceiling.
│ │ │ BLOCKED at: global daily ceiling (50/day cap)
│ │ └── A1.3.4. Solve PoW at scale on free tier
│ │ └── Same as above; gated by global ceiling.
│ │
│ ├── A1.4. Bypass global daily ceiling
│ │ ├── A1.4.1. Race condition between canAccept() and recordSuccess()
│ │ │ └── BLOCKED at: tryReserve() does atomic
│ │ │ canAccept-then-increment (synchronous in JS event loop)
│ │ ├── A1.4.2. Wait for UTC midnight rollover
│ │ │ └── PARTIAL: ceiling resets at midnight, attacker can
│ │ │ try again. But total daily damage is bounded.
│ │ │ Operator can lower ceiling or kill-switch.
│ │ └── A1.4.3. Trick the rollover into firing early
│ │ └── BLOCKED at: utcDateKey() uses Date.UTC*; clock
│ │ manipulation requires server access.
│ │
│ ├── A1.5. Bypass kill-switch
│ │ ├── A1.5.1. Race the env-var change
│ │ │ └── Env-var change requires restart; no in-flight bypass
│ │ ├── A1.5.2. Bypass the file sentinel
│ │ │ └── BLOCKED at: KillSwitch polls every N seconds; once
│ │ │ sentinel exists, every request rejects with
│ │ │ signups_disabled. Polling cadence creates a few-
│ │ │ seconds window after `touch` before requests
│ │ │ reject — acceptable.
│ │ └── A1.5.3. Delete the sentinel file
│ │ └── Requires server filesystem write access, which is
│ │ server compromise (out of scope).
│ │
│ └── A1.6. Bypass health-gate (relay_out_of_funds short-circuit)
│ └── Health is INTERNAL — not user-facing. Cannot be bypassed
│ from external requests. The endpoint refuses requests
│ when balance is below the safety floor.
├── A2. Make the relay broadcast a free transfer to attacker
│ ├── A2.1. Inject a transfer op into a signup flow
│ │ └── BLOCKED at: only the literal account_create op is
│ │ broadcast; relay-built transaction structure is
│ │ hardcoded in broadcastAccountCreate().
│ ├── A2.2. Trick the relay into sending signup_dust to wrong account
│ │ └── BLOCKED at: dust transfer's `to` is the freshly-created
│ │ name from the validated request, not user-controllable
│ │ beyond the name they paid for.
│ └── A2.3. Submit malicious payload that exploits a parser bug
│ └── BLOCKED at: zod schema with .strict() on every nested
│ object — no extra keys, no arbitrary values, no
│ prototype pollution surface.
├── A3. Compromise the relay's active key
│ ├── A3.1. Read it from the running process
│ │ └── Requires server compromise (out of scope at relay-level)
│ ├── A3.2. Read it from disk
│ │ └── BLOCKED at: relay refuses to start if keyfile mode is
│ │ group-or-other-readable (must be 0400 owned by morphit-relay)
│ ├── A3.3. Read it from logs
│ │ └── BLOCKED at: key is never logged; SECURITY.md contract
│ ├── A3.4. Read it from a response
│ │ └── BLOCKED at: only public keys are ever in responses
│ ├── A3.5. Brute-force the encrypted-envelope passphrase
│ │ └── Argon2id KDF with strong defaults; envelope format
│ │ in $crypto/keyEnvelope.ts. Brute-force impractical
│ │ against a strong passphrase.
│ └── A3.6. MITM the operator setting up the relay
│ └── Out of scope (operator OS-level threat model).
├── A4. Witness emergency-raise account_creation_fee to drain via signups
│ ├── A4.1. Witness sets fee to 1000 BLURT, signups still flow
│ │ └── BLOCKED at: relay refuses broadcast if chain fee >
│ │ 110% of operator-configured MORPHIT_INDEXER_ACCOUNT_CREATION_FEE_BLURT
│ ├── A4.2. Witness slowly increases fee just below 110% trigger
│ │ └── PARTIAL: a slow steady increase under 110% per check
│ │ could compound. Mitigation: witness fee changes are
│ │ publicly visible on chain; operator can monitor via
│ │ OPERATIONS.md §4 procedure.
│ └── A4.3. Witness colluding with attacker to time-coordinate raise
│ └── Same as A4.2; operator monitoring is the defense.
└── A5. Exploit the queue drainer to send unintended transfers
├── A5.1. Insert hostile rows into relay_pending_transfers
│ └── Requires DB write access (operator compromise; out of scope)
├── A5.2. Trick the indexer into queueing hostile rows
│ └── BLOCKED at: only feedback handler queues welcome
│ bonuses, only on first-trade-complete with order_permlink
│ owned by subject; loyalty handler queues BP rewards
│ on trade volume, deterministically.
└── A5.3. Replay-attack the drainer to double-broadcast
└── BLOCKED at: per-row error_count + retry cap;
broadcast_at marks row done; concurrent drainer
invocations are single-process so no external
replay surface.
LEAVES MARKED "BLOCKED" — defended.
LEAVES MARKED "PARTIAL" — bounded; operator-monitoring closes the loop.
LEAVES MARKED "Out of scope" — relay-level threat model assumes the host
isn't compromised.
```
## Discoveries from the attack-tree walk
### REVISIT-LIST item: per-/64 IPv6 bucketing for rate limits
A1.1.4 notes that per-full-IPv6-address bucketing isn't very effective
because IPv6 prefixes give attackers /64 (or wider) breadth. A polished
deployment would bucket by /64 prefix (and /24 for IPv4). The global
daily ceiling closes the worst-case loop, but a more granular per-prefix
limit would catch botnet abuse earlier in the funnel.
This isn't a launch-blocker — the ceiling already caps absolute damage.
Logging in REVISIT-LIST as a future polish item.
### Witness-coordinated fee creep (A4.2)
The 110% trip-wire detects abrupt fee increases. A patient adversarial
witness who raises the fee 5% per check could compound to 2× over 14
checks without ever triggering. The OPERATIONS.md §4 procedure tells
operators to monitor fee changes; but there's no automated alerter.
REVISIT-LIST item: add a chain-fee delta alert that fires when the live
chain fee has moved more than X% over the last Y days. Same channel as
the operator-balance alerter.