본문 바로가기

시스템해킹

(시스템 해킹)셸코드-with-dreamhack

 

https://learn.dreamhack.io/

 

해커들의 놀이터, Dreamhack

해킹과 보안에 대한 공부를 하고 싶은 학생, 안전한 코드를 작성하고 싶은 개발자, 보안 지식과 실력을 업그레이드 시키고 싶은 보안 전문가까지 함께 공부하고 연습하며 지식을 나누고 실력 향

dreamhack.io

*이 글은 dreamhack + 저의 개인적인 역량을 합쳐서 만든 정리 글입니다. 그렇기에 많이 부족할 수 있습니다. 피드백 해주시면 감사하겠습니다.

 

 

<글을 보기전, 알아야 할 배경지식>

1. 프로그래밍 언어(C++, C)

2. 어셈블리어(ASM):https://mankind.tistory.com/category/%EC%96%B4%EC%85%88%EB%B8%94%EB%A6%AC%EC%96%B4

 

'어셈블리어' 카테고리의 글 목록

여러 보안, 개발, 코딩 블로그 입니다.

mankind.tistory.com

3. 파일 입출력 : https://mankind.tistory.com/134

 

(C++)파일 입출력

<파일 식별자> 파일 열기, 닫기를 진행하려면 파일과 프로세스간에 연결이 필요합니다. 이떄 파일에 대한 어떠한 '번호'를 부여해줘야 합니다.(컴퓨터는 숫자에 불과하니까요) 그 번호에 대한값

mankind.tistory.com

4. 리눅스 기초 : https://mankind.tistory.com/106

 

(잡지식)리눅스 기초

 

mankind.tistory.com

5. 어셈블리어 코드 작성하는법 :

 

 

 

 

<셸코드-서론>

해킹 분야에서 상대 시스템을 공격하는 것을 익스플로잇(exploit)이라고 부릅니다. 익스플로잇은 번역하면 '부당하게 이용하다'라는 뜻이 있는데, 상대 시스템에 침투하여 시스템을 악용한다는 맥락과 같습니다.

 

이번 글에서는 시스템해킹에서 가장 기초적이면서, 가장 중요한 "셸코드(shellcode)"에 대해서 알아봅니다.

 

셸코드(shellcode)는 익스플로잇을 위해 제작된 어셈블리 코드 조각입니다. 일반적으로 익스플로잇의 목적은 상대방 시스템의 셸을 획득하는것이므로 시스템 해킹 관점에서 매우 중요한데, 그 이유는 execve 셸코드에서 살펴보겠습니다.

 

앞으로 파일 읽고 쓰기(orw), 셸 획득(execve)과 관련된 셸코드를 작성해보고, 직접 디버깅을 통하여 셸코드가 어떤식으로 작동되는지 확인해 보겠습니다.

 

<셸코드-orw>

orw셸코드는 파일을 읽고, 읽은 뒤 화면에 출력해줍니다. 예제로 "/tmp/flag"를 읽는 셸코드를 작성해 봅니다.

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

저희가 구현하려는 쉘코드의 C언어로 바꾸면 위와 같은 모습입니다.

출저 :&nbsp;https://learn.dreamhack.io/50#4

위 코드를 기계어로 작성할 떈 syscall을 사용하여 호출합니다.

 

각 한줄씩 코드를 기계어로 변환해보고, 분석해 봅니다.

 

 

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

// https://code.woboq.org/userspace/glibc/bits/fcntl.h.html#24
/* File access modes for `open' and `fcntl'.  */
#define        O_RDONLY        0        /* Open read-only.  */
#define        O_WRONLY        1        /* Open write-only.  */
#define        O_RDWR          2        /* Open read/write.  */

*각 옵션에 대한 비트값은 위와 동일합니다.

출저 :&nbsp;https://learn.dreamhack.io/50#5

현재 O_REDONLY는 0이므로, rsi = 0이 들어갑니다.

open함수에 3번째 인자는 "파일 권한 설정"인데 저희는 NULL값으로 줬으니까, rdx역시 rdx = 0이 들어갑니다.

마지막으로 rax에는 syscall 값인 2로 들어갑니다.(rax = 2)

=> open(2, 0, 0)

 

첫 번째로 해야할 일은, "/tmp/flag"라는 문자열을 메모리 공간에 매핑시키는 것입니다.

