はじめに
Windowsのメモリ管理は、APIレベルの抽象化(VirtualAlloc等)の裏側で、PTE(Page Table Entry)やPFN(Page Frame Number)といったハードウェアに近いレイヤーが複雑に連動しています。
本記事では、メモリの確保、属性変更、プロセッサ間共有、そしてOSのコミット限界における挙動を、WinDbgを用いたカーネルデバッグを通じて検証した5つのステップをまとめます。
この記事は後半3つのステップとなります。
3. VirtualAllocEx:他プロセスへのメモリ注入
共有メモリに行く前に似たような技術であるVirtualAllocEx を用いて、Process BからProcess Aの空間にメモリを確保・書き込みした際のコストを計測しました。
それぞれのコードは以下の通りです
プロセスAコード(抜粋)
int main() {
const size_t allocationSize = 4096 * 100;
LPVOID pLocalMem = VirtualAlloc(NULL, allocationSize, MEM_COMMIT | MEM_RESERVE, PAGE_READWRITE);
if (!pLocalMem) return 1;
// 2. プロセスBの起動コマンドラインを構築
// フォーマット: "ProcessB.exe <PID> <Address>"
std::wstring cmdLine = L"virtualAllocEx_memory_check_B.exe "
+ std::to_wstring(GetCurrentProcessId()) + L" "
+ std::to_wstring(reinterpret_cast<ULONG_PTR>(pLocalMem));
STARTUPINFOW si = { sizeof(si) };
PROCESS_INFORMATION pi = { 0 };
// 3. プロセスBを子プロセスとしてキック
BOOL success = CreateProcessW(
L"virtualAllocEx_memory_check_B.exe",
&cmdLine[0], // コマンドライン引数
NULL, NULL, FALSE, 0, NULL, NULL, &si, &pi
);
if (!success) {
DbgLog("[Process A]CreateProcess failed. Error: %d\n", GetLastError());
VirtualFree(pLocalMem, 0, MEM_RELEASE);
return 1;
}
// 子プロセスが終了するまで待機
WaitForSingleObject(pi.hProcess, INFINITE);
}
プロセスBコード(抜粋)
int main(int argc, char* argv[]) {
DWORD pid = std::stoul(argv[1]);
// A側が std::to_wstring で出力しているため、10進数としてパース
ULONG_PTR addrValue = std::stoull(argv[2], nullptr, 10);
LPVOID pRemoteMem = reinterpret_cast<LPVOID>(addrValue);
// プロセスAのハンドルを取得(メモリ情報取得のために PROCESS_QUERY_INFORMATION が必須)
HANDLE hProcess = OpenProcess(
PROCESS_VM_OPERATION | PROCESS_VM_WRITE | PROCESS_QUERY_INFORMATION,
FALSE,
pid
);
// --- 検証フェーズ 1: 初期状態 ---
PrintBothMemoryUsage(hProcess, "1. Initial State");
// --- 検証フェーズ 2: VirtualAllocEx の実行 ---
const size_t allocSize = 4096 * 100; // 400KB
LPVOID pAllocatedMem = VirtualAllocEx(hProcess, NULL, allocSize, MEM_COMMIT | MEM_RESERVE, PAGE_READWRITE);
// ここで A の Private だけが増え、B は変わらないことを確認
PrintBothMemoryUsage(hProcess, "2. After VirtualAllocEx");
// --- 検証フェーズ 3: WriteProcessMemory の実行 ---
char* data = new char[allocSize];
memset(data, 0x41, allocSize); // ダミーデータ
SIZE_T bytesWritten = 0;
BOOL result = WriteProcessMemory(hProcess, pAllocatedMem, data, allocSize, &bytesWritten);
delete[] data;
// ここで A の Working Set が増え、B は変わらないことを確認
PrintBothMemoryUsage(hProcess, "3. After WriteProcessMemory");
}
注意点としてプロセスAが最初に用意したアドレスに対してプロセスBが書き込むわけではなく、プロセスBが確保したアドレスのみ書き込まれる。
そのため、プロセスAからはプロセスBが確保したアドレスを知る方法はない。
実際のプロセスA側から見たログは以下の通り
0: kd> !pte 00000276DCEC0000
VA 00000276dcec0000
PXE at FFFFEA753A9D4020 PPE at FFFFEA753A804ED8 PDE at FFFFEA75009DB738 PTE at FFFFEA013B6E7600
contains 0A000000B7733867 contains 0A000000B6634867 contains 0A000000C56A5867 contains 0000000000000000
pfn b7733 ---DA--UWEV pfn b6634 ---DA--UWEV pfn c56a5 ---DA--UWEV not valid
そしてプロセスB側からはこう見える
0: kd> !pte 00000276DCF30000
VA 00000276dcf30000
PXE at FFFFEA753A9D4020 PPE at FFFFEA753A804ED8 PDE at FFFFEA75009DB738 PTE at FFFFEA013B6E7980
contains 0A000000B7733867 contains 0A000000B6634867 contains 0A000000C56A5867 contains 80000000A04FE867
pfn b7733 ---DA--UWEV pfn b6634 ---DA--UWEV pfn c56a5 ---DA--UWEV pfn a04fe ---DA--UW-V
そしてメモリ使用量の変化は以下の通り
===== [ Memory Comparison: 1. Initial State ] =====
[Process A (Target)] Private: 992 KB | WorkingSet: 4148 KB
[Process B (Self) ] Private: 504 KB | WorkingSet: 3780 KB
==============================================
===== [ Memory Comparison: 2. After VirtualAllocEx ] =====
[Process A (Target)] Private: 1392 KB | WorkingSet: 4148 KB
[Process B (Self) ] Private: 504 KB | WorkingSet: 3804 KB
==============================================
===== [ Memory Comparison: 3. After WriteProcessMemory ] =====
[Process A (Target)] Private: 1392 KB | WorkingSet: 4548 KB
[Process B (Self) ] Private: 908 KB | WorkingSet: 4212 KB
==============================================
プロセスB側がプロセスA側のメモリを確保することになるのでプロセスA側のPrivateがまず増える。
そのあと書き込み処理を行うとプロセスAのWorkingSetは当然増える。
ここで注意点、メモリ使用量はプロセスB側も増えている。
これはプロセスAの領域に書き込む事前処理として自分のメモリ領域に書き込むデータを展開する必要があるため、結果的に2倍メモリが必要になる。
大容量のメモリ注入を行う設計では、この「送信側のスパイク」が原因で注入側が落ちる可能性もある。
セキュリティ(ASLR等)やプロセス間のアイソレーションを維持するためのトレードオフだが、バルクデータ転送においてはこのオーバーヘッドが致命傷になり得る。これを回避する手段こそが、次章の共有メモリである
4. 共有メモリ(File Mapping)の正体:Prototype PTE
では本題の共有メモリで、複数のプロセスで同じメモリを共有する際、物理ページ(PFN)がどのように管理されているかを深掘りしました。
それぞれのコードは以下の通りです
プロセスAコード(抜粋)
int main() {
// 1. ページファイルをバックにする名前付きファイルマッピングオブジェクトの作成
HANDLE hMapFile = CreateFileMappingW(
INVALID_HANDLE_VALUE, // ページファイルを使用
NULL,
PAGE_READWRITE,
0,
static_cast<DWORD>(allocationSize),
szMappingName
);
// 2. 自プロセスの仮想アドレス空間にマッピング
LPVOID pLocalMem = MapViewOfFile(hMapFile, FILE_MAP_ALL_ACCESS, 0, 0, allocationSize);
if (!pLocalMem) {
CloseHandle(hMapFile);
return 1;
}
const size_t allocSize = 4096 * 100;
memset(pLocalMem, 0x41, allocSize);
// 3. プロセスBの起動(引数にはPIDのみ渡す。アドレスは各自マッピングするため不要だが、同期用にPIDを渡す)
std::wstring cmdLine = L"shared_memory_B.exe " + std::to_wstring(GetCurrentProcessId());
STARTUPINFOW si = { sizeof(si) };
PROCESS_INFORMATION pi = { 0 };
BOOL success = CreateProcessW(
L"shared_memory_B.exe", &cmdLine[0],
NULL, NULL, FALSE, 0, NULL, NULL, &si, &pi
);
// 子プロセスの終了を待機
WaitForSingleObject(pi.hProcess, INFINITE);
// クリーンアップ
CloseHandle(pi.hThread);
CloseHandle(pi.hProcess);
UnmapViewOfFile(pLocalMem);
CloseHandle(hMapFile);
return 0;
}
プロセスBコード(抜粋)
int main(int argc, char* argv[]) {
DWORD pid = std::stoul(argv[1]);
const size_t allocSize = 4096 * 100;
const wchar_t* szMappingName = L"Local\\MySharedMemoryVerify";
HANDLE hProcessA = OpenProcess(PROCESS_QUERY_INFORMATION, FALSE, pid);
// --- 検証フェーズ 1: マッピング前 ---
PrintBothMemoryUsage(hProcessA, "1. Before Open/Map");
// 1. プロセスAが作ったオブジェクトを名前でオープン
HANDLE hMapFile = OpenFileMappingW(FILE_MAP_ALL_ACCESS, FALSE, szMappingName);
// 2. 自空間にマッピング(Aとは異なる仮想アドレスになる可能性がある)
LPVOID pRemoteMem = MapViewOfFile(hMapFile, FILE_MAP_ALL_ACCESS, 0, 0, allocSize);
// --- 検証フェーズ 2: マッピング直後(まだ誰も書き込んでいない) ---
PrintBothMemoryUsage(hProcessA, "2. After MapViewOfFile");
// --- 検証フェーズ 3: プロセスBによる物理メモリへのコミット(書き込み) ---
// ここでデータを書き込むと、物理ページが割り当てられる
if (pRemoteMem) {
memset(pRemoteMem, 0x41, allocSize);
}
PrintBothMemoryUsage(hProcessA, "3. After Process B Writes");
UnmapViewOfFile(pRemoteMem);
CloseHandle(hMapFile);
CloseHandle(hProcessA);
}
まずプロセスAで共有メモリを作成する。そうするとVADとPTEは以下のようになる。書き込んではいないのでPTEはnot validのまま。
1: kd> !vad 000002E754860000
VAD Level Start End Commit
ffffe60b74795e00 5 2e754860 2e7548c3 0 Mapped READWRITE Pagefile section, shared commit 0x64
1: kd> !pte 000002E754860000
VA 000002e754860000
PXE at FFFFC2E170B85028 PPE at FFFFC2E170A05CE8 PDE at FFFFC2E140B9D520 PTE at FFFFC28173AA4300
contains 0A00000039CC9867 contains 0A000000870CA867 contains 0A0000004470A867 contains 0000000000000000
pfn 39cc9 ---DA--UWEV pfn 870ca ---DA--UWEV pfn 4470a ---DA--UWEV not valid
書き込むとPFNも割り当てられる
0: kd> !pte 000002E754860000
VA 000002e754860000
PXE at FFFFC2E170B85028 PPE at FFFFC2E170A05CE8 PDE at FFFFC2E140B9D520 PTE at FFFFC28173AA4300
contains 0A00000039CC9867 contains 0A000000870CA867 contains 0A0000004470A867 contains C00000003331A867
pfn 39cc9 ---DA--UWEV pfn 870ca ---DA--UWEV pfn 4470a ---DA--UWEV pfn 3331a ---DA--UW-V
0: kd> !pfn 3331a
PFN 0003331A at address FFFF8E00009994E0
flink 00000001 blink / share count 00000001 pteaddress FFFF9B018B3ECAA0
reference count 0001 used entry count 0000 Cached color 0 Priority 5
restore pte 00000080 containing page 0925E9 Active MP
Modified Shared
ただこれはあくまでPTEに割り当てられたものであり、ProtoPteではない。なのでVADから取得したFirstProtoPteで確認。
0: kd> dp ffff9b018b3ecaa0 L4
ffff9b01`8b3ecaa0 8a000000`3331a921 8a000000`2431b921
ffff9b01`8b3ecab0 8a000000`06e1c921 8a000000`8941d921
これが大きな特徴。物理的には離れたPFNが割り当てられているが、共有メモリは仮想メモリなので横並びにすることが可能という証拠。
ではそれぞれのPFNがどうなっているかを確認。
0: kd> !pfn 3331a
PFN 0003331A at address FFFF8E00009994E0
flink 00000001 blink / share count 00000001 pteaddress FFFF9B018B3ECAA0
reference count 0001 used entry count 0000 Cached color 0 Priority 5
restore pte 00000080 containing page 0925E9 Active MP
Modified Shared
これはコピペミスではなくハードウェアPTE側に割り当てられたPFNとFirstProtoPteから読み解けるPFNは同じ値が割り当てられる。
理由はCPUがProtoPteなんてものを知らないために発生する。ちなみに他もすべて「containing page 0925E9」となっている。
かっこよく表現するならハードウェアPTEは「今、この瞬間の物理アドレス」を指し、ProtoPteは「このセクション全体の管理状態」を保持する。この二段構えこそが、ページアウト中(Transition状態)でも共有関係を維持できる。
そしてプロセスB側でも共有メモリにアクセスする。
1: kd> !vad 0000018C316C0000
VAD Level Start End Commit
ffffe60b7479d600 4 18c316c0 18c31723 0 Mapped READWRITE Pagefile section, shared commit 0x64
1: kd> !pte 0000018C316C0000
VA 0000018c316c0000
PXE at FFFFC2E170B85018 PPE at FFFFC2E170A03180 PDE at FFFFC2E140630C58 PTE at FFFFC280C618B600
contains 0A000000D3A0D867 contains 0A000000C980F867 contains 0A00000018C4D867 contains 0000000000000000
pfn d3a0d ---DA--UWEV pfn c980f ---DA--UWEV pfn 18c4d ---DA--UWEV not valid
プロセスAとは違うアドレスになる。理由はプロセスごとに仮想アドレスは割り当てられるため。ただ共有メモリは同じところを見る必要がある。そのために仲介するのがProtoPteとなる。
そして何故既に書き込まれているにもかかわらず、「not valid」なのか。これはOS側の徹底した遅延であり、書き込まれるまではたとえ共有メモリでもnot validというルールを守っているから。
なので書き込む。そうすると
1: kd> !pte 0000018C316C0000
VA 0000018c316c0000
PXE at FFFFC2E170B85018 PPE at FFFFC2E170A03180 PDE at FFFFC2E140630C58 PTE at FFFFC280C618B600
contains 0A000000D3A0D867 contains 0A000000C980F867 contains 0A00000018C4D867 contains C00000003331A867
pfn d3a0d ---DA--UWEV pfn c980f ---DA--UWEV pfn 18c4d ---DA--UWEV pfn 3331a ---DA--UW-V
このようになる。注意点はpfn 3331a。これはプロセスAが割り当てられたPFNと同じ。なので同じ場所を見ているということになる。
そして対象のPFNを見ると「share count 00000002」となっているためプロセスAとプロセスBが見ているという証拠となる。
1: kd> !pfn 3331a
PFN 0003331A at address FFFF8E00009994E0
flink 00000001 blink / share count 00000002 pteaddress FFFF9B018B3ECAA0
reference count 0001 used entry count 0000 Cached color 0 Priority 5
restore pte 00000080 containing page 0925E9 Active MP
Modified Shared
続いてメモリ使用量は以下の通り
===== [ Memory Comparison: 1. Before Open/Map ] =====
[Process A] Private: 544 KB | WorkingSet: 4168 KB
[Process B] Private: 460 KB | WorkingSet: 3336 KB
==============================================
===== [ Memory Comparison: 2. After MapViewOfFile ] =====
[Process A] Private: 544 KB | WorkingSet: 4168 KB
[Process B] Private: 460 KB | WorkingSet: 3456 KB
==============================================
===== [ Memory Comparison: 3. After Process B Writes ] =====
[Process A] Private: 544 KB | WorkingSet: 4168 KB
[Process B] Private: 460 KB | WorkingSet: 3856 KB
==============================================
メモリ使用量の変化としてもプロセスBが書き込んだタイミングでもプロセスAのWorkingSetは増えない。増加するのはプロセスBのWorkingSetのみ。これは書き込むときのPFNが同じなのでVirtualAllocと違い別の領域を確保する必要がなく純粋にWorkingSetのみが増えるため。
なのでメモリ共有の方がリソース面においても利用すべきものとなる。
5. 限界実験:共有メモリのハンドルリレーとOSの自浄作用
メモリ共有は完璧なのか。
共有メモリの問題点としてWindowsは参照カウント(Section Ref や User Ref)が残っている限り、物理メモリやページファイルのコミット枠は解放しないはず。
フェーズ1:親子1対1リレー(OSの自浄作用との遭遇)
親が500MBのセクションを作成後、子を起動して即終了するようにプログラムを作成し観測。(コードは省略)
以下のログが観測された。
hParentMap: 188 pParentMem:1221591040,
[Gen 15] Created Local\MySharedMemory_Gen15 and allocated. Triggering Break...
世代が15世代まで行くのはなぜなのか。VMのメモリは4GBのはず。
そのため、ログを確認してみるとhParentMap: 0となっているパターンがいくつかあり、親プロセスが取れていないことが分かった。
エラーを確認してみると「ERROR_INVALID_HANDLE」が発生。
!!! Critical Failure: Could not catch the legacy of Gen 14. Error: 6
恐らくリソース不足か何かでOpenFileMappingをミスり、セクションとコミットを綺麗に掃除してしまった。その掃除したハンドルを操作しようとして「ERROR_INVALID_HANDLE」が発生していると思われる。
フェーズ2:同期型リレー(参照カウントの維持)
CreateProcess してすぐに親が終了するようになっていたので子が「準備完了(セクションを掴んだ)」というシグナルを送るまで、親を WaitForSingleObject 等で少し待機させるようにした。
結果、コミットチャージがほぼ横ばいをキープする結果になる。
Committed pages: 628232 ( 2512928 Kb)
Commit limit: 1043755 ( 4175020 Kb)
恐らく「直近の親子」しか持っていなさそう。この場合、2世代しか持たないので結果1GBしか持っていない。
フェーズ3:負債全部抱え込みモデル
ならば「過去の全コミットを、最新の1プロセスがすべて背負う」という状態に変更してすべての世代を持つようにして見た。
5世代目で完全にWinDbgが停止。5×500MBなのでほぼ限界のはず。
[react-native-xaml] Unhandled call to RemoveAllChildren with parent: Windows.UI.Xaml.Controls.TextBlock
Populating UpdatePolicy AllowListSKU MDM licensing allow list string from SLAPI:
AboveLock|Accounts|ActiveXControls|ADMXIngest|AllowMessageSync|AppDeviceInventory|AppHVSI|ApplicationDefaults|ApplicationManagement|AppRuntime|AppVirtualization|Audit|Authentication|BitLocker|BITS|Bluetooth|Browser|Camera|Connectivity|ControlPolicyConflict|CredentialsDelegation|Cryptography|DataProtection|Defender|DeliveryOptimization|DeviceGuard|DeviceLock|Display|DmaGuard|Eap|Education|EnterpriseCloudPrint|Experience|ExploitGuard|FederatedAuthentication|Feeds|FileExplorer|FileSystem|Games|Handwriting|HumanPresence|KioskBrowser|Knobs|LanmanServer|LanmanWorkstation|Licensing|LocalPoliciesSecurityOptions|LocalSecurityAuthority|LocalUsersAndGroups|Lockdown|Maps|MemoryDump|MSSecurityGuide|MSSLegacy|Multitasking|NetworkIsolation|NetworkListManager|Notifications|OneDrive|OOBE|Privacy|Search|Security|ServiceControlManager|Settings|SettingsSync|SmartScreen|SpeakForMe|Speech|Start|Sudo|System|SystemServices|TaskManager|TaskScheduler|TextInput|TimeLanguageSettings|Update|WebThreatDefense|WiFi|WirelessDisplay|AttachmentManager|Autoplay|Cellular|CredentialProviders|CredentialsUI|DataUsage|Desktop|DesktopAppInstaller|DeviceHealthMonitoring|DeviceInstallation|ErrorReporting|EventLogService|InternetExplorer|Kerberos|Location|NewsAndInterests|Power|Printers|RemoteAssistance|RemoteDesktop|RemoteDesktopServices|RemoteManagement|RemoteProcedureCall|RemoteShell|RestrictedGroups|Storage|TenantRestrictions|Troubleshooting|UserRights|VirtualizationBasedTechnology|WindowsAI|WindowsAutopilot|WindowsConnectionManager|WindowsDefenderSecurityCenter|WindowsInkWorkspace|WindowsLogon|WindowsPowerShell|WiredNetwork|WindowsSandbox|ADMX_
All policies are allowed
Populating UpdatePolicy AllowListSKU MDM licensing allow list string from SLAPI:
AboveLock|Accounts|ActiveXControls|ADMXIngest|AllowMessageSync|AppDeviceInventory|AppHVSI|ApplicationDefaults|ApplicationManagement|AppRuntime|AppVirtualization|Audit|Authentication|BitLocker|BITS|Bluetooth|Browser|Camera|Connectivity|ControlPolicyConflict|CredentialsDelegation|Cryptography|DataProtection|Defender|DeliveryOptimization|DeviceGuard|DeviceLock|Display|DmaGuard|Eap|Education|EnterpriseCloudPrint|Experience|ExploitGuard|FederatedAuthentication|Feeds|FileExplorer|FileSystem|Games|Handwriting|HumanPresence|KioskBrowser|Knobs|LanmanServer|LanmanWorkstation|Licensing|LocalPoliciesSecurityOptions|LocalSecurityAuthority|LocalUsersAndGroups|Lockdown|Maps|MemoryDump|MSSecurityGuide|MSSLegacy|Multitasking|NetworkIsolation|NetworkListManager|Notifications|OneDrive|OOBE|Privacy|Search|Security|ServiceControlManager|Settings|SettingsSync|SmartScreen|SpeakForMe|Speech|Start|Sudo|System|SystemServices|TaskManager|TaskScheduler|TextInput|TimeLanguageSettings|Update|WebThreatDefense|WiFi|WirelessDisplay|AttachmentManager|Autoplay|Cellular|CredentialProviders|CredentialsUI|DataUsage|Desktop|DesktopAppInstaller|DeviceHealthMonitoring|DeviceInstallation|ErrorReporting|EventLogService|InternetExplorer|Kerberos|Location|NewsAndInterests|Power|Printers|RemoteAssistance|RemoteDesktop|RemoteDesktopServices|RemoteManagement|RemoteProcedureCall|RemoteShell|RestrictedGroups|Storage|TenantRestrictions|Troubleshooting|UserRights|VirtualizationBasedTechnology|WindowsAI|WindowsAutopilot|WindowsConnectionManager|WindowsDefenderSecurityCenter|WindowsInkWorkspace|WindowsLogon|WindowsPowerShell|WiredNetwork|WindowsSandbox|ADMX_
All policies are allowed
Populating EnterprisePolicy AllowListSKU MDM licensing allow list string from SLAPI:
AboveLock|Accounts|ActiveXControls|ADMXIngest|AllowMessageSync|AppDeviceInventory|AppHVSI|ApplicationDefaults|ApplicationManagement|AppRuntime|AppVirtualization|Audit|Authentication|BitLocker|BITS|Bluetooth|Browser|Camera|Connectivity|ControlPolicyConflict|CredentialsDelegation|Cryptography|DataProtection|Defender|DeliveryOptimization|DeviceGuard|DeviceLock|Display|DmaGuard|Eap|Education|EnterpriseCloudPrint|Experience|ExploitGuard|FederatedAuthentication|Feeds|FileExplorer|FileSystem|Games|Handwriting|HumanPresence|KioskBrowser|Knobs|LanmanServer|LanmanWorkstation|Licensing|LocalPoliciesSecurityOptions|LocalSecurityAuthority|LocalUsersAndGroups|Lockdown|Maps|MemoryDump|MSSecurityGuide|MSSLegacy|Multitasking|NetworkIsolation|NetworkListManager|Notifications|OneDrive|OOBE|Privacy|Search|Security|ServiceControlManager|Settings|SettingsSync|SmartScreen|SpeakForMe|Speech|Start|Sudo|System|SystemServices|TaskManager|TaskScheduler|TextInput|TimeLanguageSettings|Update|WebThreatDefense|WiFi|WirelessDisplay|AttachmentManager|Autoplay|Cellular|CredentialProviders|CredentialsUI|DataUsage|Desktop|DesktopAppInstaller|DeviceHealthMonitoring|DeviceInstallation|ErrorReporting|EventLogService|InternetExplorer|Kerberos|Location|NewsAndInterests|Power|Printers|RemoteAssistance|RemoteDesktop|RemoteDesktopServices|RemoteManagement|RemoteProcedureCall|RemoteShell|RestrictedGroups|Storage|TenantRestrictions|Troubleshooting|UserRights|VirtualizationBasedTechnology|WindowsAI|WindowsAutopilot|WindowsConnectionManager|WindowsDefenderSecurityCenter|WindowsInkWorkspace|WindowsLogon|WindowsPowerShell|WiredNetwork|WindowsSandbox|ADMX_
All policies are allowed
Cloud Campaign Feature ->GetCachedPayload ENABLEDCloud Campaign Feature ->GetCachedPayload ENABLEDCloud Campaign Feature ->RefreshOneSettingsForApp ENABLEDCloud Campaign Feature ->GetCachedPayload ENABLEDCloud Campaign Feature ->RefreshOneSettingsForApp ENABLEDPopulating UpdatePolicy AllowListSKU MDM licensing allow list string from SLAPI:
AboveLock|Accounts|ActiveXControls|ADMXIngest|AllowMessageSync|AppDeviceInventory|AppHVSI|ApplicationDefaults|ApplicationManagement|AppRuntime|AppVirtualization|Audit|Authentication|BitLocker|BITS|Bluetooth|Browser|Camera|Connectivity|ControlPolicyConflict|CredentialsDelegation|Cryptography|DataProtection|Defender|DeliveryOptimization|DeviceGuard|DeviceLock|Display|DmaGuard|Eap|Education|EnterpriseCloudPrint|Experience|ExploitGuard|FederatedAuthentication|Feeds|FileExplorer|FileSystem|Games|Handwriting|HumanPresence|KioskBrowser|Knobs|LanmanServer|LanmanWorkstation|Licensing|LocalPoliciesSecurityOptions|LocalSecurityAuthority|LocalUsersAndGroups|Lockdown|Maps|MemoryDump|MSSecurityGuide|MSSLegacy|Multitasking|NetworkIsolation|NetworkListManager|Notifications|OneDrive|OOBE|Privacy|Search|Security|ServiceControlManager|Settings|SettingsSync|SmartScreen|SpeakForMe|Speech|Start|Sudo|System|SystemServices|TaskManager|TaskScheduler|TextInput|TimeLanguageSettings|Update|WebThreatDefense|WiFi|WirelessDisplay|AttachmentManager|Autoplay|Cellular|CredentialProviders|CredentialsUI|DataUsage|Desktop|DesktopAppInstaller|DeviceHealthMonitoring|DeviceInstallation|ErrorReporting|EventLogService|InternetExplorer|Kerberos|Location|NewsAndInterests|Power|Printers|RemoteAssistance|RemoteDesktop|RemoteDesktopServices|RemoteManagement|RemoteProcedureCall|RemoteShell|RestrictedGroups|Storage|TenantRestrictions|Troubleshooting|UserRights|VirtualizationBasedTechnology|WindowsAI|WindowsAutopilot|WindowsConnectionManager|WindowsDefenderSecurityCenter|WindowsInkWorkspace|WindowsLogon|WindowsPowerShell|WiredNetwork|WindowsSandbox|ADMX_
All policies are allowed
Suspending
このようなログがでてプロセスが消える。
フェーズ4:負債全部抱え込みモデル+優先度最高
勝手に落とすのはどうかと思うのでHIGH_PRIORITY_CLASS を設定し、OSの回収作業を力ずくで拒否。
こんな感じで順調に増加中
Committed pages: 627652 ( 2510608 Kb)
Commit limit: 1043755 ( 4175020 Kb)
Committed pages: 759149 ( 3036596 Kb)
Commit limit: 1043755 ( 4175020 Kb)
Committed pages: 886257 ( 3545028 Kb)
Commit limit: 1043755 ( 4175020 Kb)
Committed pages: 1018298 ( 4073192 Kb)
Commit limit: 1043755 ( 4175020 Kb)
4.17GBの制限に対し、4.07GBまで攻め込めた。残り約100MB
※ちなみに優先度を上げていない状態だとこんなに綺麗にはならない。
では次でどうなるか。
フェーズ3と同じく完全停止。そのあとプロセス終了。いつも通りではありますが、ここで「MDMやポリシー再読み込みログ」が大量に発生する。最近はこのログに慣れてきた感がある。
詳細を調べてみたところ、まず動作を確認する限り、OS自身に必要だと思われる最低限のメモリ量(残り100MB)を守るため、優先度すら無視して プロセス を 強制停止させ、VA空間を物理的に引き剥がした様子が見られた。
イベントログには、無関係な MsMpEng.exe(Defender)がメモリ不足の犯人として記録された。共有メモリによる攻撃は、プロセスのPrivate Usageしか見ない標準診断が原因と思われる。
信頼性モニターには例外コード **c0000005 (Access Violation)**が記録されていた。メモリ不足であれば「メモリが確保できなかった(c000012d)」ではなく「確保していたメモリへのパス(PTE)を維持できなくなった(c0000005)」このことからプロセスがまだ動いている最中にマッピング(VAD)を強制的に無効化したと思われる。
最終コード(抜粋)
int wmain(int argc, wchar_t* argv[]) {
std::wstring mySectionName = L"Local\\MySharedMemory_Gen" + std::to_wstring(generation);
std::wstring parentSectionName = L"Local\\MySharedMemory_Gen" + std::to_wstring(generation - 1);
std::wstring readyEventName = L"Local\\Event_Gen" + std::to_wstring(generation) + L"_Ready";
SetPriorityClass(GetCurrentProcess(), HIGH_PRIORITY_CLASS);
std::vector<HANDLE> hLegacyHandles;
for (int i = 1; i < generation; ++i) {
std::wstring name = L"Local\\MySharedMemory_Gen" + std::to_wstring(i);
HANDLE h = OpenFileMappingW(FILE_MAP_ALL_ACCESS, FALSE, name.c_str());
if (h) {
MapViewOfFile(h, FILE_MAP_ALL_ACCESS, 0, 0, 0); // 空間に縛り付ける
hLegacyHandles.push_back(h);
}
}
// 2. 自分のセクションを作成して物理メモリを汚す
const size_t allocationSize = 1024 * 1024 * 500;
HANDLE hMyMap = CreateFileMappingW(INVALID_HANDLE_VALUE, NULL, PAGE_READWRITE, 0, (DWORD)allocationSize, mySectionName.c_str());
LPVOID pMyMem = MapViewOfFile(hMyMap, FILE_MAP_ALL_ACCESS, 0, 0, allocationSize);
memset(pMyMem, 0x41, allocationSize);
// 3. 次の世代のためのイベント準備と起動
std::wstring nextEventName = L"Local\\Event_Gen" + std::to_wstring(generation + 1) + L"_Ready";
HANDLE hNextReadyEvent = CreateEventW(NULL, FALSE, FALSE, nextEventName.c_str());
std::wstring cmdLine = L"zombie_relay.exe " + std::to_wstring(GetCurrentProcessId()) + L" " + std::to_wstring(generation + 1);
STARTUPINFOW si = { sizeof(si) };
PROCESS_INFORMATION pi = { 0 };
}
おわりに
カーネルの深層を覗くことは、OSが設計者の意図した「論理的な挙動」を超えて、システムを守るために「生物的な生存本能」を見せる瞬間を観測することでもありました。
教訓としては以下3つです
- プロセス間通信で数百MBを超えるなら
VirtualAllocExは避け、迷わずCreateFileMappingを選ぶべき - OSのコミット限界付近では、プロセスの優先度は「延命」には役立っても「生存」の保証にはならない
- 低レイヤーでのリソースの「握り込み」は、時としてOSに「敵」と見なされ、強制排除(VAD解体)の対象となる