
DLL PEBモジュール構造の操作を利用したコード実行/インジェクション技術
実行時にロードされたモジュールのEntryPointを変更することによるステルスなコード実行。
Windowsプロセスには、実行時にさまざまなモジュールがロードされています。これらの各モジュールにはDllMain()関数が定義されており、プロセスやスレッドの生成/破棄時に呼び出されます(4つのシナリオがあります)。
プロセスの存続期間中にこれらの関数を適切に呼び出すために、Windowsローダー関数(ntdll!Ldrp*)は、各モジュールの主要パラメータ(EntryPointフィールドを含む)を含むエントリのリストを参照します。
このDLLのEntryPointを上書きすることで、コード実行を任意の場所にリダイレクトできます。
これは、コード実行プリミティブとしても、APIプロキシとしても使用できます。つまり、正当なWindows関数によって呼び出されるため、不審に見えないコールスタックで特定のAPIを実行するために使用できます。
また、攻撃者が対象プロセスのメモリを読み書きできる場合、リモートプロセスでの実行をトリガーするためにも使用できます。Threadless Injectionと同様に、実行に関連する古典的なAPI(CreateRemoteThread、QueueUserAPC)を呼び出さずにプロセス内でコードを実行する能力を提供します。
Windowsプロセス内でのモジュールのロード/アンロードは複雑なテーマであり、多くの課題、不安定性の可能性、競合状態、クラッシュを引き起こす可能性があります。例えば、DllMain()関数の一部としてコードを実行することに関連するよく知られたハードルは、ローダーロックが働いていること、また、完全にセットアップされていない、または終了処理中のスレッドで実行しているという事実にあります。
そのため、何が可能で何が不可能かを適切に文書化するよう努めました。例えば、通常のAPI呼び出しのほとんどは実行できますが、本格的なビーコンを実行するには、wininet.dllやwinhttp.dllで使用される関数によって引き起こされるデッドロックを避けるために、別のプロセスで実行するという特定の要件があります。
各プロセスは、実行時に_LDR_DATA_TABLE_ENTRY構造体のリストを保持しています。これらの構造体には、DLLに関連する多くの詳細(上書きするEntryPoint、名前、特定のハッシュ、タイムスタンプ、各種フラグなど)が含まれます。これらの構造体の一部はドキュメント化されており、一部はされていません。
これらは、次のWinDbgコマンドで視覚化できます。
dt nt!_LDR_DATA_TABLE_ENTRY 0xdeadbeef
0:006> dt nt!_LDR_DATA_TABLE_ENTRY 0x18d1c4838c0
ntdll!_LDR_DATA_TABLE_ENTRY
+0x000 InLoadOrderLinks : _LIST_ENTRY [ 0x0000018d`1c485de0 - 0x0000018d`1c4832b0 ]
+0x010 InMemoryOrderLinks : _LIST_ENTRY [ 0x0000018d`1c485df0 - 0x0000018d`1c4832c0 ]
+0x020 InInitializationOrderLinks : _LIST_ENTRY [ 0x0000018d`1c4832d0 - 0x0000018d`1c482c80 ]
+0x030 DllBase : 0x00007ffe`e87e0000 Void
+0x038 EntryPoint : 0x00007ffe`e8838d00 Void
+0x040 SizeOfImage : 0x2fe000
+0x048 FullDllName : _UNICODE_STRING "C:\WINDOWS\System32\KERNELBASE.dll"
+0x058 BaseDllName : _UNICODE_STRING "KERNELBASE.dll"
+0x068 FlagGroup : [4] "???"
+0x068 Flags : 0x8a2cc
+0x068 PackagedBinary : 0y0
+0x068 MarkedForRemoval : 0y0
+0x068 ImageDll : 0y1
(...)
+0x0e0 MappingInfoIndexNode : _RTL_BALANCED_NODE
+0x0f8 OriginalBase : 0x00007ffe`e87e0000
+0x100 LoadTime : _LARGE_INTEGER 0x01db5f86`2fa735fc
+0x108 BaseNameHashValue : 0x235bec4
+0x10c LoadReason : 0 ( LoadReasonStaticDependency )
+0x110 ImplicitPathOptions : 0x4000
+0x114 ReferenceCount : 1
+0x118 DependentLoadFlags : 0x800
+0x11c SigningLevel : 0 ''
構造体のアドレスは、プロセスPEB内のPEB_LDR_DATA構造体で参照される双方向リンクリストをたどることで取得できます。
dt nt!_PEB_LDR_DATA 0xb4b4c3c3
DontCallForThreadsフラグに注意してください。名前が示すように、このフラグが設定されている場合、OSはスレッドイベント(つまりDLL_THREAD_ATTACHまたはDLL_THREAD_DETACH)に対してそのモジュールのDllMain()を呼び出しません。
DLLを作成する際、OSローダー関数と連携して動作するためには、次のテンプレートに従う必要があります。
BOOL WINAPI DllMain(
HINSTANCE hinstDLL, // handle to DLL module
DWORD fdwReason, // reason for calling function
LPVOID lpvReserved ) // reserved
{
// Perform actions based on the reason for calling.
switch( fdwReason )
{
case DLL_PROCESS_ATTACH:
// Initialize once for each new process.
// Return FALSE to fail DLL load.
break;
case DLL_THREAD_ATTACH:
// Do thread-specific initialization.
break;
case DLL_THREAD_DETACH:
// Do thread-specific cleanup.
break;
case DLL_PROCESS_DETACH:
if (lpvReserved != nullptr)
{
break; // do not do cleanup if process termination scenario
}
// Perform any necessary cleanup.
break;
}
return TRUE; // Successful DLL_PROCESS_ATTACH.
}
上記の概要の通り、この手法は実行をリダイレクトするためにDLLのEntryPointを一時的に上書きします。実行のリダイレクト以外は制御できないため、何を、どのような引数で実行し、戻り値をどう取得するかを処理するために、こちら側でいくつかの準備を行う必要があります。
これは、ヒープ上にDATA_T構造体を定義し、さまざまなステップを通じてアクセス可能な状態を保つことで実現されます。
その構造体は次のように定義されています。
typedef struct _DATA_T {
// LDR structures manipulation
ULONG_PTR runner; // malicious entry point to execute
ULONG_PTR bakOriginalBase; // backup of overwritten OriginalBase
ULONG_PTR bakEntryPoint; // backup of overwritten EntryPoint
HANDLE event; // event signalling that the Runner has executed
// function call
ULONG_PTR ret; // return value
DWORD createThread; // run this API call in a new thread (required for wininet/winhttp)
ULONG_PTR function; // Windows API to call
DWORD dwArgs; // number of args
ULONG_PTR args[MAX_ARGS]; // array of args
} DATA_T, * PDATA_T;
API実行をセットアップするには、これらのフィールドを準備する必要があります。ret値は実行後の戻り値を収集するものです。eventは同期に使用され、実行が完了したことを通知します。他のすべてのフィールドは入力であり、呼び出すAPI(function)、その引数(dwArgsとargs[])、実行がリダイレクトされるRunner()関数のアドレス、および上書きされた元のDLLエントリのバックアップ(bakOriginalBaseとbakEntryPoint)を定義します。
createThreadフィールドは、DllMain()セットアップではうまく動作しない複雑なAPI関数のために1に設定する必要があります(これには多くのwininetおよびwinhttpライブラリが含まれます)。
これは、PoCで見られるMessageBoxA()への呼び出しをセットアップする例です。
pDataT->dwArgs = 4;
pDataT->runner = (ULONG_PTR)Runner;
pDataT->function = (ULONG_PTR)MessageBoxA;
pDataT->args[0] = (ULONG_PTR)0;
pDataT->args[1] = (ULONG_PTR)"Hello";
pDataT->args[2] = (ULONG_PTR)"LDRSHUFFLE";
pDataT->args[3] = (ULONG_PTR)MB_OKCANCEL;
pDataT->event = CreateEventA(NULL, FALSE, FALSE, "ExecEvt");
UpdateLdr()関数は、対象モジュールの_LDR_DATA_TABLE_ENTRYに正しい変更を加える役割を担います。
RestoreLdr()は、後の段階でこれらの変更を復元します(Runner()によって呼び出されます)。
これらの関数は基本的にPEBを特定し、モジュール構造体を走査して正しいDLLとそのフィールドを特定します。ヘッダーファイルでは、Batsecが彼のDarkLoadLibraryで使用している定義を再利用しており、読者にはこのプロジェクトと関連するMDSecのブログ記事を確認することをお勧めします。彼がWindowsでのモジュールロードの内部について行ってきた素晴らしい仕事から利益を得られるでしょう。
注:このPoCは犠牲DLL(SACRIFICIAL_DLL_NAME)をロードし、このDLLに対してそれらの変更を行います。ただし、既にロードされているDLLを修正することも十分に可能です。これは実際、プロセス間インジェクションで取られるアプローチです。安定性の観点から、ntdllやkernel32のような重要なDLLには触れないことをお勧めします。これらのDLLはセキュリティソリューションによってより厳しく監視される傾向もあります。
スレッドの作成または破棄時に、実行はRunner()にリダイレクトされます。Runner()はモジュールの偽のDllMain()として機能します。この関数は次のことを行います:
PDATA_T構造体)がヒープ上のどこにあるかを特定しますRestoreLdr())DllMain()呼び出しを実行します(実質的に通常のDLL呼び出しをプロキシします)。この時点で、「通常の」OS実行が実行されました。その後、私たちのペイロードを続行します:
DATA_T構造体に保存された値と引数に従って、悪意のあるAPI呼び出しを実行します。このAPIが新しいスレッドで実行されるようにマークされている場合(createThread = 1)、この呼び出しは新しいスレッドで実行されます。pDataT->event)をシグナルして、メインコードが呼び出しが実行されたことを認識できるようにします。Windowsが最終的に偽のEntryPoint(Runner()関数のアドレス)を呼び出すと、コールスタックは次のようになります:

