0%

스텔스 프로세스, 코드 패치 기법을 이용해서 -1

일명 나뭇잎 책이라고 불리는 리버싱 핵심 원리 책을 보면서, 배운 내용을 정리하는 포스팅입니다.

해당 포스팅에서는 API 후킹 방법을 이용해서 유저 모드에서 StealthProcess를 만드는 기법을 배웁니다.


들어가기에 앞서

스텔스 프로세스를 만드는 방법을 알기전에 API 후킹 방법중 코드 패치를 이용하는 방식에 대해 알아야 합니다. 핵심 부분을 말하자면 프로세스 메모리에 로딩된 라이브러리 이미지에서 후킹을 원하는 API 코드를 수정하는 방법입니다. 해당 내용에 대해 더 자세히 알고 싶다면, Windows API Hooking - Code Patch 참고하시면 됩니다.


스텔스 프로세스

특정 프로세스를 은폐시키기 위해서는 다른 모든 프로세스의 메모리에 침투하여 API를 후킹하는 것이 유저 모드에서 구현 가능한 스텔스 프로세스라고 합니다. 작업 대상은 자기 자신이 아니라 다른 프로세스입니다.

프로세스는 커널 객체이기 때문에 유저 모드 프로그램에서는 직접적으로 접근할 수 있는 방법이 없습니다. 따라서 API를 통해서만 검색 및 접근을 할 수 있다. 일반적으로 유저 모드에서 프로세스를 검색하기 위한 API가 2종류가 있다고 합니다.

    1. CreateToolhelp32Snapshot() & EnumProcesses()
1
2
3
4
5
6
7
8
9
10
HANDLE CreateToolhelp32Snapshot(
DWORD dwFlags,
DWORD th32ProcessID
);

BOOL EnumProcesses(
DWORD *lpidProcess,
DWORD cb,
LPDWORD lpcbNeeded
);

CreateToolhelp32Snapshot() API 같은 경우에는 프로세스에서 사용되는 힙, 모듈, 및 스레드의 스냅샷을 찍어서 프로세스에 대한 정보를 추출할 떄 사용하는 함수입니다. 프로세스와 관련된 모든 부분을 검색할 수도 있고, 일부분만 검색을 할 수 있습니다. 자세한 사항은 MSDN에서 검색해보시면 알 수 있습니다.

EnumProcesses() API 같은 경우에는 프로세스 ID의 정보를 넘겨 받을 수 있는 함수입니다. 이를 이용해서 MSDN에서 제공해주는 예제처럼 프로세스 이름과 ID를 알아낼 수 있습니다.

-> 해당 예제 실습은 여기서 확인.

위 2가지 API들은 모두 내부적으로 ntdll.ZwQuerySystemInformation() API를 호출한다고 합니다.

    1. ZwQuerySystemInformation()
1
2
3
4
5
6
NTSTATUS WINAPI ZwQuerySystemInformation(
_In_ SYSTEM_INFORMATION_CLASS SystemInformationClass,
_Inout_ PVOID SystemInformation,
_In_ ULONG SystemInformationLength,
_Out_opt_ PULONG ReturnLength
);

ZwQuerySystemInformation() API를 이용하면 실행 중인 모든 프로세스의 구조체를 연결 리스트 형태로 얻을 수 있다고 합니다. 그 연결 리스트를 조작하면 해당 프로세스를 은폐 시킬 수 있게 됩니다.

그렇기에 유저 모드에서는 CreateToolhelp32Snpshort() 또는 EnumProcesses() API를 따로 후킹할 필요 없이 ZwQuerySystemInformation() API 하나만 후킹하면 확실하게 원하는 프로세스를 은폐 시킬 수 있다고 합니다.


해당 기법의 문제점

해당 기법을 통해서 하고 싶은 행위는 procexp.exe(프로세스 익스플로러), taskmgr.exe(윈도우 작업 관리자) 같은 프로세스 검색 유틸리티한테서 특정 프로세스를 숨기고 싶은 것입니다.

하지만 프로세스 검색 유틸리티는 이 두가지만 있는 것이 아닙니다. 수많은 프로세스 검색 유틸리티가 존재할 수도 있고, 사용자가 직접 만든 유틸리티도 있을 수 있습니다. 따라서
시스템에 실행 중인 모든 프로세스를 후킹해야만 특정 프로세스가 은폐되었다고 볼 수 있습니다. 또한, 후킹을 하고 난후에 나중에 프로세스 검색 유틸리티가 실행이 되었다면, 나중에 실행된 유틸리티는 후킹되지 않았으므로 특정 프로세스를 검색할 수 있습니다. 따라서 나중에 실행되는 프로세스 검색 유틸리티 또한 자동으로 후킹될 수 있도록 작성해야지 특정 프로세스가 은폐되었다고 볼 수 있습니다.

이러한 문제점을 가지고 있기 때문에 글로벌 후킹의 개념이 생겨났습니다. 시스템에 실행 중인 모든 프로세스를 후킹하고, 나중에 실행되는 모든 프로세스에 대해서도 후킹을 하는 것이 글로벌 후킹입니다.

먼저, 단순한 코드 패치를 통해서 스텔스 프로세스를 만드는 방법을 익히고, 다음 포스팅에서 글로벌 후킹을 통해서 문제점을 해결하는 방법을 익힐 예정입니다.


분석

나뭇잎 책에서 실습할 수 있도록, DLL을 인젝션할 수 있는 HideProc.exe 와 Stealth.dll 파일을 제공을 했습니다. Stealth.dll은 인젝션된 프로세스의 ntdll.ZwQuerySystemInformation() API를 후킹하는 역할을 합니다.


HideProc.exe -> HookProc.cpp

HideProc.exe은 DLL을 인젝션 할 수 있는 인젝터입니다. 나뭇잎책에서 제공되어진 인젝터랑 다른 점은 InjectAllProcess() 함수 부분입니다.

