Введение
Устав от бесконечных патчей ядра как антивирусными продуктами, так и малварью, Microsoft в 2005м году ввели технологию защиты ядра от изменений - Kernel Patch Protection, или Patch Guard. PG разработан только для х64 систем, он появился в системах Windows XP и Windows Server 2003 Service Pack 1.
В данной статье будет рассмотрена реализация этой технологии( PG версии 1 ). Методов обхода PG в данной статье нет.
Итак, при патче некоторых системных структур или патче кода ядра( и не только его ) через некоторый промежуток времени произойдет BSOD с багчеком CRITICAL_STRUCTURE_CORRUPTION(0x00000109).
Чтобы получить некоторое общее представление о том, что контролирует PG, обратимся к описанию данного багчека, которое можно найти в msdn.
Тип, описание:
0x0, A generic data region
0x1, A function modification or the Itanium-based function location
0x2, A processor interrupt dispatch table (IDT)
0x3, A processor global descriptor table (GDT)
0x4, A type-1 process list corruption
0x5, A type-2 process list corruption
0x6, A debug routine modification
0x7, A critical MSR modification
Итак, список довольно внушительный. Контролируются системные таблицы, MSR'ы, недопускается патч функций ядра и некоторых других модулей( об этом ниже ) и т.д.
Самозащита PG и её отключение
Кроме того, что патч гвард защищает ядро ОС от изменений, он еще защищается и от любопытных глаз. Как он это делает?
Ну во-первых, любой ресерчер самым первым делом берет в руки отладчик. И в случае с PG получает неприятную вещь - он не работает одновременно с отладчиком. Получаем проблему - с включенным отладчиком не можем отлаживать PG, а с выключенным не можем ставить хуки на функции для получения информации о PG. Проблемы будем решать по мере их поступления, неплохо было бы, для начала, найти точку инициализации PG и заставить таки его работать вместе с отладчиком.
Патчгвард инициализируется незаметным способом на этапе инициализации ядра:
KeInitSystem:
mov rcx, qword ptr cs:KiTestDividend
mov edx, 0CB5FA3h
call KiDivide6432
cmp eax, 5EE0B7E5h
jnz loc_83B82C
...
loc_83B82C:
mov ecx, 5Dh
call KeBugCheck
Сама же функция KiDivide6432 просто выполняет деление:
KiDivide6432 proc near
mov eax, ecx
mov r8, rdx
shr rcx, 20h
mov edx, ecx
div r8d
retn
KiDivide6432 endp
Переменная которая будет разделена на 0CB5FA3 расположена таким образом, что перекрывается с переменной для обнаружения отладчика:
dq nt!KiTestDividend L1
fffff800011766e0 004b5fa3a053724c
db nt!KdDebuggerNotPresent L1
fffff800`011766e7 00
Таким образом без отладчика она равна: 014b5fa3a053724c
А с отладчиком данная переменная равна: 004b5fa3a053724c
Таким образом при включенном отладчике результат деления будет: 004b5fa3a053724c / 0CB5FA3 = 5EE0B7E5, то есть как раз той величине, с которой сравнивается. Но без отладчика, результат будет другой: 014b5fa3a053724c / 0CB5FA3 = 1A11F49AE.
В описании команды div сказано:
"Команда выполняет целочисленное деление операндов с выдачей результата деления в виде частного и остатка от деления. При выполнении операции деления возможно возникновение исключительной ситуации: 0 — ошибка деления. Эта ситуация возникает в одном из двух случаев: делитель равен 0 или частное слишком велико для его размещения в регистре eax/ax/al."
Видно, что результат 1A11F49AE явно в eax не поместится, так что возникает исключение Divide Error, которое задействует обработчик int 0.
IDT на х64 системах выглядит несколько иначе, чем на х86 системах, в плане названия ф-ций обработчиков:
0000000000831A90 KiInterruptInitTable dq 0
0000000000831A98 off_831A98 dq offset KiDivideErrorFault
0000000000831AA0 dq 1
0000000000831AA8 dq offset KiDebugTrapOrFault
0000000000831AB0 dq 30002h
0000000000831AB8 dq offset KiNmiInterrupt
0000000000831AC0 dq 303h
0000000000831AC8 dq offset KiBreakpointTrap
0000000000831AD0 dq 304h
0000000000831AD8 dq offset KiOverflowTrap
KiDivideErrorFault это нужный нам обработчик.
Далее через цепочку ф-ций дело приходит в: KiDivideErrorFault => KiExceptionDispatch => KiDispatchException => KiPreprocessFault => KiOpDecode => KiOpLocateDecodeEntry(вызов через KiOpOneByteTable[index]) => KiOp_Div
KiOp_Div:
call sub_403AA0 // вызов неизвестной ф-ции
Которая становится немного более известной:
sub_403AA0 proc near
mov eax, eax
KiFilterFiberContext proc near
...
KiFilterFiberContext endp
В виде псевдокода KiFilterFiberContext выглядит как:
BOOLEAN KiFilterFiberContext( PCONTEXT contextRecord )
{
PUCHAR exceptionAddress = (PUCHAR)KiDivide6432; // берется адрес ф-ции KiDivide6432, которая была вызвана для инициализации PG
exceptionAddress += 0xB; // к этому адресу прибавляется 0xB, таким образом он будет равен адресу вызвавшему исключение
if ( contextRecord->Rip != exceptionAddress ) // если адрес другой - пропускаем его
return FALSE;
if ( !KiInitializePatchGuard() ) // если исключение вызвано инструкцией div r8d внутри ф-ции KiDivide6432, то вызываем ф-цию инициализации PG
{
contextRecord->Rdx = 0xFFFF;
return FALSE;
}
contextRecord->Rdx = 0xFFFFFF; // если все прошло хорошо - ресетим rdx так, чтобы исключения не было. Таким образом, в этой точке PG полностью проинициализирован и ядро продолжит загрузку
return TRUE;
}
Стек инициализации патч гварда:
nt!KiFilterFiberContext+0x2a
nt!KiOp_Div+0x29
nt!KiPreprocessFault+0xc7
nt!KiDispatchException+0x85
nt!KiExceptionExit
nt!KiDivideErrorFault+0xb7
nt!KiDivide6432+0xb
Разобравшись с инициализацией PG встречаем еще одну проблему - невозможность поставить любой брекпойнт. Точнее, поставить то мы его сможем, но PG спустя время выдаст нам BSOD, ведь брекпойнт это int 3 в коде, а значит патч, а патчить что-либо под PG строго запрещается.
Любой, у кого есть хотя бы небольшой опыт отладки сразу же вспомнит про другой способ установки брекпойнтов - memory breakpoints, работающие через DR регистры процессора. Что же, это нам подходит. Однако и тут PG подложил нам свинью - он сбрасывает DR регистры! Вот как выглядит функция с помощью которой он это делает:
KiNoDebugRoutine proc near
xor eax, eax
mov dr7, rax
retn
KiNoDebugRoutine endp
PG просто обнуляет управляющий регистр, и все наши брекпойнты сбрасываются!
Далее, предположим, мы поставили брекпойнт, самый обычный, через bp address. PG обнаружил патч, сгенерировал BSOD. Как нам найти первоисточник, то есть функцию самого PG, которая ответственна за проверку целостности кода? Казалось бы ответ очевиден, BSOD генерируется известной функцией - KeBugCheckEx, ставим брекпойнт на нее, и смотрим стек вызовов, среди него будет и функция PG.
Однако тут мы встречаем проблему номер три - стек вызовов пустой, все регистры обнулены! Это еще один пусть защиты PG от изучения.
Делаются все эти нехорошие действия тут:
SdbpCheckDll proc near
arg_20 = qword ptr 28h
arg_28 = qword ptr 30h
arg_30 = qword ptr 38h
mov rsi, [rsp+arg_28]
mov rdi, [rsp+arg_20]
mov r10, [rsp+arg_30]
xor eax, eax
loc_567411:
mov [r10], rax
sub r10, 8
cmp r10, rsp
jnb short loc_567411
mov [rsp+arg_20], rdi
mov rbx, rax
mov rdi, rax
mov rbp, rax
mov r10, rax
mov r11, rax
mov r12, rax
mov r13, rax
mov r14, rax
mov r15, rax
jmp rsi // rsi = nt!KeBugCheckEx
SdbpCheckDll endp
В псевдокоде:
//
// Ф-ция зануляет в стеке обширный кусок данных, что не дает восстановить по колстеку причину падения.
// Также обнуляются все регистры. После чего вызывается BSOD с багчеком CRITICAL_STRUCTURE_CORRUPTION(109h)
//
VOID SdbpCheckDll( __in ULONG BugCheckCode, __in ULONG_PTR P1, __in ULONG_PTR P2, __in ULONG_PTR P3, __in ULONG_PTR P4, __in PVOID KeBugCheckExPtr, __in ULONG unkPtr )
{
ULONG size = (ULONG)( (PUCHAR)unkPtr - (PUCHAR)rsp );
size = size / sizeof(ULONG64);
memset( rsp, 0, size );
__asm
{
mov [rsp+28h], rdi ; обновляется P4, он нужен для вывода информации в bsod'е, т.к. показывает тип испорченных данных
xor eax, eax
mov rbx, rax
mov rdi, rax
mov rbp, rax
mov r10, rax
mov r11, rax
mov r12, rax
mov r13, rax
mov r14, rax
mov r15, rax
}
return KeBugCheckExPtr( BugCheckCode, P1, P2, P3, P4 );
}
Теперь, когда вся информация есть, можно заставить PG работать вместе с отладчиком. Чтобы это сделать, нужно скопировать ядро, переименовать его, например, в MyKernel.exe пропатчить его на диске.
Патчить нужно, чтобы решить три проблемы перечисленные выше:
1) Заставить PG работать вместе с отладчиком, для этого патчим константу таким образом, чтобы сгенерировалось исключение даже с включенным отладчиком. Собственно, это довольно тривиально, поэтому я не буду расписывать такие вещи подробно.
2) Заставить PG не обнулять отладочные регистры - заполняем nop'ами функцию KiNoDebugRoutine.
3) Заставить PG не обнулять стек, чтобы можно было увидеть call-stack.
После всех патчей, нужно прописать наше ядро в boot.ini следующим образом:
multi(0)disk(0)rdisk(0)partition(1)\WINDOWS="My Kernel Windows XP DEBUG" /noexecute=optin /fastdetect /debug /debugport=com1 /kernel=MyKernel.exe
Напомню, что все эксперименты ведутся на 64х битной Windows XP.
Теперь пару слов о том, почему патчится файл на диске, а не в памяти. Дело в том, что код PG находится в discardable секциях, то есть, данные секции будут выгружены из памяти сразу после инициализации ядра.
А код PG, содержащийся в них будет скопирован в память, и зашифрован, причем ключ шифрования будет меняться каждый раз, когда будет срабатывать таймаут для проверки целостности ОС. Об этом ниже.
Теперь можно спокойно подключать отладчик, беспрепятственно ставить hardware breakpoints и смотреть стеки вызовов в случае bsod'a вызванного PG'ом.
Инициализация PG
В начале статьи была упомянута функция KiInitializePatchGuard, самое время рассмотреть, что она делает.
Первым делом проверяется переменная InitSafeBootMode, и если ОС загружена в safe mode - инициализация PG завершается. Далее случайным образом выбирается тэг для памяти, из массива тагов 'AcpSFileIpFIIrp MutaNtFsNtrfSemaTCPc', в этой памяти будет храниться контекст PG.
Контекст заполняется так: копируется содержимое CmpAppendDllSection, представляющее собой функцию расшифровки, получаются адреса нужных PG функций. Случайным образом выбирается ключ шифрования для контекста PG, входом для генератора случайных чисел является инструкция rdtsc. Через cpuid определяется число валидных для адреса битов( для 64х битных платформ в действительности адреса 48-ми разрядные, а не 64х, как можно было бы подумать ), затем эта информация сохраняется в контексте PG. Далее опять выделяется память, случайного размера, в нее копируется контекст и код PG из секции INITKDBG. После этого, подсчитываются хеши для секций ntoskrnl.exe таких как .pdata, .edata, .idata, потом подсчитываются хеши для hal.dll и для ndis.sys.
Выглядит это в отладчике примерно так ( например, для секции .pdata ntoskrnl.exe ):
kd> dqs fffffadfe78bbb37
fffffadf`e78bbb37 00000000`00000001 // type
fffffadf`e78bbb3f fffff800`011a2000 nt!CcCleanSharedCacheMapList <PERF> (nt+0x1a2000) // section virtual address
fffffadf`e78bbb47 43986eee`00080bbc // hash | section virtual Size
После подсчета хешей секций, считаются хеши для системных структур:
KiServiceTable
KeServiceDescriptorTable
Gdt
Idt
Далее опять выделяется память и в нее копируется весь контекст вместе с хешами.
Запоминаются адреса KdpStub, KdpTrap, KiDebugRoutine. Генерируется случайный индекс, по нему из таблицы DPC процедур выбирается одна, адрес её запоминается в контексте PG. После этого, весь контекст зашифровывается с ранее выбранным случайно сгенерированным ключем. Далее случайным образом выбирается период для таймера, инициализируется таймер с DPC, которая запустится после окончания таймаута.
Этим действием заканчивается инициализация PG. По сути инициализация состоит в сборе информации о целостности системы, сохранению её в контексте PG, и подготовке DPC с зашифрованным контекстом, в котором находятся как хеши, так и определенная часть кода PG. Исходный же код PG будет удален из памяти загрузчиком ядра, так как находится в выгружаемой после инициализации секции ( discardable ).
Проверка целостности ОС
При инициализации PG выбиралась одна DPC из массива функций:
KiInitializePatchGuard:
INIT:0000000000821BFE lea rcx, cs:400000h
INIT:0000000000821C05 mov rax, [rcx+rax*8+42F4A8h] ; 82F4A8 - по этому адресу будет массив DPC процедур патч гварда
...
INIT:000000000082F4A8 dq offset KiScanReadyQueues
INIT:000000000082F4B0 dq offset ExpTimeRefreshDpcRoutine
INIT:000000000082F4B8 dq offset ExpTimeZoneDpcRoutine
Эти DPC функции играют ключевую роль в проверке целостности ОС.
Вернемся к инициализации DPC, она выполняется функцией VOID KeInitializeDpc( __out PRKDPC Dpc, __in PKDEFERRED_ROUTINE DeferredRoutine, __in_opt PVOID DeferredContext );
Ранее, при заполнении контекста PG через cpuid было получено число битов валидных для адреса. При инициализации DPC, в DeferredContext идет рандомный адрес, который содержит заведомо большее число битов, чем поддерживается.
Теперь рассмотрим, что произойдет в самой функции DPC, когда сработает таймер PG и она выполнится:
ExpTimeRefreshDpcRoutine proc near
{
.text: mov [rsp+arg_0], rcx
.text: sub rsp, 68h
.text: mov eax, 1
.text: xadd [rdx], eax ; cb419eef84149c09 <====== GP fault, goto KiGeneralProtectionFault
kd> !pte cb419eef84149c09
VA cb419eef84149c09
PXE at FFFFF6FB7DBED9E8 PPE at FFFFF6FB7DB3DDF0 PDE at FFFFF6FB67BBE100 PTE at FFFFF6CF77C20A48
contains 0000000000000000
not valid
Адрес естественно невалидный, что приводит нас к исключению, которое может быть обработано в SEH. Самое интересное, что эти DPC существовали и до PG, то есть это часть системы, и инженеры микрософт довольно искусно
вплели в них незаметный способ перехода на код проверки целостности системы - через исключения и обработку их в SEH.
Итак, теперь нужно увидеть SEH обработчики, поможет это сделать команда отладчика .fnent:
Описание команды: The .fnent command displays information about the function table entry for a specified function.
kd> .fnent nt!ExpTimeRefreshDpcRoutine
Debugger function entry 00000000`007c9298 for:
(fffff800`01101090) nt!ExpTimeRefreshDpcRoutine | (fffff800`01101180)
nt!ExpCenturyDpcRoutine
Exact matches:
nt!ExpTimeRefreshDpcRoutine = <no type information>
BeginAddress = 00000000`00101090
EndAddress = 00000000`001010bd
UnwindInfoAddress = 00000000`0013101c
Unwind info at fffff800`0113101c, 10 bytes
version 1, flags 1, prolog 9, codes 1
handler routine: nt!_C_specific_handler (fffff800`01029830), data 1
00: offs 9, unwind op 2, op info c UWOP_ALLOC_SMALL.
SEH обработчик располагается сразу за телом функции DPC, и первое, что он делает - передает управление на память контекста, которая по мере саморасшифровки превращается в функцию CmpAppendDllSection. А конечный результат этой функции - расшифровать контект и выполнить переход на еще одну функцию.
Псевдокод этой ф-ции:
VOID sub_565530( __in ULONG64 xoredPgContext )
{
PatchGuardContext *pgContext = (PatchGuardContext*)( ROL( xoredPgContext, 0x10 ) );
KiNoDebugRoutine();
pgContext->pgWorkItem.List = NULL;
pgContext->pgWorkItem.WorkerRoutine = (PUCHAR)pgContext + pgContext->AddressOfWorkItemFunc; // FsRtlUninitializeSmallMcb
pgContext->pgWorkItem.Parameter = pgContext;
return pgContext->ExQueueWorkItemPtr( &pgContext->pgWorkItem, DelayedWorkQueue );
}
Функция FsRtlUninitializeSmallMcb ответственна за проверку целостности ОС. Вернее, если быть точным, то она вызывает FsRtlMdlReadCompleteDevEx и освобождает предыдущий контект.
FsRtlMdlReadCompleteDevEx делает самопроверку контекста PG, и если данные в нем изменены - нас ждет неминуемый BSOD.
Далее рандомно выбирается число, которое идет в отдельный switch, каждый case которого - это проверка определенных данных ОС, то есть подсчет хешей и сравнение их с вычисленными хешами на этапе инициализации PG. После того, как проверка сделана, вызывается либо BSOD с кодом CRITICAL_STRUCTURE_CORRUPTION если найдены несовпадения, либо выполнение идет дальше - на создание новой области памяти с рандомным тэгом и рандомным размером. После чего контекст туда перекопируется, предварительно зашифровавшийся с новым ключем, выберется новая рандомная DPC функция из массива функций, выберется случайный таймаут и новая DPC будет ждать своего часа проверки целостности ОС.
Таким образом, код PG вместе с контекстом постоянно кочует в памяти, меняя тэги, ключи шифрования и размер памяти, в которой он сидит.
Заключение
Вот и подошел к концу небольшой рассказ о внутренностях Patch Guard. Была рассмотрена первая версия, появившаяся аж 7 лет назад, после этой версии вышли как минимум еще 2, в vista и в win7.
Решили ли микрософт свою задачу - прекратить практику патча ядра? И да, и нет. Разработчики проактивных защит негодуют по поводу скудности предоставленных интерфейсов для контроля системы, а вирусописатели обходят или отключают патчгвард. Получается, микрософт лишь частично выполнила свою задачу, и даже в какой-то мере помогла вирусописателям.
Но в любом случае, Patch Guard получился довольно интересным решением, и если вы хотите разобраться как работает Patch Guard, то лучший способ - это пройти все самому.
Устав от бесконечных патчей ядра как антивирусными продуктами, так и малварью, Microsoft в 2005м году ввели технологию защиты ядра от изменений - Kernel Patch Protection, или Patch Guard. PG разработан только для х64 систем, он появился в системах Windows XP и Windows Server 2003 Service Pack 1.
В данной статье будет рассмотрена реализация этой технологии( PG версии 1 ). Методов обхода PG в данной статье нет.
Итак, при патче некоторых системных структур или патче кода ядра( и не только его ) через некоторый промежуток времени произойдет BSOD с багчеком CRITICAL_STRUCTURE_CORRUPTION(0x00000109).
Чтобы получить некоторое общее представление о том, что контролирует PG, обратимся к описанию данного багчека, которое можно найти в msdn.
Тип, описание:
0x0, A generic data region
0x1, A function modification or the Itanium-based function location
0x2, A processor interrupt dispatch table (IDT)
0x3, A processor global descriptor table (GDT)
0x4, A type-1 process list corruption
0x5, A type-2 process list corruption
0x6, A debug routine modification
0x7, A critical MSR modification
Итак, список довольно внушительный. Контролируются системные таблицы, MSR'ы, недопускается патч функций ядра и некоторых других модулей( об этом ниже ) и т.д.
Самозащита PG и её отключение
Кроме того, что патч гвард защищает ядро ОС от изменений, он еще защищается и от любопытных глаз. Как он это делает?
Ну во-первых, любой ресерчер самым первым делом берет в руки отладчик. И в случае с PG получает неприятную вещь - он не работает одновременно с отладчиком. Получаем проблему - с включенным отладчиком не можем отлаживать PG, а с выключенным не можем ставить хуки на функции для получения информации о PG. Проблемы будем решать по мере их поступления, неплохо было бы, для начала, найти точку инициализации PG и заставить таки его работать вместе с отладчиком.
Патчгвард инициализируется незаметным способом на этапе инициализации ядра:
KeInitSystem:
mov rcx, qword ptr cs:KiTestDividend
mov edx, 0CB5FA3h
call KiDivide6432
cmp eax, 5EE0B7E5h
jnz loc_83B82C
...
loc_83B82C:
mov ecx, 5Dh
call KeBugCheck
Сама же функция KiDivide6432 просто выполняет деление:
KiDivide6432 proc near
mov eax, ecx
mov r8, rdx
shr rcx, 20h
mov edx, ecx
div r8d
retn
KiDivide6432 endp
Переменная которая будет разделена на 0CB5FA3 расположена таким образом, что перекрывается с переменной для обнаружения отладчика:
dq nt!KiTestDividend L1
fffff800011766e0 004b5fa3a053724c
db nt!KdDebuggerNotPresent L1
fffff800`011766e7 00
Таким образом без отладчика она равна: 014b5fa3a053724c
А с отладчиком данная переменная равна: 004b5fa3a053724c
Таким образом при включенном отладчике результат деления будет: 004b5fa3a053724c / 0CB5FA3 = 5EE0B7E5, то есть как раз той величине, с которой сравнивается. Но без отладчика, результат будет другой: 014b5fa3a053724c / 0CB5FA3 = 1A11F49AE.
В описании команды div сказано:
"Команда выполняет целочисленное деление операндов с выдачей результата деления в виде частного и остатка от деления. При выполнении операции деления возможно возникновение исключительной ситуации: 0 — ошибка деления. Эта ситуация возникает в одном из двух случаев: делитель равен 0 или частное слишком велико для его размещения в регистре eax/ax/al."
Видно, что результат 1A11F49AE явно в eax не поместится, так что возникает исключение Divide Error, которое задействует обработчик int 0.
IDT на х64 системах выглядит несколько иначе, чем на х86 системах, в плане названия ф-ций обработчиков:
0000000000831A90 KiInterruptInitTable dq 0
0000000000831A98 off_831A98 dq offset KiDivideErrorFault
0000000000831AA0 dq 1
0000000000831AA8 dq offset KiDebugTrapOrFault
0000000000831AB0 dq 30002h
0000000000831AB8 dq offset KiNmiInterrupt
0000000000831AC0 dq 303h
0000000000831AC8 dq offset KiBreakpointTrap
0000000000831AD0 dq 304h
0000000000831AD8 dq offset KiOverflowTrap
KiDivideErrorFault это нужный нам обработчик.
Далее через цепочку ф-ций дело приходит в: KiDivideErrorFault => KiExceptionDispatch => KiDispatchException => KiPreprocessFault => KiOpDecode => KiOpLocateDecodeEntry(вызов через KiOpOneByteTable[index]) => KiOp_Div
KiOp_Div:
call sub_403AA0 // вызов неизвестной ф-ции
Которая становится немного более известной:
sub_403AA0 proc near
mov eax, eax
KiFilterFiberContext proc near
...
KiFilterFiberContext endp
В виде псевдокода KiFilterFiberContext выглядит как:
BOOLEAN KiFilterFiberContext( PCONTEXT contextRecord )
{
PUCHAR exceptionAddress = (PUCHAR)KiDivide6432; // берется адрес ф-ции KiDivide6432, которая была вызвана для инициализации PG
exceptionAddress += 0xB; // к этому адресу прибавляется 0xB, таким образом он будет равен адресу вызвавшему исключение
if ( contextRecord->Rip != exceptionAddress ) // если адрес другой - пропускаем его
return FALSE;
if ( !KiInitializePatchGuard() ) // если исключение вызвано инструкцией div r8d внутри ф-ции KiDivide6432, то вызываем ф-цию инициализации PG
{
contextRecord->Rdx = 0xFFFF;
return FALSE;
}
contextRecord->Rdx = 0xFFFFFF; // если все прошло хорошо - ресетим rdx так, чтобы исключения не было. Таким образом, в этой точке PG полностью проинициализирован и ядро продолжит загрузку
return TRUE;
}
Стек инициализации патч гварда:
nt!KiFilterFiberContext+0x2a
nt!KiOp_Div+0x29
nt!KiPreprocessFault+0xc7
nt!KiDispatchException+0x85
nt!KiExceptionExit
nt!KiDivideErrorFault+0xb7
nt!KiDivide6432+0xb
Разобравшись с инициализацией PG встречаем еще одну проблему - невозможность поставить любой брекпойнт. Точнее, поставить то мы его сможем, но PG спустя время выдаст нам BSOD, ведь брекпойнт это int 3 в коде, а значит патч, а патчить что-либо под PG строго запрещается.
Любой, у кого есть хотя бы небольшой опыт отладки сразу же вспомнит про другой способ установки брекпойнтов - memory breakpoints, работающие через DR регистры процессора. Что же, это нам подходит. Однако и тут PG подложил нам свинью - он сбрасывает DR регистры! Вот как выглядит функция с помощью которой он это делает:
KiNoDebugRoutine proc near
xor eax, eax
mov dr7, rax
retn
KiNoDebugRoutine endp
PG просто обнуляет управляющий регистр, и все наши брекпойнты сбрасываются!
Далее, предположим, мы поставили брекпойнт, самый обычный, через bp address. PG обнаружил патч, сгенерировал BSOD. Как нам найти первоисточник, то есть функцию самого PG, которая ответственна за проверку целостности кода? Казалось бы ответ очевиден, BSOD генерируется известной функцией - KeBugCheckEx, ставим брекпойнт на нее, и смотрим стек вызовов, среди него будет и функция PG.
Однако тут мы встречаем проблему номер три - стек вызовов пустой, все регистры обнулены! Это еще один пусть защиты PG от изучения.
Делаются все эти нехорошие действия тут:
SdbpCheckDll proc near
arg_20 = qword ptr 28h
arg_28 = qword ptr 30h
arg_30 = qword ptr 38h
mov rsi, [rsp+arg_28]
mov rdi, [rsp+arg_20]
mov r10, [rsp+arg_30]
xor eax, eax
loc_567411:
mov [r10], rax
sub r10, 8
cmp r10, rsp
jnb short loc_567411
mov [rsp+arg_20], rdi
mov rbx, rax
mov rdi, rax
mov rbp, rax
mov r10, rax
mov r11, rax
mov r12, rax
mov r13, rax
mov r14, rax
mov r15, rax
jmp rsi // rsi = nt!KeBugCheckEx
SdbpCheckDll endp
В псевдокоде:
//
// Ф-ция зануляет в стеке обширный кусок данных, что не дает восстановить по колстеку причину падения.
// Также обнуляются все регистры. После чего вызывается BSOD с багчеком CRITICAL_STRUCTURE_CORRUPTION(109h)
//
VOID SdbpCheckDll( __in ULONG BugCheckCode, __in ULONG_PTR P1, __in ULONG_PTR P2, __in ULONG_PTR P3, __in ULONG_PTR P4, __in PVOID KeBugCheckExPtr, __in ULONG unkPtr )
{
ULONG size = (ULONG)( (PUCHAR)unkPtr - (PUCHAR)rsp );
size = size / sizeof(ULONG64);
memset( rsp, 0, size );
__asm
{
mov [rsp+28h], rdi ; обновляется P4, он нужен для вывода информации в bsod'е, т.к. показывает тип испорченных данных
xor eax, eax
mov rbx, rax
mov rdi, rax
mov rbp, rax
mov r10, rax
mov r11, rax
mov r12, rax
mov r13, rax
mov r14, rax
mov r15, rax
}
return KeBugCheckExPtr( BugCheckCode, P1, P2, P3, P4 );
}
Теперь, когда вся информация есть, можно заставить PG работать вместе с отладчиком. Чтобы это сделать, нужно скопировать ядро, переименовать его, например, в MyKernel.exe пропатчить его на диске.
Патчить нужно, чтобы решить три проблемы перечисленные выше:
1) Заставить PG работать вместе с отладчиком, для этого патчим константу таким образом, чтобы сгенерировалось исключение даже с включенным отладчиком. Собственно, это довольно тривиально, поэтому я не буду расписывать такие вещи подробно.
2) Заставить PG не обнулять отладочные регистры - заполняем nop'ами функцию KiNoDebugRoutine.
3) Заставить PG не обнулять стек, чтобы можно было увидеть call-stack.
После всех патчей, нужно прописать наше ядро в boot.ini следующим образом:
multi(0)disk(0)rdisk(0)partition(1)\WINDOWS="My Kernel Windows XP DEBUG" /noexecute=optin /fastdetect /debug /debugport=com1 /kernel=MyKernel.exe
Напомню, что все эксперименты ведутся на 64х битной Windows XP.
Теперь пару слов о том, почему патчится файл на диске, а не в памяти. Дело в том, что код PG находится в discardable секциях, то есть, данные секции будут выгружены из памяти сразу после инициализации ядра.
А код PG, содержащийся в них будет скопирован в память, и зашифрован, причем ключ шифрования будет меняться каждый раз, когда будет срабатывать таймаут для проверки целостности ОС. Об этом ниже.
Теперь можно спокойно подключать отладчик, беспрепятственно ставить hardware breakpoints и смотреть стеки вызовов в случае bsod'a вызванного PG'ом.
Инициализация PG
В начале статьи была упомянута функция KiInitializePatchGuard, самое время рассмотреть, что она делает.
Первым делом проверяется переменная InitSafeBootMode, и если ОС загружена в safe mode - инициализация PG завершается. Далее случайным образом выбирается тэг для памяти, из массива тагов 'AcpSFileIpFIIrp MutaNtFsNtrfSemaTCPc', в этой памяти будет храниться контекст PG.
Контекст заполняется так: копируется содержимое CmpAppendDllSection, представляющее собой функцию расшифровки, получаются адреса нужных PG функций. Случайным образом выбирается ключ шифрования для контекста PG, входом для генератора случайных чисел является инструкция rdtsc. Через cpuid определяется число валидных для адреса битов( для 64х битных платформ в действительности адреса 48-ми разрядные, а не 64х, как можно было бы подумать ), затем эта информация сохраняется в контексте PG. Далее опять выделяется память, случайного размера, в нее копируется контекст и код PG из секции INITKDBG. После этого, подсчитываются хеши для секций ntoskrnl.exe таких как .pdata, .edata, .idata, потом подсчитываются хеши для hal.dll и для ndis.sys.
Выглядит это в отладчике примерно так ( например, для секции .pdata ntoskrnl.exe ):
kd> dqs fffffadfe78bbb37
fffffadf`e78bbb37 00000000`00000001 // type
fffffadf`e78bbb3f fffff800`011a2000 nt!CcCleanSharedCacheMapList <PERF> (nt+0x1a2000) // section virtual address
fffffadf`e78bbb47 43986eee`00080bbc // hash | section virtual Size
После подсчета хешей секций, считаются хеши для системных структур:
KiServiceTable
KeServiceDescriptorTable
Gdt
Idt
Далее опять выделяется память и в нее копируется весь контекст вместе с хешами.
Запоминаются адреса KdpStub, KdpTrap, KiDebugRoutine. Генерируется случайный индекс, по нему из таблицы DPC процедур выбирается одна, адрес её запоминается в контексте PG. После этого, весь контекст зашифровывается с ранее выбранным случайно сгенерированным ключем. Далее случайным образом выбирается период для таймера, инициализируется таймер с DPC, которая запустится после окончания таймаута.
Этим действием заканчивается инициализация PG. По сути инициализация состоит в сборе информации о целостности системы, сохранению её в контексте PG, и подготовке DPC с зашифрованным контекстом, в котором находятся как хеши, так и определенная часть кода PG. Исходный же код PG будет удален из памяти загрузчиком ядра, так как находится в выгружаемой после инициализации секции ( discardable ).
Проверка целостности ОС
При инициализации PG выбиралась одна DPC из массива функций:
KiInitializePatchGuard:
INIT:0000000000821BFE lea rcx, cs:400000h
INIT:0000000000821C05 mov rax, [rcx+rax*8+42F4A8h] ; 82F4A8 - по этому адресу будет массив DPC процедур патч гварда
...
INIT:000000000082F4A8 dq offset KiScanReadyQueues
INIT:000000000082F4B0 dq offset ExpTimeRefreshDpcRoutine
INIT:000000000082F4B8 dq offset ExpTimeZoneDpcRoutine
Эти DPC функции играют ключевую роль в проверке целостности ОС.
Вернемся к инициализации DPC, она выполняется функцией VOID KeInitializeDpc( __out PRKDPC Dpc, __in PKDEFERRED_ROUTINE DeferredRoutine, __in_opt PVOID DeferredContext );
Ранее, при заполнении контекста PG через cpuid было получено число битов валидных для адреса. При инициализации DPC, в DeferredContext идет рандомный адрес, который содержит заведомо большее число битов, чем поддерживается.
Теперь рассмотрим, что произойдет в самой функции DPC, когда сработает таймер PG и она выполнится:
ExpTimeRefreshDpcRoutine proc near
{
.text: mov [rsp+arg_0], rcx
.text: sub rsp, 68h
.text: mov eax, 1
.text: xadd [rdx], eax ; cb419eef84149c09 <====== GP fault, goto KiGeneralProtectionFault
kd> !pte cb419eef84149c09
VA cb419eef84149c09
PXE at FFFFF6FB7DBED9E8 PPE at FFFFF6FB7DB3DDF0 PDE at FFFFF6FB67BBE100 PTE at FFFFF6CF77C20A48
contains 0000000000000000
not valid
Адрес естественно невалидный, что приводит нас к исключению, которое может быть обработано в SEH. Самое интересное, что эти DPC существовали и до PG, то есть это часть системы, и инженеры микрософт довольно искусно
вплели в них незаметный способ перехода на код проверки целостности системы - через исключения и обработку их в SEH.
Итак, теперь нужно увидеть SEH обработчики, поможет это сделать команда отладчика .fnent:
Описание команды: The .fnent command displays information about the function table entry for a specified function.
kd> .fnent nt!ExpTimeRefreshDpcRoutine
Debugger function entry 00000000`007c9298 for:
(fffff800`01101090) nt!ExpTimeRefreshDpcRoutine | (fffff800`01101180)
nt!ExpCenturyDpcRoutine
Exact matches:
nt!ExpTimeRefreshDpcRoutine = <no type information>
BeginAddress = 00000000`00101090
EndAddress = 00000000`001010bd
UnwindInfoAddress = 00000000`0013101c
Unwind info at fffff800`0113101c, 10 bytes
version 1, flags 1, prolog 9, codes 1
handler routine: nt!_C_specific_handler (fffff800`01029830), data 1
00: offs 9, unwind op 2, op info c UWOP_ALLOC_SMALL.
SEH обработчик располагается сразу за телом функции DPC, и первое, что он делает - передает управление на память контекста, которая по мере саморасшифровки превращается в функцию CmpAppendDllSection. А конечный результат этой функции - расшифровать контект и выполнить переход на еще одну функцию.
Псевдокод этой ф-ции:
VOID sub_565530( __in ULONG64 xoredPgContext )
{
PatchGuardContext *pgContext = (PatchGuardContext*)( ROL( xoredPgContext, 0x10 ) );
KiNoDebugRoutine();
pgContext->pgWorkItem.List = NULL;
pgContext->pgWorkItem.WorkerRoutine = (PUCHAR)pgContext + pgContext->AddressOfWorkItemFunc; // FsRtlUninitializeSmallMcb
pgContext->pgWorkItem.Parameter = pgContext;
return pgContext->ExQueueWorkItemPtr( &pgContext->pgWorkItem, DelayedWorkQueue );
}
Функция FsRtlUninitializeSmallMcb ответственна за проверку целостности ОС. Вернее, если быть точным, то она вызывает FsRtlMdlReadCompleteDevEx и освобождает предыдущий контект.
FsRtlMdlReadCompleteDevEx делает самопроверку контекста PG, и если данные в нем изменены - нас ждет неминуемый BSOD.
Далее рандомно выбирается число, которое идет в отдельный switch, каждый case которого - это проверка определенных данных ОС, то есть подсчет хешей и сравнение их с вычисленными хешами на этапе инициализации PG. После того, как проверка сделана, вызывается либо BSOD с кодом CRITICAL_STRUCTURE_CORRUPTION если найдены несовпадения, либо выполнение идет дальше - на создание новой области памяти с рандомным тэгом и рандомным размером. После чего контекст туда перекопируется, предварительно зашифровавшийся с новым ключем, выберется новая рандомная DPC функция из массива функций, выберется случайный таймаут и новая DPC будет ждать своего часа проверки целостности ОС.
Таким образом, код PG вместе с контекстом постоянно кочует в памяти, меняя тэги, ключи шифрования и размер памяти, в которой он сидит.
Заключение
Вот и подошел к концу небольшой рассказ о внутренностях Patch Guard. Была рассмотрена первая версия, появившаяся аж 7 лет назад, после этой версии вышли как минимум еще 2, в vista и в win7.
Решили ли микрософт свою задачу - прекратить практику патча ядра? И да, и нет. Разработчики проактивных защит негодуют по поводу скудности предоставленных интерфейсов для контроля системы, а вирусописатели обходят или отключают патчгвард. Получается, микрософт лишь частично выполнила свою задачу, и даже в какой-то мере помогла вирусописателям.
Но в любом случае, Patch Guard получился довольно интересным решением, и если вы хотите разобраться как работает Patch Guard, то лучший способ - это пройти все самому.
Толковая статья. Спасибо!
ОтветитьУдалитьНеплохо!
ОтветитьУдалить