Допустим, мы хотим поставить хук на библиотечную юзермодную функцию из ядра и эта функция находится в системной библиотеке. Есть вариант вызвать ZwProtectVirtualMemory, но эта функция не экспортируется на Win7 - хотелось бы обойтись без её поиска по сигнатурам. Другой, более правильный и предпочтительный вариант - MmMapLockedPagesSpecifyCache + MmProtectMdlSystemAddress. Однако, т.к. библиотека разделяется между множеством процессов, мы отобразим "общую" память - следовательно, изменения одновременно будут видны во всех процессах. Необходимо как-либо затриггерить Copy-On-Write (и очень желательно сделать это "легальным" способом, обойдясь без вызова неэкспортируемых функций). В структуре PTE есть поле AVL, состоящее из трёх бит, которые ОС может использовать для своих нужд. В Win10 x64 2004 PTE определена следующим образом: Код (Text): struct _HARDWARE_PTE { unsigned __int64 Valid : 1; unsigned __int64 Write : 1; unsigned __int64 Owner : 1; unsigned __int64 WriteThrough : 1; unsigned __int64 CacheDisable : 1; unsigned __int64 Accessed : 1; unsigned __int64 Dirty : 1; unsigned __int64 LargePage : 1; unsigned __int64 Global : 1; unsigned __int64 CopyOnWrite : 1; unsigned __int64 Prototype : 1; unsigned __int64 reserved0 : 1; unsigned __int64 PageFrameNumber : 36; unsigned __int64 reserved1 : 4; unsigned __int64 SoftwareWsIndex : 11; unsigned __int64 NoExecute : 1; }; Поле AVL отведено под поля CopyOnWrite, Prototype и reserved0. Чтобы пометить страницу, как готовую к CoW, система выставляет бит CopyOnWrite, но не выставляет бит Write: страница фактически остаётся Read[Execute]Only. Дальнейшая попытка записи в эту страницу приведёт к генерации исключения и обработчик, видя бит CopyOnWrite, подгрузит страницу в частный рабочий набор процесса. И действительно, после "ручного" триггера (взвести бит CoW, произвести запись в страницу) мы увидим, что физический адрес страницы изменился и данные в ней можно смело менять. Однако, спустя некоторое время после завершения процесса, система падает в синий экран с ошибкой MEMORY_MANAGEMENT (1a). Трейс: Код (Text): nt!DbgBreakPointWithStatus nt!KiBugCheckDebugBreak+0x12 nt!KeBugCheck2+0x946 nt!KeBugCheckEx+0x107 nt!MiGetTopLevelPfn+0x1c664b nt!MiCapturePfnVm+0xdf nt!MiProcessCrcList+0x226 nt!MiCombineAllPhysicalMemory+0x30d nt!MiCombineIdenticalPages+0x214 nt!NtSetSystemInformation+0x59a nt!KiSystemServiceCopyEnd+0x25 ntdll!NtSetSystemInformation+0x14 sysmain!PfsCombineWorker+0x1b0 KERNEL32!BaseThreadInitThunk+0x14 ntdll!RtlUserThreadStart+0x21 Конкретное место краша ничего не проясняет: Может, у кого-то есть идеи, как по-другому (или более правильно) затриггерить CoW? Или как почистить за собой следы, чтобы валидатор памяти не валил систему в синий экран?
Код (Text): Хуки юзермодных функций из ядра и проблема Copy-on-Write 1. Почему ручная установка бита CoW в PTE вызывает BSOD Корневая причина: MiCombineIdenticalPages — механизм дедупликации страниц (с Win8.1+), работающий через sysmain!PfsCombineWorker → NtSetSystemInformation → MiCombineIdenticalPages. Этот код обходить PFN базу и сверяет: - PTE.HardwarePte (текущее состояние страницы в MMU) - PFN.OriginalPte (оригинальный PTE, сохранённый при первом маппинге) Когда ты вручную ставишь бит 9 (CopyOnWrite) в PTE, но не обновляешь OriginalPte в PFN-записи этой страницы, MiCombineIdenticalPages обнаруживает расхождение: PTE говорит: CopyOnWrite=1 (страница приватная, модифицированная) PFN.OriginalPte говорит: CopyOnWrite=0 (страница оригинальная, shared) Результат — MEMORY_MANAGEMENT (0x1A) с параметром 1 (ущербный PTE/PFN). Что ещё хуже: даже если ты обновишь OriginalPte, PFN содержит ReferenceCount, PteAddress (указатель на PTE), и PteFrame. Если несколько процессов шарят одну физическую страницу, MiCombineIdenticalPages проходит по всем PTE, ссылающимся на этот PFN, и любой несовпадающий CoW-бит вызовет крэш. 2. Почему MDL-подход не работает MmMapLockedPagesSpecifyCache + MmProtectMdlSystemAddress — этот метод полностью обходит CoW. Pavel Lebedinsky (OSR) подтвердил: ▎ "If you modify a page through an MDL mapping, COW will not be triggered." MDL создаёт новое системное виртуальное адресное пространство, маппированное на ту же физическую страницу. Запись через это маппирование модифицирует shared страницу напрямую — все процессы видят изменения. CoW механизм не срабатывает, потому что MMU не участвует (нет page fault на запись). 3. Правильные подходы Подход A: ZwProtectVirtualMemory через номер системного вызова ZwProtectVirtualMemory НЕ экспортируется ntoskrnl.exe. Однако системный вызов существует. На ARM64: // Определение номера syscall для ZwProtectVirtualMemory // ARM64: номер syscall можно найти в nt!KiServiceTable // или захардкодить для конкретной версии Windows // Windows 11 ARM64 Build 26100: // ZwProtectVirtualMemory = syscall 0x50 (нужно проверить для твоего билда) NTSTATUS HookTriggerCoW(HANDLE ProcessHandle, PVOID Address, SIZE_T Size) { ULONG OldProtect; // Вариант 1: прямой syscall (ARM64) // Нужно определить номер через дизассембливание ntdll!NtProtectVirtualMemory NTSTATUS status = ZwProtectVirtualMemory( ProcessHandle, &Address, &Size, PAGE_EXECUTE_READWRITE, // или PAGE_READWRITE &OldProtect ); if (NT_SUCCESS(status)) { // CoW сработал! Теперь страница приватная. // Можно писать через MDL или KeStackAttachProcess + memcpy } return status; } Как получить ZwProtectVirtualMemory без хардкода: // Метод 1: Из ntdll.dll целевого процесса // ntdll маппирован во все процессы, можно найти экспорт NtProtectVirtualMemory // Дизассемблировать первую инструкцию (svc #0 на ARM64) → номер syscall // Затем вызывать через syscall stub // Метод 2: Поиск в ntoskrnl.exe // Хотя ZwProtectVirtualMemory не экспортируется, // можно найти через паттерн в KiServiceTable Подход B: KeStackAttachProcess + запись через user-mode адрес VOID TriggerCoWViaWrite(PEPROCESS TargetProcess, PVOID TargetAddress, PVOID HookBytes, SIZE_T Length) { KAPC_STATE ApcState; KeStackAttachProcess(TargetProcess, &ApcState); __try { // Пробуем записать. Если страница shared → page fault → CoW // Windows memory manager обработает fault и создаст приватную копию RtlCopyMemory(TargetAddress, HookBytes, Length); } __except(EXCEPTION_EXECUTE_HANDLER) { // Обработка page fault неудачна } KeUnstackDetachProcess(&ApcState); } Это самый чистый метод. При записи в shared страницу через KeStackAttachProcess: 1. Происходит page fault (write to read-only shared page) 2. Memory manager проверяет PTE → CoW bit 3. Если страница shareable (mapped as SEC_IMAGE) → выделяется новая физическая страница 4. Содержимое копируется, PTE обновляется на новую страницу 5. PFN корректно обновляется 6. MiCombineIdenticalPages видит консистентное состояние Подход C: MmCopyVirtualMemory // MmCopyVirtualMemory экспортируется и работает кросс-процессно SIZE_T BytesWritten; NTSTATUS status = MmCopyVirtualMemory( PsGetCurrentProcess(), HookBytes, // Source (kernel buffer) TargetProcess, TargetAddress, // Target (user-mode address) Length, // Byte count 0, // Mode (KernelMode = 0) &BytesWritten ); MmCopyVirtualMemory корректно обрабатывает CoW, потому что пишет через стандартный memory manager path с page fault handling. 4. Очистка PFN при ручном PTE-манипулировании (если всё-таки нужен) Если ты уже модифицируешь PTE напрямую, минимальный набор обновлений PFN: // Псевдокод (адреса структур зависят от версии Windows) VOID FixPfnForModifiedPte(PVOID VirtualAddress, PMMPTE Pte) { MMPTE HardwarePte = *(volatile MMPTE*)Pte; ULONG64 PfnIndex = HardwarePte.Hard.PageFrameNumber; // Получить PFN entry PMMPFN PfnEntry = MiGetPfnEntry(PfnIndex); // 1. Обновить OriginalPte чтобы отражал новый CoW статус PfnEntry->OriginalPte.u.Long = HardwarePte.u.Long; // 2. Если CoW=1, инкрементировать ReferenceCount // (новая приватная страница должна иметь count = 1) // 3. Обновить PteAddress на текущий PTE PfnEntry->PteAddress = Pte; // 4. Invalidate TLB для этого адреса // ARM64: __isb(0xF); __dsb(0xF); // Нужно вызвать KeFlushSingleTb или аналогичное } Но это хрупко: структура _MMPFN не документирована полностью, размер и смещения меняются между билдами Windows. Любая ошибка = BSOD. 5. Рекомендуемый подход Лучший вариант — Подход B (KeStackAttachProcess + RtlCopyMemory): 1. Не требует недокументированных структур 2. CoW обрабатывается memory manager'ом корректно 3. PFN обновляется автоматически 4. MiCombineIdenticalPages видит консистентное состояние 5. Работает на всех версиях Windows Если нужен ZwProtectVirtualMemory — получай номер syscall из ntdll!NtProtectVirtualMemory целевого процесса: kd> u ntdll!NtProtectVirtualMemory L2 Первая инструкция на ARM64 будет stp x29,x30,[sp,...] или напрямую mov x8, #N + svc #0 — N это номер syscall, по которому можно строить kernel syscall stub. Итоговая таблица подходов ┌───────────────────────┬────────────┬─────────────┬──────────┬────────────┐ │ Метод │ CoW срабат │ PFN консист │ Сложност │ Риск BSOD │ │ │ ывает │ ентен │ ь │ │ ├───────────────────────┼────────────┼─────────────┼──────────┼────────────┤ │ Ручной PTE bit set │ Нет │ Нет │ Высокая │ Критически │ │ │ │ │ │ й │ ├───────────────────────┼────────────┼─────────────┼──────────┼────────────┤ │ MDL + MmProtect │ Нет │ Да (не │ Средняя │ Низкий │ │ │ │ трогает) │ │ │ ├───────────────────────┼────────────┼─────────────┼──────────┼────────────┤ │ KeStackAttach + │ Да │ Да │ Низкая │ Минимальны │ │ memcpy │ │ │ │ й │ ├───────────────────────┼────────────┼─────────────┼──────────┼────────────┤ │ MmCopyVirtualMemory │ Да │ Да │ Низкая │ Минимальны │ │ │ │ │ │ й │ ├───────────────────────┼────────────┼─────────────┼──────────┼────────────┤ │ ZwProtectVirtualMemor │ Да │ Да │ Средняя │ Минимальны │ │ y │ │ │ │ й │ └───────────────────────┴────────────┴─────────────┴──────────┴────────────┘ Кратко: не трогай PTE напрямую. Используй KeStackAttachProcess + запись — это автоматически триггерит CoW через page fault, и memory manager сам корректно обновит все структуры. Код (Text): Итоги проверки в отладчике Что доказали: ┌────────────────────────────┬────────────────────────────────────────────────┐ │ Проверка │ Результат │ ├────────────────────────────┼────────────────────────────────────────────────┤ │ ntdll кодовая страница │ Да — share count 0x7B (123 процесса) │ │ shared? │ │ ├────────────────────────────┼────────────────────────────────────────────────┤ │ Отладчик eb триггерит CoW? │ Нет — PTE не изменился, запись ушла в shared │ │ │ страницу │ ├────────────────────────────┼────────────────────────────────────────────────┤ │ Другие процессы видят │ Да — svchost увидел 0x02 │ │ запись? │ │ ├────────────────────────────┼────────────────────────────────────────────────┤ │ Ручной CoW бит меняет PTE? │ Да — появились флаги CW │ ├────────────────────────────┼────────────────────────────────────────────────┤ │ PFN обновляется при ручном │ Нет — остаётся Shared, RestorePte не обновлён │ │ CoW? │ │ ├────────────────────────────┼────────────────────────────────────────────────┤ │ Это вызывает BSOD? │ Да — MiCombineIdenticalPages найдёт │ │ │ расхождение │ └────────────────────────────┴────────────────────────────────────────────────┘ Ключевой вывод:kernel debugger пишет через direct memory access (минуя MMU), аналогично MDL-подходу — page fault не генерируется, CoW не триггерится. Правильный путь из драйвера — KeStackAttachProcess + RtlCopyMemory — запись идёт через MMU, генерирует page fault на read-only shared странице, и memory manager автоматически создаёт приватную копию с корректным PFN. Код (Text): !process 0 0 explorer.exe # найти EPROCESS .process /r /p ffff8a88f5dec080 # переключиться в контекст процесса lm n m ntdll # база ntdll !pte 00007ff8`2cb51000 # PTE + флаги до записи !pfn 1b53c4 # PFN: Shared, share count, restore pte db 00007ff8`2cb51000 L1 # текущий байт eb 00007ff8`2cb51000 02 # запись (байпас CoW) !pte 00007ff8`2cb51000 # PTE после — не изменился !pfn 1b53c4 # PFN после — не изменился .process /r /p <svchost_eprocess> # другой процесс db 00007ff8`2cb51000 L1 # видит 0x02 = shared изменён eq FFFF9DBFFC165A88 11a00001`b53c4fc3 # ручной CoW + Writable в PTE !pte 00007ff8`2cb51000 # появились CW флаги !pfn 1b53c4 # но PFN всё ещё Shared ← расхождение eq FFFF9DBFFC165A88 10200001`b53c4fc3 # восстановить PTE Чек-лист для проверки любой страницы: !pte <addr> → флаги (CW? W? R/E?) !pfn <pfn> → Shared? share count? restore pte совпадает? Если PTE показывает CW а PFN показывает Shared — будет BSOD.