InjectAllProcess() 함수에서 CreateTool32SnapShot() API를 이용해서 시스템에 실행 중인 모든 프로레스 리스트를 얻어내고, Process32First()와 Process32Next() API를 이용해서 프로세스 PID를 구하는 것을 확인할 수 있습니다.

이렇게 구해진 프로세스 PID를 가지고, 모든 프로세스한테 DLL을 인젝션합니다. 이때, 시스템 PID가 100 미만 프로세스들은 인젝션을 수행하지 않는데, 시스템 안정성 떄문에 그렇습니다. 실습 환경에 따라서 해당 부분은 적절하게 조절하면 됩니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
BOOL InjectAllProcess(int nMode, LPCTSTR szDllPath)
{
DWORD dwPID = 0;
HANDLE hSnapShot = INVALID_HANDLE_VALUE;
PROCESSENTRY32 pe;

// Get the snapshot of the system
pe.dwSize = sizeof(PROCESSENTRY32);
hSnapShot = CreateToolhelp32Snapshot(TH32CS_SNAPALL, NULL);

// find process
Process32First(hSnapShot, &pe);
do
{
dwPID = pe.th32ProcessID;

// 시스템의 안정성을 위해서
// PID 가 100 보다 작은 시스템 프로세스에 대해서는
// DLL Injection 을 수행하지 않는다.
if (dwPID < 100)
continue;

if (nMode == INJECTION_MODE)
InjectDll(dwPID, szDllPath);
else
EjectDll(dwPID, szDllPath);
} while (Process32Next(hSnapShot, &pe));

CloseHandle(hSnapShot);

return TRUE;
}

하지만 Windows10 64bit 환경에서 인젝터가 중간에 멈추는 현상을 발견을 했습니다. 해당 코드를 확인 해보니 InjectDll() 함수내의 코드중 WaitForSingleObject()에서 쓰레드가 종료될 떄까지 무한히 기다리고 있다는 것을 알게 되었습니다. 그래서 시스템에서 실행 되고 있는 프로세스 중에서 CreateRemoteThread() API를 이용해서 DLL 인젝션이 되지 않는 프로세스가 있구나를 알게 되었습니다. 여기서의 목적은 프로세스 검색 유틸리티에 Stealth.dll을 인젝션해서 은폐 프로세스를 못보게 하는 것이라서 WaitForSingleObject(hThread,INFINITE); 를 WaitForSingleObject(hThread,1000); 으로 바꿨습니다. 이때, DLL 인젝션이 되지 않는 프로세스가 프로세스 검색 유틸리티가 아니라서 가능한 방법이었습니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
BOOL InjectDll(DWORD dwPID, LPCTSTR szDllPath)
{
HANDLE hProcess, hThread;
LPVOID pRemoteBuf;
DWORD dwBufSize = (DWORD)(_tcslen(szDllPath) + 1) * sizeof(TCHAR);
LPTHREAD_START_ROUTINE pThreadProc;

if (!(hProcess = OpenProcess(PROCESS_ALL_ACCESS, FALSE, dwPID)))
{
printf("OpenProcess(%d) failed!!!\n", dwPID);
return FALSE;
}

pRemoteBuf = VirtualAllocEx(hProcess, NULL, dwBufSize,
MEM_COMMIT, PAGE_READWRITE);

WriteProcessMemory(hProcess, pRemoteBuf,
(LPVOID)szDllPath, dwBufSize, NULL);

pThreadProc = (LPTHREAD_START_ROUTINE)
GetProcAddress(GetModuleHandle(L"kernel32.dll"),
"LoadLibraryW");
hThread = CreateRemoteThread(hProcess, NULL, 0,
pThreadProc, pRemoteBuf, 0, NULL);
WaitForSingleObject(hThread, 1000);

VirtualFreeEx(hProcess, pRemoteBuf, 0, MEM_RELEASE);

CloseHandle(hThread);
CloseHandle(hProcess);

return TRUE;
}

그리고, 그전에 HideProc.exe 프로세스의 권한을 상승시켜 놓아야지 전체 프로세스의 리스트를 정확하게 얻을 수 있습니다. 해당 코드에서 SetPrivilege() 함수를 보면, 내부적으로 AdjustTokenPrivileges() API 호출을 이용하여 프로세스의 권한을 상승시키는 것을 볼 수 있습니다. 하지만 프로세스의 권한을 상승시키지 않아도 프로세스의 리스트를 어느정도 뽑을 수 있고, DLL 인젝션도 정상적으로 동작합니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
BOOL SetPrivilege(LPCTSTR lpszPrivilege, BOOL bEnablePrivilege)
{
TOKEN_PRIVILEGES tp;
HANDLE hToken;
LUID luid;

if (!OpenProcessToken(GetCurrentProcess(),
TOKEN_ADJUST_PRIVILEGES | TOKEN_QUERY,
&hToken))
{
printf("OpenProcessToken error: %u\n", GetLastError());
return FALSE;
}

if (!LookupPrivilegeValue(NULL, // lookup privilege on local system
lpszPrivilege, // privilege to lookup
&luid)) // receives LUID of privilege
{
printf("LookupPrivilegeValue error: %u\n", GetLastError());
return FALSE;
}

tp.PrivilegeCount = 1;
tp.Privileges[0].Luid = luid;
if (bEnablePrivilege)
tp.Privileges[0].Attributes = SE_PRIVILEGE_ENABLED;
else
tp.Privileges[0].Attributes = 0;

// Enable the privilege or disable all privileges.
if (!AdjustTokenPrivileges(hToken,
FALSE,
&tp,
sizeof(TOKEN_PRIVILEGES),
(PTOKEN_PRIVILEGES)NULL,
(PDWORD)NULL))
{
printf("AdjustTokenPrivileges error: %u\n", GetLastError());
return FALSE;
}

if (GetLastError() == ERROR_NOT_ALL_ASSIGNED)
{
printf("The token does not have the specified privilege. \n");
return FALSE;
}

return TRUE;
}

