SIN@SAPPOROWORKSの覚書

C#を中心に、夜な夜な試行錯誤したコードの記録です。

64ビット対応のDLLインジェクション

CreateRemoteThread+LoadLibrary編

久々にDLLインジェクションを書いていたのですが、64ビットに関する情報が意外に少なく少し苦労しました。せっかくですので、今回把握できた事項を可能な限りまとめたいと思います。突っ込み所満載だと思います、ぜひ、色々教えてください。

【サンプルプログラム】
ソース DllInjection-src.zip
バイナリ DllInjection-bin.zip

Windowsで、他のプロセス上でコードを実行する方法の1つにDLLインジェクションという手法があります。これを利用すると、通常のプログラムの枠を超えた機能を実現することが可能になります。
DLLインジェクションは、クラッキング等にも悪用され「危険なもの」というイメージもあるようですが、上手く使用すれば、非常に有用なプログラムを作成することができると思います。

[使用例]
・修正ができないプログラムを実行時に修正(メッセージ・タイトル・挙動など)
・他のプロセスの動作(メッセージ監視、トラップなど)
・他のプロセスの権限で動作(SYSTEM権限での動作など)

1 DLLインジェクションの動作イメージ

Winddows上で動作する各プログラムは、それぞれ別の(仮想)メモリ空間の中で動作し、通常、相互に干渉することはありません。
各プロセスのメモリ空間内には、当該プロセスの独自コードのほか、カーネルコードや必要なDLLがロードされています。そして、このDLLのロードの方法には、次の2種類があります。
(1) プログラム作成時にスタティックにリンク
(2) プログラム実行時にダイナミックにロード

DLLを「プログラム実行時にダイナミックにロード」する場合、Win32APIのLoadLibrary()及びFreeLibrary()を使用して行いますが、この動作を他のプロセスから行うのが、CreateRemoteThread+LoadLibraryによるDLLインジェクションです。図で表現すれば、プロセスAがプロセスBのメモリ空間にtest.dllを挿入するイメージです。
いったん挿入されたtest.dllのコードは、プロセスBのメモリ空間で、プロセスBの権限で動作します。
挿入しただけでは、何も起こりませんので、通常は、プロセスA(挿入元)から意図した動作ができるように、何らかの仕組み(トリガ)を組み込む事になります。

2 自プロセス内でのDLLのダイナミックロード

比較のため、最初に、一般的なDLLのダイナミックロードのコードを示します。これは自メモリ空間にDLLを読み込んで使用する普通のコードです。

HMODULE hDll = ::LoadLibrary( "test.dll" ); //test.dllをロードする
if ( hDll != NULL ){
    //DLLを使用するコードをここに記述する
    ::FreeLibrary( hDll );//test.dllをアンロードする
}

LoadLibrary()が成功した時点で、test.dllは、メモリ空間に展開され、DLL内のコードであるDllMain()が呼び出されます。
先の図で言えば、プロセスBが自分のメモリ空間にtest.dllをロードするコードになります。

3 他プロセスでのDLLのダイナミックロード

他のプロセス上でコードを実行するAPIとして、CreateRemoteThread()があります。このAPIは、ターゲットのプロセス空間で指定したスレッドを実行するもです。

HANDLE CreateRemoteThread(
    HANDLE hProcess,        // 新しいスレッドを稼働させるプロセスを識別するハンドル
    LPSECURITY_ATTRIBUTES lpThreadAttributes,// スレッドのセキュリティ属性へのポインタ
    DWORD dwStackSize,     // 初期のスタックサイズ (バイト数)
    LPTHREAD_START_ROUTINE lpStartAddress,// 新しいスレッドに実行させる関数へのポインタ
    LPVOID lpParameter,   // 新しいスレッドの引数へのポインタ
    DWORD dwCreationFlags,  // 作成フラグ
    LPDWORD lpThreadId      // 取得したスレッド識別子へのポインタ
);

このAPIでは、第4パラメータに「新しいスレッドに実行させる関数へのポインタ」を指定しますが、ここには、
LPTHREAD_START_ROUTINE 型の関数ポインタが必要です。

typedef DWORD (__stdcall *LPTHREAD_START_ROUTINE) (
    [in] LPVOID lpThreadParameter
);

しかし、LPTHREAD_START_ROUTINE型は、1つのポインタ型のパラメータを受け取るという意味で、LoadLibrary()やFreeLibrary()と同じですので、代わりにLoadLibrary()等の関数ポインタを指定できるという事になります。

