뉴비에욤

Shellcode 만들기 - 4 (쉘코드 동작 원리 이해 및 제작) 본문

System Hacking(OS)/Shellcode

Shellcode 만들기 - 4 (쉘코드 동작 원리 이해 및 제작)

초보에욤 2015. 10. 1. 16:52

(대부분) 시스템의 명령어 쉘을 실행시키는 기계어 코드로 작성된 쉘코드를 만들기 위해, 먼저 시스템의 명령어 쉘을 실행시키는 간단한 C 언어 프로그램을 작성하고, 이를 어셈블리어 코드로 변환한 뒤, 끝으로 불필요한 부분을 제거하여 최적화된 기계어 코드를 얻는 과정을 거친다.

 

먼저 시스템의 명령어 쉘을 실행시키는 간단한 프로그램을 작성한다.

 

 

본 프로그램에서는 unistd.h 헤더 파일에 정의된 execve 함수를 사용하여 "/bin/sh"에 있는 명령어 쉘을 실해시킨다. execve 함수는 파일 이름이 가리키는 실행 가능한 바이너리 파일이나 스크립트 파일을 실행하는 함수로, 함수 프로토타입(함수 원형)은 다음과 같이 정의되어 있다.

 

 

* 사진이 잘렸을 경우 사진 클릭시 원본 사이즈로 보여짐

 

 

각 인자는 const char*의 형태로 가각 파일 이름, 실행할 파일 인자의 포인터, 환경 변수의 포인터를 의미한다. 본 프로그램의 컴파일에 앞서 표준 라이브러리(libc)를 사용하는 execve 함수를 디버깅하기 위해, 해당 함수가표준 라이브러리를 사용하지 않고 정적으로 바이너리 파일 내에 포함되어 컴파일 될 수 있도록 "-static" 옵션을 사용한다.

 

 

먼저 첫 번째부터 세 번째 코드는 main 함수의 프롤로그 과정을 나타내며, 네 번째와 다섯번째 코드는 char 형 배열 sh에 "/bin/sh"와 NULL을 연달아 대입하는 부분이다. 네번째 코드 "mov DOWRD PTR [ebp-0x8], 0x80b362c"에서 "0x80b362c"는 정의된 문자열 "/bin/sh"가 저장된 데이터 세그먼트 내의 위치로, EBP-0x8에 저장된다. 이 때 "/bin/sh"는 한 워드 단위로 구성되는 스택의 특성상 "/bin" 과 "/sh\0"으로 구분되어 16진수 변한된며 , 바이트 정렬(Byte Ordering) 방식에 따라 작은 단위의 바이트가 앞에 오는 리틀 엔디언(Little Endian) 방식에 따라 거꾸로 저장된다. 따라서 "/bin"은 "0xE69622F" 형태로, "/sh\0"은 "0x0068732F"의 형태로 각각 저장된다. 한편 다섯번째 코드 "mov DWORD PTR [ebp-0x4], 0x0"은 EBP-0x4 위치에 NULL을 의미하는 0을 저장한다.

 

여섯 번째부터 execve 함수 호출 전까지의 코드는 해당 함수를 호출하기 위해 스택에 인자를 역순으로 배치하는 과정이다. 여섯 번째 코드 "mov eax, DWORD PTR [ebp-0x8]"은 execve 함수의 첫 번째 인자인 파일 이름을 지정하기 위해, 스택에 넣기에 앞서 먼저 EAX 레지스터에 "/bin/sh"을 저장하는 단게이다. 일곱 번째 코드 "mov DWORD PTR [esp+0x8] 0x0""은 ESP+0x8의 위치에 execve 함수의 세 번째 인자인 NULL을 의미하는 0을 저장한다. 여덞 번째 코드 "lea edx, [ebp-0x8]"은 execve 함수의 두 번째 인자인 실행할 파일 인자의 포인터를 지정하기 위해, 스택에 넣기에 앞서 "/bin/sh"와 NULL로 이루어진 char형 배열 sh를 EDX에 저장하는 단계이다. 아홉 번째 코드 "mov DOWRD PTR [esp+0x4], edx"는 바로 전 단계에서 EDX에 저장한 execve 함수의 두 번째 인자를 ESP+0x4 위치에 저장하고, 열번쨰 코드 "mov DWORD PTR [esp], eax" 역시 앞서 여섯 번째 코드에서 EAX에 저장한 execve 함수의 첫 번째 인자를 ESP의 위치에 저장한다. 이상 execve 함수 호출 이전의 세그먼트 구조를 나타내면 아래 그림과 같다.