전체 소스 코드 -> HookProc.cpp

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
#include "windows.h"
#include "stdio.h"
#include "tlhelp32.h"
#include "tchar.h"

typedef void (*PFN_SetProcName)(LPCTSTR szProcName);
enum { INJECTION_MODE = 0, EJECTION_MODE };

BOOL SetPrivilege(LPCTSTR lpszPrivilege, BOOL bEnablePrivilege)
{
TOKEN_PRIVILEGES tp;
HANDLE hToken;
LUID luid;

if (!OpenProcessToken(GetCurrentProcess(),
TOKEN_ADJUST_PRIVILEGES | TOKEN_QUERY,
&hToken))
{
printf("OpenProcessToken error: %u\n", GetLastError());
return FALSE;
}

if (!LookupPrivilegeValue(NULL, // lookup privilege on local system
lpszPrivilege, // privilege to lookup
&luid)) // receives LUID of privilege
{
printf("LookupPrivilegeValue error: %u\n", GetLastError());
return FALSE;
}

tp.PrivilegeCount = 1;
tp.Privileges[0].Luid = luid;
if (bEnablePrivilege)
tp.Privileges[0].Attributes = SE_PRIVILEGE_ENABLED;
else
tp.Privileges[0].Attributes = 0;

// Enable the privilege or disable all privileges.
if (!AdjustTokenPrivileges(hToken,
FALSE,
&tp,
sizeof(TOKEN_PRIVILEGES),
(PTOKEN_PRIVILEGES)NULL,
(PDWORD)NULL))
{
printf("AdjustTokenPrivileges error: %u\n", GetLastError());
return FALSE;
}

if (GetLastError() == ERROR_NOT_ALL_ASSIGNED)
{
printf("The token does not have the specified privilege. \n");
return FALSE;
}

return TRUE;
}

BOOL InjectDll(DWORD dwPID, LPCTSTR szDllPath)
{
HANDLE hProcess, hThread;
LPVOID pRemoteBuf;
DWORD dwBufSize = (DWORD)(_tcslen(szDllPath) + 1) * sizeof(TCHAR);
LPTHREAD_START_ROUTINE pThreadProc;

if (!(hProcess = OpenProcess(PROCESS_ALL_ACCESS, FALSE, dwPID)))
{
printf("OpenProcess(%d) failed!!!\n", dwPID);
return FALSE;
}

pRemoteBuf = VirtualAllocEx(hProcess, NULL, dwBufSize,
MEM_COMMIT, PAGE_READWRITE);

WriteProcessMemory(hProcess, pRemoteBuf,
(LPVOID)szDllPath, dwBufSize, NULL);

pThreadProc = (LPTHREAD_START_ROUTINE)
GetProcAddress(GetModuleHandle(L"kernel32.dll"),
"LoadLibraryW");
hThread = CreateRemoteThread(hProcess, NULL, 0,
pThreadProc, pRemoteBuf, 0, NULL);
WaitForSingleObject(hThread, 1000);

VirtualFreeEx(hProcess, pRemoteBuf, 0, MEM_RELEASE);

CloseHandle(hThread);
CloseHandle(hProcess);

return TRUE;
}

BOOL EjectDll(DWORD dwPID, LPCTSTR szDllPath)
{
BOOL bMore = FALSE, bFound = FALSE;
HANDLE hSnapshot, hProcess, hThread;
MODULEENTRY32 me = { sizeof(me) };
LPTHREAD_START_ROUTINE pThreadProc;

if (INVALID_HANDLE_VALUE ==
(hSnapshot = CreateToolhelp32Snapshot(TH32CS_SNAPMODULE, dwPID)))
return FALSE;

bMore = Module32First(hSnapshot, &me);
for (; bMore; bMore = Module32Next(hSnapshot, &me))
{
if (!_tcsicmp(me.szModule, szDllPath) ||
!_tcsicmp(me.szExePath, szDllPath))
{
bFound = TRUE;
break;
}
}

if (!bFound)
{
CloseHandle(hSnapshot);
return FALSE;
}

if (!(hProcess = OpenProcess(PROCESS_ALL_ACCESS, FALSE, dwPID)))
{
CloseHandle(hSnapshot);
return FALSE;
}

pThreadProc = (LPTHREAD_START_ROUTINE)
GetProcAddress(GetModuleHandle(L"kernel32.dll"),
"FreeLibrary");
hThread = CreateRemoteThread(hProcess, NULL, 0,
pThreadProc, me.modBaseAddr, 0, NULL);
WaitForSingleObject(hThread, INFINITE);

CloseHandle(hThread);
CloseHandle(hProcess);
CloseHandle(hSnapshot);

return TRUE;
}

BOOL InjectAllProcess(int nMode, LPCTSTR szDllPath)
{
DWORD dwPID = 0;
HANDLE hSnapShot = INVALID_HANDLE_VALUE;
PROCESSENTRY32 pe;

// Get the snapshot of the system
pe.dwSize = sizeof(PROCESSENTRY32);
hSnapShot = CreateToolhelp32Snapshot(TH32CS_SNAPALL, NULL);

// find process
Process32First(hSnapShot, &pe);
do
{
dwPID = pe.th32ProcessID;

// 시스템의 안정성을 위해서
// PID 가 100 보다 작은 시스템 프로세스에 대해서는
// DLL Injection 을 수행하지 않는다.
if (dwPID < 100)
continue;

if (nMode == INJECTION_MODE)
InjectDll(dwPID, szDllPath);
else
EjectDll(dwPID, szDllPath);
} while (Process32Next(hSnapShot, &pe));

CloseHandle(hSnapShot);

return TRUE;
}

