no image
Memory Detection(메모리 진단)
1. Introdution특정 악성코드 파일을 찾고자 할 때 우리가 사용할 수 있는 방법은 각 파일의 HASH 값을 비교한다거나 특정 바이너리가 그 파일에 포함되어 있는지 확인하는 등의 방법이 있다. 그렇다면 어떤 악성코드가 다른 정상적인 프로세스에 인젝션하여 동작하는 경우에는 어떻게 진단해야 할까? 파일로 존재하는 악성코드는 파일의 바이너리를 비교하여 쉽게 찾을 수가 있을 것이다. 하지만 파일의 형태가 아닌 프로세스로 메모리에 존재하는 경우는 위와 같은 방법으로 접근할 수 없을 것이다. 위 경우가 실제로 존재할까라고 생각할 수 있지만, 인젝션 방식을 사용하는 악성코드가 꽤 많이 존재한다. 물론 explorer.exe 나 notepa.exe 와 같이 특정 프로세스에만 인젝션 한다면 상대적으로 처리가 쉬워..
2016.09.26
no image
Assembly로 보는 코드, strcmp 문자열 비교
* 컴파일한 어셈블리 코드는 컴파일러나 속성 등 여러 요인에 따라 많이 상이 할 수 있습니다.우선 문자열 비교를 위한 코드는 아래와 같다. buf1 에는 "Compare" 라는 문자열을 저장해놓고 buf2 에는 사용자로부터 입력을 받도록 하였다. 그리고 strcmp 를 통해 두 값을 비교하는 코드이다. 위 코드에서 strcmp 부분의 어셈블리 코드는 아래와 같다. 여기서 buf2 에 "Comparz" 를 넣어주므로 ARG.1 에는 "Compare", ARG.2 에는 "Comparz" 가 위치하게 된다. 눈으로 한번 살펴본 뒤 이에 대한 자세한 설명을 읽어보자. 우선 strcmp(buf1, buf2) 에서 buf1 은 "Compare" 이고 buf2는 "Comparz" 라는 문자열을 입력해주었다. 스택에 ..
2016.08.08
WFP 무력화
WFP (Windows File Protection)WFP는 중요한 Windows 시스템 파일이 대체 또는 변경되는 것을 방지하기 위해 Windows에서 기본적으로 제공하는 기능(Vista부터는 WRP로 대체)으로, 프로그램들이 Windows 시스템의 중요한 파일들을 덮어씌울 수 없게 하여 프로그램과 운영체제로부터 발생할 수 있는 문제를 사전에 방지한다. WFP는 보호하고자 하는 시스템 파일이 올바른지 확인하기 위해 코드 서명에 의해 생성된 카탈로그와 파일 시그니쳐를 사용하여 확인한다. 그렇다면 정상적인 경우라도 이러한 파일의 변경이 일어날 수 없을까? 시스템 파일에 치명적인 취약점이 발견되었을 경우 WFP에 의해 해당 파일을 대체하지 못한다면 이는 위험을 품고 있는 OS가 되어버릴 것이다. 따라서 보호..
2016.06.21
no image
DLL이란?
DLL ( Dynamic Link Library ) DLL은 동적 링크 라이브러리(Dynamic Link Library)의 약자로 일반적으로 확장자가 DLL인 파일이다. 라이브러리라는 말에서 알 수 있듯이 다른 프로그램에서 이용하는 함수들을 모아둔 것이다. 하지만 표준 C 라이브러리 같은 일반 라이브러리의 파일과는 구조나 사용법이 다소 다르다. 일반 라이브러리는 소스코드를 컴파일한 결과로 생성되는 객체 파일(.OBJ)을 그대로 모아둔 것이다. 링커는 이 중에서 필요한 함수가 포함된 객체 파일을 꺼내서 실행 파일에 결합하는 '정적 링크' 방식이다. 아래 그림은 정적 링크를 나타내는 것으로 C/C++ 프로그램의 소스 코드를 기계어 코드로 변환하는 컴파일 단계를 거치게 된다. 여기서 C/C++에는 수많은 표준..
2016.05.29
no image
PE구조의 이해
개요우리가 컴퓨터로 무엇인가 작업하기 위해서는 언제나 특정 프로그램을 실행시킨다. 이러한 실행 파일 또는 응용 프로그램이라 불리는 EXE 파일 말고도 프로그램 실행을 위한 DLL 파일도 프로그램 실행 시에 같이 물려 메모리 상에 로드된다. 이러한 EXE 파일 관련 DLL 파일들이 메모리 상에 로드되면서 비로소 프로그램이라는 것이 사용 가능하게 되고 이렇게 로드된 하나의 EXE와 여러 개의 관련 DLL들이 소위 운영체제론에서 이야기하는 하나의 프로세스를 구성하게 된다. 그림 1. HxD로 본 PE 구조 이러한 실행 파일들은 항상 MZ라는 식별 가능한 문자로 시작하는데 이는 무의미한 문자가 아니라 PE(Portable Executable)구조로 된 PE 파일들을 나타낸다. PE파일은 이름과 같이 플랫폼에 관..
2016.05.04
윈도우 후킹 원리 [PDF]
목차1 Intro. 42 Prior Knowledge. 52.1 What is an API?. 52.2 What is an API Hooking?. 63 User Mode Hooking. 73.1 IAT Hooking. 73.2 Message Hooking. 134 Kernel Mode Hooking. 154.1 System Call 154.2 INT 0x2E Hooking. 164.3 SYSENTER Hooking. 194.4 SSDT Hooking. 225 Conclusion. 266 Reference. 27 그림그림 1. User Mode & Kernel Mode. 5그림 2. 정상호출과 후킹된 호출... 6그림 3. PE View로 본 IAT. 7그림 4. Sleep API 7그림 5. 메모리에서..
2016.04.23
no image
윈도우 후킹 원리 (3) - Kernel [SSDT]
SSDT HookingSSDT(System Service Dispatch Table)는 시스템 호출을 요청한 뒤, 전달되는 서비스 번호에 맞는 함수를 찾을 때 참조한다. 위 과정에서 시스템 호출을 요청하는 두 가지 명령어(INT 0x2E와 SYSENTER)에 대하여 알아보았는데, 결국 두 명령어 모두 KiSystemService(System Service Dispatcher)를 호출한다고 언급하였다.KiSystemService가 호출될 때 EAX에는 사용자 영역에서 요청한 서비스 번호가 저장되어 있으며, EDX에는 이러한 서비스에 사용될 인자가 저장되어있다. 이러한 시스템 호출 번호(EAX)에 맞게 KeServiceDescriptorTalbe에서 Native API의 주소를 가지고 온다. 그 후 시스템 ..
2016.04.23
no image
윈도우 후킹 원리 (2) - Kernel [SYSTEM CALL]
Kernel Mode Hooking3장에서는 사용자 모드 후킹에 대하여 알아보았다면 이번 장에서는 커널 모드 후킹에 대하여 알아볼 것이다. 커널 모드에서 이루어지는 후킹의 경우 단순히 JMP 명령어를 설치하는 것이 아니라, 특정한 구조체에 포함된 값을 수정하는 등 작업을 수행해야 하기 때문에 아무래도 사용자 모드의 후킹보다 복잡하다. 이제 이러한 커널 모드 후킹에 대하여 알아보자. System Call운영체제는 사용자 모드(Ring 3)와 커널 모드(Ring 0)라는 두 가지 형태의 권한이 존재하고 있다. 이렇게 분리되는 이유는 다양하지만, 아무래도 보안과 관련된 점 또한 매우 중요하다. 만약 분리되어 있지 않을 경우 어떠한 프로세스든지 운영체제의 핵심 기능을 조작할 수 있게 되므로 이를 방지하기 위해선..
2016.04.23

1.  Introdution

특정 악성코드 파일을 찾고자 할 때 우리가 사용할 수 있는 방법은 각 파일의 HASH 값을 비교한다거나 특정 바이너리가 그 파일에 포함되어 있는지 확인하는 등의 방법이 있다. 그렇다면 어떤 악성코드가 다른 정상적인 프로세스에 인젝션하여 동작하는 경우에는 어떻게 진단해야 할까? 파일로 존재하는 악성코드는 파일의 바이너리를 비교하여 쉽게 찾을 수가 있을 것이다. 하지만 파일의 형태가 아닌 프로세스로 메모리에 존재하는 경우는 위와 같은 방법으로 접근할 수 없을 것이다.

위 경우가 실제로 존재할까라고 생각할 수 있지만, 인젝션 방식을 사용하는 악성코드가 꽤 많이 존재한다. 물론 explorer.exe notepa.exe 와 같이 특정 프로세스에만 인젝션 한다면 상대적으로 처리가 쉬워질 것이다. 하지만 특정 프로세스가 아닌 임의의 프로세스에 인젝션 한다면 매우 번거로워질 것이다. 일반적인 사용자의 PC 에서 평소에 돌고 있는 프로세스의 수는 결코 적지 않을뿐더러 각 프로세스가 하나 이상의 스레드를 가진다는 점을 생각하면 문제는 더 복잡해진다. 따라서 이번 문서에서는 이러한 문제를 해결하기 위한 코드를 제작해보려 한다.


2. Body

이번 문서에서의 주 목적은 특정 코드를 가진 스레드를 찾는 것이다. 그러므로 파일을 탐색할 때 FindFirstFile FindNextFile API 를 사용하듯이 스레드를 탐색하는 API 를 사용할 것이다. 윈도우 환경에서 시스템의 스레드를 열거하는 가장 편리한 방법은 바로 ToolHelp 라이브러리를 사용하는 것이다. 해당 라이브러리는 프로세스, 스레드, 모듈 열거와 관련된 라이브러리로 가장 중요한 함수는 바로 CreateToolhelp32Snapshot API 이다. 이 함수는 호출 시점에 시스템 정보에 대한 스냅샷을 만들어주는 역할을 한다. 여기서 dwFlags TH32CS_SNAPTHREAD 를 전달하면 시스템에서 실행되고 있는 스레드 스냅샷을 생성할 수 있다

1
2
3
HANDLE WINAPI CreateToolhelp32Snapshot(DWORD dwFlags, DWORD th32ProcessID);
BOOL WINAPI Thread32First(HANDLE hSnapshot, LPTHREADENTRY32 lpte);
BOOL WINAPI Thread32Next(HANDLE hSnapshot, LPTHREADENTRY32 lpte);
cs

스냅샷 핸들을 통해 스레드 탐색을 시작할 수 있다. Thread32First Thread32Next API 의 인자를 보면 스냅샷 핸들과 THREADENTRY32 형태의 인자를 받는 것을 확인할 수 있다. 해당 구조체는 아래와 같은 구조를 가진다. 구조체에는 스레드 ID 와 스레드가 속한 프로세스의 ID 값까지 존재하고 있는 것을 확인할 수 있다.

1
2
3
4
5
6
7
8
9
typedef struct tagTHREADENTRY32 {
    DWORD dwSize;
    DWORD cntUsage;
    DWORD th32ThreadID;
    DWORD th32OwnerProcessID;
    LONG  tpBasePri;
    LONG  tpDeltaPri;
    DWORD dwFlags;
} THREADENTRY32, *PTHREADENTRY32;
cs

지금까지의 과정을 코드로 나타내면 다음과 같다. 여기서 유의해야 할 점은 Thread32First API 를 호출하기 전에 THREADENTRY dwSize 를 초기화 해주어야 한다. 이를 초기화 해주지 않으면 Thread32First 함수는 실패하게 된다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
THREADENTRY32 t32;
HANDLE hSnap;
 
/* Create a Snapshot Handle */
hSnap = CreateToolhelp32Snapshot(TH32CS_SNAPTHREAD, 0);
if (hSnap == INVALID_HANDLE_VALUE)
    return 0;
/* if you do not initialize THREADENTRY32.dwSize, Thread32First API fails */
t32.dwSize = sizeof(THREADENTRY32);
if (Thread32First(hSnap, &t32))
{
    do
    { /* insert code what you want */
        …
    } while (Thread32Next(hSnap, &t32));
}
CloseHandle(hSnap);
 
cs

스레드의 목록과 스레드가 속한 프로세스의 ID 정보를 출력하는 프로그램을 구현할 수 있게 되었다. 하지만 우리는 특정 코드가 포함된 스레드를 찾는 것이 목표이므로 계속해서 알아보자. 다음으로 알아볼 것은 탐색한 스레드의 Start Address 이다. 일반적인 경우 Code Injection 을 수행할 때 VirtualAllocEx 를 통해 할당한 공간에 코드를 기록한 뒤, 이 할당된 공간의 주소를 스레드가 실행하도록 CreateRemoteThread 의 인자로 넘겨준다. 따라서 우리는 스레드의 시작 위치를 알아낸 후 그곳의 코드 및 바이너리를 비교하면 된다.

스레드의 시작 위치를 알아내기 위한 핵심 API Ntdll.dll NtQueryInformationThread 이다. API 는 특정 스레드에 대한 정보를 얻기 위한 API 로 두 번째 인자인 ThreadInformationClass ThreadQuerySetWin32StartAddress(0x9) 를 넘겨주면 세 번째 인자 ThreadInformation 에 스레드의 시작 주소를 반환해준다.

1
2
3
4
5
6
7
8
NTSTATUS WINAPI NtQueryInformationThread(
    _In_      HANDLE            ThreadHandle,
    _In_      THREADINFOCLASS  ThreadInformationClass,
    _Inout_   PVOID              ThreadInformation,
    _In_      ULONG             ThreadInformationLength,
    _Out_opt_ PULONG           ReturnLength
);
 
cs

Ntdll 의 함수를 사용하기 위해 LoadLibrary 를 통해 해당 모듈을 로드하고 GetProcAddress 를 통해 우리가 사용하고자 하는 NtQueryInformationThread 의 주소를 가지고 올 것이다. 다음과 같은 코드로 구성할 수 있다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
typedef NTSTATUS(WINAPI *NtQueryInfoThread)(HANDLE, ULONG, PVOID, ULONG, PULONG);
 
PVOID ThreadInfo;
ULONG ThreadInfoLength;
PULONG ReturnLength;
NtQueryInfoThread NtQueryInformationThread;
 
HMODULE hNtdll = LoadLibrary("ntdll.dll");
NtQueryInformationThread = (NtQueryInfoThread) GetProcAddress(hNtdll, "NtQueryInformationThread");
 
if (!NtQueryInformationThread)
    return FALSE;
 
/* if THREADINFOCALSS is a ThreadQurtySetWin32StartAddress, return start address of thread */
HANDLE hThread = OpenThread(THREAD_QUERY_INFORMATION, 0, tid);
NTSTATUS NtStat = NtQueryInformationThread(hThread, ThreadQuerySetWin32StartAddress, &ThreadInfo, sizeof(ThreadInfo), NULL);
 
return TRUE;
 
cs