execve 함수 호출 이전의 세그먼트 구조

 

 

이어서 execve 함수를 살펴보면 아래 사진과 같다.

 

 

이전과 마찬가지로 첫 번째와 두 번째 코드는 execve 함수 프롤로그 과정을 의미하며, 세 번째 코드부터 call 명령어 전까지의 코드가 인터럽트 호출을 위해 레지스터에 인자를 할당하는 부분이다. 세 번째 코드에 대한 설명에 앞서, execve 함수 프롤로그 과정 이후의 EBP를 기준으로 한 스택 세그먼트 구조를 나타내면 아래 그림과 같다.

 

 

 

세 번쨰 코드 "mov edx, DWORD PTR [ebp+0x10]"은 execve 함수의 세 번째 인자인 NULL을 EDX에 저장하고, 네 번째 코드 "push ebx"는 앞으로 인자로 사용될 EBX의 값을 보존하는 단계이다. 다섯 번째 코드 "mov ecx, DWORD PTR [ebp+0xc]는 execve 함수의 두 번째 인자인 실행하고자하는 파일 인자의 포인터 "/bin/sh"의 주소를 ECX에 저장하고, 여섯 번째 코드 "mov ebx, DWORD PTR [ebp+0x8]"은 execve 함수의 첫 번째 인자인 파일 이름을 EBX에 저장한다. 일곱 번째 코드 "mov eax, 0xb"는 호출할 인터럽트의 종류를 지정하는 부분으로, execve의 경우 11번이기 때문에 EAX에 0xB(11)을 저장한다. 참고로 해당 숫자를 시스템 호출 번호(System Call Number)라 부르며, 아래 그림처럼 "/usr/include/asm"에 위치한 "unistd.h" 헤더 파일을 통해 확인할 수 있다.

 

 

 

이처럼 인터럽트를 호출하기 위해 EAX에 인터럽트 종류를 지정하고, EBX 부터 EDX까지 필요한 인자를 차례로 저장했다. 끝으로 인터럽트를 호출하는 여뎗 번째 "call DWORD PTR ds:0x80d6788" 코드를 따라가 보면, 최종적으로 다음과 같은 코드에 도달한다.

 

(gdb) x/x 0x80d6788
0x80d6788 <_dl_sysinfo>: 0xb7fff414
(gdb)
(gdb) disas _dl_sysinfo_int80
Dump of assembler code for function _dl_sysinfo_int80:
   0x08055b40 <+0>: int    0x80
   0x08055b42 <+2>: ret   
End of assembler dump.
(gdb)

 

해당 코드 int 0x80은 시스템 호출에 해당하는 인터럽트 번호 0x80번을 가리키며, 인터럽트가 정의된 테이블(IDT, Interrupt Descriptor Table)에서 0x80번에 해당하는 인터럽트를 호출한다.

 

 

지금까지 살펴본 과정을 기반으로 불필요한 부분을 제거하여 최적화된 어셈블리어 코드를 만들기 위해 간단히 정리해보면, 먼저 NULL과 "/bin/sh"를 차례로 스택에 넣어 배열을 구성하고, 이 때의 ESP는 "/bin/sh"를 가리키므로 execve 호출을 위한 첫 번째 인자를 나타내는 EBX에 저장한다. 한편 두 번째 인자를 구성하기 위해 NULL과 "/bin/sh"를 가리키는 EBX를 차례로 스택에 넣은 뒤, "/bin/sh"와 NULL을 가리키는 주소를 나타내는 이 때의 ESP를, execve 호출을 위한 두 번째 인자를 나타내는 ECX에 저장한다. 끝으로 NULL을 execve 호출을 위한 세 번째 인자를 나타내는 EDX에 저장하고, execve에 해당하는 0xB(11)을 EAX에 넣은 뒤, 시스템 호출을 위해 0x80번 인터럽트를 호출한다. 이 과정을 토대로 설계한 스택의 구조는 아래 그림과 같다.

 

 