int _tmain(int argc, TCHAR* argv[])
{
int nMode = INJECTION_MODE;
HMODULE hLib = NULL;
PFN_SetProcName SetProcName = NULL;

if (argc != 4)
{
printf("\n Usage : HideProc.exe <-hide|-show> "\
"<process name> <dll path>\n\n");
return 1;
}

// change privilege
SetPrivilege(SE_DEBUG_NAME, TRUE);

// load library
hLib = LoadLibrary(argv[3]);

// set process name to hide
SetProcName = (PFN_SetProcName)GetProcAddress(hLib, "SetProcName");
SetProcName(argv[2]);

// Inject(Eject) Dll to all process
if (!_tcsicmp(argv[1], L"-show"))
nMode = EJECTION_MODE;

InjectAllProcess(nMode, argv[3]);

// free library
FreeLibrary(hLib);

return 0;
}

Stealth.dll -> stealth.cpp

실제 API 후킹을 당담하는 DLL 파일입니다.


SetProcName()

SetProcName 함수는 export 함수입니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// global variable (in sharing memory)
#pragma comment(linker, "/SECTION:.SHARE,RWS")
#pragma data_seg(".SHARE")
TCHAR g_szProcName[MAX_PATH] = { 0, };
#pragma data_seg()

// export function
#ifdef __cplusplus
extern "C" {
#endif
__declspec(dllexport) void SetProcName(LPCTSTR szProcName)
{
_tcscpy_s(g_szProcName, szProcName);
}
#ifdef __cplusplus
}
#endif

소스 코드를 보면, #pragma comment(linker, “/SECTION:.SHARE,RWS”) 전처리 구문을 통해서 “.SHARE”라는 데이터가 공유 가능한 메모리 섹션을 만들고, g_szProcName 버퍼를 생성을 한다는 것을 알 수 있습니다. 즉, 읽기, 쓰기, 공유 권한을 가진 데이터 섹션인 .SHARE를 만들어서 DLL 에 있는 데이터들을 공유할 때 사용하는 기법입니다. 이를 통해서 Stealth.dll이 모든 프로세스에 인젝션될 때 은폐 프로세스의 이름을 쉽게 공유할 수 있습니다.

#pragma comment() 전처리 구문을 통해서는 여러가지 일을 할 수 있습니다. 서브시스템을 설정한다거나 섹션을 설정한다거나 명시적으로 라이브러리를 링크한다거나 등을 할 수 있습니다. 프로젝트 관리에 들어가서 설정을 통해서 할 수 있는 부분들을 #pragma comment()를 통해서 편하게 설정을 할 수 있게 한 것입니다.

1
2
3
4
#pragma comment(linker, "/SECTION:.SHARE,RWS")
#pragma data_seg(".SHARE")
TCHAR g_szProcName[MAX_PATH] = { 0, };
#pragma data_seg()

다음 부분은 export 함수를 만드는 부분입니다. C++과 C는 네임맹글링(Name Mangling) 많은 차이가 있습니다. C언어의 네임맹글링은 _cdecl 방식으로 컴파일 되었을 경우 단순히 함수 이름 앞에 “언더바”를 붙여주거나, _stdcall 방식으로 컴파일 되었을 경우 함수 이름 앞에 “언더바”을 붙여주고 함수 이름 마지막에 “@ordinal”을 붙여주는 방식으로 컴파일이 됩니다. 그에 반면 C++은 컴파일마다 네임맹글링 하는 방식이 다르며, 네임맹글링 하는 방식이 다르면 DLL을 만들 때, 링킹을 할 수 없는 상황이 벌어지기도 합니다. 따라서 C++을 이용해서 DLL을 만들었다고 하더라도 C module과 C++ module의 호환성을 위해서 extern “C” 키워를 사용을 하는 것입니다.

해당 관련 문서는 이곳을 찾아보면 된다.

1
2
3
4
5
6
7
8
9
10
#ifdef __cplusplus
extern "C" {
#endif
__declspec(dllexport) void SetProcName(LPCTSTR szProcName)
{
_tcscpy_s(g_szProcName, szProcName);
}
#ifdef __cplusplus
}
#endif

DllMain()

다음은 DllMain() 함수입니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
BOOL WINAPI DllMain(HINSTANCE hinstDLL, DWORD fdwReason, LPVOID lpvReserved)
{
char szCurProc[MAX_PATH] = {0,};
char *p = NULL;

// #1. 예외처리
// 현재 프로세스가 HookProc.exe 라면 후킹하지 않고 종료
GetModuleFileNameA(NULL, szCurProc, MAX_PATH);
p = strrchr(szCurProc, '\\');
if( (p != NULL) && !_stricmp(p+1, "HideProc.exe") )
return TRUE;

switch( fdwReason )
{
// #2. API Hooking
case DLL_PROCESS_ATTACH :
hook_by_code(DEF_NTDLL, DEF_ZWQUERYSYSTEMINFORMATION,
(PROC)NewZwQuerySystemInformation, g_pOrgBytes);
break;

// #3. API Unhooking
case DLL_PROCESS_DETACH :
unhook_by_code(DEF_NTDLL, DEF_ZWQUERYSYSTEMINFORMATION,
g_pOrgBytes);
break;
}

return TRUE;
}

코드를 보면, 문자열 비교를 통해 프로세스 이름이 “HookProc.exe” 인젝터라면 API 후킹하지 않도록 예외 처리를 하는 것을 알 수 있습니다. 그리고 DLL_PROCESS_ATACH 이벤트를 통해 hook_by_code() 함수로 API 후킹을 하고, unhook_by_code() 홤수를 통해 API 후킹을 해제하는 것을 볼 수 있습니다.

hook_by_code()