提供されたPoCには、MessageBoxA()を呼び出す例が含まれています。
また、wininetを使用したHTTPダウンロードのデモも含まれています。そのコードを有効にするには、HTTP変数を定義してください。

上記で概説した原則は、プロセスのメモリ空間での読み書きに集約され、将来の任意の時点でコード実行を引き起こすためのものです。
いくつかの調整を行うことで、これらの読み書き操作をリモートプロセスに適用し、そのDLLのEntryPointを上書きできます。
前提条件として、プロセスのメモリ空間を読み書きできることが必要です。つまり:
OpenProcess(PROCESS_VM_READ, FALSE, dwPid) および OpenProcess(PROCESS_VM_WRITE | PROCESS_VM_OPERATION, FALSE, PID)
PoCには、これらの手順を実行する方法を示すLdrInjectという追加のプロジェクトがあります。簡単に言うと、次のことを行います:
ReadPEB()内で、対象プロセスの_LDR_DATA_TABLE_ENTRYリストを走査して、上書きする適切なDLLを特定します。このDLLはDontCallForThreads == 0である必要があることに注意してください。なぜなら、スレッド作成時にそのDLLのEntryPointをWindowsに呼び出させる必要があるからです。また、リストの最初のDLL(ntdll.dll、kernel32.dllなど)はセキュリティ製品によってより厳しく監視される傾向があるため、選択していません。
そのDLLの詳細はPEBINJ_DATAデータ構造体に格納されます。
シェルコード(この場合はビーコン)は、InjectShellcodeToRemoteProcess()を使用してリモートプロセス空間に書き込まれます。
2つのWriteProcessMemory()呼び出しでDLLのEntryPointを上書きし、後で復元できるようにOriginalBaseにバックアップします。
その時点で、次のDLL_THREAD_ATTACHまたはDLL_THREAD_DETACHイベントが発生すると、シェルコードが呼び出されます。これには、ビーコン実行の文脈における特定の制限と注意点が伴います。これらは次のセクションで詳しく説明します。
この手法は、非常に特殊な状況でのシェルコードの実行をもたらします。ローダーロックがアクティブであり(OSがDLLのロード/アンロード処理中であると認識しているため)、スレッドが作成または破棄されている最中です。一般的に、スレッド同期の問題、デッドロックなどが発生する可能性があります。
テスト中に、2つの課題が観察されました:
典型的なCobalt Strikeビーコンを実行すると、wininet.dllまたはwinhttp.dllのAPIを使用する際にデッドロックが発生します。
スレッド破棄時に実行すると、破棄処理中のスレッドで実行しているため、安定性の問題が発生します。
安定性を高めるには、次のことを行う必要があります:
ビーコンが新しいスレッドで実行されるようにします。そのため、UDRLは通常のCobalt StrikeリフレクティブDLLエントリポイントを呼び出す前にCreateThreadを実行します。
破棄されつつあるスレッドではなく、作成中のスレッドでのみ実行します。これを行うには、OSによってEntryPointが呼び出されたときに、呼び出される理由がfdwReason == DLL_THREAD_ATTACHであることを確認します:
winApi.CreateThread(NULL, 4096, (LPTHREAD_START_ROUTINE)&runner, (LPVOID)&ct_data, 0, &dwThId);
の代わりに、通常の
((DLLMAIN)entryPoint)((HINSTANCE)loaderStart, 4, NULL);
ULONG_PTR __cdecl ReflectiveLoader(HINSTANCE hinstDLL, DWORD fdwReason, LPVOID lpvReserved) {
// only run for a Thread creation event
if (fdwReason != DLL_THREAD_ATTACH) {
return TRUE;
}
...
}
これらの2つの追加ステップは、UDRLのデモに組み込まれています。
VirtualAlloc
VirtualProtect
CreateThread
Sleep
MessageBoxA
InternetOpenW(createThread = 1で実行する必要があります)
InternetOpenUrlA(createThread = 1で実行する必要があります)