Skip to main content

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 주소를 릭할 수 있다.

메모리 읽기 (임의 주소)

특정 주소의 값을 읽고 싶으면:

  1. 포맷 문자열 버퍼가 스택에 있는 위치(오프셋)를 먼저 찾는다
  2. 읽고 싶은 주소를 버퍼 앞에 넣고 %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에서 접근 순서

  1. 취약점 확인printf(buf) 패턴 찾기, checksec으로 보호 기법 확인
  2. 오프셋 찾기AAAA%1$x, %2$x... 반복
  3. — 카나리, libc base, 스택 주소 등 필요한 거 먼저 빼오기
  4. 쓰기 — GOT 오버라이트 또는 ret addr 덮기
  5. 익스플로잇 — 셸 or 플래그

방어

  • printf(buf) 절대 쓰지 말 것 → printf("%s", buf)
  • 컴파일러 경고 -Wformat -Wformat-security 켜기
  • FORTIFY_SOURCE 활성화 (-D_FORTIFY_SOURCE=2)

현대 바이너리에선 ASLR + PIE + RELRO 조합으로 익스가 복잡해졌지만, 릭 하나만 있으면 여전히 뚫린다. CTF Pwn 카테고리에서 medium 난이도면 십중팔구 이 조합이다.

연습 추천

  • pwnable.krfd, passcode 문제
  • pwn.college — Format String 모듈
  • picoCTF — 아카이브에 포맷 스트링 문제 다수

오래된 취약점이지만 지금도 임베디드 기기나 레거시 코드에서 종종 등장한다. 기본기로 잡아두면 손해 없다.