티스토리 뷰

HackerSchool FTZ WARGAME Solution

 

Start : 15.07.03

 

LEVEL20

(level19는 기존의 버퍼 오버플로우와 같아서 보고서 작성하지 않았습니다 level11 참조 해주세요 :) )

 

[그림 1]

 

hint 를 열어보면 [그림 1]과 같이 출력이 된다. 이번 문제의 hint를 읽어보면 fgets 에서 bleh 배열에 79byte 만큼의 데이터만 가져와서 저장을 하게 되는데 중요한 것은 bleh 배열의 크기가 80byte 밖에 안된다는 것이다. 기본적으로 버퍼 오버플로우를 하려면 기본 배열의 크기와 dummy 메모리 공간까지 합한 영역을 오버플로우 시켜야 하는데 배열보다 크기가 작은 데이터를 가져오도록 코딩이 되어있다. 즉 버퍼 오버플로우를 발생시키기에 적합하지 않는 즉, 보안코딩이 되어있다고 볼 수 있다.

 

[그림 2]

 

처음에는 어떻게든 오버 플로우 시킬 순 없을까 생각을 해 보았지만 짧은 시간과 현재의 지식으로는 불가능 했으며 gdb로 분석하려해도 main함수에 대한 symbol이 없다는 에러가 출력 되면서 분석 자체도 안된다. 아마 문제를 만들면서 일부러 main 함수에 대한 정보가 담겨있는 symbol을 없애버린 것 같고, 디버깅을 통해서 main 함수를 찾는다 하더라도 버퍼 오버플로우가 발생하기 힘든 코드기 때문에 다른 방법을 찾아 보는게 좋겠다는 판단이 들었다. 그러던중 printf 함수 형태가 특이해서 보던 중 '포맷 스트링 버그(Format String Bug)' 가 떠올랐다. [그림 1]을 보면 printf의 사용법이 일반적인 방식으로 사용하지 않았음을 알 수 있다.

 

printf의 형태를 보면 "int printf (const char * format, ...);" 인데 쉽게 풀어 보자면 printf(문자열,...); 이다. 뒤에 '...'은 인자의 개수가 정해져있지 않고 넣는데로 인자가 전달된다는 의미이며, 여기에는 printf의 서식 문자(%d, %s ...) 에 출력이 될 값이 인자로서 사용된다. const char * 의 경우 문자열이 존재하는 주소값을 전달받는다. 우리가 일반적으로 printf("Hello World\n"); 와 같이 쓰면 사실 "Hello World" 라는 문자열 자체가 printf 함수로 전달되는 것이 아니라 저 문자열이 존재하는 메모리의 주소값이 전달되는 것이다. "Hello World" 라는 문자열은 메모리의 text 영역에 위치 된다. 그래서 [그림 1]과 같이 printf 의 인자로 문자열이 아닌 배열의 주소를 넘겨주는 것이 가능한 것이고, printf는 bleh 배열에 저장된 문자들을 문자열로 출력을 하게 되는 것이다.

 

포맷 스트링 버그는 [그림 1]과 같이 printf 가 사용되었을 때 생기는 버그인데 지금 printf를 보면 bleh 배열 주소만 printf 의 인자로 전달을 하고 있다. 이 때 만약 bleh 배열에 "%x"나 "%d"와 같은 서식 문자가 저장되어 있다면 printf는 이 서식 문자를 보고 뒤에 인자값을 출력하게 되는데 지금은 뒤에 인자값으로 사용될 인자가 없다. 따라서 메모리에 있는 내용을 그냥 출력해버리는데 이것이 바로 포맷 스트링 버그이다.

 

[그림 3]

 

printf("i : %d\n", 10); 이 동작될 때 스택을 간단하게 보면 [그림 3]과 같다. c언어의 경우 함수호출규약cdecl 을 사용하기 때문에 함수의 맨 오른쪽 인자부터 스택에 PUSH 된다. printf함수는 문자열에서 %d 를 만나면 [ebp+12]에 있는 값을 출력을 하게 되는 것이다.

 

[그림 4]

 