다음은 코드 패치 기법을 이용해서 API 후킹하는 함수입니다. x86비트에서 후킹 하는 방법은 Windows API Hooking Code Patch 글을 보면 된다. 현재 여기서는 코드를 살짝 수정해서 x64비트에서 API 후킹을 했습니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
BYTE g_pOrgBytes[14] = { 0, };
BOOL hook_by_code(LPCSTR szDllName, LPCSTR szFuncName, PROC pfnNew, PBYTE pOrgBytes)
{
FARPROC pfnOrg;
DWORD dwOldProtect;
BYTE pBuf1[6] = { 0xFF, 0x25, 0x00, 0x00, 0x00, 0x00 };
BYTE pBuf2[8] = { 0, };
PBYTE pByte;

// 후킹 대상 API 주소를 구한다
pfnOrg = (FARPROC)GetProcAddress(GetModuleHandleA(szDllName), szFuncName);
pByte = (PBYTE)pfnOrg;

// 만약 후킹되어 있다면 return FALSE
if (pByte[0] == 0xFF && pByte[1] == 0x25)
return FALSE;

// 기존코드 14바이트 백업 및 후킹 함수 시작 주소 구하기
memcpy(pOrgBytes, pfnOrg, 14);
memcpy(pBuf2, &pfnNew, 8);

// 14바이트 패치를 위하여 메모리에 Write 속성 추가
VirtualProtect((LPVOID)pfnOrg, 14, PAGE_EXECUTE_READWRITE, &dwOldProtect);

// Hook: 14바이트 패치
memcpy(pfnOrg, pBuf1, 6);
memcpy((LPVOID)((DWORD_PTR)pfnOrg + 6), pBuf2, 8);

// 메모리에 속성 복원
VirtualProtect((LPVOID)pfnOrg, 14, dwOldProtect, &dwOldProtect);

return TRUE;
}

Code Patch를 통해서 API 후킹할 떄, 32bit 환경에서는 JMP 명령어중 “E9 XXXXXXXX”를 사용 했었으면 되었습니다. 하지만 64bit 환경에서는 32bit 환경보다 가상메모리 공간이 훨씬 더 커지면서 해당 JMP 명령어를 통해서 후킹을 할 수가 없게 되었습니다. (32bit 가상메모리공간 -> 4GB, 64bit 가상메모리공간 -> 16TB)

따라서 새로운 방법을 찾아야 했는데, 그 중 한가지가 JMP 명령어중 “FF 25 XXXXXXXX”을 이용해서 패치하는 방식입니다. 이를 이용하면 8Byte 크기의 주소 공간을 이동할 수가 있습니다.

1
2
0x00007FFF 00000001 'FF 25 00000000' JMP QWORD PTR ds:[7FFF00000007] # JMP QWORD PTR [RIP+addr0]
0x00007FFF 00000007 'XXXXXXXX XXXXXXXX' # 후킹 함수 주소

FF25로 시작하는 JMP 명령어는 레지스터 RIP + offset(XXXXXXXX)을 더한 주소로 이동하는 방식입니다. 이를 통해서 RIP+offset(XXXXXXXX) 주소에다가 후킹 함수 주소를 넣음으로써, 후킹 함수로 이동하게 되는 테크닉입니다.

이외에도 여러가지 방법이 존재하는데, 다른 방법은 WindowsX64Hooking 글을 참고하면 됩니다.


unhook_by_code()

다음은 후킹을 해제하는 unhook_by_code() 함수입니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
BOOL unhook_by_code(LPCSTR szDllName, LPCSTR szFuncName, PBYTE pOrgBytes)
{
FARPROC pfnOrg;
DWORD dwOldProtect;
PBYTE pByte;

// API 주소를 구한다.
pfnOrg = GetProcAddress(GetModuleHandleA(szDllName), szFuncName);
pByte = (PBYTE)pfnOrg;

// 만약 이미 언후킹되어 있다면 FALSE
if (pByte[0] != 0xFF && pByte[1] != 0x25)
return FALSE;

// 기존 코드 14바이트를 덮어쓰기 위헤 메모리에 WRITE 속성 추가
VirtualProtect((LPVOID)pfnOrg, 14, PAGE_EXECUTE_READWRITE, &dwOldProtect);

// unHook: 14바이트 패치
memcpy(pfnOrg, pOrgBytes, 14);

// 메모리 속성 복원
VirtualProtect((LPVOID)pfnOrg, 14, dwOldProtect, &dwOldProtect);

return TRUE;
}

언훅의 동작 원리는 원래 코드의 14바이트를 복원을 하는 것입니다.


NewZwQuerySystemInformation()

다음은 후킹 함수 NewZwQuerySystemInformation() 입니다. 해당 함수의 동작원리를 알기 위해서는 ntdll.ZwQuerySystemInformation() API에 대해 먼저 알아야 합니다.

1
2
3
4
5
6
NTSTATUS WINAPI ZwQuerySystemInformation(
_In_ SYSTEM_INFORMATION_CLASS SystemInformationClass,
_Inout_ PVOID SystemInformation,
_In_ ULONG SystemInformationLength,
_Out_opt_ PULONG ReturnLength
);

ntdll.ZwQuerySystemInformation() 은 지정된 시스템 정보를 검색하는데 사용하는 API 라고 합니다. 근데 Windows 8부터는 사용할 수 없다고 합니다. 하지만 Windows 10 64bit에서도 사용이 가능했습니다. 그 이유는 …? 새롭게 Windows 10 64bit에서 사용할 수 있게 하고 MSDN을 업데이트 하지 않은 것인가?

그리고 ntdll.ZwQuerySystemInformation() API의 MSDN 보다 ntdll.NtQuerySystemInformation() API의 MSDN에 내용이 더 자세합니다. 따라서 해당 함수를 이용할 때,
NtQuerySystemInformation - MSDN글 또한 참고하는 것이 좋은 것 같습니다.

Nt와 Zw의 차이점을 검색을 해보니 둘다 똑같은 커널 모드에서 동작하도록 설계된 루틴들의 집합체라고 합니다. 시스템콜, 즉 커널함수 라고 볼 수 있습니다. 예를 들어, CreateFile()을 호출을 하면, NtCreateFile() 또는 ZwCreateFile()을 내부적으로 호출을 하는 것입니다.

