드림핵 - Format String Bug

HoHk☔️🐁·2026년 2월 15일

Dreamhack SYS

목록 보기
1/5

포맷 스트링 버그 정리


3줄 요약

  1. printf(user_input)는 사용자가 포맷 스트링을 조종해서 스택과 레지스터를 읽을 수 있는 구조다
  2. %n$...n은 글자 개수가 아니라 printf가 참조하는 인자 슬롯 인덱스다
  3. 오프셋은 계산으로 깔끔히 안 떨어지는 경우가 많고 %p 스캔으로 실측하는 게 제일 안정적이다

1. 포맷 스트링이 뭔지

포맷 스트링은 printf 계열 함수가 문자열을 출력할 때 해석하는 규칙이다

대표적으로 이런 패턴이 있다

printf("num=%d\n", x);

여기서 %d 같은 토큰이 specifier다
printf는 specifier를 만나면 그에 맞는 인자를 꺼내서 출력한다


2. 포맷 스트링 문법에서 진짜 중요한 두 개

전체 문법은 복잡해도 FSB에서 중요한 건 두 개만 잡으면 된다

2.1 parameter

%n$... 형태에서 n이 parameter다
이건 참조할 인자의 인덱스를 의미한다

예를 들어 아래 코드는 인자 순서를 바꿔서 출력한다

// Name: fs_param.c
// Compile: gcc -o fs_param fs_param.c
#include <stdio.h>

int main() {
  printf("%2$d, %1$d\n", 2, 1);
  return 0;
}

중요한 포인트는 여기다
parameter 값이 전달된 인자 개수 범위를 넘어가도 printf가 막아주지 않는다는 점이다
인자가 1개뿐이어도 %20$p 같은 걸로 스택 어딘가를 억지로 읽게 만들 수 있다

2.2 specifier

FSB에서 제일 많이 쓰는 specifier는 이 네 개다

specifier의미역할
%p포인터 출력오프셋 찾기, 스택 스캔
%s포인터가 가리키는 문자열 출력임의 주소 읽기 AAR
%n출력된 글자 수를 메모리에 기록임의 주소 쓰기 AAW
%hn %hhn2바이트 1바이트만 기록큰 값 쪼개서 쓰기

3. 왜 인자를 안 줬는데도 값이 출력되냐

취약 코드의 정석은 이거다

scanf("%s", format);
printf(format);

printf는 포맷을 해석하면서 추가 인자를 읽으려고 한다
근데 호출자가 인자를 안 줬어도 포맷이 요구하면 그냥 읽어버린다
표준 관점에서는 정의되지 않은 동작인데 실습 환경에서는 보통 값이 그대로 새어나온다


4. 레지스터와 스택 읽기

4.1 예제 코드

// Name: fsb_stack_read.c
// Compile: gcc -o fsb_stack_read fsb_stack_read.c
#include <stdio.h>

int main() {
  char format[0x100];
  printf("Format: ");
  scanf("%s", format);
  printf(format);
  return 0;
}

입력으로 %p를 여러 개 던지면 값이 줄줄 나온다

$ ./fsb_stack_read
Format: %p.%p.%p.%p.%p.%p.%p.%p
0xa.(nil).0x7f....(nil).0x55....0x7025....0x2520....0x2070....

이걸 보고 처음엔 진짜 어이가 없다
인자를 안 줬는데 왜 나오냐
답은 printf가 인자 개수를 제대로 확인해주지 않고 인자 슬롯을 계속 참조하려 하기 때문이다


5. x86 64에서 7번째부터 스택이라는 말의 의미

리눅스 x86 64 SysV 기준으로 포인터와 정수 인자는 이렇게 전달된다

인자 번호전달 위치
1rdi
2rsi
3rdx
4rcx
5r8
6r9
7부터스택

그래서 %7$... 같은 말이 자주 나온다
다만 여기서 착각하면 안 되는 게 있다

%7$...의 7은
내가 입력한 문자열 길이랑 아무 상관이 없다
오직 인자 슬롯 인덱스다


6. 임의 주소 읽기 AAR

%s는 포인터 따라가서 문자열을 찍는다
그래서 스택 어디든 포인터 값이 있으면 그걸 따라가서 읽을 수 있다

6.1 스택에 이미 포인터가 있을 때

// Name: fsb_aar_example.c
// Compile: gcc -o fsb_aar_example fsb_aar_example.c
#include <stdio.h>

char *secret = "THIS IS SECRET";

int main() {
  char *addr = secret;
  char format[0x100];

  printf("Format: ");
  scanf("%s", format);
  printf(format);
  return 0;
}

addr가 스택에 들어있고
그 슬롯이 %n$s에 걸리면 secret 문자열이 출력된다

여기서 내가 헷갈렸던 지점이 있다
main 디스어셈에서 addr이 rsp+8처럼 보이는데
왜 어떤 예제는 %7$s고 어떤 예제는 %8$s거나 %10$s

결론은 간단하다
오프셋은 환경과 호출 시점에 따라 달라질 수 있고
결국 실측으로 확정하는 게 안전하다

6.2 입력 버퍼에 주소를 심어서 읽기

스택에 우연히 포인터가 있을 필요 없이
내가 원하는 주소를 입력 버퍼 끝에 8바이트로 붙여서 심는 방식도 있다

