Затриггерить Copy-on-write из ядра

Тема в разделе "WASM.NT.KERNEL", создана пользователем HoShiMin, 12 июл 2020.

  1. HoShiMin

    HoShiMin Well-Known Member

    Публикаций:
    5
    Регистрация:
    17 дек 2016
    Сообщения:
    1.502
    Адрес:
    Россия, Нижний Новгород
    Допустим, мы хотим поставить хук на библиотечную юзермодную функцию из ядра и эта функция находится в системной библиотеке.

    Есть вариант вызвать ZwProtectVirtualMemory, но эта функция не экспортируется на Win7 - хотелось бы обойтись без её поиска по сигнатурам.

    Другой, более правильный и предпочтительный вариант - MmMapLockedPagesSpecifyCache + MmProtectMdlSystemAddress.
    Однако, т.к. библиотека разделяется между множеством процессов, мы отобразим "общую" память - следовательно, изменения одновременно будут видны во всех процессах.

    Необходимо как-либо затриггерить Copy-On-Write (и очень желательно сделать это "легальным" способом, обойдясь без вызова неэкспортируемых функций).

    В структуре PTE есть поле AVL, состоящее из трёх бит, которые ОС может использовать для своих нужд.
    В Win10 x64 2004 PTE определена следующим образом:
    Код (Text):
    1.  
    2. struct _HARDWARE_PTE
    3. {
    4.     unsigned __int64 Valid : 1;
    5.     unsigned __int64 Write : 1;
    6.     unsigned __int64 Owner : 1;
    7.     unsigned __int64 WriteThrough : 1;
    8.     unsigned __int64 CacheDisable : 1;
    9.     unsigned __int64 Accessed : 1;
    10.     unsigned __int64 Dirty : 1;
    11.     unsigned __int64 LargePage : 1;
    12.     unsigned __int64 Global : 1;
    13.     unsigned __int64 CopyOnWrite : 1;
    14.     unsigned __int64 Prototype : 1;
    15.     unsigned __int64 reserved0 : 1;
    16.     unsigned __int64 PageFrameNumber : 36;
    17.     unsigned __int64 reserved1 : 4;
    18.     unsigned __int64 SoftwareWsIndex : 11;
    19.     unsigned __int64 NoExecute : 1;
    20. };
    21.  
    Поле AVL отведено под поля CopyOnWrite, Prototype и reserved0.
    Чтобы пометить страницу, как готовую к CoW, система выставляет бит CopyOnWrite, но не выставляет бит Write: страница фактически остаётся Read[Execute]Only.
    Дальнейшая попытка записи в эту страницу приведёт к генерации исключения и обработчик, видя бит CopyOnWrite, подгрузит страницу в частный рабочий набор процесса.
    И действительно, после "ручного" триггера (взвести бит CoW, произвести запись в страницу) мы увидим, что физический адрес страницы изменился и данные в ней можно смело менять.

    upload_2020-7-12_1-10-25.png

    Однако, спустя некоторое время после завершения процесса, система падает в синий экран с ошибкой MEMORY_MANAGEMENT (1a).

    Трейс:
    Код (Text):
    1.  
    2. nt!DbgBreakPointWithStatus
    3. nt!KiBugCheckDebugBreak+0x12
    4. nt!KeBugCheck2+0x946
    5. nt!KeBugCheckEx+0x107
    6. nt!MiGetTopLevelPfn+0x1c664b
    7. nt!MiCapturePfnVm+0xdf
    8. nt!MiProcessCrcList+0x226
    9. nt!MiCombineAllPhysicalMemory+0x30d
    10. nt!MiCombineIdenticalPages+0x214
    11. nt!NtSetSystemInformation+0x59a
    12. nt!KiSystemServiceCopyEnd+0x25
    13. ntdll!NtSetSystemInformation+0x14
    14. sysmain!PfsCombineWorker+0x1b0
    15. KERNEL32!BaseThreadInitThunk+0x14
    16. ntdll!RtlUserThreadStart+0x21
    17.  
    Конкретное место краша ничего не проясняет:

    upload_2020-7-12_0-58-5.png

    Может, у кого-то есть идеи, как по-другому (или более правильно) затриггерить CoW?
    Или как почистить за собой следы, чтобы валидатор памяти не валил систему в синий экран?
     
    Последнее редактирование: 12 июл 2020
  2. galenkane

    galenkane Active Member

    Публикаций:
    1
    Регистрация:
    13 янв 2017
    Сообщения:
    469
    Код (Text):
    1. Хуки юзермодных функций из ядра и проблема Copy-on-Write
    2.  
    3.   1. Почему ручная установка бита CoW в PTE вызывает BSOD
    4.  
    5.   Корневая причина: MiCombineIdenticalPages — механизм дедупликации страниц (с
    6.   Win8.1+), работающий через sysmain!PfsCombineWorker → NtSetSystemInformation →
    7.   MiCombineIdenticalPages. Этот код обходить PFN базу и сверяет:
    8.  
    9.   - PTE.HardwarePte (текущее состояние страницы в MMU)
    10.   - PFN.OriginalPte (оригинальный PTE, сохранённый при первом маппинге)
    11.  
    12.   Когда ты вручную ставишь бит 9 (CopyOnWrite) в PTE, но не обновляешь OriginalPte в
    13.    PFN-записи этой страницы, MiCombineIdenticalPages обнаруживает расхождение:
    14.  
    15.   PTE говорит: CopyOnWrite=1 (страница приватная, модифицированная)
    16.   PFN.OriginalPte говорит: CopyOnWrite=0 (страница оригинальная, shared)
    17.  
    18.   Результат — MEMORY_MANAGEMENT (0x1A) с параметром 1 (ущербный PTE/PFN).
    19.  
    20.   Что ещё хуже: даже если ты обновишь OriginalPte, PFN содержит ReferenceCount,
    21.   PteAddress (указатель на PTE), и PteFrame. Если несколько процессов шарят одну
    22.   физическую страницу, MiCombineIdenticalPages проходит по всем PTE, ссылающимся на
    23.   этот PFN, и любой несовпадающий CoW-бит вызовет крэш.
    24.  
    25.   2. Почему MDL-подход не работает
    26.  
    27.   MmMapLockedPagesSpecifyCache + MmProtectMdlSystemAddress — этот метод полностью
    28.   обходит CoW. Pavel Lebedinsky (OSR) подтвердил:
    29.  
    30.   ▎ "If you modify a page through an MDL mapping, COW will not be triggered."
    31.  
    32.   MDL создаёт новое системное виртуальное адресное пространство, маппированное на ту
    33.    же физическую страницу. Запись через это маппирование модифицирует shared
    34.   страницу напрямую — все процессы видят изменения. CoW механизм не срабатывает,
    35.   потому что MMU не участвует (нет page fault на запись).
    36.  
    37.   3. Правильные подходы
    38.  
    39.   Подход A: ZwProtectVirtualMemory через номер системного вызова
    40.  
    41.   ZwProtectVirtualMemory НЕ экспортируется ntoskrnl.exe. Однако системный вызов
    42.   существует. На ARM64:
    43.  
    44.   // Определение номера syscall для ZwProtectVirtualMemory
    45.   // ARM64: номер syscall можно найти в nt!KiServiceTable
    46.   // или захардкодить для конкретной версии Windows
    47.  
    48.   // Windows 11 ARM64 Build 26100:
    49.   // ZwProtectVirtualMemory = syscall 0x50 (нужно проверить для твоего билда)
    50.  
    51.   NTSTATUS HookTriggerCoW(HANDLE ProcessHandle, PVOID Address, SIZE_T Size) {
    52.       ULONG OldProtect;
    53.  
    54.       // Вариант 1: прямой syscall (ARM64)
    55.       // Нужно определить номер через дизассембливание ntdll!NtProtectVirtualMemory
    56.       NTSTATUS status = ZwProtectVirtualMemory(
    57.           ProcessHandle,
    58.           &Address,
    59.           &Size,
    60.           PAGE_EXECUTE_READWRITE,  // или PAGE_READWRITE
    61.           &OldProtect
    62.       );
    63.  
    64.       if (NT_SUCCESS(status)) {
    65.           // CoW сработал! Теперь страница приватная.
    66.           // Можно писать через MDL или KeStackAttachProcess + memcpy
    67.       }
    68.       return status;
    69.   }
    70.  
    71.   Как получить ZwProtectVirtualMemory без хардкода:
    72.  
    73.   // Метод 1: Из ntdll.dll целевого процесса
    74.   // ntdll маппирован во все процессы, можно найти экспорт NtProtectVirtualMemory
    75.   // Дизассемблировать первую инструкцию (svc #0 на ARM64) → номер syscall
    76.   // Затем вызывать через syscall stub
    77.  
    78.   // Метод 2: Поиск в ntoskrnl.exe
    79.   // Хотя ZwProtectVirtualMemory не экспортируется,
    80.   // можно найти через паттерн в KiServiceTable
    81.  
    82.   Подход B: KeStackAttachProcess + запись через user-mode адрес
    83.  
    84.   VOID TriggerCoWViaWrite(PEPROCESS TargetProcess, PVOID TargetAddress,
    85.                            PVOID HookBytes, SIZE_T Length) {
    86.       KAPC_STATE ApcState;
    87.  
    88.       KeStackAttachProcess(TargetProcess, &ApcState);
    89.  
    90.       __try {
    91.           // Пробуем записать. Если страница shared → page fault → CoW
    92.           // Windows memory manager обработает fault и создаст приватную копию
    93.           RtlCopyMemory(TargetAddress, HookBytes, Length);
    94.       } __except(EXCEPTION_EXECUTE_HANDLER) {
    95.           // Обработка page fault неудачна
    96.       }
    97.  
    98.       KeUnstackDetachProcess(&ApcState);
    99.   }
    100.  
    101.   Это самый чистый метод. При записи в shared страницу через KeStackAttachProcess:
    102.   1. Происходит page fault (write to read-only shared page)
    103.   2. Memory manager проверяет PTE → CoW bit
    104.   3. Если страница shareable (mapped as SEC_IMAGE) → выделяется новая физическая
    105.   страница
    106.   4. Содержимое копируется, PTE обновляется на новую страницу
    107.   5. PFN корректно обновляется
    108.   6. MiCombineIdenticalPages видит консистентное состояние
    109.  
    110.   Подход C: MmCopyVirtualMemory
    111.  
    112.   // MmCopyVirtualMemory экспортируется и работает кросс-процессно
    113.   SIZE_T BytesWritten;
    114.   NTSTATUS status = MmCopyVirtualMemory(
    115.       PsGetCurrentProcess(), HookBytes,    // Source (kernel buffer)
    116.       TargetProcess, TargetAddress,        // Target (user-mode address)
    117.       Length,                              // Byte count
    118.       0,                                   // Mode (KernelMode = 0)
    119.       &BytesWritten
    120.   );
    121.  
    122.   MmCopyVirtualMemory корректно обрабатывает CoW, потому что пишет через стандартный
    123.    memory manager path с page fault handling.
    124.  
    125.   4. Очистка PFN при ручном PTE-манипулировании (если всё-таки нужен)
    126.  
    127.   Если ты уже модифицируешь PTE напрямую, минимальный набор обновлений PFN:
    128.  
    129.   // Псевдокод (адреса структур зависят от версии Windows)
    130.   VOID FixPfnForModifiedPte(PVOID VirtualAddress, PMMPTE Pte) {
    131.       MMPTE HardwarePte = *(volatile MMPTE*)Pte;
    132.       ULONG64 PfnIndex = HardwarePte.Hard.PageFrameNumber;
    133.  
    134.       // Получить PFN entry
    135.       PMMPFN PfnEntry = MiGetPfnEntry(PfnIndex);
    136.  
    137.       // 1. Обновить OriginalPte чтобы отражал новый CoW статус
    138.       PfnEntry->OriginalPte.u.Long = HardwarePte.u.Long;
    139.  
    140.       // 2. Если CoW=1, инкрементировать ReferenceCount
    141.       //    (новая приватная страница должна иметь count = 1)
    142.       // 3. Обновить PteAddress на текущий PTE
    143.       PfnEntry->PteAddress = Pte;
    144.  
    145.       // 4. Invalidate TLB для этого адреса
    146.       //    ARM64: __isb(0xF); __dsb(0xF);
    147.       //    Нужно вызвать KeFlushSingleTb или аналогичное
    148.   }
    149.  
    150.   Но это хрупко: структура _MMPFN не документирована полностью, размер и смещения
    151.   меняются между билдами Windows. Любая ошибка = BSOD.
    152.  
    153.   5. Рекомендуемый подход
    154.  
    155.   Лучший вариант — Подход B (KeStackAttachProcess + RtlCopyMemory):
    156.  
    157.   1. Не требует недокументированных структур
    158.   2. CoW обрабатывается memory manager'ом корректно
    159.   3. PFN обновляется автоматически
    160.   4. MiCombineIdenticalPages видит консистентное состояние
    161.   5. Работает на всех версиях Windows
    162.  
    163.   Если нужен ZwProtectVirtualMemory — получай номер syscall из
    164.   ntdll!NtProtectVirtualMemory целевого процесса:
    165.  
    166.   kd> u ntdll!NtProtectVirtualMemory L2
    167.  
    168.   Первая инструкция на ARM64 будет stp x29,x30,[sp,...] или напрямую mov x8, #N +
    169.   svc #0 — N это номер syscall, по которому можно строить kernel syscall stub.
    170.  
    171.   Итоговая таблица подходов
    172.  
    173.   ┌───────────────────────┬────────────┬─────────────┬──────────┬────────────┐
    174.   │         Метод         │ CoW срабат │ PFN консист │ Сложност │ Риск BSOD  │
    175.   │                       │   ывает    │    ентен    │    ь     │            │
    176.   ├───────────────────────┼────────────┼─────────────┼──────────┼────────────┤
    177.   │ Ручной PTE bit set    │ Нет        │ Нет         │ Высокая  │ Критически │
    178.   │                       │            │             │          │ й          │
    179.   ├───────────────────────┼────────────┼─────────────┼──────────┼────────────┤
    180.   │ MDL + MmProtect       │ Нет        │ Да (не      │ Средняя  │ Низкий     │
    181.   │                       │            │ трогает)    │          │            │
    182.   ├───────────────────────┼────────────┼─────────────┼──────────┼────────────┤
    183.   │ KeStackAttach +       │ Да         │ Да          │ Низкая   │ Минимальны │
    184.   │ memcpy                │            │             │          │ й          │
    185.   ├───────────────────────┼────────────┼─────────────┼──────────┼────────────┤
    186.   │ MmCopyVirtualMemory   │ Да         │ Да          │ Низкая   │ Минимальны │
    187.   │                       │            │             │          │ й          │
    188.   ├───────────────────────┼────────────┼─────────────┼──────────┼────────────┤
    189.   │ ZwProtectVirtualMemor │ Да         │ Да          │ Средняя  │ Минимальны │
    190.   │ y                     │            │             │          │ й          │
    191.   └───────────────────────┴────────────┴─────────────┴──────────┴────────────┘
    192.  
    193.   Кратко: не трогай PTE напрямую. Используй KeStackAttachProcess + запись — это
    194.   автоматически триггерит CoW через page fault, и memory manager сам корректно
    195.   обновит все структуры.
    196.  
    197.  
    Код (Text):
    1.   Итоги проверки в отладчике
    2.  
    3.   Что доказали:
    4.  
    5.   ┌────────────────────────────┬────────────────────────────────────────────────┐
    6.   │          Проверка          │                   Результат                    │
    7.   ├────────────────────────────┼────────────────────────────────────────────────┤
    8.   │ ntdll кодовая страница     │ Да — share count 0x7B (123 процесса)           │
    9.   │ shared?                    │                                                │
    10.   ├────────────────────────────┼────────────────────────────────────────────────┤
    11.   │ Отладчик eb триггерит CoW? │ Нет — PTE не изменился, запись ушла в shared   │
    12.   │                            │ страницу                                       │
    13.   ├────────────────────────────┼────────────────────────────────────────────────┤
    14.   │ Другие процессы видят      │ Да — svchost увидел 0x02                       │
    15.   │ запись?                    │                                                │
    16.   ├────────────────────────────┼────────────────────────────────────────────────┤
    17.   │ Ручной CoW бит меняет PTE? │ Да — появились флаги CW                        │
    18.   ├────────────────────────────┼────────────────────────────────────────────────┤
    19.   │ PFN обновляется при ручном │ Нет — остаётся Shared, RestorePte не обновлён  │
    20.   │  CoW?                      │                                                │
    21.   ├────────────────────────────┼────────────────────────────────────────────────┤
    22.   │ Это вызывает BSOD?         │ Да — MiCombineIdenticalPages найдёт            │
    23.   │                            │ расхождение                                    │
    24.   └────────────────────────────┴────────────────────────────────────────────────┘
    25.  
    26.   Ключевой вывод:kernel debugger пишет через direct memory access (минуя MMU),
    27.   аналогично MDL-подходу — page fault не генерируется, CoW не триггерится.
    28.   Правильный путь из драйвера — KeStackAttachProcess + RtlCopyMemory — запись идёт
    29.   через MMU, генерирует page fault на read-only shared странице, и memory manager
    30.   автоматически создаёт приватную копию с корректным PFN.
    Код (Text):
    1. !process 0 0 explorer.exe                          # найти EPROCESS
    2.   .process /r /p ffff8a88f5dec080                     # переключиться в контекст
    3.   процесса
    4.   lm n m ntdll                                       # база ntdll
    5.   !pte 00007ff8`2cb51000                             # PTE + флаги до записи
    6.   !pfn 1b53c4                                        # PFN: Shared, share count,
    7.   restore pte
    8.   db 00007ff8`2cb51000 L1                            # текущий байт
    9.   eb 00007ff8`2cb51000 02                            # запись (байпас CoW)
    10.   !pte 00007ff8`2cb51000                             # PTE после — не изменился
    11.   !pfn 1b53c4                                        # PFN после — не изменился
    12.   .process /r /p <svchost_eprocess>                  # другой процесс
    13.   db 00007ff8`2cb51000 L1                            # видит 0x02 = shared изменён
    14.   eq FFFF9DBFFC165A88 11a00001`b53c4fc3              # ручной CoW + Writable в PTE
    15.   !pte 00007ff8`2cb51000                             # появились CW флаги
    16.   !pfn 1b53c4                                        # но PFN всё ещё Shared ←
    17.   расхождение
    18.   eq FFFF9DBFFC165A88 10200001`b53c4fc3              # восстановить PTE
    19.  
    20.   Чек-лист для проверки любой страницы:
    21.  
    22.   !pte <addr>     →  флаги (CW? W? R/E?)
    23.   !pfn <pfn>      →  Shared? share count? restore pte совпадает?
    24.  
    25.   Если PTE показывает CW а PFN показывает Shared — будет BSOD.
     
    Последнее редактирование: 29 май 2026 в 18:48