본문 바로가기

* DevSecOps/Security

버퍼오버플로우(Buffer Over Flow)

 

버퍼오버플로우(Buffer Over Flow)


버퍼오버플로우라는 키워드를 검색하셔서 이 글을 보시고 계신다면, 버퍼오버플로우가 대충 무엇인지는 알고 계실거라 생각합니다.

 

버퍼오버플로우를 이해하는데 가장 필요한 것들이라 생각되는 것과 제가 너무 어렵게 느꼈던 부분에 대하여 최대한 쉽고 간단하게 정리를 해보고자 합니다.
오버플로우에는 힙 기반 오버플로우와 스택 기반 오버플로우 두 가지가 있습니다.
여기에서는 스택 기반 오버플로우에 대해서 다루도록 하겠습니다.


1. 스택의 특성을 알아야 한다는데...



스택의 특성에 대한 개념자체는 찹 쉽습니다. 좋은 문서들을 많이 읽어보셨을테니 이에 대해서는 생락을 하고 스택의 특성을 눈으로 확인해가면서 마음으로 받아들이는 기회를 가져 볼게요.


  (1)프로그램 실행시 메모리의 구조

    하나의 프로그램이 실행되면 프로그램 수행에 필요한 메모리가 할당 됩니다. 하나의 프로그램에 할당되는 메모리의 전체적인 모습은 다음과 같죠.

                 /--------------------/  메모리의 높은 숫자의 주소
                 |                         |       
                 |         Stack        |
                 |                         |
                 /------------------/
                 |                         |
                 |          Heap       |
                 |                         |
                 /--------------------/
                 |                         |
                 |         Data        |
                 |                         |
                 /--------------------/
                 |                         |
                 |          Text        | 
                 |                         |  
                 /--------------------/   메모리의  낮은 숫자의 주소

    Heap영역이나 Data영역, Text영역에서 변수에 그 자리를 줄 때에는 "메모리의 낮은 주소"에서 부터 "메모리 높은 주소"로 순서대로 한다고 합니다.

    스택이 거꾸로 자란다는 것은, 위에서 말한 다른 메모리의 영역과는 반대로 변수에 그 자리를 내어줄 때에  "메모리의 높은 주소"에서부터 "메모리 낮은 주소"로 한다는 것 입니다. 스택이 독특한거죠.


  (2)예제를 통해 이해해 보아요...(스택은 거꾸로 자란다)

int a;
int b;
int c;                       //각각 a, b, c라는 전역변수가 순서대로 선언되었습니다.

int main( void )
{
   char buffer1[7];
   chat buffer2[7];  //main함수 안에서 각각 buffer1, buffer2라는 배열이 선언되었습니다.
}



이러한 내용을 가진 프로그램이 수행될 때 전체메모리의 구조를 그림으로 보도록 하겠습니다.


                 /-------------------------/   메모리의 높은 숫자의 주소
                 |          ret     ①   |
                 |                         |
                 |     sfp(4byte)②  |
                 |                         |  Stack
                 |        buffer1 ③ |
                 |                         |
                 |        buffer2 ④ |
                 /-------------------------/
                 |                         |  Heap
                 /-------------------------/
                 |           c ③        |
                 |           b ②        | Data
                 |           a ①        |
                 /-------------------------/
                 |                         | Text
                 /-------------------------/   메모리의  낮은 숫자의 주소


  스택영역과 데이타영역을 비교해보니 '스택은 거꾸로 자란다'는 말이 이해되시죠? 먼저 선언된 순서에 따라 메모리에 자리를 잡는데 Data영역과 Stack영역이 반대로 진행되었습니다.

  다시 한번 더 말하자면, 데이터영역은 변수가 선언된 순서대로 메모리의 낮은 주소에서 시작해서 할당이 되는데, 스택영역에서는 먼저 선언된 변수가 메모리 높은 주소에서 시작해서 할당 받는 것입니다.

  (3)스택에 자리잡은 변수에 입력한 데이타가 들어간 모습

  저는 처음에 스택이 거꾸로 자란다길레  스택에 있는 변수에 데이터가 들어갈 때 "메모리 높은 주소"에서부터 들어가는줄 알았습니다.(좋은 문서들을 주의깊게 잘 읽어보지 못한 탓이었겠죠 -_-;; 멍청하거나 띨빵하거나..) 혹시 저와같이 오해를 하는 분이 있을까 스택의 변수에 데이터가 들어가는 모습을 살펴보겠습니다.

  스택영역만 떼어내서 보도록 하죠.

  buffer1에는 "1234567"이 들어가고 buffer2에는 "ABCDEFG"가 들어갔다고 가정한 결과의 모습입니다.


                 /-------------------------/  메모리의 높은 숫자의 주소
                 |       ret          |
                 /-------------------/
                 |   sfb(4byte)   |
                 /-------------------/
                 |        7           |       
                 |        6           |      
                 |        5           |
                 |        4           |  buffer1
                 |        3           |
                 |        2           |
                 |        1           |
                 /-------------------/           Stack
                 |        G           |
                 |        F           |
                 |        E           |
                 |        D           |  buffer2
                 |        C           |
                 |        B           |
                 |        A           |
                 /-------------------------/   메모리의  낮은 숫자의 주소


 자 이해되셨죠? 스택이 거꾸로 자란다는 것과 오해 없으시길 바랍니다.

