셸코드 (Shellcode)
: 익스플로잇을 위해 제작된 어셈블리 코드 조각
- 셸 획득을 위한 목적에서 주로 사용되므로 '셸'이 접두사로 붙음
- 셸 획득은 시스템 해킹의 관점에서 매우 중요
[ Shell Code의 특징 ]
- 어셈블리어로 구성되어 공격 수행 대상 아키텍처와 운영체제, 셸코드의 목적에 따라 다르게 작성됨
- 공유된 셸코드는 범용적으로 작성되어 시스템 환경을 완전히 반응하지는 못함
orw 셸코드
(1) orw 셸코드 작성
✅ orw 셸코드란?
파일을 열고, 읽은 뒤 화면에 출력해주는 셸코드
▶ "/tmp/flag"를 읽는 C언어 형식의 의사코드
char buf[0x30];
int fd = open("/tmp/flag", RD_ONLY, NULL);
read(fd, buf, 0x30);
write(1, buf, 0x30);
📌 orw 셸코드 작성을 위해 알아야하는 syscall
✅ 의사코드를 어셈블리로 구현
1단계) int fd = open("/tmp/flag", O_RDONLY, NULL)
: "/tmp/flag" 문자열을 메모리에 위치시키고 파일을 여는 단계
[과정 요약]
- 스택에 0x616c662f706d742f67(/tmp/flag)를 push
- rdi가 이를 가리키도록 rsp를 rdi로 옮김
- 파일을 읽을 때, mode는 의미를 갖지 않으므로 rdx를 0으로 설정
- rax를 open의 syscall 값인 2로 설정
[어셈블리 코드]
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)
- 파일 경로 설정
- 64비트 리틀 엔디언 방식을 사용하여 문자열 "/tmp/flag"를 스택에 push
- 리틀 엔디언 방식을 사용하므로 문자열 마지막 바이트 'g'를 스택에 먼저 push
- 이후, 문자열의 나머지 부분인 '/tmp/flag'를 레지스터 rax에 로드한 후 스택에 push
▶ 스택에 문자열 "/tmp/flag"가 메모리에 저장됨
- 시스템 콜 인자 설정
- mov rdi, rsp: 스택 포인터 rsp의 현재 값을 rdi로 복사. 이는 rdi를 파일 경로 "/tmp/flag"를 가리키는 포인터로 만듦
- xor rsi, rsi: rsi 레지스터를 0으로 설정
- xor rdx, rdx: rdx도 0으로 설정. 해당 부분은 open 시스템 콜의 3번째 인자인 파일 모드 지정 부분인데, 사용하지 않으므로 NULL로 설정
- 시스템 콜 실행
- mov rax, 2: 시스템 콜 번호 2를 rax에 로드
- syscall: 실제로 시스템 콜을 실행
✅ 참고 사항
- XOR 연산 : 자기 자신과의 XOR 연산의 결과는 항상 0
- 파일 모드에 대한 값
1. O_RDONLY (Open read-only) : 0
2. O_WRONLY (Open write-only) : 1
3. O_RDWR (Open read/write) : 2
2단계) read(fd, buf, 0x30)
: open으로 가져온 파일을 읽어내는 단계
[과정 요약]
- 1단계에서의 syscall 반환값은 rax에 저장됨
- 따라서, open으로 획득한 fd는 rax에 저장
- read의 첫번째 인자를 이 값으로 하기 위해 rax에 rdi를 대입
- rsi는 파일에서 읽은 데이터를 저장할 주소를 가리키므로 rsi에 rsp - 0x30을 대입
- rdx는 파일로부터 읽어낼 데이터의 길이인 0x30으로 설정
- rax는 read 시스템콜 호출을 위해 0으로 설정
[어셈블리 코드]
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, rax: rax의 레지스터 값을 rdi 레지스터로 이동
- mov rsi, rsp: 스택포인터 즉 rsp의 현재 값을 rsi 레지스터로 복사 → buf 인자 설정을 위한 준비 단계
- sub rsi, 0x30: rsp에서 0x30(48바이트)를 빼고 그 결과를 rsi에 저장. 이는 buf의 주소를 스택 상의 현재 위치로부터 48바이트 위로 설정 → 스택의 이 부분을 읽기 버퍼로 사용 가능
- mov rdx, 0x30: rdx 레지스터에 0x30(48바이트)을 저장. → 이 값은 count 인자로, 읽은 바이트 수 표시
- mov rax, 0x0: rax 레지스터에 0을 저장 → read 시스템 콜 번호는 0을 의미
- syscall: 실제 시스템 콜을 실행
✅ fd란?
파일 서술자 (File Descriptor, fd)는 유닉스 계열의 운영체제에서 파일에 접근하는 소프트웨어에 제공하는 가상의 접근 제어자를 의미한다. 프로세스마다 고유의 서술자 테이블을 갖고 있으며, 그 안에 여러 파일 서술자를 저장한다. 서술자 각각은 번호로 구별되는데, 일반적으로 0번은 일반 입력(Standard Input, STDIN), 1번은 일반 출력(Standard Output, STDOUT), 2번은 일반 오류(Standard Error, STDERR)에 할당되어 있으며, 이들은 프로세스를 터미널과 연결해준다. 그래서 우리는 키보드 입력을 통해 프로세스에 입력을 전달하고, 출력을 터미널로 받아볼 수 있다.
3단계) write(1, buf, 0x30)
: 출력 단계
[과정 요약]
- stdout으로 출력하기 위해 rdi를 0x1로 설정
- rsi와 rdx는 read에서 사용한 값을 그대로 사용
- rax는 write 시스템 콜을 호출하기 위해 1로 설정
[어셈블리 코드]
mov rdi, 1 ; rdi = 1 ; fd = stdout
mov rax, 0x1 ; rax = 1 ; syscall_write
syscall ; write(fd, buf, 0x30)
- mov rdi, 1: rdi 레지스터에 1을 저장. write 시스템 콜의 첫 번째 인자인 파일 디스크립터(fd)로, 표준 출력(stdout)을 나타내는데 사용됨. 파일 디스크립터 1은 표준 출력을 의미
- mov rax, 0x1: rax 레지스터에 1을 저장. rax는 시스템 콜 번호를 저장하는데 사용되며 1은 write의 시스템 콜 번호
(2) orw 셸코드 컴파일 및 실행
윈도우는 PE, 리눅스는 ELF와 같이 운영체제는 실행 가능한 파일의 형식(ELF, Executable and Linkable Format)을 규정하고 있다. ELF는 크게 헤더와 코드 그리고 기타 데이터로 구성되어 있는데 헤더에는 실행에 필요한 여러 정보가 적혀있고, 코드에는 CPU가 이해할 수 있는 기계어 코드가 적혀있다.
▶ 현재 작성한 orw 셸코드는 아스키로 작성된 어셈블리 코드이므로 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(); }
1. orw.c 파일 생성
2. "/tmp/flag" 파일 생성
3. orw.c 컴파일 및 실행
(3) orw 셸코드 디버깅
1. orw를 gdb로 열고, run_sh()에 브레이크 포인트 설정
2. run 명령어로 run_sh()의 시작 부분까지 코드 실행
3. 시스템 콜들의 구현 여부 확인
3-1) int fd = open(“/tmp/flag”, O_RDONLY, NULL)
- 첫번째 syscall이 위치한 run_sh+29 브레이크 포인트를 설정한 후 실행하여, 해당 시점에 syscall에 들어가는 인자를 확인
- ni 명령어로 syscall을 실행하고 나면, open 시스템 콜을 수행한 결과로 /tmp/flag의 fd(3)가 rax에 저장
3-2) read(fd, buf, 0x30)
- 두 번째 syscall이 위치한 run_sh+55에 브레이크 포인트를 설정하고 실행한 후 인자 확인
- ni 명령어로 syscall을 실행
- REGISTERS 부분의 RSI를 통해서 파일의 내용이 0x7fffffffdf68에 저장되었음을 이미 알 수 있지만, x/s 명령어로도 확인
execve 셸코드
✅ execve 셸코드란?
임의의 프로그램을 실행하는 셸코드로, 이를 이용하면 서버의 셸을 획득할 수 있다. 다른 언급없이 셸코드라고 하면 execve 셸코드를 의미하는 경우가 많다.
(1) execve 셸코드 작성
✅ execve("bin/sh", null, null)
execve 셸코드는 execve 시스템 콜만으로 구성된다.
📌 execve 시스템 콜 설명
▶ argv는 실행파일에 넘겨줄 인자, envp는 환경변수를 의미
(2) execve 셸코드 컴파일 및 실행
✅ execve 셸코드 컴파일
// 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(); }
execve 셸코드의 실행 및 디버깅 방식은 위 orw 셸코드의 실행 및 디버깅 방식과 동일하다
출처 : 드림핵 시스템해킹 로드맵 (https://dreamhack.io/lecture/roadmaps/2)
'Security > System Hacking' 카테고리의 다른 글
[Dreamhack] tcache_dup2 write-up (0) | 2024.09.09 |
---|---|
[Dreamhack] tcache_dup write-up (0) | 2024.09.09 |
[Tool] pwntools (0) | 2024.09.09 |
[Tool] gdb (0) | 2024.09.09 |
[Dreamhack] ssp_001 (0) | 2024.09.01 |