Format String Attack
[1] Format String Attack
서론
2000년도 후반에 해커들 사이에 큰 반향을 일으키 보고서 하나가 발표되었다.
Format String Attack...
Format String Attack이란 무엇인가? 이것은 기존에 가장 널리 사용되고 있던 Buffer Overflow 공격 기법에 견줄 만한 강력한 해킹 기법이었다. 이 해킹 기법이 발표되고 나서 그 동안 별 문제 없어 보였던 각종 프로그램들에 대한 취약점이 속속 발표되고 해당 프로그램을 제작했던 회사들은 이 취약점을 해결하기 위해 분주해지기 시작했다.
그렇다면 Format String Attack은 어떤 방식으로 이루어지는 것인가? 이것을 이해하기 위해서는 먼저 Format String이 무엇인지를 이해해야 하고, 일반 C프로그램에서 이러한 Format String이 어떻게 처리되는 지를 이해해야 한다.
기존의 Buffer Overflow 공격기법보다 그 난이도가 매우 높기는 하지만 이미 많은 취약점이 발견되고 exploit code가 발표되고 있다.
[2] Format String이란 무엇?
#1 Format String
Format String Attack에 대해 알아보기 전에 Format String이 무엇인지 알아보자.
다음은 일반 C프로그램에서 흔히 찾아볼 수 있는 printf 함수이다.
char str[10] = "World!"; printf("Hello, %s\n", str);
직관적으로 " " 안에 포함되어 있는 "Hello, %s\n"이 Format String이다.
즉 Format String은 이 Format String을 사용하는 함수에 대해, 어떤 형식 혹은 형태를 지정해 주는 문자열을 의미한다.
#2 Format String 사용시 문제점
Format String 공격 역시 대부분의 다른 취약점 버그 또는 버그들처럼 일반 프로그래머들의 작은 실수에서 발생된 취약점을 이용하는것이다.
일반적으로 프로그래머들이 어떤 프로그램을 작성할 때, 다음과 같은 형식으로 작성한다.
printf("%s", str); //1번 예시
하지만, 어떤 프로그래머들은 위와 같은 형태를 이용하지 않고 프로그래밍을 보다 편하게 하기 위해서 다음과 같이 사용하는 경우가 있다.
printf(str); //2번 예시
이와 같은 형태의 프로그래밍이 잘못된 것은 아니다. 어떻게 보면 같은 기능을 수행하는 두가지의 코드 중 '2번'의 소스코드가 '1번'의 소스코드보다 적은 양의 소스코드를 사용한다. 따라서 '2번'의 소스코드 형태가 좀 더 현명한 소스코드로 보일 수 있다. 그러나 '2번'의 소스코드를 이용하여 프로그래밍하는 경우에는 해커들에게 프로그램의 흐름을 바꿀 수 있는 기회를 제공하게 된다는 사실을 깨달아야 한다.
프로그래머의 잘못은 무엇일까?
printf 함수를 '2번'과 같은 형식으로 사용하더라도 프로그래머가 원하는 형태가 출력된다.▼
int main(){ char str[15] = "Hello, World!\n"; printf("%s", str); //1번 코드 printf(str); //2번 코드 return 0; }
<▲사진 01> ex01.c 컴파일 및 실행
위와 같이 문제 없이 같은 기능을 수행하는 것을 확인 할 수 있다.
그러나, printf 함수에 의해서 해석되는 문자열 "str"은 출력하고자 하는 문자열이 아니라 printf 함수에서 사용할 각종 형식 지시자(%d, %s, %c.. 등)를 포함한 Format String으로 인식하게 된다.
#3 포맷 인자
printf 함수처럼 Format String을 사용하는 함수는 포맷 인자(형식 인자)를 함수에 인자로 넘겨 특정 동작을 수행한다.
각 포맷 인자는 함수에 인자로 넘겨지며, Format String에 세 개의 포맷 인자가 있으면 함수에도 세 개의 인자가 있어야 한다.
<▲표 01> 표준입출력 함수들의 포맷 인자
#4 새로운 지시자(drective) "%n"
Format String에 사용되는 형식 지시자들 중에는 출력 될 문자들의 개수를 셀 수 있는 기능을 제공하는 것이 있다. 이것이 바로 "%n"이라는 형식 지시자이다. "%n"이라는 형식 지시자를 사용하면, "%n"이 사용되기 직전에 사용된 형식에 의해 출력된 문자들의 개수가 다음 변수에 저장된다.
int pos, x=235, y=93; printf("%d %n%d\n", x, &pos, y); printf("The offset was %d\n", pos);
<▲사진 02> ex02.c 컴파일 및 실행
int pos, x=0; char buf[20]; snprintf(buf, sizeof(buf), "%.100d%n", x, &pos); printf("position: %d\n", pos);
<▲사진 03> ex03.c 컴파일 및 실행
위와 같은 경우 pos의 값은 buf의 크기 20이 아니라 100이 된다.
(%100d 형식지시자는 정수를 100자리로 표현)
여기서 좀더 이해를 돕기 위해 예제 한가지를 더 추가하도록 하겠다.
#include<stdio.h> #include<stdlib.h> int main(){ int A=5, B=7, count_one, count_two; //%n 포맷 스트링 예제 //이 X포인트까지 출력한 바이트 수는 count_one에 저장되고, //여기의 X까지의 바이트 수는 count_two에 저장된다. printf("The number of bytes written up to this point X%n is being stored", "in count_one, and the number of bytes up to here X%n is being stored in count_two.\n", &count_one, &count_two);// printf("count_one : %d\n", count_one); printf("count_two : %d\n", count_two); return 0; }
<▲사진 04> ex05.c 컴파일 및 실행
[3] printf 함수
#1 printf 함수의 동작방식
다음 예제를 통하여 printf함수가 어떻게 동작하는지 알아보도록 한다.
#include<stdio.h> #include<stdlib.h> #include<string.h> int main(int argc, char **argv){ char buf[100]; int x; for(x=0;x<100;x++) buf[x]=1; if(argc != 2) exit(1); x=1; strcpy(buf, argv[1]); printf(buf); printf("\nx is %d/%#x (@ %p)\n", x, x, &x); return 0; }
<▲사진 05> ex04.c 컴파일 및 실행
위 예제는 프로그램 실행 시 인자로 넘겨받은 문자열과 변수 x값을 출력하는 간단한 프로그램이다.
실행 결과를 보니, 입력한 문자열을 버퍼로 복사하여 그 값을 출력하였다. 또한, 변수 x의 값인 '1'을 출력하고, 변수 x가 저장되어 있는 주소인 0xbfb9da7c라는 값을 출력하였다.
일단 위 예제 프로그램이 수행되는 main 함수의 스택 영역이 어떤 형태를 가지고 있는지 확인해 보자.
<▲사진 06>- main() 함수의 스택영역
<▲사진 07>gdb를 통한 스택영역 확인
main함수는 먼저 인자(argument)들이 스택에 push되고, 복귀주소(RET)와 프레임 포인터(EBP)가 저장되고, main함수의 지역변수(여기서는 변수x와 buf[100])를 위한 공간이 확보된다.
지역 변수도 스택에 push 될 때, 배열 buf가 먼저 스택에 push되고, int형 변수 x가 그다음 스택에 push되는 것을 알 수 있다.(배열 buf를 먼저 코딩하였기 때문)
printf함수도 main함수와 마찬가지로 인수의 인자가 스택에 push되고 복귀주소(RET), 프레임 포인터(EBP)등이 push 된다.
<▲사진 08> gdb를 이용하여 printf함수 호출 되기 전 상태 분석
<▲사진 09> main함수에서 printf함수를 호출하였을 때, 스택 영역
<사진 08>과 <사진 09>를 보다시피 printf함수의 인자값을 먼저 push하고 printf함수를 스택에 쌓는다. 여기서 printf의 인자로 사용되는 것은 "buf"뿐이므로, 스택에는 "buf"만이 push된다.
이때, "buf"는 format string으로 사용될 부분이고, 이는 실제 format string이 들어가는 것이 아니라, format string포인터가 저장된다.(buf배열의 시작주소가 들어간다는 의미)
실제로 스택 내용을 gdb로 확인해보자.
<▲사진 10> gdb를 통하여 printf함수의 인자값 확인
이와 같은 스택에 대한 작업이 완료되면 printf 함수는 format string을 파싱하고 실제 출력이 이루어지게 된다.
이때, 일반 문자들의 경우에는 일반 문자 그대로를 출력하고, 형식 지시자를 만나는 경우에는 해당 형식 지시자에 대한 내용을 스택에서 pop하여 출력하게 된다. 이미 앞에서 언급한 것처럼 이때 pop되는 것은 스택 상에서 format string 포인터 다음에 위치한 내용이 된다.
따라서 위의 프로그램을 수행할 때 형식 지시자를 사용하는 경우에는 스택 영역을 확인할 수 있다.
<▲사진 11> 형식 지시자를 이용한 스택 내용 확인
위의 프로그램은 인자로 넘겨받은 문자열 그 자체를 format string으로 인식하고 출력하고 있다. 따라서 printf 함수는 입력된 "%x"문자를 지시자로 인식하고, 출력을 위해 지정된 변수와는 상관없이 스택에서 4Byte(1word)만큼을 pop하여 출력하게 된다.
<사진 11>을 보면 일단 입력된 문자열 "AAAA"를 출력하고, %08x 7개에 대한 값이 출력된다. 6번째에는 x변수의 값 "1"이 출력되고, 7번째에 비로소 문자열 "AAAA"에 해당되는 "0x41414141"이 출력된다. 이때 1번째~5번째까지의 값들은 printf함수와 main함수 사이의 dummy(쓰레기)값으로 보면된다.
스택을 그림으로 표현하면 이렇다.
<▲사진 12> 형식 지시자를 이용하여 알아낸 스택의 dummy영역
#2 Format String Attack(임의의 메모리 주소의 쓰기)
앞서 format string에 어떤 포맷 인자가 지정되어 있는 경우 발생하는 문제점과 새로운 지시자 "%n"에 대해서 알아보았다. 그렇다면 앞서 살펴본 예제 프로그램에서 프로그램 실행 시 인자로 전달했던 "%x"포맷 인자 대신 "%n"포맷 인자를 사용하면 어떻게 될까?
간단히 예상해 볼 수 있는 것은 printf함수는 "%n"포맷 인자를 만나면 이 포맷 인자의 순서에 해당하는 내용을 스택에서 pop하고 pop된 내용을 주소로 이용하여, 해당 주소에 지금까지 출력된 문자의 개수를 저장하게 될 것이다. 이때 만약 이 주소가 어떤 함수의 복귀 주소가 저장되어 있는 곳이라면 프로그램의 흐름을 바꿀 수 있다. 이것이 Format String Attack의 목적이다.
새로운 예제를 보자.
#include<stdio.h> #include<stdlib.h> #include<string.h> int main(int argc, char* argv[]){ char text[1024]; static int test_val = -72; if(argc < 2){ printf("사용법: %s <출력할 텍스트>\n", argv[0]); exit(0); } strcpy(text, argv[1]); printf("사용자 입력을 출력하기 위한 좋은 방법:\n"); printf("%s\n", text); printf("사용자 입력을 출력위해 사용하면 안 되는 나쁜 방법:\n"); printf(text); printf("\n"); //디버깅 출력 printf("[*]test_val @ %p = %d, %p\n", &test_val, test_val, test_val); return 0; }
<▲사진 13> ex06.c 컴파일 및 실행, %x포맷 인자를 이용하여 스택구조 확인
위 예제 코드를 통해, "%n"포맷 인자를 이용하여 test_val(값:-72)의 값을 바꿔보자.
<▲사진 14> %n 포맷인자를 통해 test_val값 변경
<사진 14>를 보면, -72였던 test_val변수가 31로 바뀌어 출력된다.
test_val 변수의 주소는 0x080498e0이고, 이 주소를 이용해 "%n"포맷 인자로 변경하였다.
("%n"포맷 인자 앞에 있는 포맷 인자의 필드 길이 옵션을 조작하면 그 수에 해당하는 만큼의 공백 문자가 출력되고, "%n"포맷 인자에 해당하는 변수에 저장될값을 조정할 수 있다.)
<▲사진 15> %n 포맷인자를 통해 test_val값 변경
하지만, <사진 14>와 <사진 15>와 같은 방법은 작은 수를저장할 때는 잘 동작하지만 메모리 주소와 같이 매우 큰 수를 저장할 때는 사용하기 어렵다.
test_val을 16진수로 표시한 부분(0x130)을 보면, 끝에 2자리는 쉽게 바꿀 수 있다는 것을 알 수 있다.
따라서 이것을 이용하면 된다. 예를 들어 test_val변수에 0xDDCCBBAA를 쓰고 싶다고 하자. test_val변수의 첫 번째 바이트는 0xAA이고, 그다음은 0xBB, 0xCC, 0xDD다. 다음과 같은 방법으로 원하는 값을 변수에 쓸 수 있다.
<▲표 02>
여기서 0xaa는 16진수 이므로, 10진수로 표시하면 170이고 나머지는 0xbb(187), 0xcc(204), 0xdd(221)다.
<▲사진 16> test_val 변수에 170Byte를 넣어 메모리에 0xaa값을 넣는 과정
이제는 두번째 바이트를 쓸 차례다. 두 번째 바이트는 0xbb(187)이므로 바이트 수를 187로 올려주기 위한 "%x" 포맷 인자가 필요하다. 이 포맷 인자의 내용은 어떤 것이든 상관없으며 길이가 4Byte이고 0x080498e0메모리의 다음 주소에 위치하기만 하면 된다. 여기에서는 "EUNI"라는 4Byte 문자 배열을 사용 할 것이다.
그 다음에는 "%n" 포맷 인자가 참조하는 메모리 주소인 0x080498e1이 와야 한다. 즉, 다시말해서 포맷 스트링의 맨 처음에는 대상 메모리 주소가, 그 다음에는 4Byte의 "EUNI"가, 그 다음에는 대상 메모리주소+1이 와야 한다는 것을 의미한다.
우리의 목표는 4번의 메모리 쓰기를 완료하는 것이다. 쓸 때마다 메모리 주소가 1개씩 필요하며, 그 메모리 주소 사이마다 바이트 카운터를 적절히 증가시키는 4Byte의 "EUNI" 메모리가 필요하다. 첫 번째 "%x"포맷 인자는 포맷 스트링 이전에 있는 4Byte의 메모리를 사용할 수 있지만, 나머지 포맷 인자는 포맷 스트링에 있는 데이터를 직접 사용한다. 따라서 포맷 스트링의 전체 모습은 다음과 같다.
<▲사진 17>
그럼 이제 테스트를 해보자.
<▲사진 18>
이제 두 번째 바이트에 0xbb를 써보자.
<▲사진 19>
이해가 되는가? 0xbb(187)를 넣기위해서 그다음 "%n"인자 까지 187Byte공간이 있으면되는데 이미 앞에서 170Byte를 갖고 있으므로 17Byte만 더 있으면 0xbb가 표현된다. 이러한 식으로 0xddccbbaa를 만들어 보자.
<▲사진 20>
자 이제 0xddccbbaa대신 0x0806aabb를만들어 보자. 이는 아주 중요한 부분이다. 왜냐하면 두 번째 바이트 0xaa(170)은 0xbb(187)보다 작다. "%n"포맷 인자를 통해서 바이트 카운터를 늘리 수는 있어도 줄이는 것은 불가능 하다.
해결책은 좀만 생각하면 간단하다. 0xbb(187)에서 17을 빼는 대신 0xbb(187)에 239를 더해서 0x1aa(426)을 만드는 것이다.
한번 시도해보자.
<▲사진 21>
<사진 21>을 자세히 보자. 우리가 표현하고 싶은 0x0806aabb가 완성 될거라고 생각되지만, 0x0e06aabb가 출력되었다.
이유는 바로 "%2x"에 있다. 우리는 0x06과 0x08의 차이인 2Byte만큼만 증가 시키고 싶은데 이미 "%x"포맷 인자는 기본이 8Byte이다. 따라서 0x06+0x08=0x0e가 출력된 것이다.
그럼 이러한 생각을 할 수 있다. '앞에서와 똑같이 0x208이 아닌 0x308을 만들어 08을 출력하면 되지 않을까?'
시도해보겠다.
<▲사진 22>
역시나 생각대로 0x0806aabb가 출력되었다.
하지만 생각해 줘야 할 부분이 있다. 1~3바이트 부분(0xbb, 0xaa, 0x06)을 넣는 부분에서는 앞에서 0x1aa를 넣어도 다음부분에서 덮어쓰기 때문에 상관 없었지만, 마지막 바이트에서는 그대로 메모리상에 0x308.. 즉, 우리 눈에 보이는 0x08을 제외한 0x03이 메모리에 쓰여졌다.
이것을 확인하기 위해 예제를 살짝 수정해보자.
#include<stdlib.h>
#include<string.h>
int main(int argc, char* argv[]){
char text[1024];
static int test_val = -72, next_val = 0x11111111; // next_val (추가)
if(argc < 2){
printf("사용법: %s <출력할 텍스트>\n", argv[0]);
exit(0);
}
strcpy(text, argv[1]);
printf("사용자 입력을 출력하기 위한 좋은 방법:\n");
printf("%s\n", text);
printf("사용자 입력을 출력위해 사용하면 안 되는 나쁜 방법:\n");
printf(text);
printf("\n");
//디버깅 출력
printf("[*]test_val @ 0x%08x = %d, 0x%08x\n", &test_val, test_val, test_val);
//next_val 디버깅 출력(추가)
printf("[*]next_val @ 0x%08x = %d, 0x%08x\n", &next_val, next_val, next_val);
return 0;
}
next_val 이라는 static int형 변수를 한가지 더 추가해주고, next_val의 주소와 값, 16진수값을 출력하는 printf문을 추가하였다.
<▲사진 23> ex07.c 컴파일 하여 testing 출력
ex07의 예제에서는 test_val의 변수의 주수가 0x0804992c이고, next_val의 변수의 주소는 0x08049930이다. 즉 next_val의 변수는 test_val의 주소랑 붙어 있음을 알 수 있다.
ex06예제 마지막 부분에서 시도했던 0x0806aabb를 시도해보자.(새로 컴파일 하여 ex06과 test_val의 주소가 다르므로 헷갈리지 않길 바란다.
<▲사진 24>
<사진 24>를 통해서 마지막 4바이트 부분에 0x308을 넣다보니 그 다음 주소에 있던 next_val의 변수의 값이 변조된 것을 확인 할 수 있다.
#3 Format String Attack(인자에 직접 접근)
인자에 직접 접근하는 방법은 Format String Attack을 단순화 하는 한 가지 방법이다. 앞에서 살펴봤던 공격에서는 각 포맷 인자(지시자)에 해당하는 값을 찾으려고 여러 메모리 주소를 건너뛰어야 했다. 그래서 포맷 스트링의 맨 앞부분에 도달할 때 까지 여러 "%x" 포맷 인자를 사용해야만 했다. 그리고 임의의 메모리 주소에 쓰려고 3개의 추가적인 4바이트 쓰레기 값("EUNI")을 넣어야 했다.
직접 인자에 접근하는 것은 달러 기호($)를 사용해 인자에 바로 접근하는 것을 의미한다.
다음 예제를 보자.
#include<stdio.h> int main(){ printf("7th: %7$d, 4th: %4$05d \n", 10, 20, 30, 40, 50, 60, 70, 80); //%n$d는 n번째 인자를 10진수로 출력한다. //%7$d이므로 7번째 인자(70)를 출력한다. return 0; }
<▲사진 25> ex08.c 컴파일 및 실행
위의 포맷 함수는 "%7$d"와 "%4$05d"를 이용하여 2개의 인자만 접근하였다.
이 방법을 사용하면 메모리에 직접 접근할 수 있으므로 메모리를 건너뛰기 위한 노력을 하지 않아도 된다.
위에서 보았던 ex06예제를 다시보자.
#include<stdio.h> #include<stdlib.h> #include<string.h> int main(int argc, char* argv[]){ char text[1024]; static int test_val = -72; if(argc < 2){ printf("사용법: %s <출력할 텍스트>\n", argv[0]); exit(0); } strcpy(text, argv[1]); printf("사용자 입력을 출력하기 위한 좋은 방법:\n"); printf("%s\n", text); printf("사용자 입력을 출력위해 사용하면 안 되는 나쁜 방법:\n"); printf(text); printf("\n"); //디버깅 출력 printf("[*]test_val @ %p = %d, %p\n", &test_val, test_val, test_val); return 0; }
<▲사진 26> ex06 예제 다시보기
ex06 예제에서 포맷 스트링의 맨 앞부분은 4번째 포맷 인자에 해당한다. 이곳에 접근하려고 "%x"포맷 인자 3개를 사용해 메모리를 건너뛰는 대신 달러 기호($)를 사용해 직접 접근하는 방법을 사용할 수 있다.
그런데 달러 기호($)는 특수 문자이므로 커맨드라인에서 사용하려면 앞에 역슬래시(\) 문자를 붙여야 한다. 이렇게 하면 명령 셀이 달러 기호($)를 특수 문자로 인식하지 않는다.
직접적인 인자 접근법은 메모리 주소에 데이터를 쓰는 과정도 단순화시킨다. 이방법을 사용하면 메모리에 바로 접근할 수 있으므로 출력 바이트 카운트를 증가시키기 위한 4Byte의 쓰레기("EUNI") 데이터를 입력하지 않아도 된다.
<▲사진 27> ex06의 test_val 변수 주소 확인
<▲사진 28>
<▲사진 29>
#4 Format String Attack(쇼트 쓰기 기법)
포맷 스트링 공격을 단순화하는 또 다른 방법으로 쇼트 쓰기 기법이 있다.
쇼트(short)는 보통 2Byte 워드다. 그리고 포맷 인자는 쇼트를 다루는 특별한 방법을 가진다.
쇼트 쓰기는 2Byte 쇼트를 쓰는 포맷 스트링 공격으로 사용할 수 있다.
<사진 30>과 같이 쇼트쓰기 기법으로 더욱 쉽게 0xddccbbaa를 만들었다.
임의의 메모리 주소에 데이터를 쓸 수 있다는 것은 프로그램의 실행 흐름을 제어할 수 있음을 의미한다.
가장 대표적으로 스택 오버플로우에서 사용했던 방법인 스택 프레임의 리턴 주소(RET)를 덮어쓰는 방법이 있다.
그외에도 메모리 주소를 추정할 수 있는 다른 공격 대상이 존재한다. 스택 오버플로우 공격은 특성상 리턴 주소밖에 덮어쓸 수 없지만 포맷 스트링 공격은 임의의 메모리 주소에 데이터를 쓸 수 있으므로 다양한 공격을 할 수 있다.