shellcode란?

2023. 8. 15. 04:11보안, 해킹/시스템

쉘코드(shellcode)란?

시스템을 공격하기 위해 만든 어셈블리 코드를 쉘코드라고 한다.

시스템 해킹의 목적은 공격 대상 시스템의 쉘(shell)을 획득하는 것이다. 쉘을 획득하면 해당 시스템의 운영체제에 명령을 내릴 수 있고, 이는 시스템의 제어권을 갖게 된다는 것을 의미하기 때문이다.

일반적으로 이 쉘코드는 공격 대상의 쉘을 획득하기 위해 사용하는 방식이기 때문에 쉘코드라는 이름이 붙었다.

 

사실 명칭은 쉘코드지만, 쉘(/bin/sh)을 실행시키는 동작만 있는 것은 아니다. 필요에 따라 다른 시스템 호출(system call)을 사용하여 특정 파일을 읽을 수도 있고, 추가적인 입력을 수행하여 특정 주소에 임의로 값을 대입하는 등 공격이나 악의적인 동작을 수행하기 위한 어떠한 명령이든 포함될 수 있다. 공격자가 원하는 명령을 실행시키기 위해 작성한 어셈블리어 코드라면 전부 쉘코드로 통칭한다.

 

쉘코드의 기본적인 구조

쉘코드는 어셈블리어를 통해 시스템에 직접 명령을 내리는 형태이기 때문에, 기본적으로는 시스템 호출(system call)의 집합으로 이루어진다. 시스템 호출에 필요한 인자를 메모리에 위치시킨 후, 시스템 호출을 불러온다. 필요에 따라서 시스템 호출 이후 추가적인 시스템 호출을 수행하기도 한다.

 

 

기본 쉘코드 제작

가장 단순한 구조의 쉘코드를 제작하면서 쉘코드가 어떻게 동작하는지 확인해볼 것이다.

 

가장 단순한 구조의 쉘코드라고 한다면, 당연히 다른 명령 없이 쉘(/bin/sh)을 실행하는 명령이다.

이 동작을 c언어에서 사용하는 명령어로 쓴다면 아래와 같다.

execve("/bin/sh", NULL, NULL);

 

execve는 특정 파일을 실행하는 exec 함수로, 시스템 호출(system call)의 한 종류이다.

첫 번째 인자는 실행하려는 파일의 경로를 나타낸다. 두 번째와 세 번째 인자는 여기서는 중요하지 않으니 넘어간다.

 

참고
함수를 호출할 때는 시스템에 인자를 전달해야 한다. 시스템에 인자를 전달할 때는 특정 레지스터에 함수 인자를 위치시키면 된다. 시스템에 따라 차이가 있지만 x86 아키텍처에서 인자를 전달하는 방법은 다음과 같다.
register eax ebx ecx edx
value 시스템 호출 번호 첫 번째 인자 두 번째 인자 세 번째 인자
해당하는 인자를 각 레지스터에 위치시키면 된다.

추가로, x86-64 아키텍처에서는 위의 각 레지스터가 rax, rdi, rsi, rdx, rcx 등으로 x86과 다소 차이가 있다.

 

이를 어셈블리어 코드로 작성하면 다음과 같다. 일단은 초안이다.

