Iriton's log

Exploit Tech: Shellcode 본문

Pwnable/Study

Exploit Tech: Shellcode

Iriton 2024. 3. 27. 16:06

*본 포스트는 Dreamhack - Systemhacking Lecture 을 참고하여 작성되었습니다.

Shellcode

익스플로잇을 위해 제작된 어셈블리 코드 조각

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

어셈블리어는 기계어와 거의 일대일 대응되므로 사실상 원하는 모든 명령을 CPU에 내릴 수 있게 된다.

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

 

orw Shellcode

작성

/tmp/flag 를 읽는 쉘코드를 작성해보자.

 

참고 systemcall

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

 

Code

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

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

 

open

첫 번째로 해야 하는 건 /tmp/flag 라는 문자열을 메모리에 위치시키는 것이다.

이를 위해 stack에 /tmp/flag를 push하여 위치시키도록 해야 한다.

 

그래서 /tmp/flag를 16진수로 변경하여 push를 해준다.

flag 인자 값을 저장할 rsi와 mode 값을 저장하는 rdx에는 0을 넣어야 한다.

 

여기서 특이한 점은 직접적으로 mov rsi, 0 을 하는 게 아니라 xor rsi, rsi를 하는 것이다.

굳이 xor 명령어를 쓰는 이유는 아래와 같다.

  1. 최적화, 성능 향상 - 단순 비트 반전 연산이 CPU에 더 효율적이다.
  2. 다른 값 영향 안 감 - 어떠한 플래그도 변경되지 않고 0이라는 추가적인 값이 개입하지 않아도 된다.
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)

결과적으로 어셈블리코드는 위와 같다.

여기서 rax에 담기는 /tmp/flag의 헥사값이 대체 왜 0x616c662f706d742f 지?? 라는 의문이 들었다.

잘 들여다보니 16진수 값이 거꾸로 기재되어 있다는 걸 깨달았다.

그렇다… 리틀엔디안 방식이 사용된다는 걸 망각하고 있었다.

x86—64 아키텍처에서는 메모리에 값을 저장할 때 리틀 엔디안 방식을 사용하기 때문에 저렇게 저장하는 게 맞다.

 

read

systemcall의 반환 값은 rax로 저장된다.

따라서 open으로 획득한 /tmp/flag의 fd는 rax에 저장된다.

read의 첫 번째 인자인 fd값은 rax로 설정해야 한다.

그래서 rax를 rdi로 대입한다.(read의 첫 번째 인자는 rdi이기 때문에)

두 번째 인자 rsi에는 데이터를 저장할 주소가 들어가야 하는데 0x30만큼 읽을 거기 때문에 rsp-0x30을 한다.

← 스택 포인터 레지스터인 rsp는 스택의 최상위를 가리키기 때문에 rsi가 파일에서 읽을 데이터를 저장할 주소를 가리키기 위해서는 rsp에서 데이터 크기만큼 빼야 되기 때문이다.

rdx는 파일로부터 읽어낼 데이터의 길이인 0x30으로 설정한다.

read systemcall 번호는 0이므로 rax를 0으로 설정한다.(systemcall은 고유 번호가 있어서 그 번호로 명령어를 호출을 한다.)

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)
더보기

📍 fd란?
파일 서술자(File Descriptor)는 유닉스 계열의 운영체제에서 파일에 접근하는 SW에 제공하는 가상의 접근 제어자이다. 프로세스마다 고유의 서술자 테이블을 가지고 있으며, 그 안에 여러 파일 서술자를 저장한다. 서술자 각각은 번호로 구별되는데, 일반적으로 0번은 일반 입력, 1번은 일반 출력, 2번은 일반 오류에 할당되어 있으며, 이들은 프로세스를 터미널과 연결해준다. 프로세스 생성 이후, open 같은 함수를 통해 어떤 파일과 프로세스를 연결하려고 하면 기본으로 할당된 2번 이후의 번호를 새로운 fd에 차례로 할당해준다. 그러면 프로세스는 fd를 이용하여 파일에 접근할 수 있다.

 

write

출력은 stdout(일반출력)으로 할 거라서 rdi를 0x1로 설정한다.

rsi와 rdx는 read에서 사용한 값을 그대로 사용하고

write systemcall 호출 번호는 1이라 rax를 1로 설정한다.

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

 