[그림 1]의 printf 가 호출될 때 스택 상태를 보면 [그림 4]와 같은데 문자열의 주소값 대신 bleh 배열의 주소가 들어가 있으며, 따로 인자를 뒤에 더 추가해주지 않았기 때문에 [그림 4]의 상태로 끝이다. 이 때 bleh 에 "%d"나 "%x"같은 서식 문자가 있으면 printf 함수는 어김없이 [ebp+12]에 있는 값을 출력하게 되는데 이 때는 스택에 출력할 값이 없기 때문에 그냥 그 공간에 있던 값을 그대로 출력하게 되는 것이다.

 

[그림 5]

 

실제로 attackme를 실행시켜 bleh 에 "%x"와 같은 서식 문자를 넣으면 [그림 5]와 같이 메모리에 있는 값이 출력이 된다. 즉 포맷 스트링 버그에 취약한 프로그램이라는 것이다.

 

원래 가볍게 알고있던 포맷 스트링 버그는 그냥 메모리에 있는 값이 출력된다는 것만 알고 있었는데 여기서는 지금 쉘 코드를 실행시켜야 한다. 왠지 포맷 스트링 버그를 이용해서 메모리에 값을 쓰는 행위가 가능할 것 같아 조금 찾아보니 printf에서 사용되는 서식 문자중 "%n, %hn" 이라는 것이 있었다. 기능이 너무 새로워서 처음에 봤을 때 놀라기도 했었다. "%n"과 "%hn"은 이전까지 출력된 문자의 개수를 변수에 저장하는 기능을 하는 서식 문자이다. "%n"과 "%hn" 의 차이점은 값을 저장하는 크기인데 "%n"은 4byte만큼 메모리에 쓰고, "%hn"은 2byte만큼 메모리에 쓴다.

 

[그림 6]

 

간단하게 테스트를 해보기 위해 [그림 6]과 같이 코딩을 한 뒤 실행을 시켜 보았다. 실행 결과를 보면 "%x"로 출력이 된 내용들의 길이와 asdf의 총 길이인 18이라는 값이 전달된 &i 주소값에 해당하는 공간 즉, i변수에 저장이 된다. 그 말인 즉슨 값의 조작을 통해 원하는 값을 원하는 메모리 공간에 쓸 수 있다는 것이다.

 

이제 여기서 문제는 어떤 공간에 어떤 값을 쓰냐는 것인데 생각할 수 있는 것은 쉘 코드를 환경 변수에 저장해두고, 환경 변수의 주소를 메모리에 써서 실행되도록 하는 것이다. 가장 먼저 생각할 수 있는 것은 RET 영역에 쓰는 방법인데 중요한건 RET의 주소를 찾는게 쉽지가 않다. 스택의 주소가 계속 바뀌기 때문에 bleh 배열의 주소를 이용하기도 힘들고 바로 RET 주소를 찾는다는 것은 거의 불가능 하기 때문이다. 그래서 여러 가지를 고민해보고 시도 해보던중 포맷 스트링 버그를 이용한 공격에서 RET가 아닌 '.dtors' 라는 곳에 값을 쓰는 방법이 있다고 한다. 찾아보니 프로그램에서 '.dtors'의 주소는 바뀌지 않고 고정되어 있기 때문에 쉽게 공격할 수 있을 것 같아 조금 찾아 보았다.

 

'.dtors' 는 gcc 컴파일러가 컴파일 할 때 나타나는 특징적인 영역으로 리눅스 ELF 구조에 대한 지식이 필요하다고 한다. 본인은 아직 ELF를 공부한적이 없기 때문에 간단하게 '.dtors' 가 무슨 역할을 하고 동작되는지 정도만 알아보았다. 리눅스 gcc로 컴파일 한 프로그램은 main 함수를 호출 하기 전에 .ctors 속성의 함수를 실행하게 되고, main 함수가 종료 된 직후 .dtors 속성의 함수를 실핸한다. 코딩할 때

void attribute__((constructor)) func_Start(),void __attribute__(destructor)) func_End() 함수를 정의해 두면 main 함수 호출 하기 전에 func_Start() 가 호출되고 main이 종료된 후, func_End() 함수가 호출 된다. 그리고 이 두 함수의 정보가 각각 '.ctors'와 ',dtors' 영역에 저장되어 있다.

 

좀 더 '.dtros' 가 동작되는 방식과 특징에 대해 알아보면 좋지만 우선은 나중에 ELF 구조를 공부하면서 확인하고 지금은 대충 이런식으로 동작한다는 것까지만 확인하고 문제를 풀도록 하겠다.

 