이를 기반으로 작성한 어셈블리어 코드는 다음과 같다.

 

           push 0 <NULL>

 int 0x80

                       push 0x6E69622F </bin>

         mov ebx, esp

push 0

   push ebx

         mov ecx, esp

     mov edx, 0

        mov eax, 0xb

 int 0x80

 

어셈블리어로 작성된 해당 코드를 C언어 환경에서 작성하기 위해, main 함수 내에 인라인 어셈블리(Inline Assembly)를 사용한다. 인라인 어셈블리에서는 코드가 amin 함수 내에 작성되므로 별도의 함수 시작 및 종료 과정이 필요하지 않으며, 컴파일러가 최적화를 위해 임의로 코드의 위치를 바꾸지 않도록 "__volatile__" 옵션을 사용한다. 또한 인라인 어셈블리에서는 AT&T 문법을 사용한다는 점을 고려하여 다음과 같은 파일을 작성한다.

 

 

위 프로그램을 실행시켜 정상적으로 동작하는지 확인한 후, 기계어 코드를 살펴보기 위해 "objdump"를 사용해야 한다.

 

 

컴파일러에 의해 생성된 main 함수의 프롤로그/에필로그 과정을 제외한, 세 번째 코드부터 인터럽트를 호출하는 int 명령어까지 앞서 작성한 코드 분에 해당하는 기계어 코드를 확인할 수 있다. 해당 기계어 코드를 문자열이 아닌 16진수로 인식하기 위해, 일반적으로 다음과 같은 문자열 형식을 빌려 저장한다.

 

char c = "\x90";

 

문제는 앞서 작성한 코드 부분에 해당하는 기계어 코드를 그대로 문자열 형식에 따라 저장할 경우, char형 배열에서 NULL 문자를 나타내는 "\x00"을 문자열의 끝으로 인식하는 문제가 발생한다. 따라서 NULL 문자가 발생하지 않도록 코드를 약간 수정해야 한다.

 

 

이전 코드에서 문제가 되는 부분은 "push 0x0" 두 곳과 "push 0x68732f", "mov edx, 0x0", "mov eax, 0bx" 코드가 있다. 먼저 비트 연산 중 xor의 특징을 이용하여 EAX가 자기 자신을 xor해서 '0'이 되게 한 후, 이것을 인자로 사용하는 방법으로 "push 0x0" 코드와 "mov edx,0x0" 코드 문제를 해결할 수 있다. 또한 EAX의 모든 비트가 0으로 초기화되었으므로, 32비트 레지스터인 EAX 대신 8비트 레지스터인 AL을 사용하면 "mov eax, 0xb" 코드 문제도 해결할 수 있다. 그리고 7바이트인 "/bin/sh" 대신 같은 기능을 수행하는 "/bin//sh"를 사용하여 8바이트로 만들면 "push 0x68732f" 코드 문제도 해결할 수 있다.

 

 

objdump를 사용하여 다시 기계어 코드를 확인해 보면 이전과 달리 더 이상 NULL(\x00)  문자인 기계어 코드를 찾아 볼 수 없다.

 

 

 

끝으로 앞서 작성한 코드 부분에 해당하는 기계어 코드를 문자열 형식에 따라 저장한후, 위와 같이 간단한 프로그램을 만든다.

 

 

해당 프로그램은 main 함수 호출 전에 스택에 저장된 이전 함수의 다음 명령어 주소 부분을, 쉘코드가 저장된 문자열 주소로 변경하여 실행되도록 수정한 프로그램이다.

 

 

 