이제 탐색한 스레드의 시작 주소를 알 수 있으니, 그곳에 있는 데이터를 읽어와 우리가 비교하고자 하는 데이터와 비교해볼 것이다. 이 부분은 오히려 쉽다. 아래 코드와 같이 OpenProcess 를 통해 스레드가 속한 프로세스의 핸들을 얻고 ReadProcessMemory 의 인자로 위에서 얻은 스레드 시작 주소를 넘겨주면 된다. 그리고 한 바이너리씩 비교하면 된다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
CHAR Buffer[0x100];
CHAR CmpCode[0x100= { '\x6A''\x00''\x68''\x00''\x20', ….};
DWORD NumberofByteRead;
HANDLE hProc = OpenProcess(PROCESS_VM_READ, 0, t32.th32OwnerProcessID);
ReadProcessMemory(hProc, EntryPoint, Buffer, 0x100&NumberofByteRead);
 
for (int i = 0; i < sizeof(CmpCode); i++)
{
    if ((BYTE) CmpCode[i] != (BYTE)Buffer[i])
    {
        return FALSE;
    }
}
return TRUE;
cs

이를 통해 프로그램을 제작한 뒤 테스트를 하기 위해 Reverse_L01.exe 란 프로그램을 선택하였다. 해당 프로그램은 아주 간단한 프로그램으로 본 코드가 0x67 정도밖에 되지 않는다.

이 코드를 기준 바이너리로 하여 각 스레드의 시작 주소에서 0x67 의 크기를 비교할 것이다. 테스트를 위해 해당 프로세스를 8개 실행하였다.

실행 결과 아래 그림과 같이 8개 모두 선별해낸 것을 확인할 수 있다.


3. Conclusion

다소 간단한 코드를 제작해보았다. 하지만 파일로 진단할 수 없는 악성코드의 경우 이 코드를 기반으로 진단 프로그램을 만들 수 있을 것이다. 그렇다면 진단만으로 무엇을 할 수 있을까? 사실 진단은 그에 따른 조치를 취하기 위한 이전 단계라 할 수 있다.

분석한 악성코드의 내용에 따라 해당 프로세스를 종료시키거나 특정 스레드만 걸러내어 ResumeThread 와 같은 API 를 사용하여 동작하지 않도록 할 수 있다. 이러한 추가적인 동작은 추후에 다루어 보자.

악성코드를 다루며 분석에만 집중하는 것도 중요하지만, 실제 기업이나 고객은 조치를 원할 것이다. 따라서 충분한 분석이 이루어졌다면 다른 샘플을 보는 것이 아니라, 진단이나 치료 코드를 제작해보는 것이 매우 큰 도움이 될 것이라 생각한다.


Reference

[+] Microsoft, MSDN API : https://msdn.microsoft.com/

[+] 괴짜 프로그래머의 일상사, “스레드 열거하기” : http://www.jiniya.net/wp/archives/7676

'Reversing > Theory' 카테고리의 다른 글

DoubleAgent 공격  (1) 2017.03.28
암호학 기초 개념  (2) 2016.11.23
Assembly로 보는 코드, strcmp 문자열 비교  (0) 2016.08.08
WFP 무력화  (0) 2016.06.21
DLL이란?  (4) 2016.05.29

* 컴파일한 어셈블리 코드는 컴파일러나 속성 등 여러 요인에 따라 많이 상이 할 수 있습니다.

우선 문자열 비교를 위한 코드는 아래와 같다. buf1 에는 "Compare" 라는 문자열을 저장해놓고 buf2 에는 사용자로부터 입력을 받도록 하였다. 그리고 strcmp 를 통해 두 값을 비교하는 코드이다. 

위 코드에서 strcmp 부분의 어셈블리 코드는 아래와 같다. 여기서 buf2 에 "Comparz" 를 넣어주므로 ARG.1 에는 "Compare", ARG.2 에는 "Comparz" 가 위치하게 된다. 눈으로 한번 살펴본 뒤 이에 대한 자세한 설명을 읽어보자.

우선 strcmp(buf1, buf2) 에서 buf1 은 "Compare" 이고 buf2는 "Comparz" 라는 문자열을 입력해주었다. 스택에 들어있는 각 문자열을 EDX 와 ECX 레지스터에 넣어준다.

첫 줄에서 EDX 에 있는 기준 문자열 4 Bytes 를 EAX 로 복사한다. 이를 통해 EAX 에는 0x706D6F43 이 오게 된다. 얼핏 보면 함수의 주소 같지만 "Comp" 라는 문자에 해당하는 ASCII 값이다. 두 번째 줄부터 CMP 명령어가 나타난다. CMP 명령어는 두 값을 비교할 때 사용되는 명령어이다. AL 은 EAX 의 하위 1 Byte 로 0x43("C") 가 된다. 이 "C" 와 사용자가 입력한 문자열("Comparz") 의 첫 번째 문자를 비교한다.


AL 과 ECX 의 첫 글자가 일치한다면 ZF 는 1로 설정되어 JNE 에서 점프하지 않는다. 그리고 TEST AL, AL 은 비교한 두 문자가 0 인지 확인하는 명령어로, 두 문자 모두 0 이라면 ZF 가 1로 설정되어 JZ 에서 점프하여 RETN 으로 간다.

위에서 AL, AH 를 통해 두 문자씩 비교를 하였다. 그렇다면 세 번째 문자부터는 SHR EAX, 10 을 통해 EAX 값을 정리해준다. SHR EAX, 10 는 EAX 레지스터의 값을 0x10 bit 만큼 우측으로 이동시키는 것으로 상위 2 Bytes 의 값이 하위 2 Bytes 에 자리 잡게 된다. 이 경우 "0x706D6F43" 이 "0x0000706D" 가 된다. 그리고 다시 AL 과 AH 를 통해 나머지 문자들도 비교한다.

기준 문자열이 4 Bytes 보다 크기 때문에 주소를 옮겨 주어야 한다. 따라서 ECX 와 EDX 에 있는 주소 값에 각각 4 를 더해 다섯 번째 문자를 가리키도록 한다. 그 후 다시 문자 비교를 시작하는 위치 0x62BEF950 으로 이동한다.

반복문을 돌면서 한 문자씩 비교하는 것을 확인하였다. 이번에는 반환 값에 대하여 알아보자. 아래 두 그림은 0 을 반환하는 경우와, 0 이 아닌 값을 반환하는 경우이다. 아래 첫 번째 그림은 XOR EAX, EAX 를 통해 EAX 의 값을 0 으로 만든다. strcmp 는 두 값이 같은 경우 0 이 반환되는 것으로 바로 그 부분이다.

SBB EAX, EAX 는 SUB EAX, EAX 와 유사하다. 다만 이에 Carry Flag(CF)의 값을 다시 빼준다. 따라서 EAX 에는 어떤 값이 있더라도 자기 자신을 소거한 뒤 CF 의 값에 따라 1을 더 빼주는 셈이 된다. SBB 명령어를 통해 0 또는 0xFFFFFFFF 가 된 EAX 에 OR EAX, 0x1 을 해주므로 결국 EAX 에는 1 또는 -1 이 반환된다.

좀 더 구체적으로 1 또는 -1 은 마지막 CMP 명령어에서 결정된다. 기준 문자열의 마지막 문자 'e' 와 비교 문자열의 'z' 를 비교했을 때, 각각의 ASCII 값은 0x65 와 0x7A 이다. '기준 문자 < 비교 문자' 인 경우 CF 는 borrow 가 발생하여 1로 설정된다. 이 경우 최종적으로 반환되는 값은 -1 이 된다. 반대로 비교 문자열이 "Compara" 라 할 때, 마지막 문자 'a' 는 'e' 보다 작은(기준 문자 > 비교 문자) 값이므로 borrow 가 발생하지 않아 CF 가 0으로 설정되어 최종 반환 값은 이에 OR 1 을 하여 1 이 된다.

'Reversing > Theory' 카테고리의 다른 글

암호학 기초 개념  (2) 2016.11.23
Memory Detection(메모리 진단)  (0) 2016.09.26
WFP 무력화  (0) 2016.06.21
DLL이란?  (4) 2016.05.29
PE구조의 이해  (0) 2016.05.04

WFP 무력화

Kail-KM
|2016. 6. 21. 08:59
WFP (Windows File Protection)

WFP는 중요한 Windows 시스템 파일이 대체 또는 변경되는 것을 방지하기 위해 Windows에서 기본적으로 제공하는 기능(Vista부터는 WRP로 대체)으로, 프로그램들이 Windows 시스템의 중요한 파일들을 덮어씌울 수 없게 하여 프로그램과 운영체제로부터 발생할 수 있는 문제를 사전에 방지한다. WFP는 보호하고자 하는 시스템 파일이 올바른지 확인하기 위해 코드 서명에 의해 생성된 카탈로그와 파일 시그니쳐를 사용하여 확인한다. 그렇다면 정상적인 경우라도 이러한 파일의 변경이 일어날 수 없을까? 시스템 파일에 치명적인 취약점이 발견되었을 경우 WFP에 의해 해당 파일을 대체하지 못한다면 이는 위험을 품고 있는 OS가 되어버릴 것이다. 따라서 보호되고 있는 파일을 대체하기 위한 방법들이 존재하고 있으며, 오직 아래의 방법들을 통해서만 대체가 가능하다.

- Update.exs를 통한 Windows 서비스 팩 설치
- Update.exe나 Hotfix.exe를 통한 Hotfixes 설치
- Winnt32.exe를 통한 운영체제 업그레이드
- Windows 업데이트

만약 프로그램이 다른 방법으로 보호되고 있는 파일을 대체하고자 한다면 WFP는 원래의 파일로 복구하고자 한다. Windows Installer는 중요한 시스템 파일을 설치할 때 WFP를 준수하고, 보호된 파일 자체를 설치하거나 교체하는 대신 보호된 파일을 교체하라는 요청과 함께 WFP를 호출하게 된다.


How the WFP feature works

WFP는 두 가지 메커니즘을 통해 시스템 파일 보호 기능을 제공한다. 첫 번째 방법은 백그라운드에서 동작하는 것으로 보호되고 있는 디렉터리에서 변경이 일어난다면 변경에 대한 알림을 받은 후 동작하게 된다. 이러한 알림을 받은 WFP는 어떤 파일이 변경되었는지 결정하며 만약 그 파일이 보호되고 있다면 WFP는 파일 시그니쳐를 통해 해당 파일이 올바른 파일인지 확인하는 작업을 거치게 된다. 만약 파일이 올바르지 않다면 WFP는 새로운 파일을 Cache 폴더나 원본 설치 파일에 존재하고 있는 정상적인 파일로 바꾼다. 

1. Cache 폴더(Default : %SystemRoot%\system32\dllcache)
2. 네트워크 설치 경로(네트워크 설치를 사용하여 설치한 경우)
3. Windows CD-ROM(시스템이 CD-ROM으로부터 설치된 경우)

위의 표는 WFP가 정상 파일을 탐색하는 경로로 파일이 변조된 경우 해당 위치로부터 정상 파일을 찾아 복원한 뒤 다음과 같은 시스템 로그를 기록한다. 해당 로그에선 보호되고 있는 파일을 대체하고자 시도했다는 기록을 볼 수가 있다.

Event ID: 64001 
Source: Windows File Protection 
Description: File replacement was attempted on the protected system file c:\winnt\system32\file_name. This file was restored to the original version to maintain system stability. The file version of the system file is x.x:x.x.


How to bypass the WFP

WFP는 두 개의 DLL(SFC.DLL과 SFC_OS.DLL)을 통해 구현되며 ReadDirectoryChacnge API를 사용하여  주요 폴더의 변경 여부를 검사한다. Windows 시스템의 중요 프로세스인 winlogon.exe는 시스템 부팅시 SFC_OS.DLL을 로드시키고, 해당 라이브러리의 Ordinal#1(SfcInitProt)를 호출한다. 해당 함수는 새로운 스레드('SFC Watcher Thread)를 하나 생성하며 이 스레드는 보호 대상 파일이 존재하고 있는 폴더에 대한 Directory Change Notification 이벤트를 생성한다. 이러한 보호폴더 이벤트는 WaitForMultipleObjects 함수에 의해 동기화되며, 만약 보호 대상 파일이 변경 또는 삭제된 경우 Cache 폴더에서 해당 파일을 원상 복구하도록 한다. 만약 백업 폴더에 대상 파일이 존재하지 않다면 사용자에게 윈도우 CD를 삽입하라는 메시지를 출력하게 된다.


SFC.DLL : Windows 2000에서는 WFP의 핵심기능을 담당하지만 XP부터는 SFC_OS.DL의 보조 역할

SFC_OS.DLL : XP부터 WFP의 핵심 기능을 담당

SFCFILES.DLL : 현재 보호되고 있는 파일의 리스트를 관리

SFC.EXE : System File Checker Utility


이러한 WFP는 주요 Windows 파일을 보호하는 메커니즘이니 만큼 악성코드에 의한 타깃이 되고 있다. 최근에도 주요 시스템 파일을 교체 또는 패치하는 형태의 악성코드가 다수 등장하고 있으며, 이로 인한 시스템 불안정, BSOD 등이 발생하고 있다. 그렇다면 이제 WFP를 무력화하는 방법에 대하여 알아보자.


Method #1 


WFP를 무력화시키는 첫 번째 방법은 winlogon이 갖고 있는 Direcotry change notification handle을 종료시키는 방법으로 이 방법을 사용하면 시스템이 재부팅하기 전까지 특정 폴더에 대한 파일 보호가 이루어지지 않는다. 해당 핸들을 종료하기 위해서는 ntdll.NtDuplicateHandle이나 kernel32.DuplicateHandle을 통해 해당 핸들을 복제한 다음 CloseHandle을 통해 핸들을 닫으면 된다.

BOOL WINAPI DuplicateHandle(        // Duplicates an onject handle
  _In_  HANDLE   hSourceProcessHandle,
  _In_  HANDLE   hSourceHandle,
  _In_  HANDLE   hTargetProcessHandle,
  _Out_ LPHANDLE lpTargetHandle,
  _In_  DWORD    dwDesiredAccess,
  _In_  BOOL     bInheritHandle,
  _In_  DWORD    dwOptions
);


Method #2


WFP를 무력화하는 두 번째 방법은 Winlogin.exe가 SFC_OS.DLL을 로드한 뒤 생성하였던 'SFC Watcher Thread'를 종료시키는 것이다. 해당 스레드를 종료시키기 위해서는 SFC_OS.DLL이 export 하고 있는 #2(SfcTermintaeWatcherThread)를 이용하는 것으로 해당 API는 파라미터를 필요로 하지 않는다. winlogon.exe에서 해당 API를 호출하면 해당 스레드는 종료되고, 이로 인해 재부팅 전까지 WFP 기능은 무력화된다.


SfcTerminateWatcherThread를 호출하기 위해서는 SeDebugPrivilege 권한이 필요하며 해당 스레드를 생성한 프로세스인 winlogon.exe에서 실행되어야 한다. 그러므로 이를 위해 Injection의 기법을 사용하는 경우가 많다는 것을 알 수 있다.


Method #3


세 번째 방법은 SFC API를 이용하여 특정 파일에 대한 WFP를 1분 동안 무력화하는 방법으로, 실제로 악성코드가 주로 사용하는 방법이다. sfc_os.dll의 ordinal #5 : SfcFileException을 이용하는 방법으로, 해당 API는 특정 파일에 대하여 1분 동안 WFP 기능을 무력화시킨다. 이 방법을 사용하기 위해서는 LoadLibrary를 통해 sfc_os.dll을 로드한 뒤 GetProcAddress에 "#5"를 넘겨주어 호출하는 코드를 확인할 수 있을 것이다.

DWORD WINAPI SfcFileException(DWORD ?, PWCHAR pwszFileName, DWORD ?);

위 구조와 같이 두 번째 인자에 해당 파일의 이름을 넣어주면 되고 첫 번째 인자와 세 번째인자는 알 수 없는 인자지만 첫 번째에는 0을, 세 번째 인자에는 -1을 넣어주어야 한다. 해당 API가 성공할 경우 return value는 0이며 만약 성공하지 못한 경우에는 1이 반환된다.


Method #4


네 번째 방법은 Undocumented 레지스트리 값을 이용하는 방법으로 Windows 2000 SP1 이전 버전까지만 가능한 방법이다. 해당 레지스트리를 설정하면 WFP는 영구적으로 무력화된다. 

KEY : HKLM\Software\Policies\Microsoft\Windows NT\Windows File Protection 
Value Name : SFCDisable
Value : 0xFFFFFF9D


Method #5


마지막 방법은 Protected File List를 패치하여 특정 파일에 대한 WFP를 영구적으로 무력화하는 방법이다. WFP가 보호하고자 하는 대상은 SFCFILES.DLL에 정의되어 있음을 언급했다시피 해당 대상이 되는 파일의 내용을 패치하여 특정 파일에 대한 WFP를 영구적으로 무력화할 수 있다.


이를 위해선 SFCFILES.DLL을 복사하여 무력화하고자 하는 파일을 찾은 다음 해당 이름의 첫 바이트를 \x00으로 바꾸어 준다. 이렇게 내용을 수정한 뒤 'PEChkSum'를 이용하여 체크섬 문제를 해결하고, 'MoveLatr'를 통해 부팅 시에 원본 파일을 대신해 수정한 파일을 로드하도록 설정해주면 된다. 이러한 준비를 끝냈다면 프로세스를 완료하기 위해 재부팅해주어야 한다.



Reference


https://support.microsoft.com/en-us/kb/222193

https://bitsum.com/aboutwfp.asp

http://sinun.tistory.com/144


'Reversing > Theory' 카테고리의 다른 글

Memory Detection(메모리 진단)  (0) 2016.09.26
Assembly로 보는 코드, strcmp 문자열 비교  (0) 2016.08.08
DLL이란?  (4) 2016.05.29
PE구조의 이해  (0) 2016.05.04
윈도우 후킹 원리 [PDF]  (1) 2016.04.23

DLL이란?

Kail-KM
|2016. 5. 29. 22:55

DLL ( Dynamic Link Library )

DLL은 동적 링크 라이브러리(Dynamic Link Library)의 약자로 일반적으로 확장자가 DLL인 파일이다. 라이브러리라는 말에서 알 수 있듯이 다른 프로그램에서 이용하는 함수들을 모아둔 것이다. 하지만 표준 C 라이브러리 같은 일반 라이브러리의 파일과는 구조나 사용법이 다소 다르다. 일반 라이브러리는 소스코드를 컴파일한 결과로 생성되는 객체 파일(.OBJ)을 그대로 모아둔 것이다. 링커는 이 중에서 필요한 함수가 포함된 객체 파일을 꺼내서 실행 파일에 결합하는 '정적 링크' 방식이다.

아래 그림은 정적 링크를 나타내는 것으로 C/C++ 프로그램의 소스 코드를 기계어 코드로 변환하는 컴파일 단계를 거치게 된다. 여기서 C/C++에는 수많은 표준함수들이 존재하고 있는데 이들은 표준 라이브러리 파일 안에 어셈블리 코드의 형태로 담겨 있다. 소스 코드는 하나 이상 존재할 수가 있는데, 링크 단계는 이 여러 개의 소스 파일들이 하나의 실행 파일로 구성해낸다. 이때 각각의 소스파일에서 호출한 표준 함수들을 표준 라이브러리에서 가져와 실행파일에 붙여준다. 이러한 과정을 링크 과정이라 한다. 그리고 이러한 방식이 바로 '정적 링크'이다. 

하지만 많은 표준 함수를 사용할수록 프로그램의 크기가 커지게 되며, 똑같은 함수를 사용한다고 하더라도 이러한 정적 링크 방식은 각 프로그램마다 링크 과정에서 라이브러리를 가져와 프로그램 안에 저장하기 때문에 이는 비효율적이라 할 수 있다.

정적 링크 방식과는 다르게 DLL은 '동적 링크'에 방식으로, 이는 링크 시에 실행 파일에 결합되는 것이 아니라 프로그램 실행 시에 DLL도 함께 프로그램의 메모리 공간으로 읽어와 호출될 주소 등을 적절하게 바꾸는 것을 말한다. 일단 읽어온 DLL 함수는 프로그램 내부 함수처럼 호출할 수 있다. DLL은 실행 파일과 다른 파일이므로 필요한 시점에 메모리로 읽어오고 불필요하면 메모리에서 내릴 수 있다.

이러한 방식으로 인해 DLL은 여러 장점을 갖게 된다. 우선 여러 프로그램에서 동시에 사용할 수 있다는 것이다. 정적 링크 방식은 자신이 가진 코드를 자기 혼자만 사용하지만, 동적 링크 방식은 하나의 DLL로 존재하여 다른 프로그램에게 라이브러리를 제공해준다. 실행 파일은 DLL에 있는 함수를 Import하게 되는 것이고, DLL은 실행파일에게 함수를 Export 해주게 되는 것이다.

* Export Name Table

Offset Data Ordinal Value

==============================================

0x47C4 0xD9E1 0x01 HEEnterWindowLoop

0x47C8 0xD9F3 0x02 HEEnterWindowLoopInNewThread

0x47CC 0xDA10 0x03 HESpecifySettings

단, 함수나 변수가 실행 파일 안에 포함되지 않았기 때문에, 사용하고자 하는 함수나 변수를 컴파일러나 링커에게 알려주어야 한다. 만약 Export에 대한 정보가 없다면 실행파일은 DLL의 함수를 Import 할 수 없게 된다. 그러므로 위와 같이 Export 하는 함수에 대한 정보가 DLL에 기록되어 있어야 한다.

 

DLL Binding

EXE 파일은 사용하고자 하는 DLL의 함수를 메모리에 같은 메모리상에 올리게 되는데 이때 IAT에는 실제 사용하고자 함수들의 주소가 오게 된다. 다시 말해, 파일에서 IAT는 실제 함수의 주소를 가리키고 있지 않다. 왜냐하면 사용하고자 하는 함수의 주소를 아직 알 수 없기 때문이다. 하지만 메모리에 올라오면서 PE 로더는 IAT에 사용하고자 하는 함수의 실제 주소를 올려주므로 우리는 아무런 의심 없이 사용할 수 있다. 하지만 이러한 작업은 프로그램의 초기화 시간을 지연시키므로 MS는 이러한 제약을 피할 수 있도록 하나의 기능을 제공한다. 바로 IAT에 함수의 주소를 기록하는 작업을 미리 수행하여 로딩 시의 속도 향상을 도모하도록 한다. 이 과정을 바로 DLL 바인딩이라고 하며, 바인드 된 실행 파일의 IAT는 실제 함수의 주소를 가리키고 있게 된다.

* Import Name Table

Offset Data Name

==============================================

0xA3C 0x307C GetDriveTypeA

0xA40 0x308C ExitProcess

--------------------------------------KERNEL32.dll

0xA48 0x309A MessageBoxA

--------------------------------------USER32.dll

* Import Address Table

Offset RVA Data Value

==============================================

0xA50 0x3050 0x307C GetDriveTypeA

0xA54 0x3054 0x308C ExitProcess

--------------------------------------KERNEL32.dll

0xA5C 0x305C 0x309A MessageBoxA

--------------------------------------USER32.dll

우선 위의 표는 일반적인 파일의 Import Section 정보로 INT와 IAT가 같은 곳을 가리키고 있는 것을 확인할 수 있다. INT의 첫 번째 함수인 GetDriveTypeA인 0x307C의 이름의 위치를 IAT에서도 똑같이 가리키고 있다. 메모리에 올라오면서 IAT에 실제 주소가 기록되어 변경된다.

* Import Name Table

Offset Data Name

==============================================

0xA3C 0x307C GetDriveTypeA

0xA40 0x308C ExitProcess

--------------------------------------KERNEL32.dll

0xA48 0x309A MessageBoxA

--------------------------------------USER32.dll

* Import Address Table

Offset RVA Data Value

==============================================

0xA50 0x3050 0x6B826A00

0xA54 0x3054 0x6B827B30

--------------------------------------KERNEL32.dll

0xA5C 0x305C 0x6BAFFF90

--------------------------------------USER32.dll

위의 표는 같은 파일에 바인딩을 실시한 후의 모습으로 INT는 기존과 동일한 모습을 하고 있지만 IAT의 경우 Data에 실제 함수의 주소가 위치한 것을 확인할 수 있다. 이처럼 바인딩을 하게 되면 실제 함수의 주소를 가지게 되는 것을 알 수 있다.


DLL Relocation

DLL 재배치에 대하여 알아보기 전에 먼저 ImageBase에 대하여 알아보자. ImageBase란 PE 구조에서 해당 PE 파일이 PE 로더에 의해 메모리에 로드될 때 로드시키고자 하는 메모리의 주소가 된다. 보통 EXE 파일의 경우 0x4000000이며 DLL 파일의 경우 0x10000000이다.

typedef struct _IMAGE_OPTIONAL_HEADER {

WORD Magic;

BYTE MajorLinkerVersion;

BYTE MinorLinkerVersion;

….

DWORD AddressOfEntryPoint;

….

DWORD ImageBase;

….

하지만 위에서 말한 것과 같이 하나의 EXE 파일은 여러 라이브러리를 필요로 하는 경우가 일반적이기 때문에, 여러 DLL을 메모리에 올리고자 한다. 이 경우 DLL들의 ImageBase가 중첩된다면 하나의 메모리 주소에 여러 DLL이 존재할 수 없으므로 사용할 수 없게 된다. 다행히 DLL 재배치를 통해 원하는 ImageBase에 이미 다른 DLL이 올라와 있다면, 다른 주소에 맵핑될 수가 있다.

하지만 이러한 재배치 작업이 일어나면 PE 로더는 부차적인 작업을 수행해야 한다. DLL의 주소를 바꾸어 올리는 것뿐만 아니라, 해당 DLL의 Code Section의 일부 내용을 수정해야만 한다. 이에 대해 아래의 표를 보자. 아래의 표는 Relocation Section의 내용으로 하단 두 줄에 RVA가 있는 것을 확인할 수 있다. 일반적으로 이 주소가 가리키는 부분은 0x????????과 같은 4 Bytes의 주소를 나타내는 것으로 PE 구조에 기록되어 있는 ImageBase에 맞게 주소가 설정되어 있다. 하지만 ImageBase와 다른 곳에 로드되면 이 주소들은 ImageBase에 로드된 다른 DLL을 가리키게 되는 문제가 발생하므로 값을 수정해주어야 한다.

[+] Relocation Section --------------------

Base Address : 0x1000

Size of Block : 0x1069 (Num : 0x830)

Type Value : 0x5708 --- RVA : 0x1708 (Offset : 0xB08)

Type Value : 0x106C --- RVA : 0x106C (Offset : 0x46C)

다행히 이러한 주소 값의 수정을 사용자가 직접 하나하나 하는 것이 아니라 PE 로더가 재배치 섹션을 확인하여 알아서 수정해준다. 하지만 여기서 몇 가지 문제점이 존재하게 된다. 우선 재배치 정보가 가리키고 있는 값들은 대개 어떤 주소에 관한 값으로, 이러한 값들이 대개 코드 섹션에 위치하고 있다는 것이다. 따라서 이 값을 PE 로더가 수정하기 위해선 해당 섹션에 Write 속성을 추가한 뒤 수정을 하고, 수정을 마치면 다시 원래의 속성으로 되돌려야 한다는 것이다. 이에 더해 위 예에서는 2개의 값만 나타냈지만, 실제로는 더 많은 경우가 많기 때문에 PE 로더는 그 많은 주소의 값들을 직접 찾아 수정해주어야 한다. 만약 하나의 EXE 파일에 여러 DLL에 대하여 이러한 작업을 수행해야 한다면, 프로그램을 실행하기 위한 초기화 시간이 길어질 수 있다.

  

DLL Delay Loading & DLL Forwarding

DLL Delay Loading

상기의 이유들로 초기화 시간이 길어질 수 있다는 것에 대하여 알 수 있었다. 사실 하드웨어의 성능이 상향된 요즘은 별 상관이 없지만, 윈도우는 이러한 초기화 시간을 줄이기 위한 또 다른 방안을 구비해놓았다. 바로 DLL 지연 로딩으로, 단어에서와 같이 DLL을 프로그램 실행 시에 로드하는 것이 아니라 지연하여 로딩하는 것이다.

지연 로딩은 암시적 로딩에서의 간편함과 명시적 로딩에서의 유연함, 이 두 장점을 취하고자 하는 방식으로 EXE 작성에서 DLL 링크 시에는 암시적인 방식으로, 실제 런타임에서 사용 할 때는 명시적인 방식으로 작동하도록 한 것이다. 쉽게 말해 프로그램을 실행 시에 메모리에 매핑되는 것이 아니라 해당 DLL의 Export 함수들 중 하나가 최초로 실행될 때 그 시점에 해당 DLL을 로드해서 가상 주소 공간에 매핑한다는 것이다.

 

DLL Forwarding

DLL의 Export Function Forwarding이란 Export하고자 하는 함수를, 그 기능을 대신하는 다른 DLL 내에 정의된 함수의 호출로 대체하는 것이다. 글로 설명하는 것보다는 직접 코드를 확인 것이 더 좋으므로 일반적인 경우의 DLL의 Export 함수의 주소를 확인해보자. 아래의 표와 같이 Export하는 함수의 주소로 이동을 하면 해당 함수의 내용이 존재하고 있는 것을 확인할 수 있다. 즉, 자신의 DLL 안에 해당 코드를 그대로 잘 가지고 있는 것이다.

text:5F923F4B mov edi, edi

.text:5F923F4D push ebp

.text:5F923F4E mov ebp, esp

.text:5F923F50 sub esp, 1Ch

.text:5F923F53 mov eax, [ebp+arg_0]

.text:5F923F56 push ebx

.text:5F923F57 push esi

.text:5F923F58 push edi ; struct CApplnMgr *

.text:5F923F59 mov edi, [ebp+arg_4]

.text:5F923F5C mov [ebp+var_C], eax

이번에는 DLL Forwarding이 적용된 DLL의 내용을 확인해보자. 위 표와는 다른 DLL이기는 하지만 Export 하는 함수의 주소로 이동하여 확인해보면 심히 코드가 짧다는 것을 알 수 있다. Export 하고자 하는 함수의 이름 "LpkEditControl"과 함께 0x1000261C를 호출하는 것을 확인할 수 있다.

.text:10002BC8 MemCode_LpkEditControl proc near ; DATA XREF: .rdata:off_1001E148

.text:10002BC8 ; .data:LpkEditControl

.text:10002BC8 push offset aLpkeditcontr_0 ; "LpkEditControl"

.text:10002BCD call sub_1000261C

.text:10002BD2 jmp dword ptr [eax]

0x1000261C에는 다시 아래와 같은 내용이 있으며, 10002634를 호출한 다음, 이전에 인자로 전달 받았던 Export하고자 하는 함수의 이름과 함께 GetProcAddress를 통해 주소를 구하고자 하는 것이다.

.text:1000261C call sub_10002634

.text:10002621 push [esp+lpProcName] ; lpProcName : LpkEditControl

.text:10002625 push hModule ; hModule : lpk.dll

.text:1000262B call ds:GetProcAddress

.text:10002631 retn 4

해당 10002634를 따라가다 보면 시스템 디렉터리의 경로를 구한 뒤, 해당 Export 함수를 가진 대상(포워딩 대상)을 LoadLibarary API를 통해 로드하는 것을 확인할 수 있다. 이를 통해 해당 DLL이 로드되고 위의 과정에서와 같이 GetProcAddress를 통해 해당 함수가 로드되는 것이다.

.text:1000265B push esi ; uSize

.text:1000265C push eax ; lpBuffer

.text:1000265D call ds:GetSystemDirectoryA

.text:10002663 lea eax, [ebp+Buffer]

.text:10002669 push offset String2 ; "\\lpk.dll"

.text:1000266E push eax ; lpString1

.text:1000266F call ds:lstrcatA

.text:10002675 cmp hModule, 0

.text:1000267C pop esi

.text:1000267D jnz short loc_10002691

.text:1000267F lea eax, [ebp+Buffer]

.text:10002685 push eax ; lpLibFileName

.text:10002686 call ds:LoadLibraryA ; lpk.dll

DLL 포워딩이 그렇다면 어느 곳에 사용될 수 있을까? 필자는 악성코드에 관심이 많으므로 악성코드를 대상으로 설명하겠다. 악성코드는 문서의 형태로 존재할 수도 있고 실행파일의 형태로 존재할 수도 있다. 하지만 이 글의 취지에 맞게 DLL로 구성된 경우 악성코드 제작자는 매우 유용하게 DLL 포워딩을 사용할 수 있다. 악성코드 제작자 그 누구도 자신이 만든 애지중지한 파일이 누가 보아도 '악성'으로 보이고 싶지는 않을 것이다. 그렇기에 정상 파일인 것처럼 위장을 하게 되는데, DLL의 경우 실제 Export 하는 함수의 내용을 구현할 수 있어야 한다는 것이다. 물론 실제 DLL의 내용을 Ctrl+C/Ctrl+V를 통해 사용할 수는 있겠지만 이는 결코 좋은 방법이 아니다.

이 기능을 사용하면 해당 포워딩 설정을 실제 DLL 파일로 해놓으면 너무나 쉽게 정상적인 기능을 모두 구현할 수 있게 된다. 결국 정상적인 기능을 수행하면서, DllMain()에는 자신이 원하는 기능을 수행하도록 하면 이는 완벽한 위장이 된다. 물론 실제 시스템 DLL이 먼저 로드되는 상황이 발생되면 안되므로 DLL 로딩 순서를 변경하여 실행파일과 같은 디렉터리에 있는 악성 DLL을 먼저 로드하도록 하여 이를 로드시키면 모든 것은 끝이 난다.

 

Conclusion

단순하게 DLL을 이론만 공부했던 때에는 위의 기능들이 어떻게 사용되는지 별로 관심이 없었다. 하지만 실제로 악성 DLL을 분석해보고 난 뒤 너무나 부족한 지식을 가지고 있다고 다시 한 번 느끼게 되어 이렇게 기억하고자 정리해보았다. 이외에도 분석을 통해 어떠한 부분이 부족한지 더 자세히 알게 되어 너무나 유익하였다.

약간의 후기를 공유하자면 이론으로 알고 있던 내용이 어떻게 코드나 어셈블리어로 적용되는지 반드시 확인을 해보자. 이론으로는 자세히 알고 있더라도 그 부분을 무심코 지나칠 수 있다는 것을 뼈저리게 느끼게 되었다.


Reference

http://www.sck.pe.kr/c-cgi/whatisdll.htm

Windows 시스템 실행파일의 구조와 원리 [한빛미디어]

'Reversing > Theory' 카테고리의 다른 글

Assembly로 보는 코드, strcmp 문자열 비교  (0) 2016.08.08
WFP 무력화  (0) 2016.06.21
PE구조의 이해  (0) 2016.05.04
윈도우 후킹 원리 [PDF]  (1) 2016.04.23
윈도우 후킹 원리 (3) - Kernel [SSDT]  (0) 2016.04.23

PE구조의 이해

Kail-KM
|2016. 5. 4. 22:21


PE구조의 이해.pdf


개요


우리가 컴퓨터로 무엇인가 작업하기 위해서는 언제나 특정 프로그램을 실행시킨다. 이러한 실행 파일 또는 응용 프로그램이라 불리는 EXE 파일 말고도 프로그램 실행을 위한 DLL 파일도 프로그램 실행 시에 같이 물려 메모리 상에 로드된다. 이러한 EXE 파일 관련 DLL 파일들이 메모리 상에 로드되면서 비로소 프로그램이라는 것이 사용 가능하게 되고 이렇게 로드된 하나의 EXE와 여러 개의 관련 DLL들이 소위 운영체제론에서 이야기하는 하나의 프로세스를 구성하게 된다.

그림 1. HxD로 본 PE 구조

이러한 실행 파일들은 항상 MZ라는 식별 가능한 문자로 시작하는데 이는 무의미한 문자가 아니라 PE(Portable Executable)구조로 된 PE 파일들을 나타낸다. PE파일은 이름과 같이 플랫폼에 관계없이 Win32 운영체제가 돌아가는 시스템이면 어디서든 실행 가능하다는 의미를 지니고 있다. 따라서 우리는 이러한 PE파일의구조를 중점적으로 알아볼 것이다

  

PE 파일의 전체 구조


PE 파일의 전체적인 구조에 대하여 알아보자. PE 파일은 아래의 그림과 같은 형태로 "MZ"가 위치하고 있는 IMAGE_DOS_HEADER를 시작으로 프로그램의 많은 정보를 구조체 형태로 포함하고 있다. 도스 헤더의 경우 PE파일임을 구별할 수 있도록 시작 부분에 "MZ" Signature(4D5A)로 시작한다. 그 다음 도스 스텁이 나오는데 이는 필수적이지 않은 존재로 16 Bit 환경에서 출력될 문자열인 "This program cannot be run in DOS mode"라는 문자열 등을 포함하고 있다.

그림 2. 일반적인 PE 구조의 형태

그 다음 본격적인 "PE" Signature(5045)가 존재하고 있는 IMAGE_NT_HEADERS로 이 부분은 크게 IMAGE_FILE_HEADER와 IMAGE_OPTIONAL_HEADER 두 부분으로 나눌 수가 있다. FILE 헤더의 경우 PE 파일에 대한 파일 정보를 나타내고 OPTIONAL 헤더의 경우 PE 파일이 메모리에 로드될 때 필요한 모든 정보들을 담고 있다. OPTIONAL_HEADER 내에는 기본 필드들과 함께 주요 섹션들의 위치와 크기를 나타내는 IMAGE_DATA_DIRECTORY 구조체 배열을 담고 있다. 이에 대해선 추후에 더 자세히 설명할 것이다.

그림 3. PE View로 본 IMAGE_DATA_DIRECTORY

IMAGE_DATA_DIRECTORY를 끝으로 IMAGE_SECTION_HEADER가 여러 개 나오는데, 이는 섹션 테이블로 각 섹션의 위치와 크기 등의 정보를 포함하고 있다. MZ헤더부터 섹션 테이블까지 PE 파일 헤더라 하며, PE 헤더 뒷부분부터는 실제 코드나 데이터들이 성격에 맞게 각각의 섹션에 위치하고 있다.


PE 분석을 위한 개념 정리


PE 파일 구조를 분석하기 전에 알아야할 내용들에 대하여 언급할 것이다. RVA, Section, MMF, VSA에 대하여 알아보자.

  RVA (Relative Virtual Address)

RVA는 상대적 가상 주소로 파일 Offset과는 다른 개념이다. Offset은 파일에서의 위치를 나타낼 때 사용하는 개념이지만 RVA는 가상 주소 공간 상의 위치를 나타낼 때 사용하는 개념으로 메모리 상에서의 PE의 시작 주소에 대한 오프셋으로 생각하면 된다. 그렇다면 메모리에서는 왜 Offset이나 VA가 아닌 RVA로 나타낼까?

이는 PE 파일이 지정된 베이스 위치(ImageBase)를 기준으로 로딩된다는 보장이 없기 때문이다. EXE 파일의 경우 일반적으로 파일이 지정된 위치에 로드된다. DLL의 경우 일반적으로 ImageBase 값이 0x1000000으로 설정되어 있지만 하나의 프로세스에는 여러 개의 DLL이 존재하고 있기 때문에 ImageBase를 기준으로 할 경우 중첩된다. 이러한 중첩을 방지하기 위해 DLL Relocation이 존재하고 있으며 이러한 이유로 인해 절대 주소가 아닌 상대 주소를 사용한다. 만약 ImageBase가 0x2000000이며 RVA 값이 0x1234라고 한다면 가상 주소의 값은 0x02001234이 되는 것이다.

  Section

PE 파일에서 섹션은 PE가 가상 주소 공간에 로드된 다음 실제 내용을 담고 있는 블록들이다. 대표적인 내용으로는 명령어 코드와 데이터이며, 그 외에 실행에 관련된 여러 정보들이 섹션에 배치된다. 대표적으로 언급할만한 섹션들에 대하여 간단히 알아보자.

종류

이름

설명

코드

.text

프로그램을 실행하기 위한 코드를 담고 있는 섹션으로, 명령 포인터는 이 섹션 내에 존재하는 번지 값을 담게 된다.

데이터

.data

초기화된 전역 변수들을 담고 있는 읽고 쓰기 가능한 섹션이다.

.rdata

읽기 전용 데이터 섹션으로 문자열 표현이나 C++/COM 가상 함수 테이블 등이 .rdata에 배치되는 항목 중의 하나이다.

.bss

초기화되지 않은전역 변수들을 위한 섹션이다. 실제 PE 파일 내에서는 존재하지만 가상 주소 공간에 매핑될 때에는 보통 .data 섹션에 병합되어 메모리 상에서는 따로 존재하지 않는다.

Import API 정보

.idata

임포트 할 DLL과 그 API들에 대한 정보를 담고 있는 섹션이다. 대표적으로 IAT가 존재한다.

.didat

지연 로딩(Delay-Loading) 임포트 데이터를 위한 섹션으로 지연 로딩은 Windows 2000부터 지원되는 DLL 로딩의 한 방식으로 암시적인 방식과 명시적인 방식의 혼합이다.

Export API 정보

.edata

익스포트 할 DLL과 그 API들에 대한 정보를 담고 있는 섹션이다. 보통 API나 변수를 익스포트 할 수 있는 경우는 DLL이기 때문에 DLL PE에 이 섹션이 존재한다.

리소스

.rsrc

다이얼로그, 아이콘, 커서 등의 윈도우 APP 리소스 관련 데이터들이 이 섹션에 배치된다.

재배치 정보

.reloc

실행 파일에 대한 기본 재배치 정보를 담고 있는 섹션이다. 재배치란 PE 이미지를 원하는 기본 주소에 로드하지 못하고 다른 주소에 로드했을 경우 코드 상에서의 관련 주소 참조에 대한 정보를 갱신해야 하는 경우를 말한다. 위에서 언급한 바와 같이 주로 DLL 파일에서 재배치가 일어난다.

TLS

.tls

__declspec(thread) 지시어와 함께 선언되는 스레드 지역 저장소를 위한 섹션이다. 이 섹션에는 런타임이 필요로 하는 부가적인 변수나 __declspec(thread) 지시어에 의한 데이터의 초기 값을 포함한다.

C++ 런타임

.crt

C++ 런타임(CRT)을 지원하기 위해 추가된 섹션으로 정적 C++ 객체의 생성자와 소멸자를 호출할 때 이용되는 함수 포인터가 예이다.

Short

.sdata

전역 포인터에 상대적으로 주소 지정될 수 있는 읽고 쓰기 가능한 "Short" 데이터 섹션이다. IA-64 같은 전역 포인터 레지스터를 사용하는 플랫폼을 위해 사용된다.

.srdata

.sdata에 들어갈 수 있는 데이터들의 읽기 전용 섹션이다.

예외 정보

.pdata

IMAGE_RUNTIME_FUNCTION_ENTRY 구조체의 배열을 가지며 예외 정보를 담고 있는 섹션이다. 이 섹션의 위치는 IMAGE_DIRECTORY_ENTRY_EXCEPTION 슬롯을 통해 알 수 있으며, Table-base exception handling을 사용하는 플랫폼에서 지원된다. 이를 지원하지 않는 유일한 플랫폼은 x86 계열의 CPU이다.

디버깅

.debug$S

OBJ파일에 존재하는 가변길이 코드뷰 심벌레코드의 스트림이다.

.debug$T

OBJ파일에 존재하는 가변길이 코드뷰 심벌레코드의 스트림이다.

.debug$P

미리 컴파일된 헤더를 사용했을 때 OBJ 파일에만 존재한다.

Directives

.drectve

OBJ 파일에만 존재하는 섹션으로 Directives란 링커 명령 라인을 통해 전달할 수 있는 ASCII 문자열을 말한다.

그림 4. 대표적인 섹션의 종류

  VAS (Virtual Address Space)

마지막으로 고려할 것은 파일로 존재하는 PE구조와 이것이 메모리에 올라올 때 주어지는 가상 주소 공간(VAS)에서의 PE 구조에 대한 관계이다. 이를 위해 MMF(Memory Mapped File)에 대하여 먼저 알아보자. 32비트 환경에서 프로세스는 4GB의 VAS를 갖는데, 이 가상 공간을 실제의 물리적인 기억 장치와 연결시켜 주는 것이 가상 메모리 관리자(Virtual Memory Manager, VMM)이다. 여기서 물리적 기억장치는 RAM 뿐만 아니라 하드디스크 상의 특정 파일(기본적으로는 PageFile.sys)을 포함한다. 이와 함께 페이징 기법을 통해 프로세스에게 실제로 4GB의 주소 공간을 가진 것처럼 사용할 수 있다.

페이징 파일과 RAM 그리고 VAS는 VMM에 의해 관리되며 프로세스에 속한 특정 스레드가 가상 주소 공간 내의 특정 번지에 접근하고자 할 때, VMM은 해당 번지의 페이지를 페이징 파일과 매핑시켜 준다. 매핑된 페이지는 접근 가능한 상태가 되며, 여기서 이러한 페이징은 반드시 PageFile.sys하고만 이루어져야 하는 것은 아니라 일반 파일을 메모리에 맵핑하여 이에 대해 페이징 할 수 있다. 이처럼 일반적인 파일이 PageFile.sys의 역할을대신하는 경우를 MMF라고 한다. 이제 MMF를 사용하기 위한 함수에 대하여 알아보자.

HANDLE CreateFileMapping(

HANDLE hFILE,

LPSECRITY_ATTRIBUTES lpAttributes,

DWORD flProtect,

DWORD dwMaximumSizeHigh,

DWORD dwMaximumSizeLow,

LPCTSTR lpName);

그림 5. CreateFileMapping API

CreateFileMapping API는 운영체제에게 매핑을 수행할 파일의 물리 저장소를 알려주기 위한 API이다. 이를 통해 지정 파일을 파일 매핑 오브젝트와 연결시키며, 파일 매핑 오브젝트를 위한 충분한 물리 저장소가 존재한다는 것을 확인시킨다.

hFile의 경우 CreateFile()과 같은 API를 통해 얻은 파일의 핸들로 물리 저장소로 사용할 파일의 핸들을 주어야한다. flProtect는 MMF의 페이지 속성을 지정하는데 PAGE_READONLY, PAGE_READWRITE, PAGE_WRITECOPY와 같은 세 가지 보호 속성을 기본으로 가진다. 이 세 가지 보호 속성 외에 다섯 가지 메모리 매핑 파일만의 속성을 추가로 지정할 수 있다.

속성

설명

SEC_NOCACHE

메모리 매핑 파일에 대한 캐싱을 수행하지 못하게 한다.

SEC_IMAGE

매핑한 파일이 PE파일 이미지임을 알려주므로 실행파일 실행 시 사용

SEC_RESERVE

SEC_COMMIT

이 두개는 배타적으로 사용되어야 한다. 스파스 메모리 맵 파일과 관련이 있다.

SEC_LARGE_PAGES

큰 페이지 할당 기능과 관련 있다.

그림 6. flProtect 항목

하지만 파일 매핑 오브젝트를 생성한다 하더라도, 시스템은 곧바로 프로세스의 주소 공간 상에 영역을 예약하지 않는다. 그렇기에 파일의 데이터에 접근하기 위한 영역을 프로세스 주소 공간 내에 확보해야 하며, 이 영역에 임의의 파일을 물리 저장소로 사용하기 위한 커밋 단계를 거쳐야 하며 이를 위해 사용하는 API가 바로 MapViewOfFile이다.

PVOID MapViewOfFile(

HANDLE hFileMappingObject,

DWORD dwDesiredAccess,

DWORD dwFileOffsetHigh,

DWORD dwFileOffsetLow,

DWORD dwNumberOfBytesToMap);

그림 7. MapViewOfFile API

첫 번째 인자는 CreateFileMapping으로 얻은 핸들을 넘겨주면 되고 두 번째 인자에 사용할 수 있는 항목은 아래와 같다. 세 번째와 네 번째 인자의 경우 파일의 어디부터 매핑할 것인지 지정해주는 것으로 파일의 오프셋 값은 반드시 시스템의 할당 단위의 배수여야 한다. 마지막 인자의 경우 얼마만큼 해당할지 설정하는 것으로 값이 0일 경우 오프셋으로부터 파일의 끝까지 구성한다.

항목

설명

FILE_MAP_READ

CreateFileMapping에서 PAGE_READ_ONLY로 설정한 경우

FILE_MAP_WRITE

CreateFileMapping에서 PAGE_READWRITE로 설정한 경우

FILE_MAP_ALL_ACCESS

FILE_MAP_READ | FILE_MAP_WRITE | FILE_MAP_COPY와 같다.

FILE_MAP_COPY

CreateFileMapping에서 PAGE_WRITECOPY로 설정한 경우로, 데이터를 쓰면 새로운 페이지가 생성된다.

FILE_MAP_EXECUTE

데이터를 코드로 수행할 수 있다.

그림 8. dwDesiredAccess 항목

이렇게 MMF를 형성할 수 있으며 프로세스 주소 공간 내에 매핑된 데이터 파일을 더 이상 유지할 필요가 없다면 UnmapViewOfFile 함수를 호출하여 영역을 해제해 주어야 한다. 사용되는 인자는 하나뿐이며 해제할 영역의 주소를 넘겨주면 된다.

BOOL WINAPI UnmapViewOfFile(

_In_ LPCVOID lpBaseAddress);

그림 9. UnmapViewOfFile API

마지막으로 이전에 얻어온 파일 오브젝트와 파일 매핑 오브젝트가 올바르게 반환이 이루어질 수 있도록 CloseHandle API를 호출해주어야 한다. 사용되는 인자는 역시 하나로 핸들을 넘겨주어야 한다.

BOOL WINAPI CloseHandle(

_In_ HANDLE hObject);

그림 10. CloseHandle API

이렇게 MMF를 사용하는 방법에 대하여 알아보았다. 사실 이 MMF에 대하여 공부하며 어느 곳에 사용해야하는 것인지 의문을 가질 수가 있다. 이렇게 MMF에대하여 자세히 알아본 이유가 무엇일까? 바로 Windows는 EXE나 DLL 등의 실행 파일을 로드할 때 MMF를 이용한다. 즉, PE 파일을 페이징 파일로 복사하는 것이 아니라 그 파일 자체를 페이징 파일로 사용한다는 것이다.

<File>

00400000 4D 5A 6C 00 01 00 00 00 02 00 00 00 FF FF 00 00 MZl.........

00400010 00 00 00 00 11 00 00 00 40 00 00 00 00 00 00 00 .......@.......

00400020 57 69 6E 33 32 20 50 72 6F 67 72 61 6D 21 0D 0A Win32 Program!..

00400030 24 B4 09 BA 00 01 CD 21 B4 4C CD 21 60 00 00 00 $???퀽?`...

00400040 47 6F 4C 69 6E 6B 2C 20 47 6F 41 73 6D 20 77 77 GoLink, GoAsm ww

00400050 77 2E 47 6F 44 65 76 54 6F 6F 6C 2E 63 6F 6D 00 w.GoDevTool.com.

00400060 50 45 00 00 4C 01 05 00 BB F5 17 47 00 00 00 00 PE..L.새G....

<Process>

00000000 4D 5A 6C 00 01 00 00 00 02 00 00 00 FF FF 00 00 MZl.........

00000010 00 00 00 00 11 00 00 00 40 00 00 00 00 00 00 00 .......@.......

00000020 57 69 6E 33 32 20 50 72 6F 67 72 61 6D 21 0D 0A Win32 Program!..

00000030 24 B4 09 BA 00 01 CD 21 B4 4C CD 21 60 00 00 00 $???퀽?`...

00000040 47 6F 4C 69 6E 6B 2C 20 47 6F 41 73 6D 20 77 77 GoLink, GoAsm ww

00000050 77 2E 47 6F 44 65 76 54 6F 6F 6C 2E 63 6F 6D 00 w.GoDevTool.com.

00000060 50 45 00 00 4C 01 05 00 BB F5 17 47 00 00 00 00 PE..L.새G....

그림 11. 파일과 프로세스에서의 PE 시작점

위 두 개의 바이너리를 보자. 파일에서의 바이너리와 할당된 가상 주소에서의 바이너리가 주소만 다르지 내용은 같다는 것을 확인할 수 있다. 이는 해당파일 자체가 그대로 가상 주소 공간에 매핑된다는 것으로, EXE나 DLL 와 같은 PE 파일을 실행할 때 PE 파일 내에 정의된 바와 같이 가상 주소 공간에 매핑된다는 것이다. 지금까지 PE의 개략적인 구조와 RVA, Section, 그리고 PE와 MMF의 관계에 대해 알아보았다. 다음 장부터는 구체적으로 PE 파일에 대하여 알아보자.


IMAGE_DOS_HEADER & IMAGE_DOS_STUB


PE 파일에서 가장 처음으로 등장하는 영역은 바로 도스 헤더와 도스 스텁 영역이다. 도스 헤더에는 총 64 Bytes로 19개의 필드를 갖지만, 실제로 중요한 필드는 단 두 개뿐이다. e_magic 필드는 MZ 헤더의 시그니처가 존재하는 필드로 PE 파일이 맞는지 아닌지 체크할 때 사용되며, 이는 도스 헤더의 시작을 알리는 코드라 할 수 있다. e_lfanew 필드는 NT헤더의 시작 위치를 나타내는 값으로 해당 오프셋을 확인해보면 NT 헤더의 시그니처인 "PE"가 존재하고 있다.

typedef struct _IMAGE_DOS_HEADER {

    WORD e_magic; /* 00: MZ Header signature */

    WORD e_cblp; /* 02: Bytes on last page of file */

    WORD e_cp; /* 04: Pages in file */

    WORD e_crlc; /* 06: Relocations */

    WORD e_cparhdr; /* 08: Size of header in paragraphs */

    WORD e_minalloc; /* 0a: Minimum extra paragraphs needed */

    WORD e_maxalloc; /* 0c: Maximum extra paragraphs needed */

    WORD e_ss; /* 0e: Initial (relative) SS value */

    WORD e_sp; /* 10: Initial SP value */

    WORD e_csum; /* 12: Checksum */

    WORD e_ip; /* 14: Initial IP value */

    WORD e_cs; /* 16: Initial (relative) CS value */

    WORD e_lfarlc; /* 18: File address of relocation table */

    WORD e_ovno; /* 1a: Overlay number */

    WORD e_res[4]; /* 1c: Reserved words */

    WORD e_oemid; /* 24: OEM identifier (for e_oeminfo) */

    WORD e_oeminfo; /* 26: OEM information; e_oemid specific */

    WORD e_res2[10]; /* 28: Reserved words */

    DWORD e_lfanew; /* 3c: Offset to extended header */

} IMAGE_DOS_HEADER, *PIMAGE_DOS_HEADER;

그림 12. IMAGE_DOS_HEADER 구조체

IMAGE_DOS_STUB은 아래와 같은 형태를 띄고 있으며 큰 의미를 갖지 않는다. 아래의 표를 보면 식별 가능한 문자열이 존재하고 있는데 이는 MS-DOS나 윈도우 3.1에서 실행하게 되면 바로 이 문장을 출력한다. MS-DOS 스텁은 위 문장을 출력하기 위한 16비트 도스용 응용 프로그램이라 할 수 있다.

00400050 54 68 69 73 20 70 72 6F 67 72 61 6D 20 6D 75 73 This program mus

00400060 74 20 62 65 20 72 75 6E 20 75 6E 64 65 72 20 57 t be run under W

00400070 69 6E 33 32 0D 0A 24 37 00 00 00 00 00 00 00 00 in32..$7........

그림 13. 예제.exe의 IMAGE_DOS_STUB

이렇게 도스 헤더와 도스 스텁에 대하여 알아보았는데, 결국 이 두 구조체에서 필수적인 항목은 단 두개 뿐인 것이다. 다시 말해, 다른 필드의 항목들은 모두 NULL이 되어도 상관없다는 것이다. 한번 직접 코드를 비교해보자. 아래는 불필요한 항목들을 제거하지 않은 상태의 코드와 불필요한 항목들을 제거한 다음의 코드를 비교한 것이다.

<수정 전>

00000000 : 4D 5A 50 00 02 00 00 00 04 00 0F 00 FF FF 00 00 MZP.............

00000010 : B8 00 00 00 00 00 00 00 40 00 1A 00 00 00 00 00 ........@.......

00000020 : 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 ................

00000030 : 00 00 00 00 00 00 00 00 00 00 00 00 00 01 00 00 ................

00000040 : BA 10 00 0E 1F B4 09 CD 21 B8 01 4C CD 21 90 90 ........!..L.!..

00000050 : 54 68 69 73 20 70 72 6F 67 72 61 6D 20 6D 75 73 This program mus

00000060 : 74 20 62 65 20 72 75 6E 20 75 6E 64 65 72 20 57 t be run under W

00000070 : 69 6E 33 32 0D 0A 24 37 00 00 00 00 00 00 00 00 in32..$7........

<수정 후>

00000000 : 4D 5A 00 00 00 00 00 00 00 00 00 00 00 00 00 00 MZ..............

00000010 : 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 ................

00000020 : 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 ................

00000030 : 00 00 00 00 00 00 00 00 00 00 00 00 00 01 00 00 ................

00000040 : 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 ................

00000050 : 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 ................

00000060 : 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 ................

00000070 : 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 ................

그림 14. 필수적이지 않은 요소 제거

코드를 이렇게 수정을 해도 프로그램이 정상적으로 구동되는 것을 확인할 수 있을 것이다. 각 필드 마다 의미를 가지고 있기는 하지만, PE 구조에서는 이러한 정보들이 존재하지 않더라도 정상적으로 구동하도록 되어있다.

  

IMAGE_NT_HEADER


IMAGE_DOS_HEADER의 e_lfanew 필드 값에 해당하는 위치에 IMAGE_NT_HEADER가 존재하고 있다. 해당 구조체에는 PE와 관련된 주요 필드들이 위치해 있다. 우선 해당 위치에는 PE Signature, IMAGE_FILE_HEADER, IMAGE_OPTIONAL_HEADER로 분류할 수 있다. 아래의 구조체를 확인해보자.

typedef struct _IMAGE_NT_HEADERS {

DWORD Signature; /* "PE"\0\0 */ /* 0x00 */

IMAGE_FILE_HEADER FileHeader; /* 0x04 */

IMAGE_OPTIONAL_HEADER32 OptionalHeader; /* 0x18 */

} IMAGE_NT_HEADERS32, *PIMAGE_NT_HEADERS32;

그림 15. IMAGE_NT_HEADER 구조체

  IMAGE_FILE_HEADER

IMAGE_FILE_HEADER에는 해당 PE 파일과 관련된 내용이 존재하고 있는 20 Bytes로 구성된 구조체이다. 첫 번째 필드에는 CPU의 ID를 나타내는 것으로 세 가지 주요 타입은 다음과 같이 Intel 386의 경우 0x014C, Intel 64의 경우 0x200, AMD64의 경우 0x8664의 값을 갖는다. 두 번째 필드의 경우 본 파일에서 섹션의 수를 나타내는 것이며 세 번째 필드 TimeDataStamp는 파일이 OBJ 형식의 파일이면 컴파일러가, EXE나 DLL과 같은 PE 파일이라면 링커가 해당 파일을 만들어낸 시간을 의미한다.

PointerToSymbolTable의 경우 COFF 심벌의 파일 오프셋을 나타내는 것으로, 이 필드는 컴파일러에 의해 생성되는 OBJ 파일이나 디버그 모드로 만들어져 COFF 디버그 정보를 가진 PE 파일에서만 사용된다. 그 다음 NumberOfSybols는 PointerToSymbolTable 필드가 가리키는 COFF 심벌 테이블 내의 심벌 수를 나타낸다. IMAGE_FILE_HEADER 다음에는 IMAGE_OPTIONAL_HEADER가 이어서 나오는데 바로 해당 구조체의 크기를 나타내는 것이 SizeOfOptionalHeader 필드이다. 마지막으로 Chracteristics 필드는 해당 PE 파일에 대한 특정 정보를 나타내는 플래그로 주요 항목 몇 가지가 [그림 17]과 같다.

typedef struct _IMAGE_FILE_HEADER {

WORD Machine;

WORD NumberOfSections;

DWORD TimeDateStamp;

DWORD PointerToSymbolTable;

DWORD NumberOfSymbols;

WORD SizeOfOptionalHeader;

WORD Characteristics;

} IMAGE_FILE_HEADER, *PIMAGE_FILE_HEADER;

그림 16. IMAGE_FILE_HEADER 구조체

매크로 명

의미

IMAGE_FILE_RELOCS_STRIPPED

0x0001

현재 파일에 재배치 정보가 없다.

IMAGE_FILE_EXECUTABLE_IMAGE

0x0002

본 파일은 실행 파일 이미지이다.

IMAGE_FILE_LINE_NUMS_STRIPPED

0x0004

본 파일에 라인 정보가 없다.

IMAGE_FILE_LOCAL_SYMS_STRIPPED

0x0010

OS로 하여금 적극적으로 워킹 셋을 정리할 수 있도록 한다.

IMAGE_FILE_LARGE_ADDRESS_AWARE

0x0020

응용프로그램이 2GB 이상의 가상 주소 번지를 제어할 수 있도록 한다.

IMAGE_FILE_32BIT_MACHINE

0x0100

본 PE는 32비트 워드 머신을 필요로 한다.

IMAGE_FILE_DEBUG_STRIPPED

0x0200

디버그 정보가 본 파일에 없고 .DBG 파일에 존재한다.

IMAGE_FILE_REMOVABLE_RUN_FROM_SWAP

0x0400

PE이미지가 이동 가능 장치 위에 존재하면 고정 디스크 상의 스왑 파일로 카피해 실행한다.

IMAGE_FILE_NET_RUN_FROM_SWAP

0x0800

PE이미지가 네트워크 상에 존재하면 고정 디스크 상의 스왑 파일로 카피해서 실행한다.

IMAGE_FILE_DLL

0x2000

본 파일은 동적 링크 라이브러리(DLL)파일이다.

IMAGE_FILE_UP_SYSTEM_ONLY

0x4000

본 파일은 하나의 프로세서만을 장착한 머신에서 실행된다.

그림 17. Characteristics의 주요 PE 특성

  IMAGE_OPTIONAL_HEADER

IMAGE_FILE_HEADER의 뒷부분에 나오는 IMAGE_OPTIONAL_HEADER 구조체에는 메모리에 올라갈 때 참조해야 할 주요한 필드들이 위치하고 있다. 해당 구조체는 총 224 Bytes의 크기를 갖으며 많은 필드가 위치해있다. 아래의 구조체를 보자.

typedef struct _IMAGE_OPTIONAL_HEADER {

/* Standard fields */

WORD Magic; /* 0x10b or 0x107 */ /* 0x00 */

BYTE MajorLinkerVersion;

BYTE MinorLinkerVersion;

DWORD SizeOfCode;

DWORD SizeOfInitializedData;

DWORD SizeOfUninitializedData;

DWORD AddressOfEntryPoint; /* 0x10 */

DWORD BaseOfCode;

DWORD BaseOfData;

/* NT additional fields */

DWORD ImageBase;

DWORD SectionAlignment; /* 0x20 */

DWORD FileAlignment;

WORD MajorOperatingSystemVersion;

WORD MinorOperatingSystemVersion;

WORD MajorImageVersion;

WORD MinorImageVersion;

WORD MajorSubsystemVersion; /* 0x30 */

WORD MinorSubsystemVersion;

DWORD Win32VersionValue;

DWORD SizeOfImage;

DWORD SizeOfHeaders;

DWORD CheckSum; /* 0x40 */

WORD Subsystem;

WORD DllCharacteristics;

DWORD SizeOfStackReserve;

DWORD SizeOfStackCommit;

DWORD SizeOfHeapReserve; /* 0x50 */

DWORD SizeOfHeapCommit;

DWORD LoaderFlags;

DWORD NumberOfRvaAndSizes;

IMAGE_DATA_DIRECTORY DataDirectory[IMAGE_NUMBEROF_DIRECTORY_ENTRIES];

/* 0x60 */ /* 0xE0 */

} IMAGE_OPTIONAL_HEADER32, *PIMAGE_OPTIONAL_HEADER32;

그림 18. IMAGE_OPTIONAL_HEADER 구조체

첫 번째 필드인 Magic은 IMAGE_OPTIONAL_HEADER를 나타내는 Signature로 32비트 PE의 경우 0x010B이고, 64비트 PE의 경우 0x020B, ROM 이미지 파일에 대해서는 0x0107의 값을 갖고 있는 것을 확인할 수 있다. MajorLinkerVersion과 MinorLinkVersion는 본 파일을 만들어낸 링커의 버전을 나타낸다. SizeOfCode의 경우 코드 섹션(.text) 섹션의 크기이며 SizeOfInitializedData, SizeOfUninitializedData의 경우 각각 코드 섹션을 제외한 "초기화된 데이터 섹션의 크기"와 "초기화되지 않은 데이터 섹션의 크기"를 나타낸다.

AddressOfEntryPoint는 로더가 실행을 개시할 주소를 나타낸다. 이 주소는 RVA로서 보통 .text 섹션 내의 특정 번지가 된다. 이 필드의 값은 프로그램이 처음으로 실행될 코드를 담고 있는 주소이다. 즉, 프로그램이 로드된 후 이 프로세스의 메인 스레드 문맥의 EIP 레지스터가 가질 수 있는 최초의 값이라 할 수 있다. BaseOfCode, BaseOfData의 경우 각각 첫 번째 코드 섹션이 시작되는 RVA, 데이터 섹션이 시작되는 RVA를 의미한다.

ImageBase 필드는 해당 PE가 가상 주소 공간에 매핑될 때 매핑시키고자 하는 메모리 상의 시작 주소이다. 일반적으로 EXE 파일의 ImageBase 값은 0x400000이며 DLL의 경우 0x1000000이지만, DLL의 경우 하나의 VAS에 여러 DLL이 존재할 수 있으므로 DLL Relocation을 필요로 하게 된다.

PE 파일은 섹션으로 나뉘어져 있는데 파일에서 섹션의 최소단위를 나타내는 것이 FileAlignment이며 메모리에서 섹션의 최소단위를 나타내는 것이 SectionAlignment이다. 각 섹션의 시작 주소는 언제나 각 필드의 배수가 되는 주소가 되도록 보장해야 한다.

MajorOperatingSystemVersion, MinorOperatingSystemVersion은 해당 PE를 실행하는데 필요한 운영체제의 최소 버전을 의미한다. MajorImageVersion과 MinorImageVersion은 유저가 정의 가능한 필드로, 제작할 때 PE 파일에 제작자가 버전을 기입할 수 있도록 하는 것이다. MajorSubsystem과 MinorSubsystem은 본 PE를 실행하는데 필요한 서브시스템의 최소 버전을 의미한다. Win32VersionValue는 이전엔 예약 필드였지만 VC++ 7.0부터는 이름을 가지게 되었다. 하지만 거의 사용되지 않으며 보통 0으로 설정된다.

SizeOfImage는 PE 파일이 메모리에 로딩되었을 때의 전체 크기를 담고 있으며 이 값은 SectionAlignment 필드 값의 배수가 되어야 한다. SizeOfHeaders는 PE 헤더의 전체 크기를 나타내는 것으로 이 값 역시 FileAlignment의 배수가 되어야 한다.

CheckSum 필드는 이미지의 체크섬 값을 의미한다. PE 파일의 체크섬 값은 IMAGEHELP.DLL의 CheckSymMappedFile API를 통해서 얻을 수 있다. 체크섬 값은 커널 모드 드라이버나 어떤 시스템 DLL의 경우 요구된다. 그 이외의 경우라면 보통 0으로 설정된다. 그리고 Subsystem 필드의 경우 sys 파일과 같이 디바이스 드라이버 같은 경우 1의 값을 가지고 Windows GUI 프로그램의 경우 윈도우 기반 응용프로그램의 경우 2, 마지막으로 CMD와 같은 콘솔 기반 응용프로그램은 3의 값을 갖는다.

DllCharacteristics 필드는 원래 PE가 DLL이라는 전제 하에 어떤 상황에서 DLL 초기화 함수가 호출되어야 하는지를 지시하는 플래그였다. 하지만 지금은 대부분 0으로 설정되어 있는 것을 확인할 수 있다.

SizeOfStackReserve, SizeOfStackCommit, SizeOfHeapReserve, SizeOfHeapCommit 필드에 대하여 알아보자. 프로세스는 가상 주소 공간에 자신만의 스택과 힙을 별도로 가진다. 따라서 프로세스 생성 시 시스템은 언제나 메인 스레드를 위한 디폴트 스택과 프로세스를 위한 디폴트 힙을 해당 프로세스 내에 생성시켜주는데, 이 스택과 힙의 크기와 속성에 관계된 설정을 이 필드들에 지정하게 된다. PE가 메모리에 로드될 때 시스템은 이 필드의 값을 참조하여 해당 프로세스에 디폴트 스택과 힙을 만들어준다.

LoaderFlags 필드는 이전에는 디버깅 지원에 관계된 목적으로 존재하는 것 같지만, 현재는 0으로 설정된다. NumberOfRvaAndSize 필드는 바로 뒤에 나오는 IMAGE_DATA_DIRECTORY 구조체 배열의 원소 개수를 의미하는데, 이 값은 항상 16(0x10)이다.

typedef struct _IMAGE_DATA_DIRECTORY {

DWORD VirtualAddress;

DWORD Size;

} IMAGE_DATA_DIRECTORY, *PIMAGE_DATA_DIRECTORY;

그림 19. IMAGE_DATA_DIRECOTRY 구조체

IMAGE_DATA_DIRECTORY는 구조체의 배열로, 배열의 각 항목마다 정의된 값을 가지게 된다. 각 항목은 위와 같이 VirtualAddress와 Size 필드로 구성되어 있으며 각 16개의 구조는 다음과 같은 의미를 가진다.

ENTRY

설명

IMAGE_DIRECTORY_ENTRY_EXPORT

Export Section의 시작 주소를 가리킨다.

IMAGE_DIRECTORY_ENTRY_IMPORT

Import Section의 시작 주소를 가리킨다

IMAGE_DIRECTORY_ENTRY_RESOURCE

Resource Section의 시작 주소를 가리킨다.

IMAGE_DIRECTORY_ENTRY_EXCEPTION

예외 핸들러 테이블을 가리킨다.

IMAGE_DIRECTORY_ENTRY_SECURITY

WinTrust.h에 정의된 WIN_CERTIFICATE 구조체들의 리스트의 시작 번지를 가리킨다. 이 리스트는 메모리 상에 매핑되지 않기 때문에 VirtualAddress필드는 RVA가 아닌 Offset이다.

IMAGE_DIRECTORY_ENTRY_BASERELOC

ImageBase를 기준으로 메모리에 매핑되지 않을 경우 코드 상의 포인터 연산과 관련된 주소를 다시 갱신해야하는 "재배치"가 일어나야 하는데, 이를 위한 재배치 섹션을 가리킨다.

IMAGE_DIRECTORY_ENTRY_DEBUG

해당 이미지의 디버그 정보를 기술하고 있는 곳을 가리킨다.

IMAGE_DIRECTORY_ENTRY_ARCHITECTURE

IMAGE_ARCHITECTURE_HEADER 구조체의 배열에 대한 포인터이다. x86 또는IA-64계열에서는 사용되지 않는다.

IMAGE_DIRECTORY_ENTRY_GLOBALPTR

글로벌 포인터(GP)로 사용되는 RVA를 나타내며 Size필드는 사용되지 않는다.

IMAGE_DIRECTORY_ENTRY_TLS

Thread Local Storage 초기화 섹션에 대한 포인터이다.

IMAGE_DIRECTORY_ENTRY_LOAD_CONFIG

IMAGE_LOAD_CONFIG_DIRECTORY 구조체에 대한 포인터이다.

IMAGE_DIRECTORY_ENTRY_BOUND_IMPORT

DLL 바인딩과 관련된 정보를 담고 있는 곳을 가리키는 포인터이다.

IMAGE_DIRECTORY_ENTRY_IAT

첫 번째 IAT의 시작 번지를 가리키며 Size 필드는 모든 IAT의 전체 크기를 가리킨다.

IMAGE_DIRECTORY_ENTRY_DELAY_IMPORT

지연 로딩에 대한 정보를 가리키는 포인터다.

IMAGE_DIRECTORY_ENTRY_COM_DESCRIPTOR

.NET 응용 프로그램이나 DLL 용 PE를 위한 것으로 PE 내의 .NET 정보에 대한 최상위 정보의 시작 번지를 가리킨다.

NULL

마지막 엔트리는 항상 NULL 값이다.

그림 20. IMAGE_DATA_DIRECTORY ENTRY

  IMAGE_SECTION_HEADER

PE 헤더 바로 다음엔 IMAGE_SECTION_HEADER가 나온다. 섹션 헤더는 각 섹션의 속성이 정의되어 있는 구조체로 각 섹션 헤더 마다 40 Bytes로 구성된다. 각 필드에 대하여 알아보자.

typedef struct _IMAGE_SECTION_HEADER {

BYTE Name[IMAGE_SIZEOF_SHORT_NAME];

union {

DWORD PhysicalAddress;

DWORD VirtualSize;

} Misc;

DWORD VirtualAddress;

DWORD SizeOfRawData;

DWORD PointerToRawData;

DWORD PointerToRelocations;

DWORD PointerToLinenumbers;

WORD NumberOfRelocations;

WORD NumberOfLinenumbers;

DWORD Characteristics;

} IMAGE_SECTION_HEADER, *PIMAGE_SECTION_HEADER;

그림 21. IMAGE_SECTION_HEADER 구조체

Name 필드는 섹션의 아스키 이름을 나타내며 만약 섹션의 이름이 8 Bytes를 넘을 경우 8 Bytes 이후의 문자열은 잘린 뒤 이 필드 값을 채운다. 또한 이 값은 섹션의 이름을 참고할 용도뿐이라 해당 이름을 바꾸어도 프로그램의 실행에는 아무런 지장이 없다.

PhysicalAddress필드는 이전엔 OBJ 파일에서 섹션의 물리적인 번지를 지정했지만, 지금은 사용되지 않아 0으로 지정되어 있다. VirtualSize 필드는 메모리에서 섹션이 차지하는 크기를 나타낸다. VirtualAddress는 PE에서 해당 섹션을 매핑시켜야 할 가상 주소 공간 상의 RVA를 가지고 있다. SizeOfRawData는 파일에서 섹션이 차지하는 크기를 나타낸다. 그리고 PointerToRawData는 파일에서 해당 섹션의 위치를 나타낸다.

PointerToRelocations는 본 섹션을 위한 재배치 파일 오프셋으로 OBJ 파일에서만 사용되고 실행파일에서는 0이 된다. PointerToLinenumbers는 본 섹션을 위한 COFF 스타일의 라인 번호를 위한 파일 오프셋이다.

NumberOfRelocations는 PointerToRelocations 필드가 가리키는 구조체 배열의 원소 개수를 나타내며, NumberOfLinenumbers는 PointerToLinenumbers 필드가 가리키는 구조체 배열의 원소 개수를 나타낸다.

마지막으로 Characteristics는 해당 섹션의 속성을 나타내는 플래그의 집합으로 아래와 같은 속성 값이 존재하고 있다.

속성 값

설명

IMAGE_SCN_CNT_CODE(0x20)

섹션이 코드를 포함하고 있다.

IMAGE_SCN_CNT_INITIALIZED_DATA(0x40)

섹션이 초기화된 데이터를 포함하고 있다.

IMAGE_SCN_CNT_UNINITIALIZED_DATA(0x80)

섹션이 초기화되지 않은 데이터(ex.bss)를 가지고 있다.

IMAGE_SCN_MEM_DISCARDABLE(0x2000000)

이 섹션은 실행 이미지가 메모리에 완전히 매핑되고 난 뒤 버려질 수 있다.

IMAGE_SCN_MEM_NOT_CACHED(0x4000000)

해당 섹션은 페이지되지 않거나 캐쉬되지 않는다. 페이지 되지 않는다는 것은 페이지 파일로 스왑되지 않는다는 것을 의미하며 이는 항상 RAM에 존재하는 섹션임을 의미한다.

IMAGE_SCN_MEM_NOT_PAGED(0x8000000)

IMAGE_SCN_MEM_SHARED(0x10000000)

이 섹션은 공유 가능한 섹션임을 나타낸다.

IMAGE_SCN_MEM_EXECUTE(0x20000000)

이 섹션은 실행 가능하 섹션임을 나타낸다.

IMAGE_SCN_MEM_READ(0x40000000)

이 섹션은 읽기 가능한 섹션이다.

IMAGE_SCN_MEM_WRITE(0x80000000)

이 섹션은 쓰기 가능한 섹션이다.

IMAGE_SCN_LNK_INFO(0x0x200)

해당 섹션이 링커에 의해 사용될 주석이나 다른 어떤 종류의 정보를 가진다.

IMAGE_SCN_LNK_REMOVE(0x800)

링크 시에 최종 실행 파일의 일부가 되지 말아야 할 섹션의 내용들을 지시한다.

IMAGE_SCN_LINK_COMDAT

해당 섹션의 내용들은 공용 데이터이다.

IMAGE_SCN_ALIGN_XBYTES

_XBYTES의 값으로 _1BYTES부터 _8192Bytes까지의 정렬 단위를 나타낸다. 특별히 지정되지 않으면 디폴트로 16바이트에 해당하는 IMAGE_SCN_ALIGN_16BYTES가 된다.

그림 22. IMAGE_SECTION_HEADER 속성 값

.code 섹션의 경우 주로 CNT_CODE, MEM_EXECUTE, 그리고 MEM_READ 속성 값을 가지며 .data 섹션과 .idata 섹션의 경우 CNT_INITALIZED_DATA, MEM_READ, 그리고 MEM_WRITE의 속성 값을 가진다.

  

Section


각 PE 파일마다 가지는 섹션은 다를 수 있지만, 대부분 섹션은 유사한 기능을 한다. 그러므로 이러한 각 섹션에 대하여 알아보자.

  Code Section

코드 섹션 또는 텍스트 섹션은 컴파일러나 어셈블러가 최종적으로 생성하는 일반 목적 코드가 존재하는 섹션으로 실행 명령어들이 이곳에 존재하고 있다. 우선 예제 파일을 통해 실제 코드 섹션의 내용을 확인해보자. 아래의 바이너리와 같이 우리가 읽을 수 없는 코드로 이루어져 있기 때문에 우리는 기계어를 해석하기 위하여 디스어셈블러와 같은 도구를 사용하여야 한다.

00000600 : 6A 00 68 00 20 40 00 68 12 20 40 00 6A 00 E8 4E j.h. @.h. @.j..N

00000610 : 00 00 00 68 94 20 40 00 E8 38 00 00 00 46 48 EB ...h. @..8...FH.

00000620 : 00 46 46 48 3B C6 74 15 6A 00 68 35 20 40 00 68 .FFH;.t.j.h5 @.h

00000630 : 3B 20 40 00 6A 00 E8 26 00 00 00 EB 13 6A 00 68 ; @.j..&.....j.h

…(skip)

그림 23. 예제.exe의 .text 섹션

디스어셈블러를 통해 해당 섹션의 내용을 확인하면 아래와 같은 명령어가 위치해있는 것을 알 수 있다. 이러한 명령어들이 하나 하나 실행되면서 프로그램의 정의된 대로 동작하게 된다. 실행의 흐름을 위하여 EIP 레지스터에는 실행할 명령어의 위치가 담겨 있다.

00401000 |. 6A 00 PUSH 0 ; /Style = MB_OK|MB_APPLMODAL

00401002 |. 68 00204000 PUSH test1.00402000 ; |Title = "abex' 1st crackme"

00401007 |. 68 12204000 PUSH test1.00402012 ; |Text = "Make me think your HD is a CD-Rom."

0040100C |. 6A 00 PUSH 0 ; |hOwner = NULL

0040100E |. E8 4E000000 CALL <JMP.&USER32.MessageBoxA> ; \MessageBoxA

...(skip)

그림 24. 예제.exe의 .text 섹션 – 어셈블리 코드

그렇다면 왜 파일에서의 위치(Offset)은 0x600인데 메모리에서의 위치(RVA)는 401000일까? 아무 이유 없이 이렇게 메모리에 올라오는 것이 아니라, 이전에 언급한 바와 같이 ImageBase나 해당 섹션의 RVA, PointerToRawData 등에 의해 메모리에 올라오면서 정의된 대로 위치하게 되는 것이다. 해당 프로그램의 필드 값 몇가지를 확인해보자.

필드 이름

필드 값

ImageBase

0x400000

RVA

0x1000

PointerToRawData

0x600

그림 25. 예제.exe의 몇 가지 필드 값

우선 코드 섹션의 PointerToRawData는 0x600으로 파일에서 해당 섹션의 위치가 0x600임을 알려준다. 그렇기에 해당 위치를 확인해보면 실행할 코드가 존재하고 있는 것을 확인할 수 있다. 메모리에서 해당 섹션의 위치는 RVA인 0x1000으로 이에 ImageBase 값을 더하면 위 그림에서의 주소인 0x401000임을 알 수가 있다. 이처럼 RVA와 RAW(Offset)의 주소의 관계는 직접 코드를 파일에서 수정하고자 할 때와 같은 경우에, 이를 변환할 줄 알아야 한다.

코드 섹션에 실행을 위한 명령어들이 있다고 하여 .text 섹션의 첫 부분이 프로그램의 실행을 위한 첫 명령어가 아니다. 흔히 디버거를 통해 프로그램의 시작 부분으로 이동되는 주소는 Entry Point로 IMAGE_OPTIONAL_HEADER의 AddressOfEntryPoint에 ImageBase를 더한 위치가 프로그램의 시작 주소가 된다.

  Data Section

데이터 섹션은 그 종류가 여러 가지이다. 일반적으로 .data라는 이름을 가진 섹션이 존재하며 이 안에 .idata나 .edata, 또는 .rdata 섹션이 존재하기도 한다. 이에 대해서는 뒤에서 상세히 다룰 것이다. 데이터 섹션은 그 속성이 읽기/쓰기 가능한 섹션으로 전역 변수나 정적 변수를 정의하게 되면 이러한 변수들이 이 섹션에 위치하게 된다.

아래는 실제 예제 프로그램의 데이터 섹션이다. 해당 데이터 섹션에는 ASCII 형태의 문자열들이 존재하고 있는 것을 확인할 수 있다.

00000800 : 61 62 65 78 27 20 31 73 74 20 63 72 61 63 6B 6D abex' 1st crackm

00000810 : 65 00 4D 61 6B 65 20 6D 65 20 74 68 69 6E 6B 20 e.Make me think

00000820 : 79 6F 75 72 20 48 44 20 69 73 20 61 20 43 44 2D your HD is a CD-

00000830 : 52 6F 6D 2E 00 45 72 72 6F 72 00 4E 61 68 2E 2E Rom..Error.Nah..

00000840 : 2E 20 54 68 69 73 20 69 73 20 6E 6F 74 20 61 20 . This is not a

00000850 : 43 44 2D 52 4F 4D 20 44 72 69 76 65 21 00 59 45 CD-ROM Drive!.YE

00000860 : 41 48 21 00 4F 6B 2C 20 49 20 72 65 61 6C 6C 79 AH!.Ok, I really

00000870 : 20 74 68 69 6E 6B 20 74 68 61 74 20 79 6F 75 72 think that your

00000880 : 20 48 44 20 69 73 20 61 20 43 44 2D 52 4F 4D 21 HD is a CD-ROM!

00000890 : 20 3A 70 00 63 3A 5C 00 00 00 00 00 00 00 00 00 :p.c:\.........

그림 26. 예제.exe의 .data Section

데이터 섹션과 유사 .rdata 섹션은 읽기 전용 데이터 섹션으로 해당 섹션 헤더를 확인해보면 MEM_WRITE 속성이 존재하지 않는 것을 확인할 수 있다. 따라서 이 섹션에 무엇인가 기록하고자 하면 시스템은 예외를 나타내며 프로그램이 종료될 것이다. 또한 .rdata 섹션은 이러한 용도뿐만아니라 다른 섹션들이 병합되는 곳이기도 하다. 이후에 나올 .edata나 .idata 섹션이 .rdata섹션에 병합되는 경우도 종종 있다는 것을 잊지 말자.

  Export Section

Export 섹션은 주로 DLL에서 나타나는 섹션으로 자신이 가진 함수의 기능을 외부 프로그램이 사용할 수 있도록 제공하는 것이 목적이다. 만약 A.exe와 B.exe라는 프로그램이 존재할 때 두 프로그램 모두 TEST_Function()이라는 함수를 정의하여 사용하고 있다고 가정하자. 두 프로그램에 있어 TEST_Function을 각각 써넣어주는 것보단 용량이나 이후 관리를 위하여 TEST_Function()을 가진 DLL을 하나 만든 다음 이를 Import하여 사용할 수 있다. 반대로 해당 DLL은 Export를 제공하는 것이다.

먼저 Export Section의 IMAGE_EXPORT_DIRECTORY 구조체에 대하여 알아보자. 해당 구조체는 Export Section에서 가장 중요한 정보들을 담고 있는 구조체이며, IAT와는 다르게 PE 파일 당 하나만 존재한다. 해당 필드의 목록은 아래의 그림과 같다.

typedef struct _IMAGE_EXPORT_DIRECTORY {

DWORD Characteristics;

DWORD TimeDateStamp;

WORD MajorVersion;

WORD MinorVersion;

DWORD Name;

DWORD Base;

DWORD NumberOfFunctions;

DWORD NumberOfNames;

DWORD AddressOfFunctions;

DWORD AddressOfNames;

DWORD AddressOfNameOrdinals;

} IMAGE_EXPORT_DIRECTORY,*PIMAGE_EXPORT_DIRECTORY;

그림 27. IMAGE_EXPORT_DIRECTORY 구조체

첫 번째부터 필드는 사용되지 않으며 두 번째 필드는 해당 파일이 생성된 시간을 나타낸다. 그 다음 버전과 관련된 필드 역시 사용되지 않는다. 다섯 번째 Name필드는 해당 DLL의 이름을 나타내는 ASCII 코드 문자열의 위치를 지시하는 RVA이다. 파일에서 해당 DLL의 이름은 RVA를 RAW로 변환해주면 해당 Offset에서 이름을 확인할 수 있다. Base 필드는 Export된 함수들에 대한 서수의 시작 번호이다.

NumberOfFunctions는 뒤에 나오는 AddressOfFunctions 필드가 가리키는 RVA 배열의 원소 개수를 나타낸다. AddressOfFunctions는 export된 함수들의 함수 포인터를 가진 배열을 가리킨 RVA 값으로 이 함수 주소들은 본 모듈 내에서 각각 export된 함수에 대한 엔트리 포인터이다.

NumberOfNames는 AddressOfNames 필드가 가리키는 RVA 배열의 원소 개수와 AddressOfNameOrdinals 필드가 가리키는 서수 배열의 원소 개수를 동시에 나타낸다. AddressOfNames 필드는 export된 함수의 심벌을 나타내는 문자열 포인터 배열을 가리키는RVA 값이고, AddressOfNameOrdinals는 export된 모든 함수들의 서수를 담고 있는 배열에 대한 포인터이다.

여기서 NumberOfNames와 NumberOfFunctions은 다를 수 있는데 보통 NumberOfFunctions 필드가 더 크거나 같다. 하지만 실제 export된 함수의 정확한 개수는 NumberOfNames 필드의 값이다. 이렇게 IMAGE_EXPORT_DIRECTORY에 대하여 알아보았다. 아래는 실제 DLL 파일의 .edata 섹션을 분석한 내용이다.

구조체

필드

IMAGE_EXPORT_DIRECTORY

Characteristics

0000

TimeDateStamp

2009/07/13 23:38:00 UTC

Major Version

0

Minor Version

0

Name RVA

10DA4(adsnsext.dll)

Ordinal Base

1

Number Of Functions

2

Number Of Names

2

Address Of Functions

10D90

Address Of Names

10D98

Address Of Name Ordinals

10DA0

구조체

데이터

Export Address Table

2D6C

DllCanUnloadNow

2D51

DllGetClassObject

Export Name Pointer Table

10DB1

DllCanUnloadNow

10DC1

DllGetClassObject

Export Ordinal Table

0001

DllCanUnloadNow

0002

DllGetClassObject

그림 28. 예제.dll의 .edata 섹션

IMAGE_EXPORT_DIRECTORY 구조체 외에 3개의 Export Table이 존재하는 것을 확인할 수 있다. 하나는 Export 함수 포인터 테이블이며 다른 하나는 Export 함수 이름 포인터 테이블, 마지막으로 Export 함수 서수 테이블임을 알 수가 있다.

  Import Section

DLL의 입장에서는 함수를 Export 해주었다면 반대로 그 함수를 사용하기 위해선 다른 실행파일에서 이를 Import 해주어야 한다. 이렇게 사용하고자 import 하는 함수들과 그 DLL에 대한 정보를 가지고 있는 것이 바로 임포트 섹션이다. 아래 IMAGE_IMPORT_DESCRIPTOR 구조체를 확인해보자.

typedef struct _IMAGE_IMPORT_DESCRIPTOR {

union {

DWORD Characteristics;

DWORD OriginalFirstThunk; // INT Address (RVA)

} DUMMYUNIONNAME;

DWORD TimeDateStamp;

DWORD ForwarderChain;

DWORD Name;

DWORD FirstThunk; // IAT Address (RVA)

} IMAGE_IMPORT_DESCRIPTOR,*PIMAGE_IMPORT_DESCRIPTOR;

그림 29. IMAGE_IMPORT_DESCRIPTOR 구조체

우선 첫 번째 필드인 Characteristics 필드는 더 이상 사용하지 않고 OriginalFirstThunk라는 이름의 필드로 사용한다. OriginalFirstThunk 필드는 INT(Import Name Table)의 RVA 주소 값을 가지고 있다. 그 다음 TimeDataStamp는 시간과 날짜를 나타내며, 바인딩되지 않을 경우 항상 0이다. ForwarderChain 필드는 바인딩 여부와 관계되는 필드로 바인딩 되지 않은 이미지의 경우 0 이며 바인딩된 경우 이 값은 0이 아니다. Name 필드는 import된 DLL의 이름이 존재하는 RVA 값을 가진다. 마지막으로 FirstThunk 필드는 IAT(Import Address Table)의 RVA 주소 값을 가지고 있다.

이러한 구조는 로드하는 DLL의 수만큼 존재하며 맨 마지막에는 NULL로 채워진 해당 구조체가 존재하므로 배열의 끝을 알려준다. IMAGE_IMPORT_DESCRIPTOR의 필드 항목을 통해 알 수 있는 INT와 IAT의 값을 확인해보자.

배열 이름

오프셋

데이터

 

 

Import Name Table

0x00000A3C

0x0000307C

GetDriveTypeA

0x00000A40

0x0000308C

ExitProcess

0x00000A44

0x00000000

KERNEL32.DLL

0x00000A48

0x0000309A

MessageBoxA

0x00000A4C

0x00000000

USER32.DLL

 

 

Import Address Table

0x00000A50

0x0000307C

GetDriveTypeA

0x00000A54

0x0000308C

ExitProcess

0x00000A58

0x00000000

KERNEL32.DLL

0x00000A5C

0x0000309A

MessageBoxA

0x00000A60

0x00000000

USER32.DLL

그림 30. INT와 IAT

여기서 INT와 IAT가 동일한 값을 가리킨다는 것을 알 수 있다. 하지만 메모리에 올라오면서 PE 로더가 IAT엔 실제 함수의 명령어 위치를 채워주게 되며, INT에는 파일에서와 마찬가지로 임포트하는 함수의 이름을 가리키고 있다. 그렇다면 메모리에서 IAT 기록되어 있는 함수의 주소는 어떻게 얻어오는 것일까? 크게 네 단계로 나눌 수가 있다. 아래의 그림을 보자.

그림 31. IAT에 함수 주소 기록 과정

우선 로더는 A.EXE 파일이 필요로 하는 DLL을 로드하고자 한다. 이를 위해 Import 섹션의 존재하고 있는 IMAGE_IMPORT_DESCRIPTOR(IID)를 통해 어떠한 DLL을 필요로 하는지 이름을 얻는다. 그리고 해당 DLL들을 LoadLibrary API를 통해 메모리에 올리고자 한다. 해당 DLL을 찾은 프로세스는 DLL을 매핑하기 위한 공간을 확보한 다음 ImageBase에 지정된 주소로 매핑을 시도하며, 만약 해당 주소에 매핑하지 못한 경우 재배치를 하여 다른 주소에 매핑을 한다.

매핑이 되었다면 로더는 IID의 OriginalFirstThunk 필드를 통해 INT에 존재하고 있는 함수에 대한 정보를 얻어온다. 그 다음으로 로더는 해당 함수들의 함수 포인터 즉, 함수의 시작 주소를 얻고자 획득하고자 한다.

로더는 dll의 Export 섹션에서 IMAGE_EXPORT_DIRECTORY 구조체를 참고하여 AddessOfName 멤버를 통해 해당 함수의 이름을 비교하여 원하는 함수의 이름을 찾는다. 이때 몇 번째 인덱스에 존재하는지 확인을 한 다음 AddressOfNameOrdinals 필드를 참조한다. Ordinal 배열에서 해당 인덱스 번호에 맞는 값을 찾은 뒤, AddressOfFunctions 멤버를 이용해 EAT에서 해당 인덱스 번호에 맞는 함수의 시작 주소(RVA)를 얻는다.

마지막으로 해당 함수의 포인터를 획득한 다음 로더는 중요한 과정을 수행하게 되는데, 바로 위 과정을 통해 얻은 함수의 포인터(함수의 시작 주소)를 저장하는 것이다. IID의 FirstThunk 필드 값을 통해 IAT의 주소를 얻을 수가 있고, 첫 번째 과정에서 읽은 것과 같은 함수에 세 번째 과정에서 얻은 함수의 시작 주소를 기록하게 된다.

이러한 과정을 통해 파일에서 IAT는 INT와 같은 곳을 가리키지만, 메모리에서는 INT와는 전혀 다른 실제 함수의 시작 위치를 가리키고 있게 된다. 다시 말해, IAT는 PE 파일 이미지로 존재할 때와 실제로 프로세스 주소 공간 내로 매핑되었을 때의 내용이 달라진다. DLL 바인딩을 할 경우 DLL을 로딩하기 전에, IAT에 실제 함수의 주소를 고정시켜버리게 되어 프로그램이 실행될 때마다 이러한 과정을 거치지 않게 된다.

  Relocation Section

앞서 논의한 바와 같이 로더는 실행 파일을 로드할 때 지정된 ImageBase에 실행 파일 이미지를 로드하고자 한다. 하지만 해당 주소에 이미 다른 실행 파일 이미지가 로드되어 있는 경우 중첩되어 그 주소를 사용할 수는 없다. 이런 경우 로더는 매핑 가능한 다른 주소를 찾아 해당 주소에 로드해야한다. 대부분의 DLL은 0x10000000 영역이 기본 ImageBase로 처음에 로드되는 DLL의 경우 상관 없지만 두 번째부터는 다른 주소를 사용해야만 한다.

로딩 주소가 바뀌게 되면 절대 주소를 사용한 것은 반드시 바뀐 주소에 해당하는 값으로 고쳐 주어야 한다. 그렇지 않으면 0x10000000에 로딩된 다른 DLL의 메모리 영역을 참조하게 된다. PE에서는 이렇게 고쳐 주어야 하는 곳을 재배치 섹션에 모아서 저장해 두고 있다. 재배치 섹션의 구조를 한번 살펴보자.

typedef struct _IMAGE_BASE_RELOCATION

{

DWORD VirtualAddress;

DWORD SizeOfBlock;

/* WORD TypeOffset[1]; */ // 이후에 해당 배열이 뒤에 따라옴을 알려줌

} IMAGE_BASE_RELOCATION,*PIMAGE_BASE_RELOCATION;

그림 32. IMAGE_BASE_RELOCATION 구조체

기준 재배치 섹션은 단순한 구조를 가지고 있다. VirtualAddress 필드는 기준 재배치가 시작되어야 할 메모리 상의 번지에 대한 RVA이다. 실제 갱신할 위치의 표현은 "기준 RVA+재배치 Offset"으로 구성되고 이때 기준 RVA에 해당하는 것이 이 필드의 값이다. 재배치 섹션 내의 재배치 블록은 4K 단위의 구조체를 포함하는 블록이 존재하기에 나뉘어지는데, 이때 SizeOfBlock 필드의 값은 자신을 포함하고 있는 구조체의 크기를 말한다.

이 뒤에는 재배치가 적용되어야 할 대상에 해당하는 가상 주소에 대한 정보를 담은 WORD 타입의 배열이 온다. 해당 배열의 각 엔트리는 두 필드로 구성되는데 하나는 재배치 타입이며, 다른 하나는 재배치 오프셋이다.

Bit

15

14

13

12

11

10

9

8

7

6

5

4

3

2

1

0

WORD

재배치 타입

재배치 오프셋

그림 33. TypeOffset 엔트리 구조

재배치 타입의 경우 거의 큰 의미가 없는 값으로 Win32 PE의 경우 3, Win64의 경우 10이 된다. 가끔 재배치 그룹의 마지막 엔트리에 이 필드의 값이 0인 경우가 있는데 이는 해당 구조체가 4 바이트 단위로 정렬되기 때문에 이것을 맞추어 주기 위한 패딩으로 사용될 뿐이다.

다음으로 재배치 오프셋은 재배치할 대상의 번지 값에 대한 오프셋이다. 오프셋의 기준은 위에서 언급하였던 IMAGE_BASE_RELOCATION 구조체의 VirtualAddress 필드의 값이 된다. 따라서 실제로 갱신되어야 할 위치의 RVA는 VirtualAddress 필드 값에 재배치 오프셋 값을 더한 결과가 된다.

재배치 오프셋이 12 Bit 밖에 되지 않아 기준이 되는 VirtualAddress로부터 4095만큼까지만 접근이 가능하다. 그렇다면 그 이상으로 떨어진 지점은 어떻게 표현할까? 새로 VirtualAddress를 지정해주면 된다. 즉 하나 이상의 VirtualAddress 필드가 존재할 수 있으며 이에 따라 각 배열이 뒤에 붙게 된다.

재배치는 다음의 과정으로 이루어진다. 만약 ImageBase의 값과 실제 로드될 주소가 다른 경우 그 값의 차이인 델타 값을 구한다. 예로 원래는 0x10000000에 로드될 a.dll이 0x15000000에 로드되었다면 델타 값은 0x05000000이 된다. 그 다음 아래와 같은 재배치 섹션을 확인해보자.

pFile

Data

Description

0x000C1A00

0x00010000

VirtualAddress

0x000C1A04

0x0000001C

SizeOfBlock

0x000C1A08

0x3E15

TypeOffset[0]

0x000C1A0A

0x3E41

TypeOffset[1]

그림 34. a.dll의 재배치 섹션

VirtualAddress 필드 값이 0x10000인 것을 확인할 수 있다. 해당 섹션은 .text 섹션으로 TypeOffset를 따라가보자. 해당 RVA 0x10000의 RAW는 0x600으로 재배치 오프셋인 0xE15과 0xE41를 각각 더하면 TypeOffset[0]이 나타내는 파일에서의 주소는 0x1215와 0x1241이 된다. 해당 값을 확인해보자.

00001210 : 1C 53 56 8B 35 18 03 E4 6D 57 8B 78 10 85 F6 0F .SV.5...}W.x....

00001220 : 85 0A 01 00 00 E8 B0 09 00 00 8B 40 2C 64 8B 0D ...........@,d..

00001230 : 18 00 00 00 6A 44 5B 53 50 8B 41 30 FF 70 18 FF ....jD[SP.A0.p..

00001240 : 15 E4 05 D7 6D 8B F0 33 C0 3B F0 0F 84 5D 01 00 ....}..3.;...]..