이러한 Native Service API들은 거의 동일한 동작을 수행한다고 합니다. 하지만, 커널 모드에서 파라미터들을 다루는 방식이 다르다고 합니다. 커널 모드에서 Zw루틴이 호출되면, 파라미터들이 이미 검증된 파라미터라고 생각하고 파라미터 검증과정을 건더뛰고 수행된다고 합니다. 반면에 Nt루틴이 호출되면, 유저모드이건 커널모드이건 Nt루틴 내부적으로 파리미터를 검증한느 과정을 거치고 호출이 되는 방식이라고 합니다.

참고한 부분은 참고 자료에도 링크를 걸었습니다. 다음은 함수 파라미터에 대한 설명입니다.

  • SystemInformationClass

검색 할 시스템 정보의 종류를 나타내는 SYSTEM_INFORMATION_CLASS에 열겨된 값중 하나를 지정합니다. SystemBasicInformation, SystemCodeIntegrityInformation, SystemProcessInformation 등 여러가지가 있다. 더 자세한 사항은 MSDN을 참고 하면 됩니다.

여기서 사용할 값은 SystmeProcessInformation 입니다. 해당 값을 지정하면, 시스템에서 실행중인 각 프로세스마다 하나씩 SYSTEM_PROCESS_INFORMATION 구조체 배열을 리턴합니다. 해당 구조체에는 프로세스에 사용된 스레드 및 핸들 수, peak 페이지 파일 사용량 및 프로세스가 할당 한 메모리 페이지 수를 포함하여 각 프로세스의 자원 사용량에 대한 정보가 포함된다고 합니다.

  • SystemInformation

요청된 정보를 받는 버퍼에 대한 포인터를 지정합니다. 해당 파라미터는 SystemInformationClass 파라미터에서 어떤 시스템 종류를 나타낼지 선택한 거에 따라서 달라집니다.

SystemProcessInformation을 선택했다면, 해당 파리미터는 각각의 프로세스를 위한 프로세스 정보를 가지고 있는 SYSTEM_PROCESS_INFORMATION 구조체를 가지고 있다고 합니다. 또한 SYSTEM_THREAD_INFORMATION 구조체 메모리로 이어질 수 있다고 합니다. 즉, 프로세스 정보 뿐만 아니라 쓰레드 정보 뿐만 얻을 수 있습니다.

그리고, SystemInformation 파라미터가 가라키는 버퍼가 실행중인 프로세스 및 스레드 만큼 많은 SYSTEM_PROCESS_INFORMATION 및 SYSTEM_THREAD_INFORMATION 구조체를 포함하는 배열을 보유할 수 있을 정도로 커야한다는 점에서 SystemInformation 파라미터에 SYSTEM_PROCESS_INFORMATION 구조체가 배열처럼 쭈욱 기다랗게 연결되어 있다는 것을 알 수 있습니다.

ZwQuerySystemInformation 과 NtQuerySystemInformation에서의 SYSTEM_PROCESS_INFORMATION의 레이아웃이 다르므로 MSDN을 참고하셔야 합니다. ZwQuerySystemInformation의 SYSTEM_PROCESS_INFORMATION 은 다음과 같습니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
typedef struct _SYSTEM_PROCESS_INFORMATION {
ULONG NextEntryOffset;
ULONG NumberOfThreads;
BYTE Reserved1[48];
PVOID Reserved2[3];
HANDLE UniqueProcessId;
PVOID Reserved3;
ULONG HandleCount;
BYTE Reserved4[4];
PVOID Reserved5[11];
SIZE_T PeakPagefileUsage;
SIZE_T PrivatePageCount;
LARGE_INTEGER Reserved6[6];
} SYSTEM_PROCESS_INFORMATION, *PSYSTEM_PROCESS_INFORMATION;
  • SystemInformationLength

해당 파라미터는 SystemInformation 파라미터가 가리키는 버퍼 크기를 지정하면 됩니다.

  • ReturnLength

요정된 정보의 실제 크기를 반환 받고 싶을 때, 선택적으로 사용하는 파라미터입니다. 반환 값이 SystemInformationLength 파라미터 보다 작거나 같은 경우, 데이터를 SystemInformation 버퍼에 복사를 하고, 그렇지 않을 경우 NTSTATUS 오류 코드를 반환하고 요청된 데이터를 받는데 필요한 크기를 ReturnLength에 반환을 한다고 합니다.


즉, 책에서 설명했던 것처럼 ZwQuerySystemInformation() API를 호출할 때, SystemInformationClass 파라미터를 SystemProcessInformation(enum 값 : 5) 과 SystemInformationLength 파라미터에 충분한 크기를 입력하면, SystemInformation[inout] 파라미터에 SYSTEM_PROCESS_INFORMATION 구조체 싱글 링크드 리스트의 시작 주소가 저장된다는 것이 이해가 됩니다. 근데 해당 구조체내의 다음 구조체를 가리키는 값이 포인터가 아니고 NextEntryOffset 더해서 다음 구조체를 가리키는 것을 보아 연결 리스트가 아니라 배열 형태로 이루어진게 아닐까 생각이 듭니다. 배열 형태로 이루어진 것을 연결 리스트로 구현한 것처럼 다루는 느낌이 들었습니다. 그리고 해당 구조체 연결 리스트에 실행 중인 모든 프로세스의 정보가 담겨 있다는 것도 이해가 됩니다.

그래서 은폐하고 싶은 프로세스에 해당 하는 리스트 멤버를 찾아서 연결을 끊어버리면, 해당 항목이 유실되어있기 때문에 프로세스 검색 유틸리티가 CreateToolhelp32Snapshot() 또는 EnumProcesses() API를 통해서 은폐 프로세스에 대한 정보를 검색할 수 없게 되어서 자기 자신을 숨길 수 있게 되는 것입니다.

