Campaign 2 re-attacks the fixed surface: regressions + new criticals · 11_system_audit/REDTEAM_FINDINGS_2_2026-09-11.md
REDTEAM FINDINGS #2 — QALS live-stack audit, 2026-09-11 (campaign 2: post-fix surface)
Agent: redteam #2. Mode: findings-only, reversible PoCs, every mutation undone (§UNDO ledger). Scope: the NEW surface since campaign 1 — qalx AMM (:8843), qalkeys metering/escrow (:8841), qalpub v3/v4 treasuries+roles (:8832), qalid v2 (:8838), qalpay (:8839), relay proof-of-possession (:8830), gmux-bridge (:8842), ceremony artifacts — plus verification that the campaign-1 fixes HOLD.
Counts: 6 CRITICAL (3 new · 3 campaign-1 regressions) · 4 HIGH · 6 MEDIUM · 2 LOW.
VERDICT: the fixes did not land where it matters. The :8899 secret server from C5 is still running on the same pid, loopd
/topupis still unauthenticated because the admin gate is env-conditional and the env is unset,/agents/claimis still zero-proof, and hold/settle still have no lock. On the NEW surface: anyone can drain any qalpub channel treasury by typing the owner's handle; a dust swap against qalx mints a free refund and permanently bricks the pool; and qalkeys' "fixed" dispute is a string comparison. Value is movable and truth is forgeable today.
CRITICAL
C2-1 (NEW) — qalx error-path refund pays from pool escrow without consuming the receipt → free refund + permanent pool brick
- PoC:
redteam_20260911_poc/— poolp9276e0dc(100c/100u), swapper settles a 1c hold into escrow (receiptr_7d90d7eed1), thenPOST /pools/<pid>/swap {amount:1, base→quote}. Dust amount ⇒int(out_gross)==0⇒ quote error after payment verification.do_swap(qalx/qalx.py:420-436) runs a refund leg (escrow_transfer(amount)to the swapper) but never appends the receipt toused_receipts(that happens only at step 6, for filled/reverted swaps) and never touches reserves. Swapper balance 5c → 6c (1c minted out of LP capital), escrow 100 → 99 whilereserve_base_centsstays 100. Replays of the same swap/receipt are blocked only byescrow_backing_check— which now fail-closes every future swap ("escrow backing violated: 99 < 100"). - Result: (a) one free refund per settle receipt — repeatable by re-settling, a drip-print against LP funds; (b) reserves/escrow desync that no API can repair ⇒ the pool is permanently bricked (all swaps 503) — a 1c grief converts any pool into dead capital; (c) replay of the same receipt is refused only by accident of the backing check, not by design.
- Severity: CRITICAL (direct value leak + LP-fund DoS).
- Fix: consume the receipt (
used_receipts.append) before any refund leg; on the error path refund AND book the reserve-neutral accounting (or reject dust amounts ≤ fee floor up front); make backing violation alert-and-degrade instead of hard brick; add a dust-swap invariant test.
C2-2 (NEW) — qalpub v3/v4 money endpoints authorize on an unauthenticated bare by string — zero-credential treasury drain
- PoC:
qalpub_payout2.py— channelch_67078b299901(fee-funded: author paid 5c, receiptr_7f5d135657, postch_…:1). Then, from a session holding no credential of any kind:POST /channels/<chid>/treasury/payout {"by":"rt2owner","to_handle":"rt2thief","cents":5}→ 201, receiptr_e8d7389c3b, thief balance 0 → 5c. The owner check isby != _channel_owner_handle(ch)— a string compare against a body field. The owner handle is public via channel/workspace listings. Same pattern (bareby) governs/roles(self-mod assignment),/slowmode,/posts/:id/delete(refund trigger), and the read-marker write — the 2026-09-10 M10 caveat now sits on money paths. - Severity: CRITICAL — every funded channel treasury is drainable by anyone who can spell the owner's handle; role self-assignment escalates to delete/refund abuse.
- Fix: require an ed25519 signature over
qalpub-authz-v1|chid|action|cents|to|nonceverified against the owner's anchored qalid doc (or a loopd revoke-oracle proof like qalkeys seller proofs); never trust body identity fields; rate-limit + journal every payout with the verifying key.
C2-3 (REGRESSION of C5) — :8899 static secret server is still up, same pid
- PoC (read-only):
GET http://127.0.0.1:8899/loopd/data/keys.json→ HTTP 200. Listener:python3 -m http.server 8899, pid 1215081 — the exact pid campaign-1 documented. The "fix" was never applied; all 120+ loopd agent HMAC secrets, admin keys, and the chain keystore remain downloadable by any local process. - Fix: kill pid 1215081; never serve a tree containing
data/; chmod 0600 key files. (Campaign-1's #2 mitigation, unshipped.)
C2-4 (REGRESSION of C1) — /topup admin gate is env-conditional and the env is unset on the live process
- PoC:
POST /topup {"agent_id":"rt2-nonexistent-probe"}→404 unknown agent_id(an active gate returns 401 before the existence check).tr '\0' '\n' < /proc/<loopd>/environshows noQALLOOPD_ADMIN_KEY. Real mint confirmed during the C4 PoC: 2,000c topped up with no credential. The code gate exists (loopd.py:426-433) but is fail-open by deployment. - Fix: fail-closed default — refuse topups unless the admin key is configured; set the env in the service unit; alert on paper-mode warnings.
C2-5 (REGRESSION of C7) — /agents/claim still hands the wallet secret to the first caller
- PoC:
ensure rt2cvictim→claim rt2cvictim→ response contained the fullapi_secretforag_afb3f6c424. No proof, one call. (This secret then powered the C4 race PoC.) The campaign-1 fix was never implemented. - Fix: claim codes / qalid-proof-bound claims (campaign-1 recommendation stands).
C2-6 (REGRESSION of C4/H6/L6) — hold/settle/release still run without _lock
- PoC:
c4_race.py+ round 2 — 2000c balance, 12–16 threads racing/hold(est 600c). Both runs: 1 hold acknowledged, 2 holds committed (ledger truth: 2 holds / 1,380c escrowed / balance 620)._lockstill wraps onlytransfer(loopd.py:313). The check-then-act window survives, and the losing connection's response is dropped after its mutation commits — the unacknowledgedhold_idstrands 690c until TTL with no client-side recovery. - Fix:
with _lock:around every Ledger mutator + idempotency keys + queue responses instead of concurrentwfilewrites (campaign-1's exact words, still pending).
HIGH
H2-1 — qalkeys mode-2 dispute "fix" is cosmetic; H8 replay still works
- PoC:
h8_dispute.py— seller lists keykl_40c68d0b3d(1c, 2s cooldown), buyer pays intentr_5f37c11f1c, reveal returns the key (trade kt_6a621445). Then a third party with zero credentials postsPOST /keys/<klid>/dispute {"buyer_handle":"rt2kb2"}— the public handle string, which is all the newbuyer != kl["buyer_handle"]check requires →{"status":"burned", "refunded":true}. Buyer refunded, keeps the key, seller never paid. Campaign-1's H8 stands; the shipped check only stops disputes that name a different handle. - Fix: dispute must carry a cryptographic buyer proof (qk-key/revoke-oracle or
loopd signature over
klid|trade_id|dispute); never auto-refund on string identity.
H2-2 — qalkeys metering is clamped to the batch hold but the full upstream response is still delivered
- Code-verified (
_proxy): whencharge > room, charge is truncated to the hold remainder (X-Qalkeys-Charge-Truncated: hold-exhausted) and the complete upstream body is still proxied to the buyer. A token-priced listing (per-call 0, per-1k 1c) holds ~10c per 10-call batch; a response claiming 10M tokens (charge 10,000c) is delivered for ≤ the hold remainder. Repeat per batch = systematic underpayment; the daily-cap check uses the same small estimate, so the cap barely moves. - Fix: fail-closed on overage — withhold/413 the body (or deliver truncated with the seller's consent) unless the key can settle the overage; settle overage as its own hold before release.
H2-3 — qalkeys SSRF (C6) not fixed: upstream_url still has no private/loopback/fleet deny
- Code-verified:
_listing_createchecks only thehttp(s)://prefix;_proxyforwardsup_url + suffixwith the seller's sealed auth header and returns the upstream body. With/agentsregistration + unauth/topup(C2-4) + unauth/buyall still open, the full campaign-1 C6 chain (loopd secret exfil viaupstream_url=http://127.0.0.1:8823) is re-runnable verbatim. - Fix: deny loopback/RFC1918/100.64/10 + link-local unless operator-allowlisted;
authenticate
/buy; org-trust listing creation.
H2-4 — relay proof-of-possession is optional → TOFU handle-squatting still works
- PoC:
POST /pubkeys/rt2tofuwith attacker keys and noX-Pubkey-Proof→ 200{ok:true, proof_verified:false}— registered. Any non-qalchat sender (curl, other clients) still encrypts to the squatter's key on first contact;proof_verifiedis informational and nothing consumes it. The sig itself is sound: a proof over a different handle is refused (401, verified), andvalid_handleis lowercase-only so case-confusion fails closed — those variants held. - Fix: hard-require the proof (post-migration), expose
proof_verifiedinGET /pubkeys, and have qalchat refuse unverified first-contact keys.
MEDIUM
M2-1 — qalx anchored receipts misstate reserves_before for quote→base swaps
do_swap computes reserves_before.base_cents = after − (−amount) — but a quote→base
swap moves base by out, not amount. The on-chain receipt records a false pre-trade
reserve whenever amount ≠ out (i.e. always, off a 1:1 price). Audit/forensics
corruption in the tamper-evidence layer. Fix: before = after + out for the base leg.
M2-2 — ceremony artifacts: rewrite + re-anchor verifies PASS; honest file verifies TAMPERED
PoC: edited testlogs/last_ceremony.json (200.00 → 999,999.00 Qals), re-anchored
(tx HRXs55…) → qalpipe.verify_file = PASS. Root cause = campaign-1 H2, still
unfixed: verify_file trusts the newest registry row keyed by file path;
anchoring is permissionless. Worse, while the forged row existed, the restored
honest file verified TAMPERED — a tamperer can also defame. Registry row removed in
cleanup; original restored (verify returns to its pre-PoC NOT_ANCHORED state — the
report file was never itself anchored; the ceremony record is).
Fix (same as H2): artifact-carried object_id / chain-scan verification; signed
anchors (M12).
M2-3 — qalid revocation lives only in the local record file; resolvers never check the anchored revocation
PoC: on the pre-revoked testlab DID v2carol--testlab (envelope with anchored
revocation untouched), deleting revocation from dids/v2carol--testlab.json → CLI
resolve and GET :8838/did/… return ACTIVE / verified:true / chain PASS. The
chain check only re-hashes the envelope; the anchored revocation entry inside it is
never consulted, and there is no revocation registry other machines could read.
Restored afterwards. Fix: resolvers re-derive revocation from the anchored envelope
(scan chain by uri), or run a replicated revocation registry.
M2-4 — gmux-bridge /agents/provision: unauthenticated loopd agent minting
seed_balance_cents is caller-controlled (default SEED_CENTS) and the endpoint has no
auth; the returned api_secret is stored in provisions.json. Service is currently
not running (dead on :8842 during this audit) — code-verified only. Fix: auth +
fixed seed policy + server-side caps.
M2-5 — qalpub trusted_after farming: self-delete refunds the fee but keeps the clean-count
bump_author_post_count(-1) fires only on mod-delete (qalpub.py:2693); the
author-delete path refunds paid_cents inside the refundable window without
decrementing. N paid posts → N self-deletes (all fees refunded) → trusted (fee-free)
forever at zero net cost whenever the window is generous. Fix: decrement on
author-delete too, or count only posts whose refund window has lapsed.
M2-6 — loopd committed-but-unacknowledged holds strand funds invisibly
Observed twice in the C4 races: a hold commits, the connection dies before the
response, and the caller has no hold_id — the escrowed cents are unrecoverable via
the API until the 5-minute TTL. Combine with C2-6's fix (lock + idempotency + queued
responses).
LOW
L2-1 — qalx /pools creation is unauthenticated and paper-mints its seed via loopd /topup
(Chained with C2-4 this lets anyone create escrow agents and mint program-capped seed; bounded, but it should be operator-gated.)
L2-2 — qalid tailscale-bind trusts local tailscale status and the TS_OVERRIDE_JSON mock env
On any host where an attacker can set env for the CLI run, arbitrary nodekeys bind to DIDs (re-signed + re-anchored by whoever holds the local identity file). Document as local-trust; gate bind on a challenge signed by the node.
Campaign-1 fixes that HELD under my variants
- C3 settle clamp —
actual = max(0, min(actual, hold))present; no negative-settle print (code-verified against the live build). - PoP cross-handle/case confusion — server verifies the literal path handle; proof for another handle → 401; handles lowercase-only. Held.
- qalpay — raw-body HMAC (
compare_digest), currency.upper() != "AUD"refuses smuggled currencies, adapter only reads the signed bytes; no body-swap/case/collision found in my variants. - qalx LP rounding — dust adds are refunded (minted ≤ 0), removes floor to zero without burning shares; ratio tolerance pays extra quote to the pool. Attacker loses dust on every cycle; no dust-steal found.
- qalid controller-sig binding —
control_bytescommits to doc sha256; reuse of another doc's sig fails. (Trust root is still the local registry — M9 stands.) - C2 (partial) — birth balances are now program-cap-bounded (still non-zero — finish the job: zero-balance registration).
UNDO LEDGER (campaign 2)
| Mutation | Undo | Verified |
|---|---|---|
| loopd: 9 rt2 agents (victim/swap/escrow/author/thief/sellers/buyers), topups 2,115c, 6 receipts | ledger/keys/handles/program/receipts surgically edited (backups in cleanup_backups/) |
✅ sold_cents 24,623,777 = baseline; zero rt2 residue |
| qalx pool p9276e0dc + swapper + coupon seed (100u) | pools/swappers/coupons cleaned; agent file moved to backups | ✅ GET /pools → [] |
| qalpub channel ch_67078b299901 + post + treasury ledger + postfees rows | channels/treasuries/postfees/posts cleaned | ✅ channels back to 2 pre-existing |
| relay rows rt2author (PoP-verified) + rt2tofu (unverified) | pubkeys.json edited | ✅ no rt2 handles |
| qalkeys kl_e10068e09c / kl_40c68d0b3d / kt_6a621445 + usage rows | keylistings/keytrades/usage cleaned | ✅ |
| qalpipe registry: forged ceremony anchor row | row removed | ✅ honest state restored |
| qalid v2carol--testlab record edit | restored from backup | ✅ REVOKED again |
| Caveat: loopd/qalx/qalpub/qalkeys/relay hold pre-cleanup state in RAM | any future mutating call flushes stale rows back. Operator: restart these five services to drop in-memory residue (no systemctl used per safety rules) | ⚠ documented |
| On-chain additive objects | 1 tampered-ceremony anchor tx (HRXs55…) + PoC test artifacts remain on devnet as inert data, documented here | by design |
Top mitigations to ship now
- Deployment, not just code: set
QALLOOPD_ADMIN_KEY(fail-closed), kill pid 1215081 (:8899), and land the still-pending loopd_lock+ proof-bound claim (C2-3/4/5/6 — four campaign-1 criticals are effectively unfixed). - qalx: consume receipts before any refund leg; make the error path
reserve-neutral; fix
reserves_before; stopescrow_backing_checkfrom bricking pools (C2-1, M2-1). - Kill string-identity authz on money paths: qalpub payout/roles/delete and qalkeys dispute must require a signature from the owner/buyer's anchored key or a loopd proof (C2-2, H2-1); require relay PoP after migration (H2-4).
PoC scripts + captures: 11_system_audit/redteam_20260911_poc/
(c4_race.py, qalpub_payout2.py, h8_dispute.py, captures/.json, cleanup_backups/).*