[그림 7]

 

objdump 를 이용해서 [그림 7]과 같이 attackme 를 열면 '.dtors'에 대한 정보가 출력된다. grep 을 빼고 보면 알 수 있는데 두 번째 값이 .dtors 의 주소가 된다. (두 번째와 세 번째 주소는 VMA와 LMA 로 약간 다른데 VMA는 VirtualMemoryAddress, LMA는 LoadMemoryAddress 이다. 간단하게 차이를 설명하면 가상 메모리상 주소와 물리 메모리에 실제로 올려지는 메모리 주소인데 일반 프로그램은 두 값이 같고, 커널 프로램의 경우 차이가 생긴다고 한다. 이 부분에 대한 내용은 ELF 를 공부해야 하기 때문에 뒤로 미룬다. – 사실 PE구조를 공부할 때도 나오는 개념이다 -)

 

이제 '.dtors' 영역의 주소를 알았으니 쉘 코드 주소값을 덮어쓰는 일만 남았다. 참고로 저기 나와있는 주소를 그대로 쓰면 안되고 +4 한 주소값에 덮어써야 한다. '.dtors' 영역은 또 나뉘게 되는데 실행이 되게 하기 위해서는 +4 한 곳에 값을 덮어써야 한다. 실제로 func_End() 함수를 만들고 컴파일 한 뒤 확인해 보면 해당 주소의 +4 한 곳에 func_End() 함수의 정보가 담겨 있다. (정확하게 왜 +4를 해야하는지는 역시 추후에 좀 더 많은 공부를 한 뒤 정리하겠다)

 

조금 정리를 해보자면 우리가 현재 덮어써야하는 곳의 주소는 0x08049598 이며 덮어쓸 값은 0xbffffec6(필자의 쉘 코드 주소) 이다. 이 값을 printf의 출력 개수를 조작해서 정확하게 맞추어야 하는데 공격 코드의 순서를 간단하게 살펴 보자.

 

printf에 서식 문자를 쓰면 printf 함수의 ebp를 기준으로 [ebp+12] 공간의 값을 서식 문자의 인자값으로 사용하고, 계속해서 [ebp+16], [ebp+20]....이렇게 스택의 높은 주소로 4byte 식 올라가면서 값을 읽어 온다. 그러면 만약 우리가 bleh에 "AAAAAAAA%n" 를 입력한다고 생각해보자.

 

[그림 8]

 

그러면 [그림 8]과 같이 스택이 구성되는 것을 예측할 수 있다. bleh 배열에 "AAAAAAAA%n"이 들어가고 printf의 인자값은 bleh 배열의 시작 주소값이 전달 된다. 그러면 printf는 우선 출력된 문자 개수인 8을 printf 함수의 ebp 기준으로 [ebp+12] 영역에 있는 4byte 값만큼을 주소로 읽어와서 해당 주소값에 8을 저장 한다. 즉 \x41414141 이라는 주소를 가진 메모리 공간에 8을 쓴다는 것이다. 그럼 우리는 저 \x41414141에 원하는 주소를 쓰면 된다.

 

일단 우리가 0xbffffec6 이라는 값을 쓰기 위해서는 0xbffffec6 개수 만큼 문자를 출력시켜야 하는데 진짜로 그런 행동을 했다간 원하는 주소에 쓸 수 없게 되기 때문에 다른 방법을 생각해야 한다. 서식 문자를 사용하는 방법 중에 "%d"에서 %와 d 사이에 숫자를 넣으면 해당 숫자만큼 공간을 만들어서 출력하게 된다. 예를 들어

"printf("%10d", 1);" 을 실행시키면 " 1" 이렇게 10자리에 맞춰서 출력을 한다. 그럼 우리는 이것을 이용해서 bleh 배열을 낭비하지 않고 원하는 만큼 출력할 수 있게 된다. 그런데 여기서 또 다른 문제가 발생하게 되는데 우리가 원하는 0xbffffec6을 10진수로 바꾸면 3221225158 으로 int 범위를 벗어나게 된다. 이렇게 되면 int의 최대 값인 약 21억을 넘어가게 되서 값의 오버플로우가 일어나 우리가 원하는데로 출력이 안된다. 예를들어 printf("%3221225470d",1); 는 "1" 이렇게 한 자리로 출력이 된다. 이 이유는 기본적으로 컴퓨터가 정수를 처리할 때 int 형으로 처리하기 때문이다. 그렇기 때문에 우리는 0xbffffec6 을 한번에 출력해서 메모리에 쓰는 것은 불가능하다. 한번에 못 쓰기 때문에 나눠서 쓰는 것을 생각 해야하는데 아까 본 "%hn"을 사용해서 2byte씩 나눠서 저장하면 된다.

 