다음은 NewZwQuerySystemInformation() 소스 코드입니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
NTSTATUS WINAPI NewZwQuerySystemInformation(
SYSTEM_INFORMATION_CLASS SystemInformationClass,
PVOID SystemInformation,
ULONG SystemInformationLength,
PULONG ReturnLength)
{
NTSTATUS status;
FARPROC pFunc;
PSYSTEM_PROCESS_INFORMATION pCur, pPrev = { 0, };
char szProcName[MAX_PATH] = { 0, };

// 작업 전에 unhook
unhook_by_code(DEF_NTDLL, DEF_ZWQUERYSYSTEMINFORMATION, g_pOrgBytes);

// original API 호출
pFunc = GetProcAddress(GetModuleHandleA(DEF_NTDLL),
DEF_ZWQUERYSYSTEMINFORMATION);
status = ((PFZWQUERYSYSTEMINFORMATION)pFunc)
(SystemInformationClass, SystemInformation,
SystemInformationLength, ReturnLength);

if (status != STATUS_SUCCESS)
goto __NTQUERYSYSTEMINFORMATION_END;

// SystemProcessInformation 인 경우만 작업함
if (SystemInformationClass == SystemProcessInformation)
{
// SYSTEM_PROCESS_INFORMATION 타입 캐스팅
// pCur 는 single linked list 의 head
pCur = (PSYSTEM_PROCESS_INFORMATION)SystemInformation;

while (TRUE)
{
// 프로세스 이름 비교
// g_szProcName = 은폐하려는 프로세스 이름
// (=> SetProcName() 에서 세팅됨)
if (pCur->Reserved2[1] != NULL)
{
if (!_tcsicmp((PWSTR)pCur->Reserved2[1], g_szProcName))
{
// 연결 리스트에서 은폐 프로세스 제거
if (pCur->NextEntryOffset == 0)
pPrev->NextEntryOffset = 0;
else
pPrev->NextEntryOffset += pCur->NextEntryOffset;
}
else
pPrev = pCur;
}

if (pCur->NextEntryOffset == 0)
break;

// 연결 리스트의 다음 항목
pCur = (PSYSTEM_PROCESS_INFORMATION)
((DWORD_PTR)pCur + pCur->NextEntryOffset);
}
}

__NTQUERYSYSTEMINFORMATION_END:

// 함수 종료 전에 다시 API Hooking
hook_by_code(DEF_NTDLL, DEF_ZWQUERYSYSTEMINFORMATION,
(PROC)NewZwQuerySystemInformation, g_pOrgBytes);

return status;
}

후킹 함수 NewZwQuerySystemInformation()의 로직은 주석 처리가 되있어서 알아보기 쉽게 되어 있습니다. 또한 자료구조 연결 리스트를 구현해본 경험이 있다면, 그렇게 어렵지 않은 코드인 것 같습니다.

먼저, 언훅을 통해서 원본 ZwQuerySystemInformation() API로 돌려놓고, 해당 API를 호출을 합니다. 호출을 통해서 얻은 SystemProcessInformation 구조체를 연결 리스트 형식으로 검사하면서 은폐 시키고 싶은 프로세스 이름을 찾습니다. 찾았다면 SystemProcessInformation 구조체 연결을 끊어버립니다. 그리고 나서 후킹을 다시 걸고 status(NTSTATUS) 값을 반환해주면 됩니다.


Stealth.cpp 소스코드

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
#include "windows.h"
#include "tchar.h"

#define STATUS_SUCCESS (0x00000000L)

typedef LONG NTSTATUS;

typedef enum _SYSTEM_INFORMATION_CLASS {
SystemBasicInformation = 0,
SystemPerformanceInformation = 2,
SystemTimeOfDayInformation = 3,
SystemProcessInformation = 5,
SystemProcessorPerformanceInformation = 8,
SystemInterruptInformation = 23,
SystemExceptionInformation = 33,
SystemRegistryQuotaInformation = 37,
SystemLookasideInformation = 45
} SYSTEM_INFORMATION_CLASS;

typedef struct _SYSTEM_PROCESS_INFORMATION {
ULONG NextEntryOffset;
ULONG NumberOfThreads;
BYTE Reserved1[48];
PVOID Reserved2[3];
HANDLE UniqueProcessId;
PVOID Reserved3;
ULONG HandleCount;
BYTE Reserved4[4];
PVOID Reserved5[11];
SIZE_T PeakPagefileUsage;
SIZE_T PrivatePageCount;
LARGE_INTEGER Reserved6[6];
} SYSTEM_PROCESS_INFORMATION, * PSYSTEM_PROCESS_INFORMATION;

typedef NTSTATUS(WINAPI* PFZWQUERYSYSTEMINFORMATION)
(SYSTEM_INFORMATION_CLASS SystemInformationClass,
PVOID SystemInformation,
ULONG SystemInformationLength,
PULONG ReturnLength);

#define DEF_NTDLL ("ntdll.dll")
#define DEF_ZWQUERYSYSTEMINFORMATION ("ZwQuerySystemInformation")


// global variable (in sharing memory)
#pragma comment(linker, "/SECTION:.SHARE,RWS")
#pragma data_seg(".SHARE")
TCHAR g_szProcName[MAX_PATH] = { 0, };
#pragma data_seg()

// global variable
BYTE g_pOrgBytes[14] = { 0, };


BOOL hook_by_code(LPCSTR szDllName, LPCSTR szFuncName, PROC pfnNew, PBYTE pOrgBytes)
{
FARPROC pfnOrg;
DWORD dwOldProtect;
BYTE pBuf1[6] = { 0xFF, 0x25, 0x00, 0x00, 0x00, 0x00 };
BYTE pBuf2[8] = { 0, };
PBYTE pByte;

// 후킹 대상 API 주소를 구한다
pfnOrg = (FARPROC)GetProcAddress(GetModuleHandleA(szDllName), szFuncName);
pByte = (PBYTE)pfnOrg;

// 만약 후킹되어 있다면 return FALSE
if (pByte[0] == 0xFF && pByte[1] == 0x25)
return FALSE;

// 기존코드 14바이트 백업 및 후킹 함수 시작 주소 구하기
memcpy(pOrgBytes, pfnOrg, 14);
memcpy(pBuf2, &pfnNew, 8);

// 14바이트 패치를 위하여 메모리에 Write 속성 추가
VirtualProtect((LPVOID)pfnOrg, 14, PAGE_EXECUTE_READWRITE, &dwOldProtect);

// Hook: 14바이트 패치
memcpy(pfnOrg, pBuf1, 6);
memcpy((LPVOID)((DWORD_PTR)pfnOrg + 6), pBuf2, 8);

// 메모리에 속성 복원
VirtualProtect((LPVOID)pfnOrg, 14, dwOldProtect, &dwOldProtect);

return TRUE;
}