최근에는 스택의 조작을 통한 공격을 방어하기 위해, 스택 영역의 주소를 매번 변경(ASLR)하거나, 스택 영역에서 명령어의 실행을 방지하는 등(NX Bit) 여러가지 방어 대첵이 마련되어 있다. 스택 보호 기법ㅇ르 우회하여 공격할 수 있는 여러 가지 응용 기법(DEP)들이 제시되어 있지만, 원활한 실습을 위해 스택 보호 기법이 적용되지 않은 시스템을 사용하거나, 해당 기능을 사용할 수 없게(Disable) 설정하고 진행한다.

 

그리고 사실 쉘코드의 사용 목적은 단순히 대상 시스템의 명령어 쉘을 실행시키는 것이 아니라, 대상 시스템에 대한 제한적인 접근 권한을 가지고 있는 공격자가 권한 밖의 높은 권한(root)을 획득하기 위한 목적으로 사용한다. 이 때 공격자는 대체로 버퍼 오버플로우(Buffer Overflow) 등의 취약점이 있는 높은 권한을 가진 프로세스를 공격하여, 해당 프로세스와 동일한 높은 권한을 획득하게 된다. 그러나 현재 작성된 쉘코드는 해당 프로세스의 권한을 승계받아 명령어 쉘을 실행하지 못한다.

 

 

위 사진에서 보는 것처럼 현재 쉘코드을 실행하는 파일은 소유주와 소유 그룹이 root로 설정되어 있고, SetUID 비트가 설정되어 있음에도 불구하고 일반 계정에서 실행시키면 루트 권한을 획득하지 못한다.따라서 쉘코드가 해당 프로세스의 권한을 승계받을 수 있도록, "unistd.h" 헤더 파일에 정의된 setreuid 함수와 seteuid 함수를 사용해야 한다. setreui는 실제 사용자나 유효 사용자를 지정하는 함수이고 geteuid는 유효 사용자를 지정하는 함수로, 각 함수 원형은 다음과 같이 정의되어 있다.

 

 

#include <unistd.h>

uid_t geteuid(void);

 

int setreuid(uid_t ruid, uid_t euid);

uid_t geteuid(void); 

 

setreuid 함ㅅ는 인자로 각각 지정할 실제 사용자의 uid와 유효 사용자의 uid를 필요로 하며, geteuid 함수는 별도의 인자 없이 해당 프로세스 유효 사용자의 uid를 반환한다. 해당 프로세스의 권한을 승계시키기 위해 setreuid 함수에 geteuid 함수 결과를 각각 인자에 할당하여, 명령어 쉘을 실행시키는 C언어 프로그램에 다음과 같은 구문을 추가한다.

 

setreuid(geteuid(), geteuid());

 

 

또한 앞서 설명과 마찬가지로 디버깅을 통해 해당 함수의 수행 과정을 분석하고, 다음과 같이 최적화된 어셈블리 코드를 작성한다.

mov eax,0x31

int 0x80

        mov ebx, eax

        mov ecx, eax

        mov eax, 0x46

int 0x80

 

 

그리고 NULL(\x00) 문자 기계어 코드를 제거하고, 인라인 어셈블리 코드 형식에 따라 AT&T 문법에 맞춰 다음과 같이 작성한다.

 

        xor %eax,%eax

         mov $0x31, %al

int $0x80

          mov %eax,%ebx

          mov %eax,%ecx

        mov $0x46, $al

int $0x80

 

 

objdump를 통하여 확인한 기계어 코드는 다음과 같다.

\x31\xc0\xb0\x31\xcd\x80\x89\xc3\x89\xc1\xb0\x46\xcd\x80

 

해당 기계어 코드를 기존의 쉘코드 앞 부분에 추가하고, 실제 해당 프로세스의 권한 승계 여부를 확인한다.

 

 

이전과 달리 일반 계정에서 해당 프로그램을 실행하면 루트 권한이 획득된다.

Comments