なお、「関数へのポインタ」(メモリ上のアドレス)は、コードを実行するメモリ空間でのアドレス(挿入先のアドレス)である必要がありますが、LoadLibrary()及びFreeLibrary()は、kernel.dllに含まれるAPIであり、Windowsではkernel.dllが、すべてのプロセス空間で同一アドレスにロードされている(※1)ため、「挿入元でアドレスを取得」しても、その値をそのまま「挿入先のプロセス空間でのアドレス」として使用することが可能です。
関数へのポインタの取得は、GetProcAddress()を使用しています。

他プロセスにDLLをロードする一連のコードは、下記のとおりです。

//事前に挿入先のプロセスのハンドル(hProcess)が取得済みとする

//LoadLibrary()のアドレス取得(他プロセス上でも同一のアドレスとなる)
UIntPtr pAddr = GetProcAddress(GetModuleHandle("kernel32.dll"), "LoadLibraryA");
//挿入先でLoadLibraryを呼び出す pMemはパラメータのアドレス(後述)

IntPtr bytesout;
IntPtr hThread = CreateRemoteThread(hProcess, IntPtr.Zero, 0, pAddr, pMem, 0, out bytesout);
if (hThread != IntPtr.Zero) {
    //挿入したスレッドが終了するまで待機(DllMailから返るまで待つ)
    UInt32 INFINITE = 0xFFFFFFFF;//シグナル状態になるまで待機
    WaitForSingleObject(hThread,INFINITE);

    //スレッドの戻り値(LoadLibrary()の戻り値)を取得する
    if (GetExitCodeThread(hThread, out hDll)) {
        result = true;//成功
    }
    //スレッドハンドルのクローズ
    CloseHandle(hThread);
}

CreateRemoteThread()のあと、挿入したDLLのDllMain()が実行され、そこから返るのを、WaitForSingleObject()で待っています。また、GetExitCodeThread()を使用して、LoadLibrary()の戻り値を取得しています。これは、ロードしたDLLのハンドルであり、FreeLibrary()でDLLをアンロードする時のパラメータとして使用します。

4 LoadLibrary()のパラメータ

LoadLibrary()には、ロードするDLLのパス(文字列)のポインタが必要ですが、これも、挿入先のメモリ空間のアドレスである必要があります。自プロセス内の文字列のポインタをそのまま与えても動作できません。
そこで、このパス文字列を挿入先のプロセスのメモリ空間内に展開して、そのアドレスを取得する作業が必要になります。

この方法は、概ね下記の手順になります。
(1)挿入先のプロセス空間にメモリを確保する VirtualAllocEx() 
(2)挿入先のメモリ空間に書き込む WriteProcessMemory() 
(3)書き込んだメモリを使用する (Loadribrary()のパラメータbに使用)
(4)確保したメモリの開放 VirtualFreeEx()

また、コードは下記のようなものになります。

//事前に挿入先のプロセスのハンドル(hProcess)が取得済みとする

//パラメータ用の文字列を置くための領域を挿入先に確保する
UInt32 MEM_COMMIT = 0x1000;
UInt32 PAGE_EXECUTE_READWRITE = 0x40;
pMem = (IntPtr)VirtualAllocEx(hProcess, IntPtr.Zero, (uint)len, MEM_COMMIT, PAGE_EXECUTE_READWRITE);
if (pMem != IntPtr.Zero) {
    IntPtr bytesout;
    //確保した挿入先の領域にDLL名を書き込む
    WriteProcessMemory(hProcess, pMem, dllName, (UIntPtr)len, out bytesout);
    
    //確保したメモリをここで使用する(挿入先でLoadLibraryを呼び出す)

    //確保したメモリの開放
    VirtualFreeEx(hProcess, pMem, 0, 0x8000);
}

5 64ビットと32ビットの混在

64ビットOSでは、従来の32ビットプログラムもWOW64によりそのまま使用することができるため、両者が混在する状況になっています。(※2)しかし、ここで注意が必要なのは、ロードする側のEXEとロードされる側のDLLは、同一である必要があるということです。32ビットのEXEは、32ビットのDLLしか使用できないのです。
32ビットと64ビットのプログラムでLoadLibrary()を使用して、2種類のDLLをロードした試験結果は下記のとおりです。


Dllインジェクションを行う場合も、挿入先のプロセスに合わせたDLLを用意する必要があるという事になります。(※3)

6 CreateRemoteThread()でERROR_ACCESS_DENIEDが発生する

64ビットOS上では、32ビットプログラムも使用できますが、32ビットプログラムからは、CreateRemoteThread()は、ERROR_ACCESS_DENIEDが発生して使用できません
したがって、CreateRemoteThread()を使用したDLLインジェクションを行う場合、64ビットプログラムでしか実装できないことになります。

また、64ビットプログラムと32ビットプログラムでは、kernel32.dllが同一アドレスにロードされていないため(※4)、自プロセス内で取得したアドレスをそのまま使用することは、できないことになります。

