프로그램 구동 시 segment에서 일어나는 일
프로그램 구동 시 메모리와 레지스터가 어떻게 동작하는지 살펴보기 위해 다음과 같은 프로그램을 이용하겠다.
void function(int a, int b, int c){
char buffer1[15];
char buffer2[10];
}
void main(){
function(1, 2, 3);
}
이제 위 C 프로그램을 어셈블리 코드로 변환하겠다. (리눅스 gcc 사용)
어셈블리 프로그램은 다음과 같이 simple.asm이라는 파일 이름으로 생성되었다.
이제 simple.c 프로그램이 컴파일 되고, 실제 메모리 어느 위치에 자리하게 될지 알아보기 위해 gdb를 이용할 것이다. gdb를 이용해서 segment의 logical address를 알아보자.
위 사진에서 앞에 나와있는 주소가 바로 logical address다.
사진을 자세히 보면 fucntion이 main보다 아래쪽에 위치하는 것을 알 수 있다.
위 결과를 통해 simple의 segment는 다음과 같다고 생각할 수 있다.
segment의 크기는 프로그램마다 다르기 때문에 최상위 메모리의 주소는 적지 않았다.
실제 프로그램이 컴파일과 링크되는 과정에서 다른 라이브러리를 필요하기 때문에 코드가 시작되는 지점은 segment의 시작점과 일치하지는 않을 것이다. 또한 필요한 환경 변수나 실행 옵션으로 주어진 변수 등에 의해서 stack segment의 가용 영역은 최상위 메모리 주소보다 아래에 위치하고 있다. 그리고 simple.c에서는 전역변수를 만들지 않았기 때문에 data segment에는 링크된 라이브러리의 전역변수 값이 들어가 있을 것이다.
이제 simple 프로그램의 명령어를 하나씩 따라가 보자. 그전에 ESP가 어느 지점을 가리키는지 확인하기 위해 gdb로 레지스터 값을 알아보겠다.
확인 결과 esp는 0xffffddf8를 가리키고 있다.
< Step 1 >
EIP는 main()함수의 시작점을 가리키고, ESP는 stack의 제일 꼭대기를 가리키고 있다. ESP가 stack의 맨 꼭대기를 가리키는 이유는 프로그램이 실행되면서 PUSH와 POP 명령이 수행되기 때문이다. ESP가 가리키는 영역에서 PUSH와 POP 명령이 일어난다. 그리고 system architecture에 따라 PUSH와 POP 명령이 수행되는 영역이 조금씩 다르다. ESP가 가리키는 지점 또는 그 위아래 데이터에서 PUSH와 POP 명령이 동작할 수 있다.
그리고 이전에 수행하던 함수의 데이터를 보존하기 위해서 ebp를 저장한다. ebp는 base pointer라고도 부른다. 따라서 함수가 시작될 땐 stack pointer와 base pointer를 새로 지정하는데 이 과정을 함수 프롤로그 과정이라고 한다.
< Step 2 >
이제 어셈블리어의 내용을 해석할 텐데 여기서부터는 달고나 문서에 나오는 자료를 참고하여 설명하겠다.
push %ebp를 수행하여 이전 함수의 base pointer를 저장하면 stack pointer는 4바이트 아래인 0xbffffa78을 가리키게 된다(바뀌기 전 원래 stack pointer는 0xbffffa7c였다).
mov %esp, %ebp를 수행하여 ESP값을 EBP에 복사했다. 따라서 함수의 base pointer와 stack pointer가 같은 지점을 가리키게 된다.
sub $0x8, %esp는 ESP에서 8을 빼는 명령이다. 따라서 ESP는 8바이트 아래 지점을 가리키게 되고 stack에 8바이트의 공간이 생기게 되는데 이것을 stack이 8바이트 확장되었다고 말한다. 이 명렁이 수행되고 나면 ESP에 0xbffffa70이 들어간다.
and $0xfffffff0, %esp은 ESP와 11111111 11111111 11111111 11110000을 AND 연산하라는 명령이다. 이것은 ESP의 주소 값의 맨 뒤 4bit를 0으로 만들기 위함이다(별 의미 없는 명령임).
mov $0x0, %eax EAX 레지스터에 0을 넣고
sub %eas, %esp ESP에 들어 있는 값에서 EAX에 들어 있는 값만큼 뺀다. 이것 역시 stack pointer를 EAX만큼 확장시키려는 것이지만 EAX가 0이므로 의미 없는 명령이다.
sub $0x4, %esp stack을 4바이트 확장하였다. 따라서 ESP에 들어있는 값은 0xbffffa6c가 된다.
< Step 3 >
지금까지의 명령을 수행한 결과는 다음과 같다.
다음으로 수행할 명령은 push $0x03 push $0x02 push $0x01이다.
이 명령으로 function(1, 2, 3)을 수행하기 위한 인자값 1, 2, 3을 차례로 넣어준다. push하는 순서가 3, 2, 1인 것은 stack에서 꺼낼 때 1, 2, 3순으로 꺼내기 위해서다.
이 값들이 왜 여기에 들어가는지는 <그림 1>에서 argc, argv가 위치한 자리와 밑에서 설명할 function()의 프롤로그가 끝난 다음의 stack의 모습을 보면 이해가 될 것이다.
call 0x80482f4 명령은 0x80482f4에 있는 명령을 수행하라는 뜻이다. 위 그림을 보면 0x80482f4는 function함수가 위치한 곳이다
call 명령은 함수를 호출할 때 사용하는 명령으로 함수 실행이 끝나면 다음 명령을 계속 수행할 수 있도록 다음 명령이 있는 주소를 stack에 넣은 다음 EIP에 함수의 시작 주소를 넣는다.
위 그림을 보면 EIP에는 function() 함수가 있는 0x80482f4 주소값이 들어가게 되고, stack에는 "add $0x10, %esp" 명령이 있는 주소값이 들어가게 된다.
때문에 fucntion() 함수 수행이 끝나고 나면 stack에서 POP하여 다음 명령이 "add $0x10, %esp" 라는 것을 알 수 있게 되는데, 이것이 buffer overflow에서 가장 중요한 return address 이다.
< Step 4 >
이제 EIP는 function() 함수가 시작되는 위치를 가리키고 있고 stack에는 main() 함수에서 넣었던 값들이 쌓여있다.
push %ebp
mov $esp, %ebp
function() 함수에서도 마찬가지로 함수 프롤로그가 수행된다. main() 함수에서 사용하던 base pointer가 저장되고 stack pointer를 function() 함수의 base pointer로 삼는다.
< Step 5 >
function() 함수의 프롤로그가 끝나고 만난 명령은 sub $0x28, %esp이다. 이것은 stack을 28바이트 확장한다.
28바이트를 확장하는 이유는 function() 함수에서 지역 변수로 buffer1[15]와 buffer2[10]을 선언했기 때문인데, stack은 word (4byte) 단위로 자라기 때문에 buffer1[15]에는 16바이트를 할당하고, buffer2[10]에는 12바이트를 할당한다. 따라서 확장 되어야 할 stack의 크기는 28바이트이지만, gcc버전에 따라서 달라질 수 있다.
gcc 2.96 미만의 버전에서는 word 단위로 할당되어 28바이트가 확장되지만, gcc 2.96 이후의 버전에서는 stack이 16배수로 할당된다(단, 8바이트 이하의 버퍼는 1word 단위로, 9바이트 이상의 버퍼는 4word 단위로 할당됨). 그리고 8바이트의 dummy 값이 들어간다. 때문에 buffer1[15], buffer2[10]에 16바이트가 할당되고, 추가로 8바이트의 dummy가 들어가 총 40바이트의 stack이 확장될 수도 있다. 달고나 문서에 따르면 위 예시에서는 40바이트가 확장됐다고 한다.
그리고 function() 함수의 인자는 function() 함수의 base pointer와 return address(main함수의 다음 명령이 있는 주소)위에 존재하게 된다. 이것은 <그림 1>에서 보는 바와 같이 main() 함수가 호출 될 때 주어지는 인자 argc, argv가 위치한 곳과 같은 배치를 갖고 있다.
< Step 6 >
이렇게 만들어진 버퍼에는 이제 우리가 필요한 데이터를 쓸 수 있게 된다.
보통 mov $0x41, [$esp -4] mov $0x42, [$esp -8]과 같은 형식으로 ESP를 기준으로 stack의 특정 지점에 데이터를 복사해 넣는 방식으로 동작한다.
simple.c에는 데이터를 넣는 과정이 없으므로 stack이 만들어지는 과정까지 확인하는 것으로 만족하자!
이제 stack을 살펴볼 거다.
stack은 <그림 8>과 같은 형태를 갖게 된다.
< Step 7 >
이제 leave instruction을 수행할 차례다. leave instruction은 함수 프롤로그 작업을 되돌리는 일을 한다.
함수 프롤로그는 push %ebp와 mov %esp, %ebp 였다.
이것을 되돌리는 작업은 mov %ebp, %esp와 pop %ebp 이다. leave instruction 하나가 이 두 가지 일을 한꺼번에 처리한다.
stack pointer를 이전의 base pointer로 잡아서 function() 함수에서 확장했던 stack 공간을 없애고, PUSH해서 저장해 두었던 이전 함수 즉, main() 함수의 base pointer를 POP으로 복원 시킨다.
POP을 했으므로 stack pointer는 1 word 위로 올라간다.이제 stack pointer는 return address가 있는 위치를 가리키고 있을 것이다.
ret instruction은 이전 함수로 return하라는 의미이다. EIP 레지스터에 return address를 POP하여 집어 넣는 역할을 한다. 이 동작을 표현하면 "pop %eip"라고 할 수 있겠지만 EIP 레지스터는 직접적으로 수정할 수 없기 때문에 저런 명령이 통하지는 않는다.
< Step 8 >
ret를 수행하고 나면 return address는 POP되어 EIP에 저장되고 stack pointer는 1 word 위로 올라간다.
add $0x10, %esp는 stack을 16바이트 줄인다. 따라서 stack pointer는 0x804830c에 있는 명령을 수행하기 이전의 위치로 돌아가게 된다.
leave와 ret를 수행하게 되면 각 레지스터들의 값은 main() 함수 프롤로그 작업을 되돌리고, main() 함수 이전으로 돌아가게 된다.
Buffer overflow의 이해
버퍼(buffer)는 컴퓨터가 연산하는데 있어 필요한 데이터를 일시적으로 저장하는 저장공간이다. buffer overflow는 이름 그대로 정해진 buffer의 크기를 넘어서는 데이터를 저장하면 발생한다. buffer overflow가 발생하면 buffer에 저장된 기존의 데이터들을 잘못된 데이터로 덮어쓸 수 있다. 이러한 점을 이용해 buffer overflow 공격이 발생하게 된다.
CPU는 EIP 레지스터가 전달해주는 명령을 처리하게 되는데 만약 EIP에 공격자가 의도적으로 다른 명령을 입력시킨면 어떻게 될까? 해당 컴퓨터는 해커가 의도하는 대로 동작하게 될 지도 모른다. 공격자가 buffer overflow 공격을 하게 된다면 이러한 일들이 실제로 가능하다.
보통 return address는 현재 함수의 base pointer 바로 위에 존재한다. 공격자가 그 위치를 직접 변경하지 않는다면 해당 위치는 EIP에 들어가게 되어 있다. 따라서 공격자가 메모리상의 임의의 위치에다가 어떤 코드를 저장해두고, 그 주소를 return address가 있는 지점에 넣게 된다면 EIP에 공격자가 입력한 코드의 주소가 들어가게 됨으로써 악의적인 목적으로 만든 코드를 실행시킬 수 있다.
(공격자는 버퍼가 넘칠 때, 즉 버퍼에 데이터를 쓸 때 원하는 코드를 넣을 수가 있다. 이때 정확한 return address가 저장되는 곳을 찾아 return address도 정확하게 조작해야 한다. )
<그림 8>과 simple.c로 더 자세히 살펴보자.
function() 함수 내에서 정의한 buffer1[15]와 buffer2[10]의 버퍼가 있고 여기에는 40바이트의 버퍼가 할당되어 있다. function() 함수 내에서는 사용하지 않았지만 이 버퍼에 데이터를 넣으려 한다고 생각해보자. 다음과 같은 코드를 예로 들겠다.
strcpy(buffer2, receive_from_client);
위 코드는 client로부터 수신한 데이터를 buffer2와 buffer1에 복사한다. strncpy() 같은 함수는 몇 바이트를 저장할 지 지정해 주지만, strcpy() 함수는 길이 체크를 하지 않기 때문에 receive_from_clietn에 있는 데이터를 NULL(\0)를 만날 때까지 복사해야 한다.
위와 같은 데이터를 공격자가 전송한다고 할 때, 이 데이터는 receive_frome_client에 저장되고, strcpy가 호출되면 receive_from_client가 buffer2에 복사된다. 그 결과는 다음과 같을 것이다.
strcpy가 호출되고 나면 stack안의 데이터는 다음과 같아진다.
<그림 13>은 receive_from_clietn의 데이터를 버퍼에 복사한 후의 모습인데, 데이터를 잘 보면 <그림 12>에서 만들어낸 데이터와 순서에 약간의 차이가 있는 것을 볼 수 있다.
< Byte order >
byte oder는 바이트의 정렬 방식이다. 데이터의 저장 순서가 바뀐 이유도 이 때문이다. 현재 byte order는 big endian 방식과 little endian 방식으로 크게 두 가지가 있다. big endian 방식은 데이터에서 단위가 큰 부분을 낮은 메모리 주소에서 높은 메모리 주소 방향으로 저장하는 방법이다. little endian 방식은 그 반대로 데이터에서 단위가 작은 부분은 낮은 메모리 주소에서 높은 메모리 주소 방향으로 저장하는 방법이다.
예를 들어 74E3FF59라는 16진수 값을 저장한다면 big endian에서는 74E3FF59 순서대로 저장되고, little endian에서는 59FFE374 순서대로 저장된다.
때문에 공격 코드의 바이트를 정렬할 때에는 byte order를 고려해야 한다.
little endian 시스템에 return address 값을 넣을 때는 바이트 순서를 뒤집어 넣어줘야 한다.
이제 buffer overflow를 이용한 공격이 어떻게 일어나는지 다시 살펴보자!
쉘 코드가 들어있는 곳의 주소를 찾았다고 생각하자. <Step 8>의 그림을 참고해 볼 때 주소는 0xbffffa60이다. <그림 13>을 다시 그려 쉘 코드와 return address를 그려보면 다음과 같다.
<그림 14>에서 보여주는 공격 코드는 execve("/bin/sh", ...)이다. 즉 쉘을 띄우는 것이다. 쉘 코드의 시작 시점은 stack상의 0xbffffa60이다. 따라서 함수가 리턴될 때 return address는 EIP에 들어가게 될 것이고 EIP는 0xbffffa60에 있는 명령을 수행할 것이므로 execve("bin/sh", ...)를 수행하게 된다. 이게 바로 buffer overflow를 이용한 공격 방법이다.
그러나 이 방법에는 한 가지 문제점이 있다.
<그림 14>에서 공격 코드는 총 24byte 공간 안에 들어가 있다. 만약 return address위의 버퍼 공간이 쉘 코드를 넣을 만큼 충분하지 않다면 다른 공간을 찾아야 한다는 것이다. 이때 사용할 수 있는 공간은 바로 90909090...이 들어가 있는 function() 함수가 사용한 stack 공간이다. 이 공간은 main() 함수의 base pointer가 저장되어 있는 4byte까지 합해서 총 44byte를 차지하고 있다.
그 공간에 쉘 코드를 넣었다면 EIP 레지스터가 해당 영역에 있는 명령을 가리킬 수 있도록 해야 한다. return address를 그 공간의 주소로 직접 지정하면 좋겠지만, 해당 명령어가 있는 주소를 정확히 알아내는 것은 매우 어렵다. 따라서 간접적으로 그 곳으로 명령 수행 지점을 변경해 주는 방법을 사용한다.
<그림15>는 또 다른 공격 코드의 배치로 쉘 코드가 return address 아래에 있다.
<Step 7>을 연상해보자. 함수가 실행을 마치고 return할 때 return address가 stack에서 POP되어 EIP에 들어가고 나면 stack pointer는 1 word 위로 이동한다. 따라서 ESP는 return address가 있던 자리 위를 가리키게 된다.
EIP는 0xbffffa60을 가리키고 있을 테니 그 곳에 있는 명령을 수행할 것이다. <그림 14>에서 쉘 코드가 있던 자리에는 다음과 같은 코드가 들어갔다.
ESP가 가리키는 지점을 쉘 코드가 있는 위치를 가리키도록 48byte를 빼주고 jmp %esp instruction을 수행하여 EIP에 ESP가 가리키는 지점의 주소를 넣도록 한다. 이 방법은 ESP 레지스터가 사용자가 직접 수정할 수 있는 레지스터이기 때문에 가능하다.
< shell code 만들기 >
쉘은 흔히 명령어 해석기라고 불리는데 사용자의 키보드 입력을 받아서 실행파일을 실행시키거나 커널에 어떠한 명령을 내릴 수 있는 대화통로이다. 쉘 코드는 바이너리 형태의 기계어 코드로 쉘(shell)을 실행시키는데 사용한다.
8bit 퍼스널 컴퓨터 시절에는 기계어 코드를 직접 작성했었지만, 지금은 CPU instruction의 종류가 늘어났고 커널이 복잡해져서 하기 힘든 작업이다. 그래서 C를 이용하여 간단한 프로그램을 작성한 다음 컴파일러가 변환시켜준 어셈블리 코드를 최적화 시켜 쉘 코드를 생성하겠다.
- 쉘 실행 프로그램
쉘 상에서 쉘을 실행시키려면 '/bin/sh'이라는 명령을 내리면 된다. 마찬가지로 쉘 실행 프로그램 역시 이 명령을 내리는 것과 똑같은 일을 하도록 해주면 된다. 아래의 코드를 보자.
쉘을 실행시키기 위해 execve()라는 함수를 사용했다. 이 함수는 바이너리 형태의 실행파일이나 스크립트 파일을 실행시키는 함수이다. execve() 함수의 첫 번째 인자는 파일 이름, 두 번째 인자는 함께 넘겨줄 인자들의 포인터, 세 번째 인자는 환경 변수 포인터이다.
두 번째 인자인 인자들의 포인터는 C 프로그램의 main() 함수에 argv라는 인자를 떠올리면 된다. argv[0]은 해당 프로그램의 실행 파일 이름을 나타내고 argv[1]은 실행 시 주어진 첫 번째 인자인 것처럼 execve()의 두 번째 인자는 argv[0]부터 들어가는 값을 가리키는 포인터가 되어야 한다.
이 프로그램이 컴파일되어 생성될 바이너리 코드를 얻어야 하는데 execve() 함수 때문에 이 프로그램은 컴파일되면서 Linux libc와 링크된다. execve()의 실제 코드가 libc에 들어있기 때문이다. 따라서 execve()가 어떤 일을 하는지도 알아보기 위하여 static library 옵션을 주어 컴파일해야 한다.
< Dynamic Link Library & Static Link Library >
응용프로그램의 실행에 있어서 실제 프로그램의 동작에는 매우 많은 명령들이 사용된다. 그리고 많은 응용프로그램들이 공통적으로 사용하는 명령어들이 있다.
예를 들어 C 언어에서 사용하는 printf() 함수는 어떤 문자열을 출력하는 함수다. 이러한 일을 수행하는 기계어 코드가 어떤 형태로 만들어져 있을 것이다. 가령 'ps'와 'cat'이라는 프로그램도 printf() 함수를 사용하여 화면에 무언가를 출력할 것이다. 그런데 'ps'도 printf()기능의 기계어 코드를 포함하고 있고 'cat'도 printf()기능의 기계어 코드를 포함하고 있다면 같은 기능을 하는 기계어 코드가 서로 다른 실행파일에 모두 포함되게 된다.
이러한 저장 공간의 낭비를 막기 위해 운영체제는 많이 사용되는 함수들의 기계어 코드를 라이브러리 형태로 자신이 가지고 있다. 리눅스에서는 libc라는 라이브러리가 있고 실제 파일로는 .so 혹은 .a라는 확장자를 가진 형태로 존재한다. 윈도우에서는 DLL(Dynamic Link Library) 파일로 존재한다.
하지만 운영체제의 버전과 libc의 버전에 따라 호출 형태나 링크 형태가 달라질 수 있다. 그 영향을 받지 않기 위해서 printf() 기계어 코드를 실행파일이 직접 가지고 있게 할 수 있는 방법이 있는데 그 방법이 Static Link Library이다. 다만 Dynamic Link Library 방식보다 실행파일의 크기가 더 커질 것이다.
이제 sh.c 프로그램에서 호출하는 execve() 함수 내부를 들여다 보기 위해 Static Link Library 형태로 컴파일 한 후 기계어 코드를 살펴보겠다.
위 사진처럼 sh.c를 static link library(-static)로 컴파일 하여 sh라는 실행파일을 만들었다. 그리고 objdump를 이용하여 기계어 코드를 출력하게 했다. objdump로 sh를 덤프하면 엄청 긴 내용이 나오기 때문에 필요한 부분 execve() 함수만 보기 위해서 grep을 했고, execve() 부분을 보니 32라인이면 다 보이기 때문에 -A 32 옵션을 주어 32라인만 출력하게 했다.
덤프된 코드는 세 개의 column으로 출력되는데 맨 왼쪽은 address를 나타내고 가운데는 기계어 코드, 맨 오른쪽은 기계어 코드에 대응하는 어셈블리어 코드를 나타낸다. 기계어 코드는 어셈블리어 코드와 1:1 대응이 된다.
여기서부터는 달고나 문서에서 사용한 자료를 참고해서 설명하겠다.
execve() 함수 내에서 보면 함수 프롤로그를 하고 함수 호출 이전에 stack에 쌓인 인자값들을 검사하고 이상이 없으면 interrupt를 발생시켜 시스템 콜(system call)을 한다. 시스템 콜은 운영체제와 약속된 행동을 해 달라고 요청하는 것이다.
execve() 함수는 interrupt를 발생시키기 전에 범용 레지스터에 각 인자들을 집어넣어줘야 한다. 그래서 위 그림과 같은 작업을 하는데 조금 흩어져 있긴 하지만, 정리해보면 mov 0x8(%ebp), %ebx mov 0xc(%ebp), %ecx moc 0x10(%ebp), %edx를 하는 것이다.
이것은 ebp 레지스터가 가리키는 곳의 +8 byte 지점의 값을 ebx 레지스터에 넣고, +12 byte 지점의 값을 ecx 레지스터에 넣고, +16 byte 지점의 값을 edx 레지스터에 넣으라는 뜻이다.
<그림 17>을 보면 ebp는 함수 프롤로그에 의해서 execve()가 호출되고 이전 함수의 base pointer를 PUSH하고 난 다음의 esp가 가리키던 곳을 가리키고 있다. 따라서 ebp +0 byte 지점은 이전 함수의 ebp가 들어가 있을 것이다. 그리고 ebp +4 byte 지점은 return address가 들어가 있을 것이고, ebp + 8, ebp + 12, ebp + 16 지점은 execve() 함수가 호출되기 이전 함수에서 execve() 함수의 인자들이 역순으로 PUSH되어 들어갔을 것이다.
그런 다음 eax 레지스터에 11을 넣고 int $0x80을 하였다. 이 과정이 system call 과정이다. int $0x80은 운영체제에 할당된 인터럽트 영역으로 system call을 하라는 뜻이다. int $0x80을 호출하기 이전에 eax 레지스터에 시스템 콜 벡터(vector)를 지정해 줘야 하는데 execve()에 해당하는 값이 11(0xb)인 것이다.
즉 11번 시스템 콜을 호출하기 위해 각 범용 레지스터에 값들을 채우고 시스템 콜을 위한 인터럽트를 발생시킨 것이다.
32bit Intel Architecture에서의 인터럽트 및 Exception은 다음과 같다.
<그림 20>에서 볼 수 있듯이 인터럽트 0x80은 'Maskable Interrupts'로써 External interrupt 영역에 있음을 알 수 있다.
이제 execve()를 호출하기 이전에 main()에서는 어떤 처리를 했었는지 알아보자.
main() 함수에서는 execve()를 호출하기 위해서 세 번의 push를 한다. 이는 execve()의 인자로 넘겨주는 값이라는 것을 짐작할 수 있다.
제일 처음 '/bin/sh'라는 문자열이 들어있는 곳의 주소(0x8089728)를 ebp 레지스터가 가리크는 곳의 -8 byte 지점(0xfffffff8)에 넣는다. 그리고 ebp -4 byte 지점(0xfffffffc)에는 0을 넣는다. 이것은 sh.c에서 shell[0] = "/bin/sh"; shell[1] = NULL;와 같은 역할을 한다.
그리고 이제 이 값들을 PUSH하기 시작한다.
push $0x0
NULL을 push하고
lea 0xfffffff8(%ebp), %eax
push %eax
ebp +8의 주소를 eax 레지스터에 넣은 다음에 eax 레지스터를 PUSH한다. 포인터를 PUSH한 것이다.
pushl 0xfffffff8(%ebp)
call 804c75c <__execve>
ebp +8의 값을 PUSH하고 execve()를 호출한다.
위와 같은 수행을 마치고 나면 segment 내의 모습은 다음과 같게 된다.
<그림 23>의 ebp -4와 ebp -8이 바로 포인터들이 모여있는 곳이다. shell[0]은 '/bin/sh'라는 문자열이 있는 곳의 주소를 가지고 있다. '/bin/sh'는 정의된 값이므로 data segment에 위치할 것이다. 그 곳 어딘가의 주소가 0x8089728인 것을 objdump를 하여 알 수 있었다. main() 함수에서 각 값들을 PUSH하여 스택에는 '/bin/sh'가 있는 주소, shell의 주소, 그리고 0이 들어가 있다.
쉘을 띄우기 위환 과정을 다음과 같이 정리할 수 있다.
- 스택에 execve()를 실행하기 위한 인자들을 제대로 배치하고
- NULL과 인자값의 포인터를 스택에 넣어 두고
- 범용 레지스터에 이 값들의 위치를 지정해 준 다음
- interrupt 0x80을 호출하여 system call 12를 호출한다.
위의 코드에서는 '/bin/sh'가 data segment에 저장되어 있기 때문에 data segment의 주소를 이용할 수 있었지만 buffer overflow 공격 시점에서는 '/bin/sh'가 어느 지점에 저장되어 있다는 것을 기대하기 어렵고 만약 알고 있다고 하더라도 저장되어 있는 메모리 공간의 주소를 찾기도 어렵다. 따라서 직접 넣어주어야 할 것이다.
이제 이와 같은 역할을 하는 코드를 작성해 보자.
위와 같은 코드를 작성하면 된다. push '/sh\0'와 push '/bin'은 실제 어셈블리 코드가 아니다. 개념적인 의미로 적은 것이다. 이를 실제 어셈블리 코드로 만들려면 push $0x0068732f push $0x6e69622f으로 해줘야 한다. 문자를 16진수 값으로 바꾼 것으로 little endian 순서이다.
이제 이 코드가 제대로 동작하는지 컴파일 해보겠다. 이 코드는 C 프로그램 내에 인라인 어셈블(inline assemble)로 코딩할 것이고 main() 함수 안에 들어갈 것이기 때문에 함수 프롤로그가 필요 없다. 최종적으로 완성한 sh01.c의 코드는 다음과 같다.
< NULL의 제거 >
이제 기계어 쉘 코드를 얻은 다음에 이것을 문자열 형태로 전달할 것이다. 다행히도 C언어에서는 char형 변수에 바이너리 값을 넣는 방법을 제공하고 있다. 바로 char c = "\x90"과 같은 형태로 값을 넣어주면 컴파일러는 "\x90"을 16진수 90으로 인식하여 1byte 데이터로 저장한다.
그런데 push 0x0와 같은 어셈블리 코드는 기계어 코드로 6a 00이다. 이것을 문자열 형태로 전달하려면 char a[] = "\x6a\x00"과 같이 해주어야 한다. 하지만 문자열에서는 0의 값을 만나면 그것을 문자열의 끝으로 인식하게 된다. 즉 0x00 뒤에 어떤 값이 오더라도 그 이후는 무시해버린다.
0x00와 같은 기계어 코드는 많이 나올 수 있기 때문에 \x00인 기계어 코드가 생기지 않게 만들어줘야 한다. 이러한 문제점을 해결하여 위의 어셈블리 코드를 다시 작성하면 아래와 같이 만들 수 있다.
덤프한 모습을 보면 우리가 필요로 하는 코드 xor %eax, %eax (8048304)이후 부터 int $0x80 (804831b) 사이의 기계어 코드에는 00이 없다.
이제 남은 것은 이것을 문자열화 시키는 것이다. char형 배열에 16진수 형태의 바이너리 데이터를 전달할 것이다. 그러기 위해서는 \x90형식으로 바꿔야한다. 덤프한 코드에서 직접 만든 부분의 기계어 코드를 추출해 보면 아래와 같다.
이것을 문자열 배열에 넣기 위해 다시 가공하면 다음과 같이 만들어낼 수 있다.
문자열을 모두 한 줄에 써줘도 상관없다. 이제 코드를 실행시켜 보자.
위와 같은 방식으로 쉘 코드를 실행시킬 수 있다.
그러면 이 프로그램은 어떤 원리로 동작할까? gdb를 이용하여 disassemble해보자.
disassemble해보면 위와 같은 코드를 확인할 수 있다. 함수 프롤로그가 수정되고 나면 다음과 같은 코드를 수행한다.
먼저 ebp -4 byte 지점의 address를 eax 레지스터에 넣는다. 그 다음 그 address에 8을 더한다. 이것은 sh03.c에서
ret = (int *)&ret + 2; 과정이다. ret라는 포인터 변수의 address를 찾아서 8바이트 상위의 주소로 만든다.
그러면 ebp +4 지점에는 return address가 들어있을 것이다. 그런 다음 return address가 들어 있는 곳의 주소 값을 ebp -4byte 지점에 넣어준다. 그리고 eax 레지스터 값이 가리키는 지점에 $0x804963c를 넣어준다. $0x804963c는 char sc[] 데이터가 있는 지점이다. 따라서 main() 함수가 종료되고 EIP는 return address가 가리키는 지점에 있는 명령을 가리키게 될 것이다.
- 또다른 방법
쉘 코드를 저장할 변수를 int형으로 만드는 방법도 있다. 유의할 점은 little endian 순서로 정렬해야 하며 int형이므로 4byte 단위로 만들어 줘야 한다. 이렇게 만들어진 쉘 코드 실행 프로그램은 다음과 같다.
int형 배열 또는 char형 배열 중 어느 것을 이용하여 실행해도 된다. 다만 int형 배열을 사용할 때는 objdump를 이용하여 얻은 기계어 코드를 little endian 방식으로 재정렬 해줘야 한다는 귀찮은 점이 있다. 또한 대부분의 buffer overflow 공격 방법이 문자열 데이터 처리의 실수를 이용하는 것이므로 char형으로 생성하는 것이 더 편하다.
- setreuid(0,0)와 exit(0)가 추가된 쉘 코드
공격자는 buffer overflow 공격이 성공한 후 보다 많은 권한을 얻고 싶어 할 것이다. setuid 비트가 set되어 있는 프로그램을 이용하면 root 권한을 얻을 수 있다. 그럴러면 setuid 비트가 set되어 있는 프로그램을 오버플로우시켜서 쉘 코드를 실행시키고 루트의 쉘을 얻어낼 방법이 필요하다.
위에서 작성한 쉘 코드 실행 프로그램 sh03.c와 sh04.c는 root권한을 얻어주지 못한다. sh03에 setuid 비트를 붙여 실행시켜도 root 소유의 프로그램 권한을 상속받지 못했기 때문에 아무런 역할을 하지 못한다. 따라서 쉘 코드에 소유자의 권한을 얻어내는 기능이 필요하다. 이를 위해 sh.c를 아래와 같이 수정해보자.
위 코드처럼 setreuid() 함수를 이용하여 프로그램 소유자의 권한을 얻어올 수가 있다. 따라서 쉘 코드에 setreuid()가 하는 기계어 코드를 추가해야 한다.
setreuid()의 기계어 코드와 어셈블리 코드는 아래와 같다.
이것을 직접 만든 쉘 코드 앞부분에 붙여주기만 하면 된다.
한편, 더 완벽한 쉘 코드를 만들기 위해 exit(0)가 필요할 수 있다. 이것은 공격자가 overflow 공격을 수행하고 난 뒤 프로그램을 정상적으로 종료하기 위해서다. 만약 정상 종료가 되지 않는다면 에러 메세지가 발생하여 관리자에게 그대로 전달될 수도 있다.
exit(0)에 대한 기계어 코드는 아래와 같다.
"x31\xc0\xb0\x01\xcd\x80"
지금까지의 내용을 이용해서 sh03.c 코드를 수정해보자.
이렇게 해서 프로그램 소유자의 권한으로 쉘을 실행시키는 쉘 코드를 생성할 수가 있다.
***달고나 문서
'Study > System Hacking' 카테고리의 다른 글
[dreamhack] Memory Corruption - C (I) / 스택 버퍼 오버플로우 (0) | 2021.01.24 |
---|---|
[OverTheWire] bandit / Level 6 -> Level 10 (0) | 2021.01.14 |
[달고나] 정리: 끝까지 (0) | 2021.01.13 |
[OverTheWire] bandit / Level 0 -> Level 5 (0) | 2021.01.10 |
[달고나]: 1~3장 레지스터 구조 (0) | 2021.01.03 |