본문 바로가기

시스템

[시스템] 쉘코드를 작성해보자.

쉘 코드란 쉘을 실행시키는 코드를 말하며, 쉘이란 실행파일을 실행시키거나 커널에 어떠한 명령을 내릴 수 있는 대화통로이다.

 

우선 쉘코드를 만들기 위해서 먼저 쉘을 실행시키는 프로그램을 작성해야한다.

이때 쉘을 실행시키는 프로그램은 C언어로 작성하며 컴파일을 통하여 어셈블리 코드로 변환 후 라이브러리에 종속적이지 않도록 만들어주어야 한다.

 

먼저 C언어로 작성한 쉘 실행 프로그램은 다음과 같으며, 프로그램을 실행시키면 쉘이 실행되는 것을 확인할 수 있다.

execve()를 통해 쉘을 실행시키는데 execve()란 바이너리 형태의 실행파일이나 스크립트 파일을 실행시키는 함수이다.

 

execve()가 어떠한 동작을 하는지 내부를 살펴보도록 하자.

이 프로그램에서의 execve()는 Dynamic Link Library로 링킹되어 있기 때문에 이를 Static Link Library형태로 다시 컴파일 해주어야 한다. 따라서 gcc의 -static 옵션을 주어 다시 컴파일해보자.

 

위 그림은 gdb로 execve()부분을 디스어셈블한 부분으로 빨간 박스안의 부분이 우리가 중점적으로 보아야 할 핵심부분으로 해석은 다음과 같다.

1. esp에서 +0x10에 들어있는 값을 edx에 넣는다.

2. esp에서 +0xc에 들어있는 값을 ecx에 넣는다.

3. esp에서 +0x8에 들어있는 값을 ebx에 넣는다.

 

현재 우리는 esp+0x10, esp+0xc, esp+0x8 위치에 어떠한 값이 있는지 알 수 없다. 물론 우리가 C언어 코드를 작성하였기 때문에 충분히 유추할 수 있지만 여기에 어떠한 값이 들어있는지에 대해서는 잠시 뒤 main()를 살펴보면서 설명하겠다.

 

추가로 아래 명령어 두개 line도 살펴보도록 하자.

eax에 0xb의 값을 넣고 call DWORD PTR gs:0x10을 했다. 여기서 call DWORD PTR gs:0x10의 원형은 int 0x80으로 int 0x80을 최적화한 코드에 해당하며 수행하는 일은 동일하다.

 

int 0x80은 지정된 영역으로 system call을 하는 명령이다.

system call이란 운영체제에게 약속된 행동을 해달라고 요청하는 것이다.

execve()는 커널영역이 소유하고 있는 명령으로 프로그램은 운영체제의 커널에 접근할 수 없으므로 커널에게 execve()를 호출해달라고 요청하게 된다. 요청을 받은 커널은 프로그램의 제어권을 커널모드로 변환한 후 execve()를 호출한다. 호출을 마친 커널은 제어권을 다시 프로그램에게 넘겨주게 된다.

 

system call을 하기전에 mov eax, 0xb를 해주었는데 이는 목록에서 0xb에 해당하는 함수를 호출하겠다는 뜻이다.

그러면 0xb에 해당하는 함수가 무엇인지 확인해보자.

cat /usr/include/asm/unistd_32.h | more 명령어를 통해 확인가능하다.

0xb는 16진수이므로 10진수로는 11이다. 따라서 0xb는 execve()가 정의되어 있는 것을 확인할 수 있다.

이를 통해 두줄의 명령어가 execve()를 호출한 것임을 알 수 있다.

 

그러면 이제 execve()가 호출되기 이전에 main()에서는 어떠한 일이 일어났는지 살펴보도록 하자.

main함수를 디스어셈블한 코드로 breakpoint를 call 0x806c020 <execve>로 설정한 다음 실행을 한다면 스택상의 구조는 다음과 같을 것이다.

그렇다면 0x80abda8, 0xfffd1ac에는 무슨 값이 들어있는 것인지 알아보자.

0x80abda8의 0x6e69622f, 0x0068732f를 아스키코드로 변환하면 /bin/sh이 나온다는 것을 알 수 있다. (little-endian 조심하자.)

0xffffd1ac는 /bin/sh문자열이 들어있는 주소를 가지고 있는 주소인 것을 알 수 있다.

 

그리고 조금전에 살펴보았던 execve()의

mov edx, DWORD PTR [esp+0x10]

mov ecx, DWORD PTR [esp+0xc]

mov ebx, DWORD PTR [esp+0x8]

명령어에서 edx, ecx, ebx 레지스터에 어떠한 값이 들어간 것인지 알 수 있는데 이는 execve()의 인자가 들어간 것임을 알 수 있다. 물론 위 스택상의 구조그림은 execve()의 첫번째 명령어(push ebx)가 실행되기 전임을 고려해야 한다.

또한 왜 execve()가 호출되기도 전에 인자가 들어가는지에 대해 의문을 가질 수 있는데 이는 스택프레임이 생성되기전에 argc, argv, 환경변수 등의 값이 들어가고 return address가 들어간 다음 프롤로그가 시작되는 개념을 생각한다면 쉽게 이해가 될 것이다.

 

이 과정을 통해서 우리는 쉘이 띄어지는 과정에 대해 알아보았는데 이러한 과정을 기반으로 우리가 직접 어셈블리 코드를 만들 수 있다.

 

-----shell-----

push 0x0 // NULL을 넣어줌