여기까지 거꾸로 자란다는 스택의 특성을 알아보았습니다. 프로그램 수행시 메모리의 전체적인 모습도 대충 감이 오시죠?


2. 스택의 모습

(함수안에서 함수가 호출될 때)

자 이번에는 main함수 외에 다른 함수도 있고 함수에 인자가 주어진 경우에는 스택이 어떤모습인지 살펴보겠습니다.

아래와 같은 소소의 프로그램이 있다고 가정합니다.

void function( int a, int b, int c )  //function함수에 세개의 인자가 주어져 있네요.
{
     char buffer1[5];                    //buffer1 배열을 선언
     chat buffer2[5];                    //buffer2 배열을 선언
}

int main( void )                           //main함수가 시작됩니다.
{
     int super;                              //지역변수 super 선언
     function( 1, 2, 3 );                 // function 함수에 인자 값을 주며 호출합니다.  
}




이 프로그램이 수행될 때 전체 스택의 모습은 다음과 같게 될 것입니다.


                                            메모리의 높은 숫자의 주소

                 /-------------------------/ 메인함수 콜
                 |         ret             |
                 /-------------------/       
                 |     sfb(4byte)      |
                 /-------------------/               
                 |                         | 변수 super                  // int super;        Stack
                 /-------------------/
                 |        c=3            |
                 |        b=2            | function함수의 인자      // "함수의 인자"는 뒤의 것부터 
                 |        a=1            |                                       스택에 들어갑니다(c, b, a)
                 /-------------------/ function함수 콜        // 함수의 인자가 먼저 들어가고
                 |         ret             |                                     함수가 호출됩니다.
                 /-------------------/
                 |     sfb(4byte)      |
                 /-------------------/
                 |       buffer1[5]    |
                 /-------------------/
                 |       buffer2[5]    |
                  /-------------------------/  
                                             메모리의  낮은 숫자의 주소


자 함수내에서 함수가 호출될 경우와 함수에 인자가 주어진 경우 스택의 모습을 살펴보았습니다. 이해되셨죠?


3. 오버플로우



  스택의 구조와 특성을 살펴보았습니다. 자 이제 본격적으로 오버플로우로 들어갑니다. 이해하는데 어려운 점은 없으니 천천히 읽어보도록 합시다.

void function( int a, int b, int c )
{
     char buffer1[5];
     chat buffer2[5];
}

int main( void )
{
     int super;
     function( 1, 2, 3 );
}



(이 예제 프로그램에서는 buffer1이나 buffer2에 입력하는 부분은 없습니다만 편의상 buffer2에 문자열이 입력되었다고 가정하고 설명을 해나가겠습니다)

이 소스를 보면 buffer1와 buffer2는 그 크기가 각각 5바이트로 정해놓았습니다. 그런데 우리는 이 5바이트를 넘는 것을 입력함으로써 버퍼오버플로우가 발생되게 하는 것입니다.