나눠서 저장할 때는 리틀엔디언을 고려해서 먼저 0x08049598 에 0xfec6 을 쓰고, 2byte 큰 0x0804959a 에 0xbfff 를 쓰면 된다. 그리고 0x1bfff (양수는 앞에 1을 생략하고 있다) 는 십진수로 114687이고, 0xfec6은 십진수로 65222이다. 현재까지 나온 정보를 가지고 공격 코드를 만들어보면 "AAAA\x98\x95\x04\x08AAAA\x9a\x95\x04\x08%65206d%hn%49465d%hn" 이 된다. AAAA는 %d 를 출력하기 위한 4byte이고, 65206은 앞에 16byte만큼 출력되었기 때문에 65222에서 16만큼 빼준 값이고 49465는 앞에 16byte+65206 만큼 출력 되었으므로 우리가 원하는 값을 얻기 위해 114687-65222=49465 가 나온 것이다.

 

하지만 이렇게 해서는 공격이 되지 않는다. redhat 9.0에 있는 gcc버전은 함수에 인자를 넘길 때 16byte의 배수로 스택을 맞춰준다. 즉 printf에서 인자가 하나밖에 없을 경우 스택을 12byte만큼 할당을 한 뒤에 인자를 PUSH 한다.

 

[그림 9]

 

즉 스택이 [그림 9]와 같이 구성이 되어 버린다는 것이다. 이렇게 되면 %d 를 출력할 주체가 "AAAA"가 아닌 위에 dummy 가 되어 버리고 %hn 역시 dummy에 있는 값을 주소값으로 판단해 이상한 곳에 값을 써버리게 되는 것이다. 그렇기 때문에 이 dummy 부분을 처리해줘야 하는데 방법은 간단하게 "%d" 전에 "%x"을 세 번 써서 출력시켜버리면 "%d"는 정상적으로 "AAAA" 부분을 출력하게 된다. 이렇게 되면 뒤에 "%d" 출력 개수를 조금 조정해줘야 하는데 %x를 출력하면서 출력 문자가 늘어나기 때문이다. 이 것은 그냥 attackme 를 실행시켜서 %x를 세 개 넣어서 출력시켜보면 된다. dummy 값은 프로그램 실행중에 생긴 값이기 때문에 값이 고정이기 때문이다.

 

[그림 10]

 

attackme를 실행시켜보면 [그림 10]과 같이 출력이 된다. 출력된 문자수는 18개로 앞에 설정한 값에서 18만큼더 빼주면 된다.

 

[그림 11]

 

최종 공격 코드는 [그림 11]과 같다. 앞에서 만든 코드에서 달라진 점은 "%d" 앞에 "%x"를 세 번 넣어준 것과 65206에서 -18한 값으로 바뀌었다는 점이다.

 

[그림 12]

 

만든 공격 코드로 attackme 에 넣으면 [그림 12]와 같이 clear 계정 권한으로 쉘이 실행되는 것을 확인할 수 있다.

 

level20에서 포맷 스트링 버그를 이용한 공격에 대해 많은 생각을 할 수 있었는데 리눅스 ELF 구조와 정확하게 스택에 어떻 값이 PUSH가 되는지, 그리고 어떤 순서로 PUSH가 되며 함수가 동작할 때 인자값을 어떻게 처리하는 지 등 많은 지식과 정확성이 필요했었다. 중간에 이상한 방법으로 문제를 푸려고 삽질을 많이 했는데 그런 삽질을 한 만큼 포맷 스트링을 공부하면서 더 삽질을 많이하고 생각을 많이 했다. 정신적으로 고통이 많았지만 이번 문제를 풀면서 알게된 지식들이 많아서 앞으로의 공부에 많은 도움이 될 것 같다.

신고
댓글
댓글쓰기 폼