Security || AI

[시스템 공부] Smashing The Stack For Fun And Profit (2) 번역 본문

Hacking&Security/시스템[System]

[시스템 공부] Smashing The Stack For Fun And Profit (2) 번역

보안&인공지능 2018. 10. 29. 23:21

스택 영역

~~~~~~~~~~


 스택은 데이터를 포함하는 메모리의 연속적인 블록이다. 레지스터는 스택의 가장 위를 가리키고 stack pointer(SP)라고 불린다. 스택의 가장 밑은 고정된 주소에 있다. 이 크기는 실행 되는 동안 동적으로 커널에 조절된다. CPU는 스택에 PUSH를 하거나 POP을 수행한다.


 스택은 기능을 호출할 때 PUSH를 해서 반환할 때는 POP을 수행하는 논리적 스택프레임으로 구성된다. 스택프레임은 함수의 인자들, 함수의 지역 변수들과 함수 호출 시의 명령 포인터의 값을 포함한 바로 이전의 스택프레임을 복구하기 위한 데이터를 포함한다.


 구현 방식에 따라 스택은 감소하거나(메모리 주소가 낮아짐) 증가하게 된다. 우리가 쓸 예제에서는 스택이 감소할 것이다. 이 방법이 intel, Motorola, SPARC와 MIPS프로세서를 포함한 많은 컴퓨터들에서 사용되는 방식이다. 스택 포인터도 구현 여부에 따라 달라진다. 이는 스택의 마지막 주소를 가리키거나 스택 다음에 사용 가능한 다음 주소를 가리킬 수 있다. 우리는 스택에 마지막 주소를 가리키는 것으로 가정할 것이다.

 게다가 스택의 가장 위를 가리키는(가장 낮은 주소) 스택 포인터외에도, 프레임 내에서 고정된 공간을 가리키는 프레임 포인터(frame pointer)에서 편리하다. 몇몇의 문서에서는 이것을 지역 기반 포인터(local base pointer)라고도 한다. 이론적으로, 지역변수는 SP에서 오프셋을 제공하여 참조할 수 있다. 하지만, 스택이 PUSH되거나, POP이 되면 이 오프셋들은 변한다. 몇몇의 경우에는 컴파일러가 스택에 있는 데이터 수를 추적하여 오프셋을 수정할 수 있지만, 어떤 경우에는 수정할 수 없고 모든 경우에 상당한 관리가 필요하다. 더욱이, intel기반의 프로세서 같은 경우, SP로부터 알려진 거리에서 변수에 액세스하는 데는 여러가지 지침이 필요하다.



 그 결과, 많은 컴파일러는 지역 변수들과 파라미터 모두를 참조하는데 FP(frame pointer)인 두번째 레지스터를 사용한다 왜냐하면, FP로부터의 거리를 POSH와 POP들로 바꿀 수 없기 때문이다. 인텔 CPU에서, BP(EBP)는 이러한 목적으로 사용된다. Motorola CPU에서는 A7(스택 포인터)를 제외한 어느 주소 레지스터에서 이를 수행한다. 스택이 증가하는 방법 때문에, 실제 파라미터들은 FP로부터 양수를 가지고, 지역 변수들은 FP로부터 음수를 가진다.


 프로시저가 호출 되었을 때 하는 첫번째 일은 바로 전의 FP를 저장한다(프로시저가 종료될 때 이를 복원할 수 있다). 그러면 이것은 새로운 FP를 만들기 위해 SP를 FP로 복사하고, SP를 전진시켜 지역 변수를 위한 공간을 예약한다. 이 코드는 procedure prolog(프로시저 프롤로그)라고 불린다. 프로시저가 종료되면 스택은 다시 비워져야 하는데, 이는 procedure epilog(프로시저 에필로그)라고 불린다. 인텔의 ENTER과 LEAVE 명령들과 Motorola의 LINK와 UNLINK명령들이 프로시저 프롤로그와 에필로그를 대부분 효율적으로 수행한다.


 스택이 어떻게 생겼는지 간단한 예제를 통해 보자:


example1.c:

------------------------------------------------------------------------------

void function(int a, int b, int c) {

   char buffer1[5];

   char buffer2[10];

}


void main() {

  function(1,2,3);

}