push 0x0068732f // /sh\0을 넣어줌

push 0x6e69622f // /bin을 넣어줌

mov ebx, esp // 현재 esp는 /bin/sh\0문자열을 넣은 지점

-----execve()인자들-----

push 0x0 // NULL을 넣어줌

push ebx // /bin/sh\0의 포인터를 넣어줌

mov ecx, esp // 현재 esp는 /bin/sh\0의 포인터, ecx에는 /bin/sh\0의 포인터의 포인터가 들어감

mov edx, 0 // edx에 0을 넣어줌

mov eax, 0xb //system call vector 지정

int 0x80 //system call

 

이는 Intel문법으로 리눅스는 AT&T문법을 사용하기 때문에 AT&T문법으로 작성해주어야한다. 

코드를 완성했다면 코드가 제대로 동작하는지 실행해보자.

제대로 동작하는 것을 확인할 수 있다.

 

objdump하여 기계어에 해당하는 부분을 보니 00(NULL)부분이 많은 것을 확인할 수 있다.

push 0x0 명령어에서 0을 사용하기 때문에 당연히 생길 수 밖에 없을 것이다. (그밖의 코드에서도 0이 생성되는 것을 확인할 수 있다.)

우리는 쉘코드를 16진수 형태의 바이너리 데이터를 문자열 형태로 전달할 것인데 문자열에서의 0은 문자열의 끝을 의미한다. 따라서 이 형태의 쉘코드를 사용한다면 기계어코드 전체가 읽히는게 아닌 0까지의 기계어코드만 읽히게 될 것이다.

따라서 우리는 NULL을 제거해주어야 하며 다음과 같이 제거할 수 있다.

 

xor eax, eax // 같은 수를 xor연산하면 0(NULL)이 됨

push eax // NULL을 PUSH

push 0x68732f2f // //sh문자열을 넣어줌

push 0x6369622f // bin문자열을 넣어줌 /bin/sh과 /bin//sh은 동일

mov ebx, esp // 현재 esp는 /bin//sh문자열을 넣은 지점

push eax // NULL을 PUSH

push ebx // /bin//sh의 포인터를 넣어줌

mov ecx, esp // 현재 esp는 /bin//sh의 포인터, ecx에는 /bin//sh의 포인터의 포인터가 들어감

mov edx, eax // edx에 NULL을 넣어줌

mov al, 0xb // system call vector 지정 al레지스터에 넣어줌

int 0x80 // system call

 

AT&T문법으로 작성한 어셈블리 코드이다.

다시 작성한 코드를 objdump해서 살펴보니 NULL이 없는 것을 확인할 수 있다.

따라서 이 기계어코드를 문자열로 전달한다면 기계어 코드 전체가 전달될 것이다.

실행 또한 정상적으로 된다는 것을 확인할 수 있다.

 

위에서 objdump한 기계어 코드를 문자열배열에 넣기 위해 변환하면 다음과 같은 쉘코드가 만들어진다.

\x31\xc0\x50\x68\x2f\x2f\x73\x68\x68\x2f\x62\x69\x6e\x89\xe3\x50\x53\x89\xe1\x89\xc2\xb0\x0b\xcd\x80

이 쉘코드를 실행시키기 위한 프로그램을 작성하고 실행시켜 보자.

정상동작하는 것 또한 확인할 수 있다.

 

잠깐 -z execstack옵션에 대해 알아보고 가자.

메모리는 일반 파일과 같이 rwx 세가지 속성값을 가진다. 위 그림을 살펴보면 스택은 x(실행)에 대한 권한이 없음을 알 수 있다.(힙영역도 마찬가지이다.) 따라서 스택영역에 존재하는 바이너리(쉘코드)를 실행하지 못하게 되는 것이다. -z execstack옵션 사용하면 스택영역의 바이너리를 실행할 수 있다. 쉘코드를 사용할 때 종종 segmentation fault 에러를 목격할 수 있는데 이는 -z execstack옵션을 사용해 해결할 수 있다.

 

이어서 쉘코드 실행 프로그램의 디스어셈블을 통해 동작원리를 알아보자.

빨간 박스안의 부분이 핵심이 되는 코드이다. 먼저 ebp-0x4의 주소를 eax에 넣어준다. 그다음 eax(ebp-0x4의 주소)에 0x8을 더해준다. C언어 코드에서 ret = (int *)&ret + 2; 부분에 해당하는 부분이다. 자료형이 int형인 포인터에 2를 더했으므로 실제로는 8이 더해지게 된다. 그 결과 eax는 ebp+0x4가 되는 것을 알 수 있으며 우리는 ebp+0x4에 return address가 존재한다는 것을 알고 있다. 그다음으로는 eax레지스터의 값(ebp+0x4)을 ebp-0x4지점에 넣어준다. 그다음 ebp-0x4의 포인터(ebp+0x4)를 eax에 넣어준다. 그리고 edx에 edx+0x2c의 주소를 넣는다. 이 주소에는 char sc[]가 들어가 있을 것이고 이 주소를 eax포인터(ebp+0x4지점)에 넣어준다. 이렇게 되면 return address가 있는 지점에 return address대신 char sc가 들어가게 된다. 마지막으로 해당 함수가 종료되고 EIP는 return address(쉘코드문자열이 들어있는 주소)를 가리키게 되고 곧이어 쉘이 실행되는 것이다.

 

이것으로 쉘코드 작성법과 쉘코드의 동작원리에 대해 간단하게 알아보았다.

 

반응형