push 명령어를 통해 "/bin//sh"이라는 명령어를 스택 메모리에 위치시킨다. 이는 나중에 execve에 인자를 넘겨주기 위해 필요한 동작이다. (/bin/sh가 아닌 /bin//sh, 슬래시가 하나 더 들어간 것을 볼 수 있는데, 이 이유 또한 마지막에 설명한다.)

그리고 mov 명령어를 통해 ebx 레지스터에 esp(스택 포인터, 스택 top 주소) 값을 넣고, ecx와 edx에 0이라는 값을 넣었다. eax에는 execve의 시스템 호출 번호인 0xb(10진수로 11)가 들어간다. 참고로 시스템 호출 번호는 /usr/include/x86_64-linux-gnu/asm/unistd_32.h에 정의되어 있다. (x86-64의 경우 /usr/include/x86_64-linux-gnu/asm/unistd_64.h에 정의되어 있다.)

 

인자를 설정하였다면, int 0x80이라는 interrupt 명령어를 통해 시스템 호출(system call) 함수를 호출할 수 있다.

그리고 nasm 명령어를 통해 .asm 파일을 .o 파일로 변환한 후, objdump 명령어를 통해 바이너리 코드의 형태로 작성한 쉘코드를 확인한다. 이건 이따가 한번 더 확인할 것이다.

gcc를 통해 오브젝트 파일(.o 파일)을 컴파일하여 실행하였다.

segmentation fault 발생

 

왜 이런 오류가 발생했느냐..

char *str = "test";

 

위와 같은 변수에 문자열을 저장한다고 하면, str[4]에는 NULL(\0)값이 들어간다. 이는 문자열이 끝났음을 알려주는 마침표와 같은 역할을 하게 된다.

변수의 선언 위치에 따라 조금씩 다르겠지만, 기본적으로 변수의 값은 메모리 상의 한 곳에 저장이 된다. (여기서는 main함수 내부에 선언한 변수의 값이 stack 영역에 저장되었다고 가정하자.) 그렇다는건 위의 test라는 문자열도 스택 등의 특정 메모리 영역에 저장될 것인데, 당연히 NULL(\0)값이 같이 저장될 것이다. 그래야 시스템이 문자열이 끝나는 지점을 알 수 있으니까.

 

그러면 우리가 어셈블리어로 코드를 짤 때도, 문자열이 끝날 때 \0값을 넣어 마침표를 찍어줘야 하는게 당연한 것이다.

문자열을 스택에 push하기 전에 push eax라는 명령어를 추가하였다.

eax는 mov eax, 0 명령어 단계에서 값이 0으로 초기화되었고, 문자열을 스택에 push하기 전에 eax를 push하여 문자열의 맨 앞에 0x00000000를 넣어 문자열이 끝남을 알려주었다.

이렇게 해서 shellcode를 다시 위 과정을 통해 컴파일하면 위와 같이 shell을 습득할 수 있다.

시각적으로 잘 모르겠으니 ps 명령어를 통해 확인해보았다.

참고로 ps 명령어는 현재 실행중인 프로세스 목록을 불러오는 명령어다.

쉘코드를 실행시키기 전에는 bash만 실행중이지만, 쉘코드를 실행시킨 후 ps 명령어를 실행해보면 sh가 추가로 실행중인 것을 확인할 수 있었다.

 

구체적인 원리

위 그림은 gdb를 통해 작성한 쉘코드를 디버깅한 것이다. (참고로 필자는 gdb에 pwndbg라는 플러그인을 설치해 사용중이다.)

int 0x80 명령어줄을 확인해보면, <SYS_execve>를 호출하는 것을 확인할 수 있다. 이는 eax의 값에 해당하는 시스템 호출 함수를 불러오는 것이다.

또한 위 그림을 보면 execve를 실행시키기 전 execve의 인자를 확인시켜준다. 여기서 우리가 설정한 인자와 같은지 확인해볼 것이다.

 

첫 번째 인자는 ebx에 저장되어 있다. 실제로 ebx 레지스터에 저장된 값과 첫 번째 인자의 값이 동일하게 설정되어 있다. 그리고 ebx 레지스터에 저장된 메모리의 주소가 '/bin//sh'이라는 문자열을 가리키고 있는 것도 확인할 수 있다.

두 번째 인자와 세 번째 인자는 각각 ecx, edx에 저장되어 있고, 실제로 ecx, edx 레지스터에 저장되어 있는 값과 execve가 받는 인자의 값이 동일함을 확인할 수 있다.

 

이렇게 시스템의 쉘을 획득하는 쉘코드를 작성하여 정상 동작함을 확인하였다. 

 


참고 - 위 쉘코드의 한계

하지만 이 쉘코드로는 해결할 수 없는 문제가 존재한다.

버퍼 오버플로우 공격을 통해 쉘을 획득하려 할 때 이러한 쉘 코드를 활용하게 된다. NX(No-eXecute)라는 보안기법이 적용되지 않은 경우, 쉘코드를 작성하여 스택의 특정 위치에 쉘코드를 삽입하고, ret 주소를 삽입한 쉘코드의 주소로 변경시키는 공격기법을 활용할 수 있다. (설명하지 않은 부분이기 때문에 이 부분은 당장은 넘어가도 무관하다.)

 

핵심은 메모리에 쉘을 실행시키는 쉘코드를 삽입하여 시스템을 공격하는 경우, 쉘코드에 00이라는 바이트가 포함되어서는 안된다. 메모리는 NULL값을 통해 문자열을 구분한다고 했는데, 코드에 00이 포함되어 있는 경우 시스템은 코드가 더 이상 존재하지 않는다고 생각하여 우리가 작성한 코드를 끝까지 읽지 못하는 경우가 생기기 때문이다. 따라서 위 쉘코드를 약간 수정할 것이다.

 

shellcode.asm의 내용을 약간 변경하였다. 바로 push 0x68732f 부분이다.

이 부분은 문자열 '/bin//sh'의 '//sh'에서 슬래시(/)를 하나 제거한 것이다. 이때, 좌측의 바이너리 코드를 잘 확인해보면 68 2f 73 68 00임을 확인할 수 있다. push는 4byte 단위로 이루어지기 때문에, 슬래시(/)를 하나 더 넣어주어 NULL 바이트를 없애주는 것이다.

 

다음은 mov 명령어에서 NULL 바이트를 제거해야 한다.

일반적으로 이 NULL 바이트 문제 때문에 레지스터의 값을 비울 때는 xor 명령어를 대신 쓴다. 동일한 두 수를 서로 xor 연산을 하면 결과가 0인 점을 이용한다.

 

마지막으로 남은 부분은 eax 레지스터에 0xb 값을 넣는 명령어 부분이다.

eax 레지스터는 4바이트이기 때문에 0xb를 넣을 때도 0x0000000b라는 값을 넣으려고 한다.

이때, eax 대신 al을 사용하면 NULL 바이트를 없앨 수 있다. al은 eax 레지스터의 하위 1바이트(8비트)를 의미한다.

 

이렇게 최종 쉘코드가 완성되었다. NULL 바이트를 전부 제거하여 스택 버퍼 오버플로우 공격에 사용이 가능하다. 왼쪽의 바이트들을 순서대로 입력하면 /bin//sh을 실행하게 된다.

 

참고로

objcopy --dump-section .text=shellcode.bin shellcode.o

 

라는 명령어를 입력하면 쉘코드 오브젝트 파일을 .bin 파일로 변환시킬 수 있다.

 

최종 바이너리 코드는 아래와 같다

"\x31\xc0\x50\x68\x2f\x2f\x73\x68\x68\x2f\x62\x69\x6e\x89\xe3\x31\xc9\x31\xd2\xb0\x0b\xcd\x80"