포맷 스트링 공격: 서식 지정자 취약점의 메모리 읽기와 쓰기
C/C++ 포맷 스트링 공격의 발생 원리와 %n 지정자를 통한 메모리 변조, 코드 작성 및 컴파일러 방어 방법을 정리한다.
2026-08-14 · 최초 발행 2025-06-15
사용자 입력이 포맷 문자열이 될 때
포맷 스트링 공격은 C/C++의 printf(), sprintf(), fprintf()처럼 서식 지정자를 처리하는 함수에서 발생한다. 사용자 입력을 검증 없이 포맷 문자열 위치에 전달하면, 출력 함수는 입력을 단순한 문자열이 아니라 명령처럼 해석한다. 그 결과 메모리 내용이 노출되거나 임의 코드 실행으로 이어질 수 있다.
이 문제의 핵심은 문자열 자체가 아니라, %로 시작하는 서식 지정자가 함수의 가변 인자 처리 방식과 결합하는 데 있다.
서식 지정자는 출력 형식을 넘어선다
C/C++에서 서식 지정자는 출력할 값의 형식을 결정한다.
printf("정수: %d, 문자열: %s, 16진수: %x", 10, "Hello", 255);
// 출력: 정수: 10, 문자열: Hello, 16진수: ff
대표적인 지정자는 다음과 같다.
%d: 정수%s: 문자열%x: 16진수%p: 메모리 주소%n: 지금까지 출력된 바이트 수를 인자로 전달된 변수에 저장하는 특수 지정자
사용자 입력을 값으로 전달하는 코드는 안전하지만, 그 입력 자체를 서식 문자열로 쓰면 위험해진다.
// 안전한 코드
printf("%s", user_input);
// 취약한 코드
printf(user_input); // user_input이 직접 포맷 문자열로 사용됨
예를 들어 사용자가 "%x %x %x"를 입력하면 printf는 이를 포맷 문자열로 처리하고 스택에서 3개의 값을 가져와 16진수로 출력한다.
가변 인자와 스택 읽기가 만나는 지점
포맷 스트링 공격은 스택 기반 호출 규약(calling convention)과 가변 인자(variable arguments)의 특성을 이용한다. printf 계열 함수는 포맷 문자열에 적힌 지정자에 따라 스택에서 인자를 찾는다.
높은 주소
+----------------+
| 이전 함수 데이터 |
+----------------+
| 반환 주소 |
+----------------+
| 이전 EBP |
+----------------+
| 지역 변수들 |
+----------------+
| 포맷 문자열 주소 |
+----------------+
| (가변 인자들) |
+----------------+
낮은 주소
지정자 수에 맞는 인자가 제공되지 않아도 함수는 스택에서 다음 값을 읽으려 한다. 이 동작을 이용하면 공격자는 의도하지 않은 스택 데이터를 출력하게 만들 수 있다.
%x로 스택 값 노출하기
"%08x.%08x.%08x" 같은 입력은 스택의 3개 값을 16진수 형태로 출력한다.
char buffer[100];
scanf("%s", buffer);
printf(buffer); // 취약점
입력: %08x.%08x.%08x
출력: bffff394.080486a2.00000000 (스택의 실제 값)
이 방식은 메모리 내용 탐색의 출발점이 된다.
%s로 특정 주소의 문자열 읽기
%s는 인자로 전달된 값을 주소로 해석하고, 해당 위치의 내용을 문자열로 출력한다.
입력: AAAA%s
여기서 AAAA는 0x41414141 주소로 해석된다. 주소가 유효하다면 그 위치의 데이터가 문자열로 출력되어 중요 정보가 노출될 수 있다.
%n이 만드는 메모리 쓰기 위험
%n은 지금까지 출력한 문자 수를 인자로 받은 주소에 기록한다.
int count;
printf("Hello%n", &count); // count = 5 (Hello의 글자 수)
공격자는 이 특성을 이용해 임의 주소에 원하는 값을 쓰려 할 수 있다.
입력: \x41\x42\x43\x44%150x%n
이 입력은 0x44434241 주소에 154(ABCD의 4바이트 + %150x의 150바이트)를 쓴다.
입력이 정보 유출과 메모리 변조로 이어지는 흐름
입력 버퍼 위치를 확인하는 방식
공격자는 여러 %x를 사용해 스택 구조를 확인할 수 있다.
입력: AAAA.%x.%x.%x.%x.%x
출력: AAAA.bffff394.080486a2.00000000.41414141.2e78252e
네 번째 값 41414141는 AAAA의 16진수 표현이다. 이를 통해 입력 버퍼가 스택의 어느 위치에 놓였는지 파악할 수 있다.
중요 데이터가 위치한 주소를 알고 있다면 주소와 %s를 조합해 내용을 읽으려 할 수 있다.
입력: \x10\x01\x48\x08%s
이는 주소 0x08480110에 있는 문자열을 출력한다.
GOT(Global Offset Table) 항목을 덮어써 함수 호출 대상을 바꾸는 공격도 가능하다.
// printf의 GOT 주소가 0x08049714라고 가정
입력: \x14\x97\x04\x08%33960x%n
이 입력은 printf의 GOT 항목을 덮어써 다음 printf 호출 시 공격자의 셸코드로 점프하게 한다.
코드와 빌드 설정에서 차단하기
가장 먼저 지켜야 할 규칙은 사용자 입력을 포맷 문자열로 직접 전달하지 않는 것이다.
// 취약한 코드
printf(user_input);
// 안전한 코드
printf("%s", user_input);
컴파일러 경고도 함께 활성화할 수 있다.
gcc -Wformat -Werror=format-security source.c
입력에 서식 지정자가 포함되는 것을 거부하는 검증 로직도 방어층이 된다.
if (strchr(user_input, '%') != NULL) {
fprintf(stderr, "포맷 문자열 공격 시도 감지\n");
exit(1);
}
ASLR(Address Space Layout Randomization)은 중요 주소 예측을 어렵게 하고, NX(No-Execute) 비트 및 DEP(Data Execution Prevention)는 데이터 영역의 셸코드 실행을 차단한다.
웹·임베디드·레거시 코드에서의 점검 대상
일부 스크립트 언어도 C와 유사한 포맷 함수를 제공하므로, 웹 애플리케이션에서도 사용자 입력이 포맷 문자열로 쓰이는지 확인해야 한다.
// 취약한 PHP 코드
printf($user_input); // 사용자 입력이 직접 포맷 문자열로 사용됨
임베디드 시스템은 메모리 제약 때문에 포맷 스트링 함수를 자주 사용하고, 보안 업데이트가 어려워 취약점이 오래 남을 수 있다. 오래된 코드베이스 역시 이런 패턴을 포함할 가능성이 높다.
포맷 스트링 취약점은 단순한 프로그래밍 실수에서 시작하지만 메모리 읽기와 임의 코드 실행까지 이어질 수 있다. C/C++ 코드에서는 명시적 포맷 문자열을 사용하고, 코드 리뷰에서는 사용자 입력이 printf 계열 함수의 첫 번째 인자로 전달되는 패턴을 집중적으로 확인해야 한다. ASLR과 NX 비트 같은 보호 메커니즘이 있어도, 기본적인 코딩 규칙을 지키는 일이 방어의 출발점이다.