그림 35. 재배치 해야할 주소 확인

이 위치에 있는 것이 무엇을 뜻하는지 어셈블리어로 확인해보자. 첫 번째 TypeOffset[0]은 MOV 명령어의 오퍼랜드로 사용되고 두 번째 TypeOffset[1]은 CALL 명령어에 사용되는 것을 확인할 수 있다.

10001083 8B35 1803E47D MOV ESI,DWORD PTR DS:[6DE40318]

…(skip)

100010AF FF15 E405D77D CALL DWORD PTR DS:[6DD705E4]

그림 36. 재배치 할 주소의 명령어

재배치 해야할 값을 찾았으니 이제 이 값에 위에서 구한 델타 값 0x05000000을 각각 더해주게 된다. 따라서 0x1215에 있는 값은 0x15000000에 매핑된 이후 [0x6DE40318+0x05000000]이 되며, 0x1241에 있는 값은 매핑된 이후 [0x6DD705E4+0x05000000]이 된다. 이와 같이 재배치 섹션은 어떠한 값을 바꾸어야 하는지 알려주는 역할을 한다.

하지만 기준 재배치를 수행해야 할 상황이 되었을 때 발생할 수 있는 문제점 또한 존재한다. 크게 두 가지 문제점이 있는데 첫째로, 로더는 재배치 섹션을 스캔하면서 재배치 섹션 내에 존재하는 각 오프셋이 가리키는 위치의 해당 모듈의 코드를 모두 수정해야한다. 이것은 응용프로그램의 초기화 시간을 더 늘어나게 만든다.