// Name: fsb_aar.c
// Compile: gcc -o fsb_aar fsb_aar.c
#include <stdio.h>

const char *secret = "THIS IS SECRET";

int main() {
  char format[0x100];

  printf("Address of `secret`: %p\n", secret);
  printf("Format: ");
  scanf("%s", format);
  printf(format);

  return 0;
}

이 구조에서는 포맷스트링 뒤에 secret 주소를 붙이고
%n$s로 그 슬롯을 포인터로 해석하게 만들면 된다

패딩으로 aaaa를 붙이는 이유도 여기서 나온다
주소 8바이트가 qword 경계에 딱 올라가게 길이를 맞추는 용도다
출력에서 어디까지가 문자열 결과인지 구분하는 표식 역할도 한다


7. 임의 주소 쓰기 AAW

%n은 출력한 글자 수를 메모리에 기록한다
이게 곧 쓰기 primitive다

7.1 기본 예제

// Name: fsb_aaw.c
// Compile: gcc -o fsb_aaw fsb_aaw.c
#include <stdio.h>

int secret;

int main() {
  char format[0x100];

  printf("Address of `secret`: %p\n", &secret);
  printf("Format: ");
  scanf("%s", format);
  printf(format);

  printf("Secret: %d\n", secret);
  return 0;
}

원리는 이거다
1 %31337c로 출력 글자 수를 31337로 만든다
2 %n으로 그 값을 secret에 써버린다

실제로는 %n을 바로 쓰기보다 %hn이나 %hhn을 자주 쓴다
큰 값을 한 번에 만들기 부담스럽기 때문이다

7.2 2바이트 1바이트로 쪼개기

포맷쓰는 크기특징
%n보통 4바이트한 방에 쓰기, 출력 길이 커질 수 있음
%hn2바이트0에서 65535 범위, 실전에서 많이 씀
%hhn1바이트0에서 255 범위, 바이트 단위로 제어

8. 0xdeadbeef 같은 값 쓰기 흐름

바이트 단위 %hhn로 쓰는 게 가장 직관적이다
중요한 건 출력 글자 수가 누적이라서 보통 오름차순으로 맞춘다는 점이다

0xdeadbeef를 바이트로 보면
ad be de ef 순서로 출력량을 맞춰가며 써서 안정적으로 맞춘다

이때 주소도 4개가 필요하다
secret
secret+1
secret+2
secret+3
이걸 입력 끝에 8바이트씩 연속으로 붙인다

그리고 %14$hhn %15$hhn 같은 인덱스가 나오는데
이 숫자 자체가 핵심은 아니다
오프셋을 실측해서 맞추는 게 핵심이다


9. 내가 제일 오래 막힌 부분 오프셋 실측

여기가 FSB의 심장이다

결론부터 말하면
오프셋은 계산으로 끝내려 하지 말고 실측하는 게 마음 편하다

9.1 실측 루틴

1 입력 끝에 마커 8바이트를 붙인다
예를 들어 0x4141414142424242 같은 값이다

2 %1$p부터 %K$p까지 찍는다
공백은 입력 함수가 끊을 수 있으니 구분자는 점이 편하다

예시

%1$p.%2$p.%3$p.%4$p.%5$p.%6$p.%7$p.%8$p.%9$p.%10$p.%11$p.%12$p.%13$p.%14$p.%15$p.%16$p

3 출력에서 마커가 찍히는 번호가 곧 오프셋이다
그 번호가 11이면
%11$s로 읽고
%11$n %11$hn %11$hhn로 쓸 수 있다

9.2 왜 어떤 환경은 10이고 어떤 환경은 14냐

이건 흔한 착각 포인트다
main에서 본 rsp 오프셋이랑
printf가 참조하는 인자 슬롯 인덱스는 1대1로 고정되지 않는 경우가 많다

컴파일 옵션
스택 정렬
호출 시점
입력 함수
이런 변수들이 섞인다

그래서 실측이 정석이다


10. 패딩 A 7개 같은 디테일이 왜 나오냐

이건 스택 정렬 그 자체보다
주소 8바이트가 시작하는 위치를 qword 경계로 맞추기 위한 길이 조절이다

예를 들어 %64c%10$n이 9바이트면
주소를 16바이트 경계에 시작시키고 싶다
그래서 7바이트 패딩을 넣어 총 16바이트로 만든다

이러면 뒤에 붙은 주소가 8바이트 단위로 깔끔하게 읽힌다


11. 핵심 정리 표

목표쓰는 포맷준비물
오프셋 찾기%n$p 스캔마커 8바이트
임의 읽기%n$s포인터가 스택에 있거나 주소를 입력 끝에 심기
임의 쓰기%n$n %n$hn %n$hhn타겟 주소를 입력 끝에 심기, 출력량 조절

마치며

FSB는 한 번 감 잡으면 꽤 단순해진다
근데 감 잡기 전까지는 오프셋 때문에 계속 발목 잡힌다

내 결론은 이거다
오프셋은 실측 루틴을 손에 익히는 게 제일 중요하다
%p 스캔 + 마커만 제대로 하면
왜 10인지 11인지 14인지로 더 이상 시간 안 날리게 된다

profile
nyo님 좋아합니다!

0개의 댓글