컴파일 & 실행

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

윈도우의 PE, 리눅스의 ELF가 대표적이다.

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

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

 

어셈블리 코드를 컴파일 하는 방법에는 여러 가지 방법이 있지만 이번에는 쉘코드를 실행할 수 있는 스켈레톤 코드를 C언어로만 작성하고 거기에 쉘코드를 탑재하는 방법을 사용한다. 스켈레톤 코드는 핵심 내용이 비어 있는 기본 구조만 갖춘 코드를 말한다.

 

 

 

디버깅

  1. gdb orw -q 명령어로 orw를 디버거로 열어 준다.(-q 옵션은 quite 옵션으로 기본 정보 몇 가지를 출력하지 않고 조용히 디버거 프롬프트를 제공한다.)
  2. b *run_sh 명령어로 run_sh()에 브레이크 포인트를 건다.
  3. run 명령어로 run_sh() 시작 부분까지 코드를 실행 시킨다.
  4. 그럼 우리가 작성한 쉘코드가 rip에 위치한 걸 볼 수 있다.

 

 

read 시스템콜도 동일하게 잘 실행되었다.

 

ni 명령어로 systemcall을 실행시킨 뒤 레지스터를 확인하면 0x7fffffffdeb8에 저장된 걸 볼 수 있다.

 

x/s 명령어로 데이터를 확인해 보았다.

데이터 외에 알 수 없는 문자열이 출력되는데, 이는 초기화되지 않은 메모리 영역 사용에 의한 것이다.

 

더보기

📍 초기화 되지 않은 메모리 사용
각 함수가 자신들의 스택 프레임을 할당해서 사용하고 종료할 때 해제한다. 근데 스택에서 해제라는 것은 사용한 영역을 0으로 초기화 하는 게 아니라 단순히 rsp와 rbp를 호출한 함수의 것으로 이동시키는 것이다.
즉, 어떤 함수를 해제한 이후 다른 함수가 스택 프레임을 그 위에 할당하면 이전 스택 프레임은 여전히 새로 할당한 스택 프레임에 존재하게 된다. 이를 쓰레기 값이라고 표현하기도 한다.

프로세스는 쓰레기 값 때문에 예상치 못한 동작을 하기도 하며, 비의도적으로 해커에게 중요 정보를 노출하기도 한다. 이 쓰레기 값은 해커의 입장에서 아무 의미 없는 값이 아니라 어셈블리 코드의 주소나 어떤 메모리의 주소일 수 있기 때문에 메모리 릭으로 이어지는 중요한 값이 될 수 있다. 따라서 안전한 프로그램을 작성하려면 스택이나 힙을 사용할 때 항상 적절한 초기화를 거쳐야 한다.

(안전한 프로그램 작성은 시큐어 코딩 로드맵에서, 이를 이용한 공격 방법은 시스템 해킹 심화 로드맵에서 다룬다고 한다.) 

 

 

execve Shellcode

임의의 프로그램을 실행하는 쉘코드를 말한다. 이를 이용하면 서버의 쉘을 획득할 수 있다.

 

작성

 

참고 systemcall

syscall rax arg0 (rdi) arg1 (rsi) arg2 (rdx)

execve 0x3b const char *filename const char *const *argv const char *const *envp

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

sh만 실행하면 되기 때문에 그 외 다른(2,3번째 인자) 값은 전부 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)

 

컴파일 및 실행

여기서 -masm=intel 옵션은 어셈블리 코드를 intel 구문에 맞게 해석한다는 뜻이다.

 

objdump 를 이용한 shellcode 추출

asm 파일에 대해 이를 바이트 코드로 바꾸는 과정이다

sudo apt-get install nasm 명령어로 설치부터 한다.

 

step 1. shellcode.o

 

step 2. shellcode.bin

 

step 3. shellcode string

bin 파일에 저걸 \x31\xc0 … 으로 변환해 주면 된다.

'Pwnable > Study' 카테고리의 다른 글

Memory Corruption: Stack Buffer Overflow  (0) 2024.04.01
Background: Calling Convention  (0) 2024.04.01
Tool: gdb & pwntools  (0) 2024.03.20
x86 Assembly  (0) 2024.03.19
Background: Linux Memory Layout  (0) 2024.03.19
Comments