둘째로, 로더가 재배치 섹션의 엔트리가 지시하는 해당 번지 값을 수정할 때 발생하는 문제가 있다. 갱신되어야 할 해당 주소 공간의 번지 값은 .text 섹션에 존재하는데, 코드 섹션의 경우 Write 속성이 없기 때문에 결국 번지 값을 수정하기 위해 섹션의 속성을 변경해야만 한다.

  

API Hooking


위 과정에서 본 것과 같이 IAT는 읽을 수 있을 뿐만 아니라 쓰기 속성을 가지고 있다. 따라서 이러한 속성을 이용해 기존의 API에 대한 호출을 자신이 정의한 API로 향하도록 변경할 수 있다. 이를 API 후킹이라 한다. 이번 장에서는 DLL 인젝션을 진행하는 방법과 IAT 후킹에 대하여 알아보자.

  DLL Injection

몇 가지 인젝션 방법 중 세 가지 방법에 대하여 알아보자. 우선 DLL 인젝셕은 다른 프로세스에게 강제로 DLL을 로딩시키도록 하는 것으로, 원하는 기능을 수행하는 DLL을 다른 프로세스에 매핑시켜 원하는 동작을 수행하도록 한다. 여러 방법 중 우선 CreateRemoteThread API를 이용하는 방법에 대하여 알아보자.