------------------------------------------------------------------------------


 이 프로그램이 function()을 부르기 위해 무엇을 하는지 이해하기 위해 gcc를 사용하여 컴파일을 해볼 것이다. gcc를 사용할 때 어셈블리 코드를 생성하기 위해 –S 옵션을 사용한다.


$ gcc -S -o example1.s example1.c


 어셈블리어의 결과를 보면 function()이 호출 되는 것이 어떻게 바뀌었는지 볼 수 있다:

pushl $3

pushl $2

pushl $1

call function


 이것은 스택에 반대순서대로 3개의 인자들을 밀어 넣고, function을 호출한다. 우리는 저장된 IP(instruction Pointer: 명령어 포인터)를 복귀 주소(return address(RET))라고 부를 것이다. 함수 안에서 가장 먼저 처리되는 것이 바로 프로시저 프롤로그이다:

pushl %ebp

movl %esp, %ebp

subl %20,%esp


 이는 프레임 포인터인 EBP를 스택에 밀어 넣는다. 그 다음, FP 포인터를 새로 만들기 위해 현대의 SP를 EBP에 복사한다. 우리는 이것을 저장된 FP포인터인 SFP(saver frame pointer)라고 부를 것이다. 그 다음, 지역 변수들의 크기만큼 SP를 감소시켜 공간을 할당한다. 


 메모리는 word 크기의 배수로만 주소가 지정된다는 것을 기억해야만 한다. 우리의 경우 한 word는 4바이트나 32비트이다. 그러므로 5바이트 버퍼는 메모리의 8바이트(2words)만을 차지한다. 이것이 SP가 왜 20만큼 감소되는 이유이다. 따라서 function()을 호출할 때 다음과 같이 스택이 표시된다(각 공간은 바이트를 나타낸다) :


bottom of                                                            top of

memory                                                               memory

           buffer2       buffer1   sfp   ret   a     b     c

<------   [            ][        ][    ][    ][    ][    ][    ]

   

top of                                                            bottom of

stack                                                                 stack


버퍼 오버플로우

Buffer Overflows

~~~~~~~~~~~~~~~~~


 버퍼 오버플로우는 처리할 수 있는 데이터보다 더 많은 데이터를 버퍼에 채우는 것이다. 이 자주 발견되는 프로그래밍 오류를 이용하여 임의의 코드를 실행하는데 어떻게 사용될까? 다른 예시를 보자: 


example2.c

------------------------------------------------------------------------------

void function(char *str) {

   char buffer[16];


   strcpy(buffer,str);

}


void main() {

  char large_string[256];

  int i;


  for( i = 0; i < 255; i++)

    large_string[i] = 'A';


  function(large_string);

}

------------------------------------------------------------------------------

 이 프로그램은 전형적인 버퍼오버플로우 코딩 에러를 발생시키는 함수를 가지고 있다. 이 함수는 strncpy() 대신 strcpy()를 사용하여 문자열 길이의 한계를 확인하지 않고 제공된 문자열을 복사한다. 만약 이 프로그램을 실행한다면 세그멘테이션 위반(segmentation violation)이 발생할 것이다. 함수를 부를 때의 스택을 보자:



bottom of                                                            top of

memory                                                               memory

                  buffer            sfp   ret   *str

<------          [                ][    ][    ][    ]


top of                                                            bottom of

stack                                                                 stack


 여기서 어떤 일이 발생하는가? 왜 segmentation violation이 발생하는가? 간단하게 strcpy()는 *str(larger_string[])의 내용들을 null문자가 나올 때 까지 buffer[]에 복사한다. buffer[]이 *str보다 훨씬 작은 것을 볼 수 있다. buffer[]의 크기는 16바이트의 길이지만 우리는 256바이트로 채우려 할 것이다. 이는 스택에 있는 buffer뒤의 250바이트의 내용들이 덮어 씌어지는 것이다. 이것은 SFP, RET와 심지어 *str을 포함한다! 우리는 large_string을 문자 ‘A’로 채웠다. 이것의 16진수 값은 0x41이다. 이는 return address(복귀 주소)가 이제 0x41414141이 된다는 것이다. 이것은 프로세스 주소 공간의 밖에 있는 것이다. 이것이 함수가 반환되고 해당 주소에서 다음 명령을 읽으려고 하면 분할 위반(segmentation violation)이 발생하는 이유이다. 


 그래서 버퍼오버플로우는 함수의 복귀 주소(return address)가 바뀌게 한다. 우리는 이 방법을 사용해서 프로그램의 실행 흐름을 바꿀 수 있다. 다시 첫번째 예시로 가서 스택이 어떻게 생겼었는지 생각해보자:


bottom of                                                            top of

memory                                                               memory

           buffer2       buffer1   sfp   ret   a     b     c

<------   [            ][        ][    ][    ][    ][    ][    ]


top of                                                            bottom of

stack                                                                 stack


 첫번째 예시를 수정하여 반환 주소(return address)를 덮어쓰고 임의 코드를 실행하는 방법을 시연해 보자. 스택에서 buffer1[]이전은 SFP이고, SFP 바로 앞은 복귀 주소(RET)이다. buffer1[]의 끝을 지나는 것은 4바이트이다. 하지만 buffer[]의 실제로 2word이므로 8바이트의 길이라는 것을 기억해야한다. 그러므로 복귀 주소는 buffer[]의 시작으로부터 12바이트의 크기이다. 함수 호출 후, 우리는 대입문 ‘x = 1;’이 점프되는 방식으로 반환 값을 수정할 것이다. 그렇게 하기 위해 우리는 반환 주소에 8바이트를 추가하였다. 코드는 다음과 같다:


example3.c:

------------------------------------------------------------------------------

void function(int a, int b, int c) {

   char buffer1[5];

   char buffer2[10];

   int *ret;


   ret = buffer1 + 12;

   (*ret) += 8;

}


void main() {

  int x;


  x = 0;

  function(1,2,3);

  x = 1;

  printf("%d\n",x);

}

------------------------------------------------------------------------------


 우리가 한 일은 buffer1[]의 주소에 12만큼 더했다. 이 새로운 주소는 반환 주소가 저장되는 것이다. 우리는 printf호출에 대한 할당은 건너뛰고 싶다. 우리는 반환 주소(return address)에 을 추가하는 방법을 어떻게 알았나? 우리는 먼저 테스트 값을 사용했다(예제 1번을 위해), 프로그램을 컴파일했고, gdb를 실행했다:


------------------------------------------------------------------------------

[aleph1]$ gdb example3

GDB is free software and you are welcome to distribute copies of it

 under certain conditions; type "show copying" to see the conditions.

There is absolutely no warranty for GDB; type "show warranty" for details.

GDB 4.15 (i586-unknown-linux), Copyright 1995 Free Software Foundation, Inc...

(no debugging symbols found)...

(gdb) disassemble main

Dump of assembler code for function main:

0x8000490 <main>:       pushl  %ebp

0x8000491 <main+1>:     movl   %esp,%ebp

0x8000493 <main+3>:     subl   $0x4,%esp

0x8000496 <main+6>:     movl   $0x0,0xfffffffc(%ebp)

0x800049d <main+13>:    pushl  $0x3

0x800049f <main+15>:    pushl  $0x2

0x80004a1 <main+17>:    pushl  $0x1

0x80004a3 <main+19>:    call   0x8000470 <function>

0x80004a8 <main+24>:    addl   $0xc,%esp

0x80004ab <main+27>:    movl   $0x1,0xfffffffc(%ebp)

0x80004b2 <main+34>:    movl   0xfffffffc(%ebp),%eax

0x80004b5 <main+37>:    pushl  %eax

0x80004b6 <main+38>:    pushl  $0x80004f8

0x80004bb <main+43>:    call   0x8000378 <printf>

0x80004c0 <main+48>:    addl   $0x8,%esp

0x80004c3 <main+51>:    movl   %ebp,%esp

0x80004c5 <main+53>:    popl   %ebp

0x80004c6 <main+54>:    ret

0x80004c7 <main+55>:    nop

------------------------------------------------------------------------------


 우리는 function()을 호출할 때 RET가 0x8004a8이 될 것이고, 우리는 예전 명령인 0x8004ab를 점프하고 싶을 것이다. 우리가 실행하고 싶은 다음 명령은 0x80004b2에 있다. 약간의 계산을 해보면 거리가 8바이트라는 것을 알 수 있다.


다음 내용: 쉘코드

반응형