各種の組み合わせの成否を表にしてみました。


64ビットプログラムから32ビットプログラムのメモリ空間アドレスを取得する方法が、まったく無いわけではありませんが、少しややこしくなるため、ここでは割愛させて頂きます。

7 .NETによる両対応EXEの作成


.NETプログラムは中間言語であるため、32ビットと64ビットの両方で環境に応じて動作するプログラム(EXE)を作成できます。(プラットフォームターゲットでAnyCPUを選択する)
そして、実行時に64ビットで動作しているか32ビットで動作しているかを確認して、適切なDLLをインジェクションすることで両対応のプログラムとすることができます。
動作状態を確認してDLLを選択するコードは次のようになります。

//64ビットで動作している場合、ポインタは8バイトになる
var dName = (IntPtr.Size == 8)?"64bit.dll":"32bit.dll";
dllInjection.Load(dllName, exeName);//DLLのインジェクション

8 64ビット環境でDLLハンドルが取得できない

LoadLibrary()でロードしたDLLをアンロードするにはFreeLibrary()を使用しますが、この時、DLLハンドルが必要になります。先の例ではGetExitCodeThread()を使用してDLLハンドルを取得しましたが、このコードは64ビットでは問題となります。
64ビット環境では、DLLハンドルが32ビット幅を超えた位置になるのに、GetExitCodeThread()で終了ステータスを受け取るパラメータがDWORD(32ビット)のポインタになっているため、下位32ビットのアドレスしか取得できないためです。
64ビット環境でのDLLハンドルが必要な場合は、アドレスを再計算が必要です。

9 挿入するDLLの作成(動作確認用)

挿入されるコードは、ターゲットからLoadLibraryで読み込むことになりますが、これはネイティブのDLLである必要があります。C#でこれを作る事ができないので、この作業はC++となります。

動作確認用にDllMail()が呼び出された時、そのメッセージの種類をポップアップするだけのものになっています。ポップアップしたウインドウをクローズしないとDllMail()を抜けませんので、ロードする側のコードもブロックします。動作確認の為、あえてそういう仕様にしました。

//【試験用に作成した test.dll DllMailが呼び出されるたびにMessageBoxを表示する】
#include "stdafx.h"
void popup(char *caption){
	char tmp[128];
	wsprintf(tmp,"pid=%d",GetCurrentProcessId());
	MessageBox(NULL,caption,tmp,0);
}
BOOL APIENTRY DllMain( HMODULE hModule,DWORD  ul_reason_for_call,LPVOID lpReserved){
    switch (ul_reason_for_call){
        case DLL_PROCESS_ATTACH:
	   popup("DLL_PROCESS_ATTACH");
	   break;
        case DLL_THREAD_ATTACH:
	   popup("DLL_THREAD_ATTACH");
	   break;
        case DLL_THREAD_DETACH:
	   popup("DLL_THREAD_DETACH");
	   break;
        case DLL_PROCESS_DETACH:
	   popup("DLL_PROCESS_DETACH");
	   break;
    }
    return TRUE;
}

GUIを持たないサービスなどに、このDLLを挿入すると、ポップアップウインドウのOKボタンが押せないためDllMain()から戻ることができません。同プロセスはロックし再起動が必要になりますのでご注意ください。

10 動作確認

サンプルに含まれるDllImjectionTest.exeを起動すると、「Load」と「Unload」のボタンがあるウインドウが表示されます。notepad.exe「メモ帳」を起動した状態で「Load」ボタンを押すと、notepadのプロセスIDと「DLL_PROCESS_ATTACH」が表示されたメッセージボックスがポップアップします。また「Unload」ボタンを押すと、「DLL_PROCESS_DETTACH」が表示されます。
「Load」だけ押して、dllがロードされた状態で「メモ帳」を閉じると、自動的にdllが開放されるので「DLL_PROCESS_DETACH」がポップアップします。



Windows7及びVistaで実行する場合、ユーザーアカウント制御 (UAC; User Account Control)が有効になっているとOpenProcess()でACCESS_DENYEDになります。なお、UACの設定変更を反映するには、Windowsの再起動が必要ですので、動作確認の場合はご注意ください。





(※1)kernal.dllは、すべてのプロセスで同一アドレスにロードされている
下記は、ListDlls.exeを使用して、現在ロードされているDLLを列挙した結果です。
winlogon.exe及びnotepad.exeは、共にkernel.dllを0x00000000771c0000からロードしています。(64bit Vistaで確認)
[text highlight="13"]
C:>ListDlls winlogon
ListDLLs v3.1 - List loaded DLLs
Copyright (C) 1997-2011 Mark Russinovich
Sysinternals - www.sysinternals.com