흔히 DLL을 자신의 프로세스에 로드하기 위해서는 LoadLibrary API를 사용한다. 하지만 다른 프로세스에게 LoadLibrary API를 사용하여 DLL을 로드 시킬 수가 없으므로 CreateRemoteThread API를 통해 다른 프로세스에게 스레드를 실행시키도록 하여 DLL을 로드시킬 수 있다.

HANDLE WINAPI CreateRemoteThread(

_In_ HANDLE hProcess, // 프로세스 핸들

_In_ LPSECURITY_ATTRIBUTES lpThreadAttributes,

_In_ SIZE_T dwStackSize,

_In_ LPTHREAD_START_ROUTINE lpStartAddress, // 스레드 함수 주소

_In_ LPVOID lpParameter, // 스레드 파라미터 주소

_In_ DWORD dwCreationFlags,

_Out_ LPDWORD lpThreadId

);

그림 37. CreateRemoteThread API

위에는 CreateRemoteThread API의 인자로 어떠한 것이 있는지 나타내는 것으로 중요한 항목은 바로 네 번째 인자인 lpStartAddress이다. 해당 파라미터는 스레드가 수행할 함수의 주소를 넘겨주는 것으로 다른 프로세스에서 수행할 함수의 주소를 말한다. 바로 여기에 LoadLibrary API의 주소를 주고, 다섯 번째 인자에 로드시키고자 하는 DLL의 이름을 넘겨주면 된다.

