The attack campaign: 21 findings, criticals fixed same-day · 11_system_audit/REDTEAM_FINDINGS_2026-09-10.md
REDTEAM FINDINGS — QALS live-stack audit, 2026-09-10 (rounds 1–3)
Mode: findings-only, reversible PoCs only. Every mutation was undone (see §UNDO ledger). Scope: loopd :8823, relay :8830, qmarket :8824, qalpub :8832, bazaar :8834, redeem :8835, claim :8836, bridge :8837, qalid :8838, qalpay :8839, chain :9000, fleet relay 100.111.199.12:8840, qalkeys :8841 (round 2), static server :8899 (round 2), qalsh/qalmcp/wallet-app/deploy (round 3). Round-1 pre-state: loopd 80 agents, sold 6,769,700 → byte-restored. Round-2/3: surgical cleanup of 17 test agents + handle rows; program sold_cents corrected by exactly my topups.
Counts: 7 CRITICAL · 8 HIGH · 16 MEDIUM · 7 LOW (38 total).
VERDICT: the stack is money-printable today. A single unauthenticated HTTP call to loopd mints arbitrary cents; a second call drains any agent; a third steals any unclaimed wallet. Round 2 added a plain-HTTP file server exposing EVERY key in the system (incl. the chain wallet keystore) and an SSRF proxy in qalkeys that exfiltrated a loopd secret through its own response. Round 3 robbed a qalkeys seller with a zero-credential dispute. Fix loopd and :8899 first.
CRITICAL
C1 — Unauthenticated /topup: arbitrary mint + arbitrary drain of ANY agent
- PoC:
POST :8823/topup {"agent_id": X, "cents": 1234567}— no auth header of any kind. Then{"cents": -10000}on a second agent. - Result: attacker agent balance +1,234,567c (program
sold_cents6,769,700 → 8,004,267); victim agent drained 10,000c → 0. Both without any credential. (/report— see M5 — hands over every agent_id to target.) - Impact: total money supply compromise; up to the 50B-cent program cap mintable; any account in the economy can be emptied. Also corrupts the finite-mint program accounting.
- Fix: require an admin HMAC or PSP-callback signature on
/topup; rejectcents <= 0from callers; move program-cap accounting server-side only. - UNDO: full ledger/program/nfp/receipts restore + service restart (done).
C2 — Unauthenticated /agents registration with arbitrary balance_cents and cap
- PoC:
POST :8823/agents {"name":"REDTEAM-attacker","cap_cents_per_day":500000,"balance_cents":500000} - Result: agent created holding 500,000c (AU$5,000) at birth, daily cap AU$5,000, HMAC
secret returned to caller. (qmarket's own
buyer_agent()depends on this — H4.) - Impact: money printing via identity creation; no KYC/limit boundary is enforced anywhere.
- Fix: registration must create zero-balance agents only; balances move exclusively via authed top-up/settle/transfer; caps bounded by policy server-side.
- UNDO: ledger restore (done).
C3 — Negative actual_cents in /settle inflates balances (infinite money)
- PoC: hold 100c →
POST /settle {"actual_cents": -500000}(validly HMAC-signed).loopd.py:148doesactual = min(int(actual_cents), h["amount_cents"])with no lower bound;released = amount − actualthen adds 500,114c to balance, andspent_centsgoes negative, increasing daily-cap headroom. - Result: balance 1,734,567 → 2,234,681 in one call from a 100c hold. Repeatable to unlimited supply. Every downstream consumer (redeem, market, claim, pay) inherits this.
- Impact: money-printable — worst class. A malicious merchant/caller with any valid key prints balance at will.
- Fix:
actual = max(0, min(int(actual_cents), h["amount_cents"])); reject negative receipts; add a settle-vs-hold invariant test. - UNDO: ledger + receipts restore (done).
C4 — Concurrent-hold race: holds/settle/release/topup bypass _lock (double-spend)
- PoC:
11_system_audit/redteam_20260910_poc/p2_race.py— 10 threads racePOST /hold(5,000c est.) against a 10,000c balance.Ledger.hold()(loopd.py:114) checks balance then appends the hold with no lock (onlytransfer()takes_lock). - Result: 10/10 holds placed — open_holds 57,500c vs 10,000c balance (5.75× over-commit).
After settling all: balance 10,000 → 13,750 (+3,750c created) and 25,000c of daily cap
burned — daily-cap bypass confirmed. Bonus defect: ~half of concurrent responses were
dropped (
RemoteDisconnected) while their state changes still committed — non-idempotent mutation on connection loss. - Impact: classic double-spend; combined with C1/C3 the cap is the last intact limit, and this breaks that too.
- Fix: wrap every
Ledgermutator inwith _lock:; return errors atomically; make requests idempotent byhold_id-style keys. - UNDO: ledger restore (done).
HIGH
H1 — Chain JSON-RPC :9000 bound to 0.0.0.0 — reachable from the whole fleet
- PoC:
curl -X POST http://100.73.134.20:9000/ … iota_getChainIdentifier→{"result":"5a110da6"}. Every other service binds 127.0.0.1; the chain (the trust root: anchors, provenance, future value rail) is the one exposed to all of Tailscale. Devnet gas keys sit inqalnet/devnet/neton disk; a remote peer that can read a shared filesystem can anchor/spam as this node. - Fix: bind 127.0.0.1 (or ::1); if fleet access is needed, front it with an authed proxy and Tailscale ACLs; rotate devnet keys.
- UNDO: read-only PoC; nothing to revert.
H2 — qalpipe: local registry rewrite defeats tamper-evidence (TAMPERED → PASS)
- PoC:
p3_registry.py— anchor evidence → tamper it (honest registry correctly says TAMPERED) → anchor the tampered bytes as a new object → rewriteqalpipe/anchors.jsonlobject_id →verify_file()now returns PASS for tampered content. - Impact:
verify_fileis the dispute/audit oracle for qalredeem/qalpub/qalbazaar/qalsh. Anyone with write access to the registry file (it's world-writable by the same user) launders any tampering past verification. The chain still holds both hashes, but no consumer looks. - Fix: verify against the
object_id/txcarried in the artifact itself (receipt files) or enumerate chain anchors byuri; sign registry rows; treat anchors.jsonl as cache only. - UNDO: anchors.jsonl restored to pre-audit state; the two PoC anchor objects remain on-chain as inert data (additive, documented here).
H3 — qalchat relay: zero auth → TOFU key hijack, ciphertext theft, message/file deletion
- PoC (local :8830 and FLEET 100.111.199.12:8840):
1.
POST /pubkeys/rt00tofuwith attacker keys → accepted, no ownership proof. Directory now serves attacker keys to every sender (sender-side invite pins are the only defense, and they're bypassed for first-contact). 2.GET /envelopes/<handle>unauthenticated → full ciphertext + metadata (who/when/size) for any handle, local and fleet. 3.DELETE /envelopes/<h>/<id>andDELETE /files/<fid>unauthenticated → message/file destruction (demonstrated on my own canary only). - Impact: pre-registration of a victim's handle on any relay = MITM of first contact (attacker CAN decrypt); silent queue wiping; fleet relay is reachable cross-machine.
- Fix: require envelopes/acks signed by the
to_handlekey; pubkey registration requires a signed challenge (or invite-code) per handle; rate-limit enumerations. - UNDO: local canary envelope deleted;
rt00tofuremoved from local relay state + service restarted (verified unknown). Residue:rt00fleetrow on the FLEET relay — no delete API exists; remove by editingqalchat/data/relay_state/pubkeys.jsonon 100.111.199.12 (clearly namedrt00fleet).
H4 — qmarket: free buyer agents + self-buy reputation farming (wash trades)
- PoC:
POST :8824/providers(open) →POST :8824/jobs {"buyer":"rt-wash-fresh-1", …}.buyer_agent()mints a 200,000c (AU$2,000) balance for the fresh buyer via C2, pays the job, provider (also attacker's) earns + rep. - Result: reputation 100 → 101, earnings 0 → 1, jobs 0 → 1 from one self-buy with zero external money. No payer≠payee lineage anywhere. Loop with fresh buyer names = unbounded reputation/earnings farming; rep gates DELISTING and dispute outcomes.
- Fix: buyers must pre-fund via authed rails; reputation increments require a payment whose source agent is not provider-controlled (lineage check / cooling-off / stake-weighted rep).
- UNDO: providers.json restored (REDTEAM provider gone), loopd ledger restored (fresh buyer agent gone). The jobs.jsonl row (append-only, marked rt-wash) remains as evidence.
H5 — qalbazaar/qalpub: unauthenticated listing/offer creation AS any seller
- PoC:
POST :8834/offers {"seller_handle":"mcp-bot", …}→ live offerof_5b9dacd729created in mcp-bot's name with attacker-controlled inline delivery content. - Impact: content-injection to buyers (attacker picks what gets delivered after escrow), reputation/brand griefing, fake-listing spam. Escrow pays the real seller's agent, so it's not direct theft — it's integrity and phishing.
- Fix: offers/listings must be signed by the seller's ed25519 key (qalid prove/verify is already built for this); verify signature server-side on create.
- UNDO: offers.json restored (verified gone).
MEDIUM
M1 — loopd HMAC has no replay protection
Captured signed request replays indefinitely. PoC: identical signed /transfer bytes sent
twice → two receipts (r_2506503a15, r_66e4efa1f3), two balance moves.
Fix: timestamp window + per-agent jti registry (qalclaim already does this — reuse it). UNDO: ledger restore.
M2 — Cross-platform secret reuse: one loopd secret is a valid credential everywhere
PoC: loopd api_secret passed qalredeem /redeem X-Sig verification (404 no-such-sku =
signature OK). qalredeem additionally requires the client's secret in the request body —
service compromise = drain of every customer. Same pattern in qalpub/qalbazaar intents.
Fix: per-service scoped keys with audience binding; replace secret-handoff with
challenge-proof (qalid prove/verify exists; qalclaim's oracle pattern works today).
M3 — Admin keys printed to logs on every restart
qalpay.py prints admin key : {PAY.admin_key} unconditionally at startup (journald
capture; key re-printed each restart when sourced from admin.json). qalclaim/qalredeem have
similar print-once paths but qalpay re-prints. Fix: show once, store 0600, never re-print;
log only the last 4 chars (the /platforms endpoint already does this correctly).
M4 — qalpay refunds not netted against credits
refund() checks amount ≤ original credit per call but never tracks cumulative refunds —
the same receipt refunds 100% N times; counters and anchored docs corrupt silently.
Fix: refunded_cents per credit; refuse amount > credit − refunded. (v1 is ledger-only,
so no balance theft — accounting integrity only.)
M5 — Unauthenticated data exposure surfaces
GET :8823/report (all 84 agents: names, balances, caps, agent_ids — the targeting feed for
C1), GET :8835/redemptions (22 full redemption records), relay envelope enumeration (H3).
Fix: auth or aggressive redaction (ids as HMACs, balances as ranges).
M6 — qalsh whitelist: argv prefix match admits attacker-controlled extra args
whitelist_match() (qalsh.py:312) allows anything after the pinned prefix. Device
qshdevf743aa whitelists ["python3","scripts/bigoutput.py"] — every script that acts on its
argv (paths, --config=) becomes an injection surface. G2/G4/G5 (no shell, minimal env,
output sanitization, persistent req_id replay registry) are genuinely good. No daemon was
running, so no live exec was attempted.
Fix: exact-arity matching + per-position arg patterns; prefer fixed scripts.
M7 — Payment verification scans loopd /receipts (last 50 rows)
qalpub _redeem and qalbazaar fulfil verify buyer receipts by scanning /receipts. An
attacker can push 50 transfers to evict a victim's receipt mid-purchase (purchase fails) or
during settlement (fail-closed, but griefing). Fix: receipts endpoint with signed
point-lookup by receipt_id (or ship the receipt in the request and verify its anchor).
M8 — qalpub _redeem check-then-act outside any lock
do_POST :8832 /listings/:id/unlock → _redeem() runs unlock-check → loopd receipt verify →
payout → key release with no _lock (qalpub.py:1301). Concurrent same-receipt unlocks
currently fail closed only because loopd's balance check rejects the second payout — one
rewrite of the escrow model away from double-issue. qalclaim's reserve-under-lock pattern
(status="claiming") is the in-repo reference fix.
LOW
L1 — qalbridge accepts the bearer token as ?t= query parameter
PoC: GET /files?t=<token> → 200. Tokens land in access logs, shell history, phone URL bars.
Fix: header/cookie only.
L2 — Vault lockout is client-side and file-cleared
vault.state.json holds fail counters; a local attacker deletes it to bypass the 5-try/60s
cooldown. Real control is PBKDF2-200k + AES-GCM at rest, which is sound; offline guessing
remains possible (as with any local vault). Fix: document as UX-only; optionally raise KDF
iters; consider hardware-backed keys.
L3 — qalpub presence beacons fully spoofable
POST /presence {"handle":"rt00spoof",…} accepted for arbitrary handles (typing indicators,
online status). TTL 90s auto-expires. Fix: require beacon signed by handle key.
L4 — Operational: dual supervision (systemd units vs manual nohup instances)
7 user units (qal-market, qal-redeem, qal-pub, qal-chain, qal-claim, qal-id,
qal-bazaar) were in activating/auto-restart before and during the audit because
manually-started processes hold their ports; the units crash-loop forever. This bit the audit
itself: killing loopd let the unit win the port and re-flush mutated state over a file restore.
Fix: one supervisor — disable units or stop manual instances; add a start-order +
port-binding smoke test.
CRITICAL (round 2)
C5 — Plain-HTTP static file server :8899 serves the project ROOT — every secret downloadable
- PoC:
python3 -m http.server 8899 --bind 127.0.0.1(pid 1215081) has cwd =<project root>.GET http://127.0.0.1:8899/loopd/data/keys.json→ HTTP 200, 10,616B JSON with 122 agent HMAC secrets. Also verified 200:qalredeem/data/admin_key.txt(catalog admin),qalbridge/data/.token(phone bridge bearer),qalkeys/data/master.key(unseals all upstream API credentials),qalbazaar/data/escrow_keys.json, and — worst —qalnet/devnet/net/iota.keystore(the chain wallet that pays for every anchor). - Impact: any local process (compromised npm package, LLM tool, malicious script) reads
the entire trust base without touching disk perms;
loopd/data/keys.jsonis additionally 0644. Chained with C6 (SSRF) or any future SSRF, this becomes remote. Whoever holds keys.json is every agent in the economy. - Fix: kill the static server or re-point it at
dist/only; never serve a tree that containsdata/; chmod 0600 all key files; add a startup check that refuses to run inside a directory containing*.keystore/keys.json. - UNDO: read-only PoCs; nothing reverted.
C6 — qalkeys SSRF: seller-chosen upstream_url + POST-forwarding proxy with response exfil
- PoC (
p15_ssrf.py+ inline): (1) listing created withupstream_url: http://127.0.0.1:8823(seller proof = any loopd secret, trivially obtained via C2); (2)POST /buymints aqk_key with no auth (buyer_handle is a bare string); (3) payer agent funded via unauth/topup(C1); (4)POST /v1/proxy/<lid>/agents/claimforwarded the request to loopd and returned the response — the handle'sapi_secretwas exfiltrated through qalkeys (qalkeys.py:969-995:up_url + suffix, body forwarded, upstream body returned). - Impact: internal-network read/write primitive (any localhost port, any Tailscale
fleet IP — including :8899 above) with response exfil, at 1¢/call, essentially
unauthenticated.
_proxyis POST-only (GET 404s), but POST reaches every mutating endpoint in the stack. - Fix: deny
upstream_urlhosts in {127.0.0.0/8, ::1, 100.64.0.0/10, RFC1918} unless operator-allowlisted; require org-level trust for listing creation; make/buyrequire a proof from the buyer_handle. - UNDO: my qalkeys instance stopped,
qalkeys/data/*restored from backup (REDTEAM listings verified absent); loopd residue surgically removed (see ledger).
HIGH (round 2)
H6 — Double-settle race on the SAME hold releases ghost value twice
- PoC:
p13_double_settle.py— one 114c hold, two concurrent/settlethreads.Ledger.settle()mutates balance before deleting the hold and holds no lock; the losing thread'sdelraises KeyError after its mutation. - Result: balance 1000 → 1014 (+14c) from one settle receipt; the second thread
got
RemoteDisconnectedyet its mutation committed. At 115% pre-auth margin this is +15% of any hold per iteration, repeatable. - Fix: same as C4 — one lock around all mutators; make settle idempotent per hold_id (second settle → 409, not double-credit).
- UNDO: agent removed surgically; receipts filtered.
H7 — qmarket disputes: anonymous, unauthenticated, auto-slash booby trap
- PoC:
POST :8824/dispute {"job_id": <real job of minirig-remote-2775741>, "reason": "REDTEAM competitor grief…"}— accepted from a non-buyer, no disputer identity recorded anywhere in the dispute record; verdict auto-computed (REJECTED here). - Impact: (a) anonymous dispute spam against competitors; (b) the auto-verdict flips
to UPHELD — 50% stake slash, reputation −25, delisting whenever
job["output"]is unreadable (findings.append("output unreadable")→upheld=True). Output files live in a growing OUTDIR — the first disk-cleanup/rotation turns every historic dispute into a slash. Verdicts are final (one dispute per job). - Fix: require the disputer to be the job's buyer (loopd proof); never auto-uphold on missing files (needs operator confirmation); record and rate-limit the disputer.
- UNDO: REDTEAM dispute row removed from
disputes.jsonl; provider rep/stake verified untouched (77 / 500c).
MEDIUM (round 2)
M9 — qalid identity takeover inherits the H2 registry bypass (code-audit)
chain_check() → qalpipe.verify_file(envelope) which trusts the local registry for the
object_id. A local writer can re-sign a victim handle's DID doc with their own key, anchor
the new envelope, rewrite the registry row, and /did/<victim> then reports
signature: valid, chain: PASS for the attacker's keys. Same fix as H2 (bind verification
to artifact-carried object_id / chain event scan).
M10 — qalpub channels trust root is the TOFU-hijackable relay keyserver
Post-create verifies the author's ed25519 sig against author_pubkey() from the relay —
which any attacker pre-registers (H3). Chain: relay TOFU → qalpub keypin TOFU → signed
channel impersonation of any author who hasn't posted yet (sig shows sig_ok: true for
the attacker's posts). Also do_DELETE reactions/pins are handle-based with no signature
(code comments admit "paper-edition trust"). Fix: verify against anchored qalid docs, not
the relay; sign social mutations.
M11 — Invites never expire and cannot be revoked
parse_invite refuses future-dated bundles and verifies signatures properly (solid), but
age_warnings() only warns on old invites and there is no revocation list — a leaked
invite is valid forever. Fix: hard expiry + rotation (re-issued invite supersedes).
M12 — DataAnchor's on-chain signature field is unused
qalpipe.anchor_file passes '""' for the sig arg — every anchor is anonymous. Anchors
bind bytes-to-time but not bytes-to-identity; disputes cannot attribute anchoring to a
party. Fix: pass the submitter's ed25519 sig and verify it in consumers.
LOW (round 2)
L5 — Permission/hygiene inconsistencies
qalkeys/data/keytrades.json + listings.json are 0644 while siblings are 0600;
test scripts hardcode QALBRIDGE_TOKEN=testtoken123 (test value — ensure prod token never
reuses it); qalsh workdirs/transcripts inherit umask.
L6 — loopd drops responses under concurrency while committing state
Observed in P2/P13: threads receive RemoteDisconnected yet their ledger mutations land.
Clients that retry "failed" requests will double-execute. Fix alongside C4 (lock +
idempotency), plus queue responses instead of concurrent wfile writes.
CRITICAL (round 3)
C7 — loopd /agents/claim: first unauthenticated caller steals any unclaimed wallet
- PoC (P18):
POST :8823/agents/ensure {"handle":"rt00future-victim"}(unauth) → topup 5,000c (C1) →POST /agents/claim {"handle":"rt00future-victim"}→ response contains the handle agent'sapi_secret. Zero proof of ownership; one-shot (first claimer wins). - Impact: any handle in the
claimed:falsestate — exactly the state qalpay onboarding, qalchat wallet setup, and qalkeyshandle_agent_id()leave behind — can be hijacked by anyone who can spell the handle. This is the payment-rail twin of the relay TOFU (H3), and it powered the P20 theft chain below. - Fix: claim must present a proof tied to handle creation (e.g., the ensure-call returns a one-time claim code to the creator's channel, or seed-bind from day one); at minimum bind claims to a qalid proof.
- UNDO: handle + agent removed surgically.
HIGH (round 3)
H8 — qalkeys mode-2 dispute: zero-credential third party robs the seller
- PoC (P20/P20b): seller lists a key (100c) → buyer pays from the handle agent (whose
secret was stolen via C7) → reveal returns the key → a third party — no credentials at
all — posts
POST /keys/<klid>/dispute {"buyer_handle": "rt00kbuyer2"}inside the cooldown window (1.5s in this deployment; raced with threads, 120ms offset). - Result:
{"status":"burned","refunded":true}— escrow refunds the buyer +100c, the buyer keeps the revealed key, the seller is never paid.buyer_handleis public inGET /keys;_key_disputeauthenticates the "buyer" as a bare string. Net: attacker-free robbery of every live sale, scriptable against all listings. - Fix: dispute must carry a proof from the buyer's key (revoke-oracle, like seller proofs); dispute should refund only with seller agreement after cooldown or operator review; never auto-refund on an unauthenticated claim.
- UNDO: qalkeys data restored from backup (REDTEAM listings verified absent); loopd residue surgically removed; sold_cents −2,000 corrected.
MEDIUM (round 3)
M13 — qalsh PT_PAID: payment acknowledgements are trust-on-receipt (audit forgery)
The device's serve loop marks an execlog row paid from the PT_PAID message content alone
(qalsh.py:625-630, execlog_mark_paid) — no verification against loopd receipts and no
sender==caller check. In-process proof (P19): marking a row with fabricated receipt ids
FAKE-r_000/FAKE-r_111 succeeds. Any pinned contact can forge payment provenance in the
append-only log used for disputes. Fix: device verifies the transfer receipt id via loopd
before marking paid.
M14 — Relay file chunks: any sender can overwrite another file's chunks (in-transit destruction)
PoC (P21): victim uploads 2-chunk file; attacker (different from_handle) re-uploads
seq 0 with different ciphertext → 200 OK, meta sha replaced, original destroyed —
receiver's GCM decrypt fails. Only cross-recipient collisions are refused; the chunk set is
never bound to its original sender. Combined with unauth DELETE (H3) this is full
store-and-forward integrity loss. Fix: bind chunks to the creating from_handle (refuse
mismatched sender on subsequent seq uploads).
M15 — qalbazaar _offer_fulfill repeats the qalpub lockless check-then-act (M8 class)
trade_by_receipt check → loopd receipt fetch → payout spans network calls with no _lock
around the critical section (locks only wrap file ops). Same fragile pattern as M8; same
fix (qalclaim-style reservation under lock).
M16 — qmarket adapter_url: second blind-SSRF primitive, response written to disk + anchored
PoC (P23): registered a provider with adapter_url: http://127.0.0.1:8830/pubkeys/rt00ssrfmark
(no validation at registration, qmarket.py:309) and self-bought one job. The gateway
POSTed to the internal relay endpoint — the TOFU pubkey row was created by the gateway's
request (verified via GET /pubkeys/rt00ssrfmark). remote_execute then wrote the
response body toward the job output (job 500'd only because loopd/relay responses lack
output_b64). With any target returning {ok, output_b64}, internal responses are
delivered to the buyer and anchored on-chain — exfiltration with chain laundering.
Same fix family as C6: deny private/loopback/fleet targets unless operator-pinned.
LOW (round 3)
L7 — qalid challenge issuance unauthenticated and unbounded
PoC (P22): 200/200 challenge issues accepted in a tight loop, no rate limit; the
in-memory _challenges dict grows unbounded within the 5-min TTL. RAM-only, self-expiring —
a light DoS note. Fix: cap concurrent challenges per handle/IP.
L8 — Anchor gas wallet is a single shared keystore (~30M IOTA devnet gas)
iota.keystore in the repo (readable via C5) pays for every anchor in the stack; draining
it halts provenance for qalredeem/qalpub/qalsh/qalkeys while services keep transacting.
Fix: per-service gas wallets with small balances + monitoring; rotate after any exposure.
Notes (no new findings)
qalmcp is wallet-by-design (identity file secret = spend power — M2 class); wallet-app
scripts and deploy/bbmini/install.sh are clean; interactive/qals_explorer.html is static.
Controls that HELD (round 2)
- qalkeys seller proof uses the loopd revoke-oracle (secret never stored) — the right pattern; the SSRF is in the upstream fetch, not the auth.
- qalkeys rate limiting (token bucket 10rps/burst 20) and 0600 default on most data files.
- qalpub post-create does verify ed25519 against something — the gap is the trust root (M10), not the check.
- Chain module:
burn/shareare owner-gated by Move object semantics; anchoring is permissionless by design.
Controls that HELD (round 1)
- qalbridge traversal: raw
../,..%2f, and symlink-out-of-root all refused (realpath prefix check is correct). Peer-injection in/transcriptis harmless (hash-derived convo id). - qalclaim: signed tokens verify with
compare_digest; nonce single-use; reservation under lock prevents double-pay; fail counters persist across restart. Best-built service in the stack. - qalpay webhooks: raw-body HMAC + double-checked idempotency registry under lock; replay and body-swap both refuse.
- qalsh G2/G5: metachar/C0 rejection and output sanitization are well conceived ( argv prefix caveat in M6).
- Fleet relay pinning: second pubkey registration with different keys → 409 (post-first).
- qalid: doc signature verified against chain-anchored envelope; TOFU resistance depends only on anchor honesty (see H1/H2 for the caveats).
- qalkeys mode-2 reveal (
_key_reveal): whole flow under_lockincluding the reveal-once check — the correct pattern qalpub's_redeem(M8) is missing; receipt binding (escrow agent + buyer + amount + memo) is complete modulo the last-50 window (M7).
UNDO LEDGER (every PoC reversed)
| # | Mutation | Undo action | Verified |
|---|---|---|---|
| C1/C2/C3/C4, M1 | loopd ledger/keys/handles/program/nfp/receipts mutated; 6 test agents | systemctl stop qal-loopd → restore 6 files from redteam_20260910_poc/backups/ → start |
✅ 80 agents, sold 6,769,700, no REDTEAM residue |
| H2 | anchors.jsonl rows added/rewritten; 2 on-chain anchor objects | registry restored from backup (chain objects are inert additive data) | ✅ tampered file verifies NOT_ANCHORED |
| H3 local | canary envelope + rt00tofu pubkey on :8830 |
envelope deleted via API; pubkey removed from relay state; service restarted under systemd | ✅ rt00tofu → unknown handle |
| H3 fleet | rt00fleet pubkey on 100.111.199.12:8840 |
NOT removable remotely (no delete API; remote host out of scope) — operator: edit pubkeys.json there |
⚠ residue documented |
| H4 | REDTEAM provider in qmarket; rt-wash buyer agent | providers.json restored; loopd restore removed buyer agent | ✅ verified |
| H5 | impersonated offer of_5b9dacd729 |
offers.json restored | ✅ verified gone |
| L3 | presence beacon rt00spoof |
none needed — 90s TTL expired | ✅ self-cleaned |
| P13/H6 | REDTEAM-dbl agent + double-settle receipt | surgical ledger/keys/receipts removal | ✅ residue: none |
| P14/H7 | anonymous dispute row on real job | dispute row removed from disputes.jsonl | ✅ provider rep/stake intact |
| P15/C6 | qalkeys REDTEAM listing, qk keys, gateway payers; my qalkeys instance (:8841, was NOT running pre-audit) | qalkeys stopped; qalkeys/data restored from backup; 10 loopd agents surgically removed; program sold_cents corrected by exactly 1,100c | ✅ verified |
| P16/C5 | — (read-only downloads) | none needed | n/a |
| P17/M6-note | — (in-process function test) | none needed | n/a |
| P18/C7 | ensured+claimed handle rt00future-victim + 5,000c topup |
handle/agent removed surgically; topup countered in program correction | ✅ residue: none |
| P19/M13 | — (in-process on /tmp copy of execlog) | none needed (real execlog untouched) | n/a |
| P20/H8 | 2 key listings, 2 buys, refund, seller/buyer/attacker agents, 2,000c topups | qalkeys data restored from r2 backup (internally-consistent snapshot; REDTEAM listings gone); 7 loopd agents + handles removed surgically; sold_cents −2,000 | ✅ verified (note: rolled the parallel test cycle's keylistings back one regeneration — same actor, consistent state) |
| P21/M14 | canary 2-chunk file on relay | file DELETEd via API | ✅ verified gone |
| P22/L7 | 200 qalid challenges | none needed — 5-min TTL self-expiry | ✅ self-cleaned |
| P23/M16 | REDTEAM-ssrfprov provider + 1 job + relay TOFU row rt00ssrfmark + rt-ssrf-buyer agent |
providers.json + jobs.jsonl restored; relay row removed + relay restarted (systemd); buyer agent surgically removed | ✅ verified (relay 8 handles, unknown handle; providers back to 2) |
| — | PoC scripts/evidence kept (required by rules) | 11_system_audit/redteam_20260910_poc/ (scripts + backups + JSON captures) |
by design |
Top mitigations to ship tomorrow
- loopd:
_lockaround all mutators + idempotent settle (409 on second settle of a hold) +actual = max(0, min(actual, hold))+ auth on/topup+ zero-balance registration + proof-bound/agents/claim(C1–C4, C7, H6 — ~50 lines total). - Kill or re-scope the :8899 static server and tighten file perms — it hands out the chain keystore, all loopd secrets, and every admin key over plain HTTP (C5, L8); then bind the chain RPC to 127.0.0.1 (H1).
- qalkeys + qmarket: block private/loopback/fleet
upstream_urlANDadapter_urltargets (C6, M16), authenticate/buy, require a buyer proof on disputes (H8); make qalpipe verification consume artifact-carried object_id instead of the local registry (H2, M9); sign marketplace supply and relay pubkey registration + bind file chunks to their sender (H3, H5, M10, M14).
PoC scripts: p2_race.py, p3_registry.py, p13_double_settle.py, p15_ssrf.py,
p20_keytheft.py, plus captured JSON in the same directory. Prior rounds archived at
11_system_audit/archive/REDTEAM_FINDINGS_2026-09-10_round{1,2}.md.