BOOL unhook_by_code(LPCSTR szDllName, LPCSTR szFuncName, PBYTE pOrgBytes)
{
FARPROC pfnOrg;
DWORD dwOldProtect;
PBYTE pByte;

// API 주소를 구한다.
pfnOrg = GetProcAddress(GetModuleHandleA(szDllName), szFuncName);
pByte = (PBYTE)pfnOrg;

// 만약 이미 언후킹되어 있다면 FALSE
if (pByte[0] != 0xFF && pByte[1] != 0x25)
return FALSE;

// 기존 코드 14바이트를 덮어쓰기 위헤 메모리에 WRITE 속성 추가
VirtualProtect((LPVOID)pfnOrg, 14, PAGE_EXECUTE_READWRITE, &dwOldProtect);

// unHook: 14바이트 패치
memcpy(pfnOrg, pOrgBytes, 14);

// 메모리 속성 복원
VirtualProtect((LPVOID)pfnOrg, 14, dwOldProtect, &dwOldProtect);

return TRUE;
}


NTSTATUS WINAPI NewZwQuerySystemInformation(
SYSTEM_INFORMATION_CLASS SystemInformationClass,
PVOID SystemInformation,
ULONG SystemInformationLength,
PULONG ReturnLength)
{
NTSTATUS status;
FARPROC pFunc;
PSYSTEM_PROCESS_INFORMATION pCur, pPrev = { 0, };
char szProcName[MAX_PATH] = { 0, };

// 작업 전에 unhook
unhook_by_code(DEF_NTDLL, DEF_ZWQUERYSYSTEMINFORMATION, g_pOrgBytes);

// original API 호출
pFunc = GetProcAddress(GetModuleHandleA(DEF_NTDLL),
DEF_ZWQUERYSYSTEMINFORMATION);
status = ((PFZWQUERYSYSTEMINFORMATION)pFunc)
(SystemInformationClass, SystemInformation,
SystemInformationLength, ReturnLength);

if (status != STATUS_SUCCESS)
goto __NTQUERYSYSTEMINFORMATION_END;

// SystemProcessInformation 인 경우만 작업함
if (SystemInformationClass == SystemProcessInformation)
{
// SYSTEM_PROCESS_INFORMATION 타입 캐스팅
// pCur 는 single linked list 의 head
pCur = (PSYSTEM_PROCESS_INFORMATION)SystemInformation;

while (TRUE)
{
// 프로세스 이름 비교
// g_szProcName = 은폐하려는 프로세스 이름
// (=> SetProcName() 에서 세팅됨)
if (pCur->Reserved2[1] != NULL)
{
if (!_tcsicmp((PWSTR)pCur->Reserved2[1], g_szProcName))
{
// 연결 리스트에서 은폐 프로세스 제거
if (pCur->NextEntryOffset == 0)
pPrev->NextEntryOffset = 0;
else
pPrev->NextEntryOffset += pCur->NextEntryOffset;
}
else
pPrev = pCur;
}

if (pCur->NextEntryOffset == 0)
break;

// 연결 리스트의 다음 항목
pCur = (PSYSTEM_PROCESS_INFORMATION)
((DWORD_PTR)pCur + pCur->NextEntryOffset);
}
}

__NTQUERYSYSTEMINFORMATION_END:

// 함수 종료 전에 다시 API Hooking
hook_by_code(DEF_NTDLL, DEF_ZWQUERYSYSTEMINFORMATION,
(PROC)NewZwQuerySystemInformation, g_pOrgBytes);

return status;
}


BOOL WINAPI DllMain(HINSTANCE hinstDLL, DWORD fdwReason, LPVOID lpvReserved)
{
char szCurProc[MAX_PATH] = { 0, };
char* p = NULL;

// #1. 예외처리
// 현재 프로세스가 HookProc.exe 라면 후킹하지 않고 종료
GetModuleFileNameA(NULL, szCurProc, MAX_PATH);
p = strrchr(szCurProc, '\\');
if ((p != NULL) && !_stricmp(p + 1, "HideProc.exe"))
return TRUE;

switch (fdwReason)
{
// #2. API Hooking
case DLL_PROCESS_ATTACH:
hook_by_code(DEF_NTDLL, DEF_ZWQUERYSYSTEMINFORMATION,
(PROC)NewZwQuerySystemInformation, g_pOrgBytes);
break;

// #3. API Unhooking
case DLL_PROCESS_DETACH:
unhook_by_code(DEF_NTDLL, DEF_ZWQUERYSYSTEMINFORMATION,
g_pOrgBytes);
break;
}

return TRUE;
}


#ifdef __cplusplus
extern "C" {
#endif
__declspec(dllexport) void SetProcName(LPCTSTR szProcName)
{
_tcscpy_s(g_szProcName, szProcName);
}
#ifdef __cplusplus
}
#endif

실행 결과


마지막으로..

여기까지가 코드 패치를 통한 스텔스 프로세스를 구현하는 기법에 대한 내용이었습니다. 다음번 포스팅에서는 글로벙 API 후킹을 통해서 앞서 언급했던 문제점을 해결할 것입니다.


참고자료

CreateToolhelp32Snapshot()

EnumProcesses()

https://docs.microsoft.com/en-us/windows/win32/api/synchapi/nf-synchapi-waitforsingleobject]

extern “C”

Nt와 Zw Native Routine의 차이점

Nt/Zw Native API의 차이점