다음으로 레지스트리를 통해 쉽게 DLL Injection하는 방법에 대하여 알아보자. 바로 AppInit_DLLs라는 레지스트리 키로 여기에 인젝션하고자 하는 DLL의 경로를 기입해준 뒤 재부팅을 시행하면, 재부팅하면서 실행되는 모든 프로세스에 해당 DLL을 인젝션 시켜준다. 위 CreateRemoteThread가 하나의 프로세스를 지정해주는 것과는 다르게 모든 프로세스에 시행된다는 점이 차이난다고 할 수 있다. 해당 경로는 다음 과 같다.

HKEY_LOCAL_MACHINE\SOFTWARE\Microsoft\Windows NT\CurrentVersion\Windows\AppInit_DLLs

그림 38. AppInit_DLLs Registry Key

마지막으로 알아본 DLL 인젝션 방법은 윈도우 운영체제가 제공하는 API를 사용하는 방법이다. 윈도우 운영체제는 사용자에게 GUI를 제공해주고, 사용자는 제공받은 GUI를 이용하여 원하는 동작을 수행할 수 있다. 동작을 수행하는데 있어 마우스나 키보드와 관련된 동작을 수행하게 되는데 이러한 동작은 윈도우 운영체제가 Event Driven 방식으로 처리한다. 다시 말해 이러한 동작을 이벤트로 발생시켜 운영체제가 그 이벤트에 맞는 메시지를 해당 응용 프로그램에게 전달하여 처리하는 방식이다.

