printf(user_input)는 사용자가 포맷 스트링을 조종해서 스택과 레지스터를 읽을 수 있는 구조다 %n$...의 n은 글자 개수가 아니라 printf가 참조하는 인자 슬롯 인덱스다 %p 스캔으로 실측하는 게 제일 안정적이다 포맷 스트링은 printf 계열 함수가 문자열을 출력할 때 해석하는 규칙이다
대표적으로 이런 패턴이 있다
printf("num=%d\n", x);
여기서 %d 같은 토큰이 specifier다
printf는 specifier를 만나면 그에 맞는 인자를 꺼내서 출력한다
전체 문법은 복잡해도 FSB에서 중요한 건 두 개만 잡으면 된다
%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 같은 걸로 스택 어딘가를 억지로 읽게 만들 수 있다
FSB에서 제일 많이 쓰는 specifier는 이 네 개다
| specifier | 의미 | 역할 |
|---|---|---|
%p | 포인터 출력 | 오프셋 찾기, 스택 스캔 |
%s | 포인터가 가리키는 문자열 출력 | 임의 주소 읽기 AAR |
%n | 출력된 글자 수를 메모리에 기록 | 임의 주소 쓰기 AAW |
%hn %hhn | 2바이트 1바이트만 기록 | 큰 값 쪼개서 쓰기 |
취약 코드의 정석은 이거다
scanf("%s", format);
printf(format);
printf는 포맷을 해석하면서 추가 인자를 읽으려고 한다
근데 호출자가 인자를 안 줬어도 포맷이 요구하면 그냥 읽어버린다
표준 관점에서는 정의되지 않은 동작인데 실습 환경에서는 보통 값이 그대로 새어나온다
// 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가 인자 개수를 제대로 확인해주지 않고 인자 슬롯을 계속 참조하려 하기 때문이다
리눅스 x86 64 SysV 기준으로 포인터와 정수 인자는 이렇게 전달된다
| 인자 번호 | 전달 위치 |
|---|---|
| 1 | rdi |
| 2 | rsi |
| 3 | rdx |
| 4 | rcx |
| 5 | r8 |
| 6 | r9 |
| 7부터 | 스택 |
그래서 %7$... 같은 말이 자주 나온다
다만 여기서 착각하면 안 되는 게 있다
%7$...의 7은
내가 입력한 문자열 길이랑 아무 상관이 없다
오직 인자 슬롯 인덱스다
%s는 포인터 따라가서 문자열을 찍는다
그래서 스택 어디든 포인터 값이 있으면 그걸 따라가서 읽을 수 있다
// 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냐
결론은 간단하다
오프셋은 환경과 호출 시점에 따라 달라질 수 있고
결국 실측으로 확정하는 게 안전하다
스택에 우연히 포인터가 있을 필요 없이
내가 원하는 주소를 입력 버퍼 끝에 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 경계에 딱 올라가게 길이를 맞추는 용도다
출력에서 어디까지가 문자열 결과인지 구분하는 표식 역할도 한다
%n은 출력한 글자 수를 메모리에 기록한다
이게 곧 쓰기 primitive다
// 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을 자주 쓴다
큰 값을 한 번에 만들기 부담스럽기 때문이다
| 포맷 | 쓰는 크기 | 특징 |
|---|---|---|
%n | 보통 4바이트 | 한 방에 쓰기, 출력 길이 커질 수 있음 |
%hn | 2바이트 | 0에서 65535 범위, 실전에서 많이 씀 |
%hhn | 1바이트 | 0에서 255 범위, 바이트 단위로 제어 |
바이트 단위 %hhn로 쓰는 게 가장 직관적이다
중요한 건 출력 글자 수가 누적이라서 보통 오름차순으로 맞춘다는 점이다
0xdeadbeef를 바이트로 보면
ad be de ef 순서로 출력량을 맞춰가며 써서 안정적으로 맞춘다
이때 주소도 4개가 필요하다
secret
secret+1
secret+2
secret+3
이걸 입력 끝에 8바이트씩 연속으로 붙인다
그리고 %14$hhn %15$hhn 같은 인덱스가 나오는데
이 숫자 자체가 핵심은 아니다
오프셋을 실측해서 맞추는 게 핵심이다
여기가 FSB의 심장이다
결론부터 말하면
오프셋은 계산으로 끝내려 하지 말고 실측하는 게 마음 편하다
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로 쓸 수 있다
이건 흔한 착각 포인트다
main에서 본 rsp 오프셋이랑
printf가 참조하는 인자 슬롯 인덱스는 1대1로 고정되지 않는 경우가 많다
컴파일 옵션
스택 정렬
호출 시점
입력 함수
이런 변수들이 섞인다
그래서 실측이 정석이다
이건 스택 정렬 그 자체보다
주소 8바이트가 시작하는 위치를 qword 경계로 맞추기 위한 길이 조절이다
예를 들어 %64c%10$n이 9바이트면
주소를 16바이트 경계에 시작시키고 싶다
그래서 7바이트 패딩을 넣어 총 16바이트로 만든다
이러면 뒤에 붙은 주소가 8바이트 단위로 깔끔하게 읽힌다
| 목표 | 쓰는 포맷 | 준비물 |
|---|---|---|
| 오프셋 찾기 | %n$p 스캔 | 마커 8바이트 |
| 임의 읽기 | %n$s | 포인터가 스택에 있거나 주소를 입력 끝에 심기 |
| 임의 쓰기 | %n$n %n$hn %n$hhn | 타겟 주소를 입력 끝에 심기, 출력량 조절 |
FSB는 한 번 감 잡으면 꽤 단순해진다
근데 감 잡기 전까지는 오프셋 때문에 계속 발목 잡힌다
내 결론은 이거다
오프셋은 실측 루틴을 손에 익히는 게 제일 중요하다
%p 스캔 + 마커만 제대로 하면
왜 10인지 11인지 14인지로 더 이상 시간 안 날리게 된다