buffer2에 5바이트가 넘는 문자열을 넣으면 스택은 다음과 같이 됩니다. buffer2에 "ABCDEFGHI"(9바이트)가 들어갔다고 가정한 결과입니다.

 윗 그림에서  function함수 부분만 잘라와서 설명을 하겠습니다.


                                              메모리의 높은 숫자의 주소
                 /-------------------/ function함수 콜
                 |         ret             | 
                 /-------------------/
                 |     sfb(4byte)      |
                 /-------------------/
                 |                         |
                 |           I             |
                 |          H             |  buffer1[5]
                 |          G            |
                 |          F             |
                 /-------------------/
                 |          E             |
                 |          D             |
                 |          C             |  buffer2[5]
                 |          B             |
                 |          A             |
                 /-------------------------/  
                                             메모리의  낮은 숫자의 주소


어떻습니까? 그림 이해 되시죠? 

버퍼가 저장할 수 있는 데이터의 크기가 5바이트라고 해서 5바이트 까지만 저장을 하는 것이 아니라, 입력된 것을 모두 저장하기 위해 그냥 자기에게 할당되지 않은 다음 메모리영역에까지 써버러는 것입니다.


더욱 큰 문자열을 넣는다면 sfb부분과 ret부분까지 덮어 써 버릴 수가 있습니다.

이것이 바로 버퍼오버플로우 입니다.

이러한 현상은 이용자가 입력하는 문자열을 저장하는 버퍼의 크기가 5바이트일 뿐인데, 이용자로부터 입력을 받을 때에 5바이트를 초과하는 문자열을 입력할 수 있도록 프로그래밍한 경우 발생하는 현상입니다.


4.  버퍼오버플로우를 이용한 해킹



버퍼오버플로우를 이용하여 해킹을 하기 위해서는 ret가 무엇인지 알고 계셔야 합니다. 버퍼오버플로우에 대한 좋은 문서들이 많이 있으니 그런 문서를 보셨다면 충분히 아실거라 생각하고 이에 대한 설명은 생략하겠습니다.

ret는 함수가 임무를 수행하고 끝난뒤 다음 실행되어야할 명령이 위치한 메모리의 주소를 말하지요.

그런데 우리는 버퍼오버플로우를 이용하여 ret까지 덮어쓸 수가 있게 되었습니다.

따라서 버퍼오버플로우를 일으켜서 ret부분에 자신이 원하는 명령이 들어가 있는 메모리의 주소로 덮어쓴다면, 자신이 원하는 명령을 수행할 수 있도록 하는 것입니다.

대부분 해킹의 경우에는 쉘을 받아내는 명령을 수행하도록 하겠지요. 쉘을 실행시키는 코드를 메모리의 어딘가 저정해놓 뒤 그 주소를, ret부분에 써지도록 하면 함수 종료 후에 쉘을 실행시키는 코드가 실행되고, 따라서 쉘을 받게 되는 것입니다.

버퍼오버플루우가 발생할 수 있는 프로그램이 root의 setuid가 걸려있고, 쉘을 실행시키는 명령이나 코드가 들어가 있는 메모리의 주소를 ret에 덮어쓴다면 root의 쉘을 따낼 수 있는 것입니다. 이렇게 하여 버퍼오버플로우를 이용하여 해킹을 할 수 있습니다.


5. 버퍼에 대한 중요한 추가 설명



  (1)리눅스에서는 변수에 메모리를 1워드 즉 4byte단위로 할당한다고 합니다. buffer1[5]와 같이 5바이트 크기를 선언하였지만 리눅스는 8바이트를 할당하는 것입니다. 11바이트를 선언하였다면 리눅스는 메모리의 12바이트를 그 변수에 할당하게 됩니다.
(이부분에 대해서는 정말 그러한지 직접 확인해보고 싶은데, 아래 설명할 gcc 2.96버전 이상에서 발생하는 쓰레기값과 붙어있어서 확인을 못하겠네요 -_-;)

 (2)버퍼의 구조 변화