이를 위해 스택에 byte값(0x616c662f706d742f67(/tmp/flag)을 push합니다.

 

<ASM>

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의 fd는 rax에 저장됩니다. read의 첫 번째 인자를 이 값으로 설정해야 하므로 rax를 rdi에 대입합니다.

출저 :&nbsp;https://learn.dreamhack.io/50#5

rsi는 파일에서 읽은 데이터를 저장할 주소를 가리킵니다. 0x30만큼 읽을 것이므로, rsi에 rsp-0x30(buf주소)을 대입합니다. 

read 시스템콜을 호출하기 위해서 rax를 0으로 설정합니다.

 

<ASM>

mov rdi, rax      ; rdi = fd
mov rsi, rsp
sub rsi, 0x30     ; rsi = rsp-0x30 ; buf address
mov rdx, 0x30     ; rdx = 0x30     ; len
mov rax, 0x0      ; rax = 0        ; syscall_read
syscall           ; read(fd, buf, 0x30)

 

3. write(1, buf, 0x30)

rsi와 rdi값은 read때와 똑같이 사용합니다.

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

 

 

4. all ASM code

;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.s는 아스키로 작성된 어셈블리 코드이므로, 기계어로 치환하면 CPU가 이해할 수는 있으나 ELF형식이 아니므로 리눅스에서 실행될 수 없습니다. 여기서는 C언어로 코드를 작성하며, gcc 컴파일을 통해 이를 ELF형식으로 변형하겠습니다.

 

어셈블리 코드를 여러가지 방면으로 실행해 볼 수 있으나, 이 코스에서는 셸 코드를 실행할 수 있는 "스켈레톤 코드"를 쓰겠습니다. 기본적인 셸코드를 실행하기 위한 기본 구조를 갖춘걸 말합니다.

// 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 파일을 만들어줍니다.

 

여기서 잠시 주의해야 하는건, 이상한 문자열까지 같이 출력됐다는 겁니다. 디버깅을 통해 셸코드의 동작 과정을 알아보고, 왜 이상한 문자열이 나왔는지 원인을 분석해 봅시다.

 

<셸코드-orw-debug>

run_sh 함수에 BP를 걸어주겠습니다.(r)

syscall전까지 실행하시고, 인자를 봐봅시다.

rax는 "/tmp/flag"라는 문자열이 있고, push한 상태에서는 그 push한 rax를 rsp가 가리키고 있기 떄문에 rdi값으로 복사하면  rsp가리키고 있는 값을 rdi로 옮겨줄 수 있습니다.

그 다음 rsi하고, rdx는 xor연산을 통해 0으로 초기화 해주고요. 마지막으로 rax에는 syscall 함수 호출 인자인 2를 넘겨주네요. 레지스터를 이용한 인자를 넘기는 것이기 떄문에, 순차적으로 넣어준다는 오해는 하지 마시길 바랍니다.

참고로 syscall함수 호출 이후, 반환값은 rax에 들어가는걸 보실 수 있습니다.

 

다음으로 read함수를 볼텐데 역시나 두번째 syscall 호출 전까지 실행합니다.

rax(3)을 rdi에 넣고, 스택에서 0x30만큼 불러올것이기 떄문에 그 만큼 사이즈를 할당해줘야 합니다.(C언어에서는 char buf[0x30]에 해당하는 코드입니다)

rsi(rsp) - 0x30을 통해 0x30만큼 지역변수를 할당합니다.

rdx는 buf 사이즈인 0x30이 들어가고요.

마지막은 read함수를 호출하는 의미에서 0x0(0)값이 들어갑니다.

읽어온 메모리 주소는rsi레지스터에 저장합니다.

값을 보시려면

메모리 값을 string으로 변환해서 보시면 됩니다. 그리고 보시다시피 저희는 \n을 적어준적이 없습니다.

초기화 되지 않은 메모리 영역으로부터 쓰레기값이 들어간 것입니다.

 

원리는 간단합니다. 스택에서 해제할 떄 사용한 값을 0으로 초기화 하는게 아닌, 단순 rbp와 rsp를 사용하여 호출 함수로 이동합니다. 즉, 어떠한 함수를 해제한 이후 만약 다른 함수가 스택 프레임을 그 위에(이전에 해제한 적 있는 스택 공간)할당하면, 이전 스택 프레임의 데이터는 여전히 새로 할당한 스택 프레임에 잔여물로 남게 됩니다. 근데 여기서 초기화를 해준다면 이전 스택프레임의 아무리 데이터를 남겨놨더라도 초기화를 진행하였기에 새로운 값으로 바뀝니다. 하지만 그러지 못했을 경우 해커에게 의도치 않게 중요한 정보를 노출하기도 합니다.

다시 2번쨰 syscall까지 실행한 후 저장된 문자열의 주소를 들여다 보면

48바이트 중, 40바이트만 우리가 저장한 파일의 데이터이고(저희가 저장한 , 나머지 8바이트는 쓰레기값이 있네요.

초기화 하지 않으면 위 처럼 메모리 구조가 완성됩니다.

초기화 했다면(조금 징그러운데,,), 위 처럼 더미부분이 0으로 채워지고 document는 그냥 그 0으로 채워진 값을 덮은것 뿐입니다. 잔여물이 없죠. 그럼 0은 아무런 의미가 없으니까 저희가 40byte만큼 문자열 저장한 만큼만 출력되는겁니다(쓰레기값 없이)

 

관찰력이 뛰어나신 분이라면, 해당 더미값이 어셈블리 코드의 주소와 비슷한 것을 알 수 있을겁니다. 이런 중요한 값을 유출해 내는 것을 메모리 릭(Memory Leak)이라 합니다. 이 메모리 릭은 앞으로 메모리 보호 기법을 무력화하는 핵심 역할을 합니다.

 

<셸코드-execve>

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

execve 셸코드는 임의의 프로그램을 실행하는 셸코드인데, 이를 이용하면 서버의 셸을 획득할 수 있습니다.

최신의 리눅스는 대부분 sh, bash를 기본 셸 프로그램으로 탑재하고 있으며, 이 외에도 zsh, tsh 등의 셸을 유저가 설치해서 사용할 수 있습니다. 기본적으로 "/bin/sh"을 많이 실행합니다.

여기서 argv는 실행파일에 넘겨줄 인자, evbp는 환경변수 설정입니다. 하지만 우리는 sh만 실행할 것이므로 arg1와 arg2는 NULL값으로 넣어주시면 됩니다.(0)

 

따라서 우리는 execve("/bin/sh", null, null)을 실행하는것을 목표로 셸 코드를 작성하시면 됩니다.

 

<execve.s>

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

 

<execve.c>

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

앞에서 사용한 스켈레톤 코드에다가 저희가 만든 셸 코드를 적용시켜주기만 하면 됩니다.

 

 

<셸코드-objdump-를 이용한 shellcode추출>

이제 작성한 shellcode를 byte code(opcode)의 형태로 추출해줄겁니다. 그래야 나중에 어딘가 영역에 shellcode를 저장하고 이를 호출할 수 있기 떄문입니다.

 

<shellcode.asm>

; 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

위는 아까 저희가 작성했던 .S확장자랑 좀 다릅니다. 그렇습니다. 저희가 작성한 .s를 asm 어셈블리 코드로 옮겨 주셔야 opcode로 추출할 수 있습니다. 그래서 지금까지는 그냥 .s파일 확장자인 기계어 코드를 c로 바꿔서 한번 셸코드의 기능을 구현해본 것이고, 실제로 이 shellcode를 쓰기 위해서는 asm파일로 재작성하여 opcode로 추출해줘야 합니다. 그럼으로 따로 shellcode작성법이 있으니 걱정하지 않으셔도 됩니다.

 

--------------------1-----------------------
$sudo apt-get install nasm
$nasm -f elf shellcode.asm
$objdump -d shellcode.o
$objcopy --dump-section .text=shellcode.bin shellcode.o
$xxd shellcode.bin

-------------------2----------------------
$nasm -f elf [파일이름.asm]
$objdump -d [파일이름.o]
$objcopy --dump-section .text=[파일이름.bin] [파일이름.o]
$xxd [파일이름.bin]

*처음 작성하실떈 1번처럼 해주시고, 다음부터 쉘코드를 추출하실떈 2번처럼 하시면 됩니다.

 

위 코드가 opcode입니다. 이를 그냥 byte형으로 바꿔주시면 됩니다.

앞에서부터 순차적으로 \x붙이시면서 1byte씩 적어내려가면 쉘코드 추출 성공. 이제 해당 셸코드를 다음 글 스택버퍼오버플로우 공격기법에서 써먹을 수 있을겁니다.

 

 

 

<한 걸음 더 나아가기>

이 글에서 말하려는건, 위 처럼 "/bin/sh"를 실행하는것처럼 셸 획득을 목표로 제작할 수 있지만 보시다시피다양한 기능을 셸코드로 제작이 가능하며, 이를 공격코드에 활용할 수 있다는 것이다. 즉, syscall함수 호출을 사용하여 상황에 맞는 셸코드를 제작할 수도 있다.

 

그리고 내가 생각하는 가장 좋은 방법은

.S확장자로 내가 원하는 동작을 수행하는 기계어 코드 작성 => 스켈레톤 코드에 적용해보기 => 작동 확인 => asm으로 재작성 => shellcode추출

(이)제~일 정석이라고 본다.

 

asm으로 재작성하는 방법은 다음 글 "심화"파트에서 써보도록 하겠다.