Just as a heads up, I’m going to be using the local instance to demonstrate the solve because at the time of publishing this writeup, the remote instance is no longer active.
Additionally, source code was provided for this challenge.
Upon visiting the website, we get greeted by a “Memory Card Game” page, which after looking at the source code for the site, works entirely on the front end and nothing happens when you complete the game, so I decided to scrap this.
Upon looking at the source code, I saw that there was an /admin
endpoint, and upon trying to visit it, I get a 401 Unauthorized
message.
After getting this message, I decided to look at the code dealing with the logic regarding authorization to the /admin
endpoint. It was located in the middleware.ts
file, and I felt like I had reached a deadlock. No matter how many times I prompted GPT for help and tried different strategies, I could not figure out a way to gain access to the endpoint.
1 | import { NextRequest, NextResponse } from 'next/server'; |
After an hour or two of trying to figure this out, one of my friends simply suggested I run the Snyk vulnerability scanner on the npm packages, so I did. Lo and behold, there is a CVE associated with bypassing NextJS middleware: CVE-2025-29927: Next.js Middleware Authorization Bypass.
For those unaware, middleware is a security mechanism implemented on websites that basically function as a middleman for requests made to certain endpoints. When making requests to /admin
for example, the middleware “intercepts” the request, makes the necessary checks in the HTTP headers for the appropriate session token & any other required security checks before forwarding the request to the endpoint if everything checks out.
As for the vulnerability mechanism, the exploit takes advantage of how Next.js processes the x-middleware-subrequest
header. “This header was originally intended for internal use within the Next.js framework to prevent infinite middleware execution loops. When a Next.js application uses middleware, the runMiddleware
function is called to process incoming requests. As part of its functionality, this function checks for the presence of the x-middleware-subrequest
header. If this header exists and contains a specific value, the middleware execution is skipped entirely, and the request is forwarded directly to its original destination via NextResponse.next()
“
1 | x-middleware-subrequest: src/middleware:src/middleware:src/middleware:src/middleware:src/middleware |
The CVE outlines multiple different payloads that could be used as the input data for the x-middleware-subrequest
header based on the Next.js version, but the one outlined above is the one that works for this application’s version of Next.js.
This is what the HTTP request would look like before we send it off to the /admin
endpoint using Burpsuite. As you can see, the x-middleware-subrequest
header was appended.
1 | GET /admin HTTP/1.1 |
After sending this GET
request, we successfully gain access to the /admin
endpoint. We’re greeted with a page that allows us to write JavaScript code that we can either execute server-side or client-side.
I didn’t bother messing with the Client Run functionality because running client-side JS code is worthless in any context where we would have access to run server-side code.
Anyways, I try running server-side JavaScript code and look at what I get as a response in Burpsuite, and I see this.
What this tells me is that each character in the input form is encoded into Unicode, and put into the codeArray
as seen in the POST
request to /admin
.
To delve further into this, I decided to look at the logic for this page to see how it works under the hood.
1 | 'use server'; |
This is the logic for the server-side JS execution. As you can see in the 2nd if statement, it checks for UTF-16 values in between 65 and 122, since the charCodes object uses UTF-16 encoding. If the values entered (in this case the characters from the server-side JS command you’re trying to run) fall in that range, it just throws an error saying “Invalid character detected,” essentially working as a filter to try & filter out malicious code.
After trying various methods that ended up ultimately not working, I simply googled “Unicode range bypass” and came across this article: Bypassing character blocklists with unicode overflows.
What this research essentially says is that since this JS object uses UTF-16 encoding, it can store data up to 16 bits, meaning that the maximum value is 65536. The attack, called “Unicode codepoint truncation” basically outlines that you can perform a buffer overflow on this maximum value, and if you were to store, say the number 65537, it would be treated as a 0, 65538 would be treated as a 1, and so on. This is our attack vector.
1 | "codeArray":"[97,108,101,114,116,40,49,41]" |
If we want to test code execution with “alert(1)”, instead of having our codeArray
look like the above, we offset each number in the array by 65536 to bypass the WAF. I wrote a quick Python script to generate the numbers with the required offset.
1 | OFFSET = 0x10000 |
Upon using this payload, we can see that our alert(1)
command was actually executed server-side, but we did get an error message saying it wasn’t defined. This confirms that the server-side command execution is working.
Now that I’ve figured this out, I decided to test a multitude of different commands to see if I can exfil the flag, but I ended up finding out that all outbound connections from this box are blocked, so I was stuck. I decided to look at the source code again & see if I could find anything that might help.
1 | export async function runOnServer(data: { codeArray: string }) { |
After reading this code ad nauseam, I noticed that the error message from the try-catch statement is returned & appended to the HTTP response when running commands. After finding this out, I thought maybe: if I read the contents of /flag.txt
, save it to a variable, and throw the variable as an error, it would give me the flag? I built a payload that would throw an error, encoded it to UTF-16 with the 0x10000
offset, and sent the payload off.
1 | throw new Error(process.mainModule.require('fs').readFileSync('/flag.txt','utf8')) |
Upon encoding this payload & sending the HTTP request, I finally received the flag back.