저는 지금까지 버퍼오버플로우의 기본적인 이해를 위해 gcc 2.96이전 버전으로 프로그램을 컴파일한 경우에 기초하여 설명을 하였습니다.

그런데 gcc 2.96이상으로 버전이 바뀌면서 버퍼 구조에 변화가 생겼습니다. 

버퍼의 각 변수뒤에 쓰레기값이 형성된다고 합니다.

그림으로 살펴보죠.

                                           메모리의 높은 숫자의 주소

                 /-------------------/ function함수 콜
                 |         ret             | 
                 /-------------------/
                 |     sfb(4byte)      |
                 /-------------------/
                 |                         |
                 |       쓰레기        |   (gcc 2.96이상 버전으로 컴파일 되면서, 
                 |                         |     이 사이에 쓰레기 값이 형성됨)
                 /-------------------/
                 |                         |
                 |           I(?)        |
                 |          H(?)        |  buffer1[5]
                 |          G(?)        |
                 |          F(?)        |
                 /-------------------/
                 |                         |
                 |                         |  
                 |           I(?)        |  쓰레기
                 |          H(?)        |  (gcc 2.96이상 버전으로 컴파일 되면서,
                 |          G(?)        |  이 사이에 쓰레기 값이 형성됨)
                 |         F(?)        |
                 /-------------------/
                 |          E              |
                 |          D              |
                 |          C              |  buffer2[5]
                 |          B              |
                 |          A              |
                 /-------------------------/  
                                             메모리의  낮은 숫자의 주소

이렇게 됨으로써 FGHI가 어느부분에 들어간다고 확신할 수 가 없게 되었습니다.


그런데 이때 형성되는 쓰레기값들의 크기가 일정하지 않습니다. 따라서 윗 그림에서 오버플로우 된 FGHI는 buffer1에 덮어써지지 않을 가능성이 높아졌습니다.

이러한 현상으로 ret에 다른 주소값을 덮어쓰기가 어려워졌습니다. 크기를 알 수 없는 쓰레기값들 때문인것이죠.

그래서 딱히 눈에 보이는 방법이라면 입력하는 값을 하나씩 늘려가면서 매번 실행해보는 수밖에 없어보입니다.


자 여기까지 저 나름대로 공부한 버퍼오버플로우를 정리해 보았습니다. 메모리의 구조나 ret의 위치를 파악하는데 어셈블리나 gdb를 사용할 줄 안다면 큰 도움이 된다고 들었습니다. 저는 어셈블리나 gdb사용법에 대해서 아는 바가 없기때문에, 미흡한 점이 있다면 양해해 주시구요. 감사합니다.


5. FGHI는 정말 어디에 들어가 있을까 -_-;;;



  (1)실험용 소스

#include <stdio.h>

int main()

{
        char buffer1[5];
        char buffer2[5];
        fgets(buffer2,20,stdin);

        printf("%s\n", buffer2);
        printf("%s\n", buffer1);

        return 0;
}



이와 같이 소스를 짜봤습니다.


  (2)gcc 버전

gcc version 3.2.2 20030222 (Red Hat Linux 3.2.2-5)

  (3)실행결과

ABCDEFGHI    //입력한 값
ABCDEFGHI    //출력된 buffer2

`

출력된 결과를 보니 buffer1에 오버되지 않았네요.

ABCDEFGHJIKLMNOPQ //입력한 값
ABCDEFGHJIKLMNOPQ //출력된 buffer2

Q                                //출력된 buffer1

오...17바이트를 입력하니 이제서야 buffer1으로 오버되었네요.

  (4)결론( FGHI는 어디에 있을까)

buffer2 크기는 5바이트로 선언했지만, 리눅스의 특성상 4바이트 단위로 메모리를 할당하기 때문에 8바이트가 할당되었을 테니, 입력한 FGH는 buffer2의 남은 부분에 들어가 있고, I는 쓰레기값이 있는 곳에 있나보네요. 

그리고, buffer2와 buffer1사이에 8바이트 만큼 쓰레기가 들어가 있나 보다라고 잠정적인 결론을 내려봅니다.

 

출처 : http://geundi.tistory.com/118