QALS Wiki · the qalarc network⌂ qalarc.com/projects/qals
generated 2026-09-16 · qalcode autonomous research

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

C2 — Unauthenticated /agents registration with arbitrary balance_cents and cap

C3 — Negative actual_cents in /settle inflates balances (infinite money)

C4 — Concurrent-hold race: holds/settle/release/topup bypass _lock (double-spend)

HIGH

H1 — Chain JSON-RPC :9000 bound to 0.0.0.0 — reachable from the whole fleet

H2 — qalpipe: local registry rewrite defeats tamper-evidence (TAMPERED → PASS)

H3 — qalchat relay: zero auth → TOFU key hijack, ciphertext theft, message/file deletion

H4 — qmarket: free buyer agents + self-buy reputation farming (wash trades)

H5 — qalbazaar/qalpub: unauthenticated listing/offer creation AS any seller

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

C6 — qalkeys SSRF: seller-chosen upstream_url + POST-forwarding proxy with response exfil

HIGH (round 2)

H6 — Double-settle race on the SAME hold releases ghost value twice

H7 — qmarket disputes: anonymous, unauthenticated, auto-slash booby trap

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

HIGH (round 3)

H8 — qalkeys mode-2 dispute: zero-credential third party robs the seller

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)

Controls that HELD (round 1)

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

  1. loopd: _lock around 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).
  2. 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).
  3. qalkeys + qmarket: block private/loopback/fleet upstream_url AND adapter_url targets (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.