아래 그림을 보면 메시지 후킹이 어떤 지점에서 이루어지는지 볼 수 있다. 사용자가 어떠한 행위를 했을 때 이벤트가 발생되고, 이벤트 발생으로 인해 OS에서 응용 프로그램으로 보낼 메시지들이 OS Message Queue에 추가된다. 운영체제는 해당 이벤트가 어떤 응용 프로그램에서 발생했는지 파악한 다음, OS 큐에서 메시지를 꺼내 해당 응용 프로그램의 메시지 큐에 전달한다. 해당 응용 프로그램은 자신의 응용 프로그램 메시지 큐에 해당 메시지가 추가된 것을 확인하고 해당 이벤트 핸들러를 호출한다. 이러한 방식으로 윈도우는 메시지를 전달한다.

그림 39. 메시지 전달 방식

윈도우 운영체제어서는 이러한 메시지를 후킹하기 위한 API인 SetWindowsHookEx()를 기본적으로 제공한다. 이 API는 훅 체인에 응용 프로그램이 정의한 후크 프로시저를 설치하며 이를 통해 사용자는 특정 유형의 이벤트를 모니터링 할 수 있다.

HHOOK WINAPI SetWindowsHookEx(

_In_ int idHook // 훅 종류

_In_ HOOKPROC lpfn, // 지정한 이벤트 발생시 처리하는 프로시저 주소

_In_ HINSTANCE hMod, // lpfn 이 있는 DLL 의 핸들

_In_ DWORD dwThreadId

);

그림 40. SetWindowsHookEx API

만약 해당 API를 구현하는 HookKey.dll이 존재하며 이를 실행하기 위한 HookMain.exe를 제작하였다고 가정하자. HookMain.exe를 실행하면 HookKey.dll이 해당 프로세스의 메모리에 로드되며 SetWindowsHookEx()가 호출된다. 이렇게 메시지 후킹이 걸린 상테에서, 다른 프로세스가 해당 이벤트를 발생시킨다면 HookKey.dll은 그 프로세스에서도 로딩된다.

그림 41. SetWindowsHookEx를 이용한 후킹

이러한 방법들을 통해 원하는 DLL을 프로세스에 인젝션 할 수 있다.

  IAT Hooking

IAT는 위에서 자세히 설명한 바와 같이 Import Address Table로, 메모리에 매핑되면서 PE 로더가 IAT에 실제 함수의 주소를 기록해준다. 다시 말해 파일에서의 IAT는 실제 함수의 주소를 가리키고 있는 것이 아니며 일반적으로 INT와 같은 곳을 가리키고 있다. 하지만 메모리에 올라온 뒤에는 해당 프로그램이 사용하고자 하는 함수의 주소가 기록되어 있다.

IAT 후킹은 바로 이 IAT에 기록되어 있는 주소를 바꿔 원하는 함수의 주소로 가도록 하는 것이다. 이를 통해 다양한 파라미터나 리턴 값을 조작하는 등의 작업을 수행할 수 있다. 그렇다면 일반적으로 API가 호출되는 상황에 대하여 먼저 알아보자.

0040104A . 68 E8030000 PUSH 3E8 ; /Timeout = 1000. ms

0040104F . FF15 68B14300 CALL DWORD PTR DS:[43B168] ; Sleep() API

 

0043B168 > FF 10 34 76 0A 19 34 76 69 51 34 76 2F 44 34 76 4v.4viQ4v/D4v

 

763410FF > 8BFF MOV EDI,EDI

76341101 55 PUSH EBP

76341102 8BEC MOV EBP,ESP

…(skip)

그림 42. 일반적인 API 호출 과정

위 예에서 Sleep API를 호출하고자 할 때 바로 CALL 명령어를 통해 763410FF(Sleep함수)를 호출하는 것이 아니라 DS:[43B168]을 참조해 해당 주소에 있는 763410FF라는 주소를 얻어 이를 호출한다. 그렇다면 왜 바로 CALL 763410FF라 하지 않을까? 이는 DLL의 특성 상 운영체제 버전이나 언어, 서비스 팩에 따라 DLL의 버전이 다르며 해당 함수의 위치가 달라지기 때문에 IAT에 매핑된 주소를 참조하여 함수를 호출하도록 하는 것이다. 바로 43B168가 IAT의 한 부분으로 Sleep 함수의 실제 주소가 메모리에 올라오면서 기록된 것이다.

따라서 IAT를 후킹한다는 것, 좀 더 구체적으로 IAT에서 Sleep()을 후킹하는 것은 바로 해당 API의 실제 주소를 가지고 있는 IAT의 주소(43B168)에 위치한 주소 값을 바꾸는 것이다. 후킹된 모습은 다음과 같다.

0040104A . 68 E8030000 PUSH 3E8 ; /Timeout = 1000. ms

0040104F . FF15 68B14300 CALL DWORD PTR DS:[43B168] ; Sleep() API

 

0043B168 > 20 10 40 00 0A 19 34 76 69 51 34 76 2F 44 34 76 4v.4viQ4v/D4v

 

00401020 814424 04 001 ADD DWORD PTR SS:[ESP+4],1000 ; 인자 값 변조

00401028 - E9 D200F475 JMP 763410FF     ; Kernel32.Sleep()

 

763410FF > 8BFF MOV EDI,EDI

76341101 55 PUSH EBP

76341102 8BEC MOV EBP,ESP

…(skip)

그림 43. 후킹된 API 호출 과정

이전과 똑같이 Sleep()을 호출하기 위해 IAT를 참조하게 된다. 하지만 해당 IAT는 후킹되어 기존의 Sleep() 함수의 주소가 아닌, 후킹 함수의 주소(0x401020)를 가리키고 있다. 결국 프로세스는 Sleep()을 호출했지만 후킹된 주소로 넘어가게 되며, 후킹된 주소에서 파라미터를 변조한 후 원래의 Sleep() 함수로 진입하게 된다.

이와 같은 IAT 후킹은 간단하게 이루어지면서도 해당 프로세스에서 후킹한 함수를 호출할 때마다 후킹 함수를 지나가게 되므로 강력하다고 할 수 있다.

'Reversing > Theory' 카테고리의 다른 글

WFP 무력화  (0) 2016.06.21
DLL이란?  (4) 2016.05.29
윈도우 후킹 원리 [PDF]  (1) 2016.04.23
윈도우 후킹 원리 (3) - Kernel [SSDT]  (0) 2016.04.23
윈도우 후킹 원리 (2) - Kernel [SYSTEM CALL]  (0) 2016.04.23


Windows_API_Hooking.pdf


목차

1    Intro. 4

2    Prior Knowledge. 5

2.1    What is an API?. 5

2.2    What is an API Hooking?. 6

3    User Mode Hooking. 7

3.1    IAT Hooking. 7

3.2    Message Hooking. 13

4    Kernel Mode Hooking. 15

4.1    System Call 15

4.2    INT 0x2E Hooking. 16

4.3    SYSENTER Hooking. 19

4.4    SSDT Hooking. 22

5    Conclusion. 26

6    Reference. 27

 

그림

그림 1. User Mode & Kernel Mode. 5

그림 2. 정상호출과 후킹된 호출... 6

그림 3. PE View로 본 IAT. 7

그림 4. Sleep API 7

그림 5. 메모리에서 Sleep API 8

그림 6. Sleep API in IAT. 8

그림 7. 코드 패치... 9

그림 8. 호출할 함수 주소 변경... 9

그림 9. 함수 호출 - Debugger 10

그림 10. Sleep이 호출하는 주소 변경... 10

그림 11. Code Cave를 사용한 후킹... 11

그림 12. (C) 단계 원래 명령어와 조작된 명령어... 11

그림 13. 조작 코드... 12

그림 14. Ntdll.dll API 12

그림 15. 메시지 전달 방식... 13

그림 16. SetWindowsHookEx API 14

그림 17. DLL Injection. 14

그림 18. System Call 과정... 15

그림 19. INT 0x2E SYSENTER. 16

그림 20. IDT 구조... 17

그림 21. INT 0x2E ISR(KiSystemService) 17

그림 22. IDT 주소와 각 엔트리 구조... 18

그림 23. IDT 0x2E 번째 Entry. 18

그림 24. IDT Entry 0x2E 후킹... 19

그림 25. Read MSR 0x176. 20

그림 26. Write MSR 0x176. 20

그림 27. 정상적인 SYSENTER 진입... 21

그림 28. 후킹 된 SYSENTER 진입... 21

그림 29. 전체적인 시스템 호출 과정... 22

그림 30. SSDT Hooking 과정... 23

그림 31. KeServiceDescriptorTable 구조... 24

그림 32. SSDT를 통한 Native API 접근... 24

그림 33. SSDT Hooking. 25

 


 

'Reversing > Theory' 카테고리의 다른 글

DLL이란?  (4) 2016.05.29
PE구조의 이해  (0) 2016.05.04
윈도우 후킹 원리 (3) - Kernel [SSDT]  (0) 2016.04.23
윈도우 후킹 원리 (2) - Kernel [SYSTEM CALL]  (0) 2016.04.23
윈도우 후킹 원리 (1) - User Mode  (3) 2016.04.23
SSDT Hooking


SSDT(System Service Dispatch Table)는 시스템 호출을 요청한 뒤, 전달되는 서비스 번호에 맞는 함수를 찾을 때 참조한다. 위 과정에서 시스템 호출을 요청하는 두 가지 명령어(INT 0x2E와 SYSENTER)에 대하여 알아보았는데, 결국 두 명령어 모두 KiSystemService(System Service Dispatcher)를 호출한다고 언급하였다.

KiSystemService가 호출될 때 EAX에는 사용자 영역에서 요청한 서비스 번호가 저장되어 있으며, EDX에는 이러한 서비스에 사용될 인자가 저장되어있다. 이러한 시스템 호출 번호(EAX)에 맞게 KeServiceDescriptorTalbe에서 Native API의 주소를 가지고 온다. 그 후 시스템 호출을 종료하고 다시 사용자 모드로 복귀하게 되는데, 이러한 과정은 아래의 그림과 같다.

그림 29. 전체적인 시스템 호출 과정

결국 SSDT(KiServiceTable)에서 서비스 호출 번호에 맞는 주소를 얻은 다음 이를 호출하는 형태로 진행되는 것이다. 그렇다면 SSDT Hooking은 어느 부분을 후킹해야 하는 것일까? 위 그림의 과정에서 설명하자면 바로 GetFuncAddress 과정에서 후킹한다고 할 수 있다. SYSENTER를 통해 KiFastCallEntry로 진입한 후 서비스 번호에 맞는 서비스 루틴을 SSDT에서 얻어온다. 따라서 SSDT에 존재하고 있는 각 서비스 루틴의 주소를 조작하므로 후킹을 진행할 수 있다. 이를 표현한 그림은 아래와 같다.

그림 30. SSDT Hooking 과정

해당 시스템 호출의 서비스 루틴을 가지고 오는 과정에서 SSDT를 참조하는데, SSDT의 해당 번호가 나타내는 주소를 후킹하므로 우리가 원하는 흐름으로 조작할 수 있다. 위 그림을 예로 시스템 호출이 요청되었을 때 서비스 번호가 저장되어 있는 EAX의 값이 0xAD라면 SSDT에서 0xAD가 가리키는 서비스 루틴의 주소 0xCCCCCCCC가 반환되어 이를 호출한다. 하지만 만약 공격자가 SSDT를 후킹하여 0xDDDDDDDD로 서비스 루틴의 주소를 변경하였다면, 시스템 호출 0xAD가 발생할 때마다 0xDDDDDDDD를 지나가게 된다.

KeServiceDescriptorTable은 네 가지 항목을 가지고 있는 구조체로 아래 그림과 같이 나타나는 것을 확인할 수 있으며 중요한 첫 번째 항목과 네 번째 항목에 대하여 알아보자. 첫 번째 항목은 KiServiceTable(SSDT)의 주소를 담고 있는 항목으로 이 값을 통해 SSDT에 접근하여 Native API의 주소를 얻을 수가 있으며. 네 번째 항목은 ParamTableBase는 KiArgumentTable의 주소 값을 담고 있는데, 이들 각각은 SSDT의 Native API와 일 대 일로 대응한다.

kd> dd KeServiceDescriptorTable

80554fa0 80503b8c 00000000 0000011c 80504000 //ServiceDescriptor[0]

80554fb0 00000000 00000000 00000000 00000000 //ServiceDescriptor[1]

80554fc0 00000000 00000000 00000000 00000000 //ServiceDescriptor[2]

80554fd0 00000000 00000000 00000000 00000000 //ServiceDescriptor[…]

…(skip)

그림 31. KeServiceDescriptorTable 구조

SSDT의 주소는 첫 번째 항목의 값인 0x80503b8c로 해당 주소를 확인해보면 여러 주소들이 존재하고 있는 것을 아래와 같이 확인할 수 있다. 각 값들은 Native API의 실제 주소이며 해당 주소를 확인해보면 Native API의 이름을 같이 볼 수 있다.

kd> d 80503b8c // SSDT Base

80503b8c 8059b948 805e8db6 805ec5fc 805e8de8

80503b9c 805ec636 805e8e1e 805ec67a 805ec6be

80503bac 8060ddfe 8060eb50 805e41b4 805e3e0c

80503bbc 805ccde6 805ccd96 8060e424 805ad5ae

80503bcc 8060da3c 8059fdbe 805a7a00 805ce8c4

…(skip)

 

kd> u 8059b948

nt!NtAcceptConnectPort:

8059b948 689c000000 push 9Ch

8059b94d 6838b14d80 push offset nt!_real+0x128 (804db138)

8059b952 e8b9e5f9ff call nt!_SEH_prolog (80539f10)

그림 32. SSDT를 통한 Native API 접근

시스템 호출을 통해 커널 모드로 진입할 때 EAX에는 요청한 서비스 번호를 저장하고 있고 EDX에는 인자로 사용될 포인터를 포함하고 있다고 하였다. 그러므로 어떠한 Native API를 요청하는지 알기 위해선 SSDT의 주소에 [EAX*4]를 더해주면 그 주소를 알 수 있다. 실제 SSDT Hooking도 이와 같은 방식으로 진행한다. 그렇다면 이제 실제 SSDT의 주소를 변경해보자.

