[Pwn] Pwnable · 작성: 2026-07-19 19:28:48 · 수정: 2026-07-21 23:10:24 · 조회 19
Format String 취약점을 "printf(buf)라고 쓰면 안 된다"는 규칙으로만 외우면, 왜 그게 문제인지, 그리고 왜 이 취약점 하나가 스택 버퍼 오버플로우보다 오히려 더 강력한 "임의 주소 읽기/쓰기" 원시 능력을 주는지를 놓치게 된다. 이 글은 printf의 가변 인자 처리 메커니즘 수준까지 내려가서 본다.
void vuln(char *user_input) {
printf(user_input); // 취약 — 포맷 문자열을 사용자가 통제
}
정상적인 사용은 printf("%s", user_input)처럼 포맷 문자열은 개발자가 고정하고, 사용자 입력은 인자로만 들어가야 한다. printf(user_input)은 사용자가 보낸 문자열 자체를 포맷 문자열로 취급한다는 게 문제다.
printf는 스택에서 "인자"를 어떻게 찾는가x86-64 호출 규약에서 printf의 첫 인자(포맷 문자열)는 RDI로 전달되지만, %d, %x, %s 같은 추가 포맷 지정자에 대응하는 "가변 인자"들은 관례상 다음 레지스터(RSI, RDX, RCX, R8, R9)와 그 이후는 스택에서 순서대로 가져온다. printf 함수 내부는 호출자가 실제로 몇 개의 인자를 넘겼는지 전혀 알지 못한다 — 오직 포맷 문자열 안의 % 개수만 보고 "이만큼의 인자가 있겠지"라고 가정하며 레지스터/스택을 순서대로 읽어나간다.
정상적인 printf("%d", x)라면 %d 하나에 맞춰 인자 하나(RSI)만 소비하지만, 공격자가 "%x.%x.%x.%x.%x.%x.%x"처럼 실제로는 존재하지 않는 인자를 요구하는 포맷 문자열을 넘기면, printf는 존재하지도 않는 인자를 달라며 레지스터를 다 소진한 뒤 스택을 계속 그 다음, 그 다음으로 읽어나간다 — 이 과정에서 호출자가 의도하지 않았던 스택/레지스터의 다른 데이터(다른 지역 변수, 저장된 포인터, 심지어 카나리 값)가 그대로 출력된다.
여기서 결정적인 지정자가 %n이다 — %n은 값을 출력하는 대신 **"지금까지 출력한 총 글자 수를, 해당 인자가 가리키는 주소에 써넣어라"**는 지정자다. 즉 %x로 스택을 읽어나가다가, 그 스택 어딘가에 마침 공격자가 원하는 주소값이 있게 만들어두고 그 지점에서 %n을 쓰면, printf가 임의의 주소에 임의의 값을 그대로 써버리는 것이다 — 이게 Format String 취약점이 단순 정보 유출을 넘어 임의 쓰기(Arbitrary Write) 원시 능력까지 주는 이유다.
"%x.%x.%x.%x.%x.%x" 같은 페이로드로 스택을 순서대로 훑어, 원하는 값(예: 스택 카나리, libc 주소, 저장된 포인터)이 몇 번째 인자 위치에서 나오는지 찾아냄 — 이 오프셋을 알아내는 게 첫 단계%N$n(N번째 인자 위치를 직접 지정하는 GNU 확장 문법)을 이용해, 1번에서 찾은 오프셋에 있는 인자가 바로 그 대상 주소가 되도록 페이로드를 구성%c/%x로 원하는 만큼의 "출력된 글자 수"를 정밀하게 맞춘 뒤 %n을 실행 — printf가 그 글자 수 값을 대상 주소에 그대로 써넣음exit나 printf 자체의 엔트리를 system 주소나 셸코드 주소로 덮어쓰면, 그 함수가 다음에 호출되는 순간 실행 흐름이 바뀐다%x) 능력만으로도 ASLR과 정보 유출 글에서 다루는 것처럼 스택 카나리 값이나 libc 베이스 주소를 그대로 유출시켜, 이후 스택 버퍼 오버플로우 공격에서 카나리를 정확히 맞춰 우회하거나 ROP 체인의 gadget 주소를 계산하는 데 그대로 쓸 수 있다%n의 임의 쓰기 능력을 Full RELRO 환경에서의 GOT Overwrite 글에서 다루는 대상(GOT 엔트리)에 바로 적용하면, 별도의 버퍼 오버플로우 없이 Format String 취약점 단독으로도 함수 포인터 하이재킹이 가능하다 — 오버플로우형 취약점보다 오히려 더 유연한 임의 쓰기 원시 능력이라는 평가를 받는 이유다printf("%s", input)처럼 포맷 문자열을 절대 사용자 입력에서 가져오지 않는 것 — 컴파일러도 -Wformat -Wformat-security 플래그로 이런 위험한 호출을 경고해준다_FORTIFY_SOURCE=2) 같은 컴파일 타임 보호가 일부 위험한 패턴(쓰기 가능한 세그먼트를 가리키는 %n 등)을 런타임에 탐지해 프로세스를 종료시키기도 한다