SSDT를 통해 접근할 수 있는 Native API 함수 5개의 주소를 변경해보자. WinDBG를 사용하기 때문에 특정 주소 값을 수정하기 위한 "ed" 명령어를 사용하였으며, 기존의 주소를 아무 의미 없는 값들로 변경하였다. 그 후 SSDT를 확인해보면 위 그림 32에서 확인할 수 있던 주소들이 내가 수정한 값으로 변경되어 있는 것을 확인할 수 있다.

kd> ed 80503b8c

80503b8c 8059b948 ffffffff

80503b90 805e8db6 00000000

80503b94 805ec5fc ffffffff

80503b98 805e8de8 00000000

80503b9c 805ec636 ffffffff

80503ba0 805e8e1e 11111111

 

kd> d 80503b8c

80503b8c ffffffff 00000000 ffffffff 00000000

80503b9c ffffffff 11111111 805ec67a 805ec6be

80503bac 8060ddfe 8060eb50 805e41b4 805e3e0c

80503bbc 805ccde6 805ccd96 8060e424 805ad5ae

그림 33. SSDT Hooking

이렇게 후킹을 하면 해당 Native API가 요청될 때마다 후킹된 주소로 넘어가게 된다. 위 실습은 아주 극단적인 예를 보여주기 위한 과정으로 바로 블루 스크린이 나타난다. 실제 후킹 공격을 진행하기 위해선 메모리 쓰기 보호(Write Protect)를 해제하는 작업을 추가해야 하며, 매크로와 같은 방식을 통해 공격을 진행한다.

  

Conclusion


사용자 영역 후킹과 커널 영역 후킹에 대해 디버거를 통해 접근해보며 어떻게 후킹이 이루어지는지 알아보았다. 실제 공격을 하는데 있어 디버거를 사용하는 것보다는 프로그래밍을 통해 쉽게 공격이 이루어질 수 있도록 한다. 그렇기에 다른 사람들의 글 대부분이 이러한 프로그래밍에 초점을 맞추고 어떻게 코드를 설계하는지, 코드가 의미하는 것이 어떤 내용인지 잘 설명하고 있으므로 이후에 코드와 관련된 내용을 학습하면 더 좋을 것이다.

필자도 코드를 통해 어떻게 동작하겠구나 생각해볼 수 있었지만 직접 디버거를 통해 접근해볼 때마다 "여기를 수정하면 어떻게 될까?"라는 등 좀 더 깊이 있는 생각을 해볼 수가 있었다. 이후에는 여기서 다루지 못한 후킹들에 대하여 추가로 학습해볼 것이며 윈도우 운영체제와 관련된 내용을 더 공부해보아야겠다는 생각을 할 수가 있었다.

  

Reference


[+] 리버싱 핵심 원리(악성 코드 분석가의 리버싱 이야기) |이승원|인사이트|2012.09.30

[+] http://blog.naver.com/ikariksj/140056467421    

[+] http://www.codeproject.com/Articles/2082/API-hooking-revealed

[+] http://www.reversecore.com/23

[+] http://yokang90.tistory.com/58

[+] http://xcoolcat7.tistory.com/542

[+] http://egloos.zum.com/maxtrain/v/2775961

[+] http://kernel32.tistory.com/15

[+] https://msdn.microsoft.com/en-us/library/windows/desktop/ms644990(v=vs.85).aspx

[+] https://blogs.msdn.microsoft.com/kocoreinternals/2009/03/16/idt-isr/

[+] https://en.wikipedia.org/wiki/Model-specific_register

[+] http://amur.tistory.com/entry/커널모드에서-유저모드-분석하기

'Reversing > Theory' 카테고리의 다른 글

PE구조의 이해  (0) 2016.05.04
윈도우 후킹 원리 [PDF]  (1) 2016.04.23
윈도우 후킹 원리 (2) - Kernel [SYSTEM CALL]  (0) 2016.04.23
윈도우 후킹 원리 (1) - User Mode  (3) 2016.04.23
System Call & SSDT Hooking  (0) 2016.04.10
Kernel Mode Hooking


3장에서는 사용자 모드 후킹에 대하여 알아보았다면 이번 장에서는 커널 모드 후킹에 대하여 알아볼 것이다. 커널 모드에서 이루어지는 후킹의 경우 단순히 JMP 명령어를 설치하는 것이 아니라, 특정한 구조체에 포함된 값을 수정하는 등 작업을 수행해야 하기 때문에 아무래도 사용자 모드의 후킹보다 복잡하다. 이제 이러한 커널 모드 후킹에 대하여 알아보자.


System Call

운영체제는 사용자 모드(Ring 3)와 커널 모드(Ring 0)라는 두 가지 형태의 권한이 존재하고 있다. 이렇게 분리되는 이유는 다양하지만, 아무래도 보안과 관련된 점 또한 매우 중요하다. 만약 분리되어 있지 않을 경우 어떠한 프로세스든지 운영체제의 핵심 기능을 조작할 수 있게 되므로 이를 방지하기 위해선 분리되는 것이 좋다. 그렇기에 커널 모드에서는 사용자 모드를 조작할 수 있지만, 반대로 사용자 모드에서 커널 모드는 조작할 수가 없다.

하지만 커널 영역에 접근할 수 없다는 것은 해당 프로세스가 디스크의 내용을 읽을 수가 없게 되고 그 외에도 하드웨어나 프로세스에 직접 접근할 수 없게 된다. 그렇다면 어떻게 우리가 제작한 사용자 모드의 프로그램이 프로세스나 디스크와 관련된 작업을 수행할 수 있을까? 이는 바로 시스템 호출(System Call)을 사용하여 사용자 모드의 프로세스가 커널 영역에 접근을 요청할 수 있기 때문이다.

그림 18. System Call 과정

위 그림과 같이 응용프로그램이 Kernel32.dll의 API를 호출하면 해당 API는 Ntdll.dll의 함수를 호출한다. 그리고 호출된 Ntdll.dll은 자신이 커널에 요청해야 할 서비스 번호를 가지고 시스템 호출을 진행하며 이 과정이 바로 응용프로그램이 커널에게 시스템 자원 접근을 요청하는 과정이다. 이때 지정한 서비스를 요청하기 위해 EAX에 원하는 서비스 번호를 저장하고 EDX에는 이 서비스에 사용될 인자를 가리키는 포인터를 넘겨준다. 이러한 과정을 통해 커널에서는 어떠한 서비스가 필요한지, 어떠한 인자를 넘겨주었는지 알 수가 있다.

System Call은 "INT 0x2E"와 "SYSENTER" 두 가지 명령어로 나누어진다. 이렇게 나누어지는 기준은 바로 Windows XP 이전과 이후로, 이전에는 INT 0x2E를 사용하였으며, XP부터는 SYSENTER를 사용한다. INT 0x2E의 경우 상대적으로 무거운 인터럽트를 진행하므로 클럭 수를 많이 소모하였기 때문에, 이를 보완하기 위해 SYSENTER가 나온 것이다. 이러한 차이 외에 앞으로 진행할 후킹 과정에서도 차이점을 가지므로 이에 대하여 알아보자.

그림 19. INT 0x2E와 SYSENTER

우선 INT 0x2E의 경우 IDT(Interrupt Descriptor Table)을 참조하여 바로 System Service Dispatch(KiSystemService)로 간다. 하지만 SYSENTER는 SYSENTER_EIP(MSR)를 참조하여 KiFastCallEntry로 진행한 다음 KiSystemService로 간다. 마지막으로 INT 0x2E의 경우 IRET라는 명령어로 커널 모드에서 다시 사용자 모드로 복귀하고, SYSENTER의 경우 SYSEXIT라는 명령어를 통해 사용자 모드로 복귀한다. 이것이 별로 중요하게 느껴지지 않을 수 있지만, 후킹을 진행할 때 두 명령어에 따라 후킹 지점이 달라진다. 이러한 각 후킹 방법에 대해서는 바로 뒤에서 알아보자.

  

INT 0x2E Hooking

INT 0x2E는 인터럽트 0x2E로 IDT에 정의된 인터럽트 서비스 루틴(ISR)을 수행한다. 여기서 IDT는 256개의 Entry로 이루어진 배열로 엔트리 하나당 하나의 인터럽트에 대응하며 각 인터럽트는 IDT로부터 처리할 함수의 주소(ISR)을 전달받는다. 각 엔트리에는 지정된 값이 담겨 있으며 WinDBG로 확인했을 경우 아래와 같은 모습을 볼 수가 있으며 INT 0x2E의 경우 IDT에서 바로 KiSystemService를 가리키고 있는 것을 확인할 수 있다.

kd> !idt

Dumping IDT: 8003f400

…(skip)

2e:    8053f481 nt!KiSystemService

37:    806d3728 hal!PicSpuriousService37

3d:    806d4b70 hal!HalpApcInterrupt

41:    806d49cc hal!HalpDispatchInterrupt

50:    806d3800 hal!HalpApicRebootService

…(skip)

그림 20. IDT 구조

이 과정을 요약하면, XP 이전 버전에는 응용프로그램이 API를 호출하면 Ntdll.dll의 Zw*, Nt* 함수를 호출하게 된다. 이러한 함수는 결국 INT 0x2E를 통해 운영체제에게 커널 모드 작업을 요청한다. 이때 INT 0x2E가 IDT에서 KiSystemService의 주소를 참조하여 진행하는 것이다. 따라서 우리가 후킹 해야 할 부분은 바로 IDT이다. IDT가 가리키는 2E의 주소로 가보면 실제로 KiSystemSerive가 존재하고 있는 것을 아래와 같이 확인할 수 있다.

nt!KiSystemService:

8053f481 6a00 push 0

8053f483 55 push ebp

8053f484 53 push ebx

8053f485 56 push esi

8053f486 57 push edi

8053f487 0fa0 push fs

8053f489 bb30000000 mov ebx,30h

그림 21. INT 0x2E의 ISR(KiSystemService)

IDT를 후킹 할 것이므로 우선 IDT의 주소를 알아야 하는데, IDTR 레지스터에 IDT의 Base Address와 IDT의 크기가 저장되어 있다. WinDBG를 통해 알 수 있는 방법은 IDTR 레지스터의 값(주소)를 출력하거나 "!idt"를 통해 해당 주소를 알아낼 수가 있다. IDT의 Entry 구조는 아래 그림과 같이 8 바이트씩으로 이루어져 있으며 Entry가 가리키는 ISR의 주소가 하위 2바이트, 상위 주소 2바이트로 나누어져 있다.

kd> r idtr

idtr=8003f400

kd> !idt    

Dumping IDT: 8003f400

 

kd> dt _KIDTENTRY

ntdll!_KIDTENTRY

+0x000 Offset : Uint2B // 하위 오프셋

+0x002 Selector : Uint2B

+0x004 Access : Uint2B

+0x006 ExtendedOffset : Uint2B // 상위 오프셋

그림 22. IDT 주소와 각 엔트리 구조

하나의 엔트리가 8바이트로 이루어져 있다는 것을 확인했다. 그렇다면 우리가 찾고자 하는 엔트리는 0x2E 번째 엔트리이므로 IDT Base Address에 0x170을 더한 위치에 존재하고 있다. 아래 결과와 같이 8003f570부터 해당 엔트리가 존재하고 있다. 하위 2바이트와 상위 2바이트를 조합하여 INT 0x2E의 ISR은 8053f481이라는 것을 알 수 있다.

kd> db 8003f400 8003fC00

8003f400 9c 01 08 00 00 8e 54 80-14 03 08 00 00 8e 54 80 ......T.......T.

8003f410 3e 11 58 00 00 85 00 00-e4 06 08 00 00 ee 54 80 >.X...........T.

…(skip)

8003f560 80 fc 08 00 00 ee 53 80 - c0 05 08 00 00 ee 54 80 ......S.......T.

8003f570 81 f4 08 00 00 ee 53 80-80 27 08 00 00 8e 54 80 ......S..'....T.

…(skip)

그림 23. IDT 0x2E 번째 Entry

이제 우리가 어떤 주소(8003f570)를 후킹 해야 하는지 알았으니, 본격적인 후킹을 진행해보자. 이번 후킹 역시 프로그래밍을 통해 진행하는 것이 아니라 원리를 이해하기 위해 커널 디버거 WinDBG를 통해 진행할 것이다. 해당 0x2E의 ISR을 FFFFFFFF로 조작하는 과정으로 "0000"으로 채운 곳은 오프셋이 아닌 부분으로 구분을 위해 "0"으로 채운 것이다.

kd> !idt 2e // 기존 2E의 ISR 확인

2e:    8053f481 nt!KiSystemService

kd> ed 8003f570 // Windbg를 통해 직접 수정

8003f570 0008f481 0000ffff

8003f574 0000ffff ffff0000

kd> !idt 2e // 조작된 2E의 ISR 확인

2e:    ffffffff

kd> db 8003f570 8003f580 // 조작된 IDT 2E Entry 확인

8003f570 ff ff 00 00 00 00 ff ff-80 27 08 00 00 8e 54 80 .........'....T.

그림 24. IDT Entry 0x2E 후킹

이처럼 IDT가 후킹 된 상황에서 INT 0x2E를 통한 시스템 호출(System Call)이 발생하면 사용자 모드에서 커널 모드로 넘어갈 때 후킹된 주소로 가게 된다. 실제 후킹도 이와 같은 과정으로 진행되며, 대신 IDT의 주소를 얻을 때 "sidt" 명령어를 사용하여 주소를 얻는다. Sidt 명령어는 IDT의 주소를 저장하고 있는 IDTR 레지스터의 값을 참조하여 값을 얻어 온다. 또한 디버거를 통해 수정하는 것이 아니라 프로그래밍을 통해 수정하고자 할 때, 인터럽트를 비활성화("CLI" 명령어)해야 한다. 그리고 후킹이 완료되면 인터럽트를 다시 활성화("STI" 명령어) 시켜 정상적으로 구동되게끔 해야 한다.

  

SYSENTER Hooking

윈도우 XP 이상의 버전에서는 INT 0x2E가 아닌 SYSENTER를 사용한다. 시스템 호출이 요청되면 NTDLL은 EAX 레지스터에 해당 시스템 호출의 번호를 저장하고 EDX 레지스터에는 인자로 사용될 주소를 넣어준다. 그리고 SYSENTER 명령을 실행하여 커널 영역으로 들어오게 되는데, 이때 바로 커널로 들어가는 것이 아니라 실행될 커널의 주소(KiFastCallEntry)를 SYSENTER_EIP(MSR 0x176 레지스터)에서 참조하여 KiFastCallEntry 로 넘어가게 되는 것이다.

MSR은 Model-Specific Register로 디버깅이나 프로그램 실행 추적, 컴퓨터 성능 모니터링, 특정 CPU 기능 전환에 사용되는 각종 제어 레지스터x86 명령어의 집합이다. 이러한 집합 중 MSR 0x176에는 IA_SYSENTER_EIP(KiFastCallEntry)가 존재하고 있으며, MSR에 접근하고자 할 때는 "rdmsr", "wrmsr" 명령어를 통해 접근할 수가 있다. 우리가 찾아야 할 것은 MSR 0x176이며 다음과 같은 결과를 얻을 수가 있다.

kd> rdmsr 176

msr[176] = 00000000`8053f540

 

kd> u 8053f540

nt!KiFastCallEntry:

8053f540 b923000000 mov ecx,23h

8053f545 6a30 push 30h

…(skip)

그림 25. Read MSR 0x176

MSR 0x176이 제대로 KiFastCallEntry를 가리키고 있는 것을 확인할 수 있다. 바로 이 부분의 값을 바꾸어 SYSENTER를 후킹 할 수 있다. MSR 레지스터를 읽을 때는 rdmsr 명령어로 읽었다면, MSR 레지스터의 값을 변경할 때는 "wrmsr" 명령어를 사용하면 된다. 아래의 그림을 보자.

kd> wrmsr 176 11111111 // MSR 0x176의 값을 변경

kd> rdmsr 176 // MSR 0x176 값 변경 확인

msr[176] = 00000000`11111111

그림 26. Write MSR 0x176

실제로 위와 같이 옳지 않은 주소로 변경하면 당연히 블루 스크린을 맞이할 수 있을 것이다. 그렇다면 abex'sCrackMe01.exe를 가지고 직접 코드의 흐름을 조작하여 보자. 우선 아래의 코드는 정상적인 흐름을 나타낸다. Ntdll.dll의 함수를 추적하여 들어가보면 KiFastSystemCall이라는 부분을 볼 수 있는데 이는 SYSENTER 명령어를 통해 KiFastCallEntry로 가기 위한 부분이다. KiFastSystemCall에는 SYSENTER 명령어가 위치하고 있는 것을 확인할 수 있다.

ntdll!KiFastSystemCall:

001b:7c93e4f0 8bd4 mov edx,esp

001b:7c93e4f2 0f34 sysenter

kd> rdmsr 176 // MSR 0x176의 주소를 확인

msr[176] = 00000000`8053f540    

 

kd> bp 8053f540    // MSR 0x176의 주소에 BP 설정

 

kd> p    // Kernel의 KiFastCallEntry 에 올바르게 진입

Breakpoint 3 hit

nt!KiFastCallEntry:

8053f540 b923000000 mov ecx,23h

그림 27. 정상적인 SYSENTER 진입

정상적인 SYSENTER의 흐름을 wrmsr을 통해 조작해보자. MSR 0x176를 11111111 로 수정하므로 비정상적인 흐름으로 동작하도록 수정하였다. 그리고 이전과 같이 SYSENTER 명령어를 실행하기 전에 원래 MSR 0x176(KiFastCallEntry)의 주소에 BP를 설정한 다음 진행을 해보자. 정상적인 흐름이라면 KiFastCallEntry 에서 멈추어야 하지만, 조작된 MSR 0x176이 가리키는 주소 11111111로 흐름이 바뀌었다.

ntdll!KiFastSystemCall:

001b:7c93e4f0 8bd4 mov edx,esp

001b:7c93e4f2 0f34 sysenter

 

kd> bp 8053f540    // 기존 KiFastCallEntry에 BP 설정

kd> wrmsr 176 11111111    // MSR 0x176 주소 조작

 

kd> p    // 조작한 주소로 이동

Access violation - code c0000005 (!!! second chance !!!)

11111111 ?? ???

그림 28. 후킹 된 SYSENTER 진입

이러한 방법을 통해 SYSENTER Hooking(또는 MSR Hooking)을 진행할 수 있으며, 실제 11111111에 후킹 함수가 존재할 경우 시스템 호출이 발생할 때마다 후킹 함수를 지나가게 된다. 공격자의 입장에서 이러한 System Call Hooking을 진행하며 후킹 함수에 과도한 조건을 걸어놓는다면 시스템 성능이 크게 하락하여 사용자가 쉽게 알아차릴 수 있을 것이다.

'Reversing > Theory' 카테고리의 다른 글

윈도우 후킹 원리 [PDF]  (1) 2016.04.23
윈도우 후킹 원리 (3) - Kernel [SSDT]  (0) 2016.04.23
윈도우 후킹 원리 (1) - User Mode  (3) 2016.04.23
System Call & SSDT Hooking  (0) 2016.04.10
BOF에 취약한 함수  (1) 2016.03.30