Статья затрагивает такие понятия как подсистема Win32 пользователя, поддержка её на строне ядра в лице драйвера win32k.sys, способы программного перехода из юзера в кернел с использованием таблиц SSDT, ну и всё-что с ними связано. Основной упор делается на изменения, которые принесла с собой современная архитектура х86-64. Чтобы исключить всякого рода подозрения, вся теория сопровождается практикой в отладчике WinDbg. Поскольку это логическое продолжение первой части, она рекомендуется к прочтению.
1. Подсистема Win32 в архитектуре Windows
Если не считать внутренних изменений, базовая архитектура Windows остаётся неизменной вот уже на протяжении 30-ти лет. На аппаратном уровне с поддержкой самим ЦП, всё пространство вирт.памяти по-братски делится на User и Kernel – нижняя половина до адреса
На рис.ниже представлена топология основных компонентов Windows. Мы не будем расписывать здесь все элементарные вещи – этому посвящены сотни книг, например тот-же бестселлер «Внутреннее устройство Win» Марка Руссиновича, как отправная точка всех времён и народов. Нас будет интересовать лишь пользовательская подсистема Win32 и всё, что с ней связано, а это: модуль CSRSS.EXE или Client/Server Runtime SubSystem, порты межпроцессной связи ALPC, драйвер поддержки подсистемы Win32 на стороне ОС в лице Win32k.sys, и назначение его расшаренной памяти Win32k!gSharedMemory.
Изначально в Win присутствовали целых три подсистемы: OS/2, Posix и родная Win32. Но позже по дороге к Win2000 NT4 первые две отвалились, поскольку их поддержка стала не рентабельной. Так, вплоть до Win10 мы довольствовались лишь подсистемой Win32, после чего Posix опять восстановили, только на этот раз назвали её WSL или «Windows Subsystem for Linux». Однако основной остаётся всё-же Win с библиотеками окружения типа Kernel32, User32, Gdi32 и остальные, с суффиксами 32. В контексте данной темы нас будут интересовать исключительно User+Gdi, как либы поддержки графического интерфейса GUI «Graphics User Interface».
Несколько слов о файле ядра NtosKrnl.exe..
Windows построена на модульном микроядре, когда к крошечному «шарику» Kernel со всех сторон присавокупляются различного рода модули, в лице драйверов. В результате всё работает как единое-целое, хотя по факту таковым не являясь. Модульная схема позволяет без особых усилий расширять функциональность ядра, соблюдая при этом обратную совместимость. Помимо микро-ядерной архитектуры в природе имеются и монолитные ядра – старейший способ постоения ОС, когда для расширения ядра требуется полная его перекомпиляция (привет Linux, Kolibri, MS-DOS). Есть ещё и гибридные ядра, но они не обладают индивидуальностью, а потому в счёт их не берём.
На рисунке выше отчётливо видно, что ядро Ntoskrnl.exe внутри делится на 2 части – большая исполнительная Executive, и включающая в себя всего несколько критически важных процедур – непосредственно Kernel. Всю черновую работу проделывает первая, где сосредоточены почти все диспетчеры системы. Чтобы обозначить принадлежность функций ядра к тому или иному компоненту ОС, они предваряются перечисленными ниже префиксами, например NtCreateFile(), ExAllocatePool(), IoGetCurrentProcess(). Некоторые функции только для внутреннего Internals пользования, а потому Ntoskrnl не выдаёт их на экспорт и всеобщее обозрение – например Ke это внешние Externals, а Ki внутренние Internals, Ps – это внешняя Process support, а Psp внутренняя:
2. Файлы Csrss.exe, Gdi32.dll, и Win32k.sys
Интерфейс GDI (Graphics Device Interface) обеспечивает вывод графического контента на такие устройства как мониторы и принтеры. Он находится в библиотеке юзера gdi32.dll, а поддержка его в ядре обеспечивается драйвером win32k.sys, который напрямую взаимодействует уже с драйвером самого видеодаптера. Но так было не всегда. Например до Win2000 (далее Win2k NT4) в ядре находился лишь драйвер видео, а диспетчер окон и вся его братия располагались в пространстве пользователя в файле csrss.exe. Это была палка с двумя концами – поскольку дорогостоящие переходы из юзера в ядро не требовались, графика работала быстро. С другой стороны GUI – по сути основной компонент системы, а потому открывать нараспашку его двери всем юзерам было как минимум не разумно. Так, начиная с NT4 поддержка графики была перемещена в драйвер режима ядра win32k.sys, а csrss отвечает теперь только за обработку консоли Win32, и завершение работы гуя.
CSRSS работает как системная служба пользовательского режима. Если открыть диспетчер задач Win, то обнаружим две копии этого файла, для каждой сессии свой. Когда процесс юзера вызывает функцию интерфейса консольных окон типа Kernel32!Read/WriteConsole(), библиотеки подсистемы Win32 (kernel32, user32, gdi32.dll) не запрашивают переход в ядро, а по каналу межпроцессной связи LPC отправляют вызов процессу csrss.exe, который и выполняет большую часть фактической работы не беспокоя ядро. Кстати начиная с Win7 прорисовкой консольных окон занимается уже не сам csrss, а порождаемый им подпроцесс conhost.exe (хост консоли).
Другое дело, когда процесс пользователя использует графический (оконный) интерфейс GUI. Тогда к делу нужно подключать менеджер окон через интерфейс Gdi, который сосредоточен в драйвере режима ядра win32k.sys. В целях популяризации безопасного секса, большинство операций осуществляются по закрытым каналам LPC (например передача блоков данных), хотя непосредственно вызовы API-функций из либ User32 и Gdi32.dll проходят как обычно через
3. SSDT – System Service Descriptor Table
Классическим способом перехода из юзера в ядро является схема с использованием системной таблицы дескрипторов SSDT. Непосредственно переход реализован в инструкциях процессора
В современных реалиях
Осуществив переход в ядро код должен знать, что делать дальше – эту информацию система передаёт инструкции
На данном этапе часть Executive ядра обращается к своей таблице SSDT, чтобы по номеру сервиса SSN получить адрес его обработчика. Информативными являются только биты[13:0] в значении SSN, при этом биты[11:0] выделяются под индекс в таблице, а биты[13:12] выбирают одну из двух – или основную таблицу(00) с функциями ядра Ntoskrnl.exe, или-же таблицу(01) с функциями поддержки графики из Win32k.sys. Последняя называется Shadow и включает в себя полную ксерокопию первой. На рис.ниже представлен процесс получения указателя на обработчик Routine, по номеру сервиса SSN:
Как видим, каждая из таблиц SSDT#0/1 имеет свои заголовки, где в поле ServiceTable указывается адрес таблицы в системной памяти, в поле ServiceLimit лежит кол-во записей в таблице, и в поле ArgumentTable хранится указатель на таблицу аргументов. Каждая запись в таблице SSDT размером 32-бит, и к этой записи привязывается 1 байт из таблицы аргументов. В этом байте указывается число аргументов требуемой функции, чтобы выделить ей стековый фрейм подходящего размера. Таким образом, система обрабатывает эти две таблицы параллельно, т.е. если
На системах х32 записи в SSDT представляли собой абсолютный 32-битный адрес обработчика вызываемой функции, но с приходом х64 адреса теперь относительные. Чтобы получить абсолютный 64-битный адрес, нужно 32-битное значение записи сдвинуть арифметически на 4-бита вправо, и прибавить базу в виде адреса самой таблицы ServiceTable. Тогда получаем формулу:
В номере SSN под индекс записи выделяются всего 12-бит, что позволяет адресовать пространство в 4096 байт (т.е. одну страницу вирт.памяти). Но поскольку размер одной записи равен 4-байта, то получаем всего по 1024 записей в каждой из таблиц, или 400h. Важно понять, что если номер SSN меньше 400h, то задействуется системная таблица SSDT#0, если-же больше 400h, то соответственно таблица win32k SSDT#1. Если вызывающее API-функцию приложение является консольным CUI (Console User Interface), система всегда будет читать только SSDT#0, в то время как таблица win32k предназначена исключительно для кода с графическим интерфейсом GUI.
Раньше модификация записей SSDT позволяла перенаправлять системные вызовы в процедуры за пределами ядра. Этот факт не мог пройти мимо руткитов и прочей малвари, которые свободно могли исполнять свой код с привилегиями ядра. Но с приходом х64 и системного сторожа PathGuard всё изменилось – содержимое обоих таблиц теперь хэшируется, и при малейших модах Guard сразу подымает ахтунг и тревогу, с последующим вылетом в BSOD.
В былые времена указатель на активную SSDT прописывался в поле ServiceTable структуры потока
Предлагаю немного потискать отладчик WinDbg, чтобы предоставить пруфы вышесказанному.
Во-первых нужно обязательно подключиться к какому-нибудь процессу GUI, чтобы запрашивать команды в его контексте – пусть это будет системный диспетчер задач TaskMgr.exe, хотя вы можете выбрать любой произвольный процесс.
Можно попробовать найти символы экспорта в модуле командой
Команда
Как видим, в заголовке первой SSDT#0 всего одна структура, а в хидере SSDT#1 уже две. Обратите внимание на первый столбец лога – не смотря на то, что в шадов лежит точная копия первой, в памяти они расположены по разным адресам
Выше упоминалось, что записи в таблицах SSDT на системах х64 нужно преобразовывать из относительных в абсолютные адреса функций – пробуем первые из обоих таблиц. Если всё верно, то должны получить точку-входа в соответствующую функцию (указатель на неё). Кстати WinDbg имеет нюанс при арифм.операциях. Когда нам требуется расширение числа со-знаком, то значение обязательно нужно брать в скобки, иначе получим беззнаковое расширение с 32 до 64-бит:
А что, если нужно найти в SSDT запись под произвольным номером, например как выше
Осталось разобраться, почему мы сдвигаем значение записи SSDT на 4-бита вправо?
Здесь нужно вспомнить про имеющийся в заголовке указатель на таблицу аргументов. Дело в том, что в целях поддержки совместимости (а может чисто из-за лени), инженеры не стали расширять ArgumentTable на системах х64. Если раньше на х32 в этой таблице каждый байт определял кол-во аргументов функции, то на х64 эта таблица в принципе не нужна, т.к. теперь число аргументов кодируется прямо в младших 4-битах самой записи SSDT. Именно по этой причине мы и удаляем их сдвигом вправо. Рассмотрим это на сл.примере:
Значит в трёх первых записях KiServiceTable младшие 4-бита равны нулю, и эти-же нули видим в первых трёх байтах таблицы аргументов. Следующим идёт аргумент со-значением
Заключение
Под занавес хотелось-бы отдать должное инженерам Microsoft, которые не покладая рук стараются повысить безопасность своей системы, ведь в конечном счёте это проецируется и на нас с вами. Один только ASLR с PatchGuard’ом обезоружили не одно поколение малвари, не говоря уже о сотни мелких, но принципиально важных плюшек внутри оси. Актуальные в своё время атаки на таблицы SSDT теперь уже в зародыше обречены на провал, и это делает систему более стабильной. В следующей части поговорим об объектах USER и GDI, а пока на этом всё.
1. Подсистема Win32 в архитектуре Windows
Если не считать внутренних изменений, базовая архитектура Windows остаётся неизменной вот уже на протяжении 30-ти лет. На аппаратном уровне с поддержкой самим ЦП, всё пространство вирт.памяти по-братски делится на User и Kernel – нижняя половина до адреса
0x000007ff'ffffffff
отдаётся в распоряжение пользователя (8 ТБайт на десктопных ос), а верхняя до 0x0000ffff'ffffffff
является частным владением ядра. Поскольку 64-бита это просто исполинское значение, на практике используются только 48-бит из них, с мак.адресом 256 ТБайт. Чтобы по достижении потолка завернуть адрес в кольцо, неиспользуемые 16-бит инженеры взвели в единицу. В результате макс.адрес памяти стал инверсным, т.е. вместо 0x0000ffff'ffffffff
получаем 0xffff0000'00000000
. Теперь по старшим битам можем сразу сделать вывод, что структура EPROCESS
находится в адресном пространстве ядра, а РЕВ
на территории юзверя:
Код:
0: kd> dt _eprocess fffffa80’0382f900 ImageFileName Peb HighestUserAddress
nt!_EPROCESS
+0x2e0 ImageFileName : [15] "SharedInfo.EXE"
+0x338 Peb : 0x000007ff`fffd6000 _PEB
+0x430 HighestUserAddress : 0x000007ff`ffff0000 <---- макс.адрес юзера
0: kd>
На рис.ниже представлена топология основных компонентов Windows. Мы не будем расписывать здесь все элементарные вещи – этому посвящены сотни книг, например тот-же бестселлер «Внутреннее устройство Win» Марка Руссиновича, как отправная точка всех времён и народов. Нас будет интересовать лишь пользовательская подсистема Win32 и всё, что с ней связано, а это: модуль CSRSS.EXE или Client/Server Runtime SubSystem, порты межпроцессной связи ALPC, драйвер поддержки подсистемы Win32 на стороне ОС в лице Win32k.sys, и назначение его расшаренной памяти Win32k!gSharedMemory.
Изначально в Win присутствовали целых три подсистемы: OS/2, Posix и родная Win32. Но позже по дороге к Win2000 NT4 первые две отвалились, поскольку их поддержка стала не рентабельной. Так, вплоть до Win10 мы довольствовались лишь подсистемой Win32, после чего Posix опять восстановили, только на этот раз назвали её WSL или «Windows Subsystem for Linux». Однако основной остаётся всё-же Win с библиотеками окружения типа Kernel32, User32, Gdi32 и остальные, с суффиксами 32. В контексте данной темы нас будут интересовать исключительно User+Gdi, как либы поддержки графического интерфейса GUI «Graphics User Interface».
Несколько слов о файле ядра NtosKrnl.exe..
Windows построена на модульном микроядре, когда к крошечному «шарику» Kernel со всех сторон присавокупляются различного рода модули, в лице драйверов. В результате всё работает как единое-целое, хотя по факту таковым не являясь. Модульная схема позволяет без особых усилий расширять функциональность ядра, соблюдая при этом обратную совместимость. Помимо микро-ядерной архитектуры в природе имеются и монолитные ядра – старейший способ постоения ОС, когда для расширения ядра требуется полная его перекомпиляция (привет Linux, Kolibri, MS-DOS). Есть ещё и гибридные ядра, но они не обладают индивидуальностью, а потому в счёт их не берём.
На рисунке выше отчётливо видно, что ядро Ntoskrnl.exe внутри делится на 2 части – большая исполнительная Executive, и включающая в себя всего несколько критически важных процедур – непосредственно Kernel. Всю черновую работу проделывает первая, где сосредоточены почти все диспетчеры системы. Чтобы обозначить принадлежность функций ядра к тому или иному компоненту ОС, они предваряются перечисленными ниже префиксами, например NtCreateFile(), ExAllocatePool(), IoGetCurrentProcess(). Некоторые функции только для внутреннего Internals пользования, а потому Ntoskrnl не выдаёт их на экспорт и всеобщее обозрение – например Ke это внешние Externals, а Ki внутренние Internals, Ps – это внешняя Process support, а Psp внутренняя:
2. Файлы Csrss.exe, Gdi32.dll, и Win32k.sys
Интерфейс GDI (Graphics Device Interface) обеспечивает вывод графического контента на такие устройства как мониторы и принтеры. Он находится в библиотеке юзера gdi32.dll, а поддержка его в ядре обеспечивается драйвером win32k.sys, который напрямую взаимодействует уже с драйвером самого видеодаптера. Но так было не всегда. Например до Win2000 (далее Win2k NT4) в ядре находился лишь драйвер видео, а диспетчер окон и вся его братия располагались в пространстве пользователя в файле csrss.exe. Это была палка с двумя концами – поскольку дорогостоящие переходы из юзера в ядро не требовались, графика работала быстро. С другой стороны GUI – по сути основной компонент системы, а потому открывать нараспашку его двери всем юзерам было как минимум не разумно. Так, начиная с NT4 поддержка графики была перемещена в драйвер режима ядра win32k.sys, а csrss отвечает теперь только за обработку консоли Win32, и завершение работы гуя.
CSRSS работает как системная служба пользовательского режима. Если открыть диспетчер задач Win, то обнаружим две копии этого файла, для каждой сессии свой. Когда процесс юзера вызывает функцию интерфейса консольных окон типа Kernel32!Read/WriteConsole(), библиотеки подсистемы Win32 (kernel32, user32, gdi32.dll) не запрашивают переход в ядро, а по каналу межпроцессной связи LPC отправляют вызов процессу csrss.exe, который и выполняет большую часть фактической работы не беспокоя ядро. Кстати начиная с Win7 прорисовкой консольных окон занимается уже не сам csrss, а порождаемый им подпроцесс conhost.exe (хост консоли).
Другое дело, когда процесс пользователя использует графический (оконный) интерфейс GUI. Тогда к делу нужно подключать менеджер окон через интерфейс Gdi, который сосредоточен в драйвере режима ядра win32k.sys. В целях популяризации безопасного секса, большинство операций осуществляются по закрытым каналам LPC (например передача блоков данных), хотя непосредственно вызовы API-функций из либ User32 и Gdi32.dll проходят как обычно через
SYSCALL
. Значимость win32k.sys при работе с графикой подчёркивает тот факт, что в глобальной системе для его функций выделяется специальная затенённая таблица «SSDT Shadow» – рассмотрим, что она представляет собой как сущность.3. SSDT – System Service Descriptor Table
Классическим способом перехода из юзера в ядро является схема с использованием системной таблицы дескрипторов SSDT. Непосредственно переход реализован в инструкциях процессора
SYSENTER/SYSEXIT
на системах х32, и SYSCALL/SYSRET
на системах х64. В допотопные времена использовалось ещё и прерывание INT-2Eh
, но сейчас его отправили уже на свалку истории, о чём свидетельствует отсутствие этого номера в таблице диспетчеризации прерываний IDT (Interrupt Dispatch Table).
Код:
0: kd> !idt
Dumping IDT:
(.....)
13: fffff80002cbdb00 nt!KiXmmException
1f: fffff80002d09f10 nt!KiApcInterrupt
2c: fffff80002cbdcc0 nt!KiRaiseAssertion
2d: fffff80002cbddc0 nt!KiDebugServiceTrap <--- после 2d сразу идёт 2f
2f: fffff80002d0a1f0 nt!KiDpcInterrupt
(.....)
В современных реалиях
SYSENTER
берёт адрес перехода в ядро напрямую из модельно-специфичных регистров MSR.IA32_SYSENTER_CS/EIP/ESP
, а потому операция выполняется намного быстрее схемы с прерыванием INT-2Eh
. В режиме х64 инструкция SYSCALL
вызывает обработчик системных вызовов загружая RIP
из IA32_LSTAR
, а сегментные регистры кода CS
и стека SS
значениями из битов[47:32] IA32_STAR
. К регистру флагов RFLAGS
применяется маска из IA32_FMASK
, после чего процессор оказывается в процедуре ядра. Если дизассемблировать адрес RIP
из скрина ниже, то упрёмся в неэкспортируемую функцию nt!KiSystemCall64(), а значит мы на правильном пути:
Код:
0: kd> u 0xfffff80002cd2180
nt!KiSystemCall64:
fffff800`02cd2180 0f01f8 swapgs
fffff800`02cd2183 65488924251000 mov qword ptr gs:[10h],rsp
fffff800`02cd218c 65488b2425a801 mov rsp,qword ptr gs:[1A8h]
fffff800`02cd2195 6a2b push 2Bh
fffff800`02cd2197 65ff3425100000 push qword ptr gs:[10h]
fffff800`02cd219f 4153 push r11
fffff800`02cd21a1 6a33 push 33h
fffff800`02cd21a3 51 push rcx
(.....)
Осуществив переход в ядро код должен знать, что делать дальше – эту информацию система передаёт инструкции
SYSCALL
в виде аргумента в регистре EAX
. В доках данное значение известно как «System Service Number» или просто SSN. Например когда мы вызываем CreateProcess() из Kernel32.dll, она преобразуется в функцию NtCreateProcess() из Ntdll.dll – пробуем дизассемблировать последнюю:
Код:
0: kd> u NtCreateProcess
ntdll!ZwCreateProcess:
00000000`77b2c550 4c8bd1 mov r10,rcx
00000000`77b2c553 b89f000000 mov eax,9Fh <--- номер сервиса = 9Fh
00000000`77b2c558 0f05 syscall
00000000`77b2c55a c3 ret
На данном этапе часть Executive ядра обращается к своей таблице SSDT, чтобы по номеру сервиса SSN получить адрес его обработчика. Информативными являются только биты[13:0] в значении SSN, при этом биты[11:0] выделяются под индекс в таблице, а биты[13:12] выбирают одну из двух – или основную таблицу(00) с функциями ядра Ntoskrnl.exe, или-же таблицу(01) с функциями поддержки графики из Win32k.sys. Последняя называется Shadow и включает в себя полную ксерокопию первой. На рис.ниже представлен процесс получения указателя на обработчик Routine, по номеру сервиса SSN:
Как видим, каждая из таблиц SSDT#0/1 имеет свои заголовки, где в поле ServiceTable указывается адрес таблицы в системной памяти, в поле ServiceLimit лежит кол-во записей в таблице, и в поле ArgumentTable хранится указатель на таблицу аргументов. Каждая запись в таблице SSDT размером 32-бит, и к этой записи привязывается 1 байт из таблицы аргументов. В этом байте указывается число аргументов требуемой функции, чтобы выделить ей стековый фрейм подходящего размера. Таким образом, система обрабатывает эти две таблицы параллельно, т.е. если
SSN=9Fh
, то читается байт по такому-же смещению(9Fh) из таблицы аргументов.На системах х32 записи в SSDT представляли собой абсолютный 32-битный адрес обработчика вызываемой функции, но с приходом х64 адреса теперь относительные. Чтобы получить абсолютный 64-битный адрес, нужно 32-битное значение записи сдвинуть арифметически на 4-бита вправо, и прибавить базу в виде адреса самой таблицы ServiceTable. Тогда получаем формулу:
RoutineAddr = ServiceTableAddr + (EntryValue >>> 4)
.В номере SSN под индекс записи выделяются всего 12-бит, что позволяет адресовать пространство в 4096 байт (т.е. одну страницу вирт.памяти). Но поскольку размер одной записи равен 4-байта, то получаем всего по 1024 записей в каждой из таблиц, или 400h. Важно понять, что если номер SSN меньше 400h, то задействуется системная таблица SSDT#0, если-же больше 400h, то соответственно таблица win32k SSDT#1. Если вызывающее API-функцию приложение является консольным CUI (Console User Interface), система всегда будет читать только SSDT#0, в то время как таблица win32k предназначена исключительно для кода с графическим интерфейсом GUI.
Раньше модификация записей SSDT позволяла перенаправлять системные вызовы в процедуры за пределами ядра. Этот факт не мог пройти мимо руткитов и прочей малвари, которые свободно могли исполнять свой код с привилегиями ядра. Но с приходом х64 и системного сторожа PathGuard всё изменилось – содержимое обоих таблиц теперь хэшируется, и при малейших модах Guard сразу подымает ахтунг и тревогу, с последующим вылетом в BSOD.
В былые времена указатель на активную SSDT прописывался в поле ServiceTable структуры потока
KTHREAD
, а ныне это поле отправили к праотцам, чтобы не маячило зря перед глазами. Более того, начиная с Win10 скрыли и сами два указателя на заголовки таблиц KeServiceDescriptorTable/Shadow
, хотя раньше они выходили на экспорт из Ntoskrnl.exe. Единственный способ найти их на Win10 – это дизассемблировать функцию nt!KiSystemServiceRepeat(), которая входит в состав уже знакомой нам KiSystemCall64(). В общем чем дальше, тем страшнее..
Код:
0: kd> u nt!KiSystemServiceRepeat
nt!KiSystemServiceRepeat:
fffff800`02cbe2b2 4c8d1547d62300 lea r10,[nt!KeServiceDescriptorTable (fffff800`02efb900)]
fffff800`02cbe2b9 4c8d1d00d72300 lea r11,[nt!KeServiceDescriptorTableShadow (fffff800`02efb9c0)]
fffff800`02cbe2c0 f7830001000080 test dword ptr [rbx+100h],80h
fffff800`02cbe2ca 4d0f45d3 cmovne r10,r11
Предлагаю немного потискать отладчик WinDbg, чтобы предоставить пруфы вышесказанному.
Во-первых нужно обязательно подключиться к какому-нибудь процессу GUI, чтобы запрашивать команды в его контексте – пусть это будет системный диспетчер задач TaskMgr.exe, хотя вы можете выбрать любой произвольный процесс.
Код:
0: kd> !process 0 0 taskmgr.exe
PROCESS fffffa8001f189c0
SessionId: 1 Cid: 07d4 Peb: 7fffffd9000 ParentCid: 0698
DirBase: 3cf35000 ObjectTable: fffff8a001abd080 HandleCount: 124.
Image: taskmgr.exe
0: kd> .process /p fffffa8001f189c0 <-----// Подключиться к процессу!!!
Implicit process is now fffffa80`01f189c0
Можно попробовать найти символы экспорта в модуле командой
х
, передав ей маску поиска – есть такие:
Код:
0: kd> x nt!*KeServiceDescriptorTable*
fffff800`02efb900 nt!KeServiceDescriptorTable = <no type information>
fffff800`02efb9c0 nt!KeServiceDescriptorTableShadow = <no type information>
Команда
dps
подразумевает «Display Pointer & Symbol» (т.е. значение переменной с текстом), а L8
это кол-во значений для вывода. Так мы получим заголовки системной таблицы SSDT#0, и сразу таблицы win32k SSDT#1:
Код:
0: kd> dps nt!KeServiceDescriptorTable L8
fffff800`02efb900 fffff800`02cbfe00 nt!KiServiceTable
fffff800`02efb908 00000000`00000000
fffff800`02efb910 00000000`00000191 <-----------------// Всего 0x191 = 401 записей в таблице
fffff800`02efb918 fffff800`02cc0a8c nt!KiArgumentTable
fffff800`02efb920 00000000`00000000
fffff800`02efb928 00000000`00000000
fffff800`02efb930 00000000`00000000
fffff800`02efb938 00000000`00000000
0: kd> dps nt!KeServiceDescriptorTableShadow L8
fffff800`02efb9c0 fffff800`02cbfe00 nt!KiServiceTable
fffff800`02efb9c8 00000000`00000000
fffff800`02efb9d0 00000000`00000191
fffff800`02efb9d8 fffff800`02cc0a8c nt!KiArgumentTable
fffff800`02efb9e0 fffff960`00191f00 win32k!W32pServiceTable
fffff800`02efb9e8 00000000`00000000
fffff800`02efb9f0 00000000`0000033b <----------------// Всего 0x33b = 827 записей в таблице win32k.sys
fffff800`02efb9f8 fffff960`00193c1c win32k!W32pArgumentTable
Как видим, в заголовке первой SSDT#0 всего одна структура, а в хидере SSDT#1 уже две. Обратите внимание на первый столбец лога – не смотря на то, что в шадов лежит точная копия первой, в памяти они расположены по разным адресам
xxB900
и xxB9C0
, т.е это две разные таблицы. Ладно, теперь прочитаем по 4 записи из обоих таблиц, где аргумент /с1
определяет кол-во столбцов в логе Collumn=1:
Код:
0: kd> dd /c1 nt!KiServiceTable L4
fffff800`02cbfe00 04169700 <-------------// запись для SSN=0
fffff800`02cbfe04 02f9df00
fffff800`02cbfe08 fff6ec00
fffff800`02cbfe0c 02eb8e05 <-------------// запись для SSN=3
0: kd> dd /c1 win32k!W32pServiceTable L4
fffff960`00191f00 fff3a740 <-------------// запись для SSN=400h
fffff960`00191f04 fff0b501
fffff960`00191f08 000206c0
fffff960`00191f0c 001021c0 <-------------// запись для SSN=403h
Выше упоминалось, что записи в таблицах SSDT на системах х64 нужно преобразовывать из относительных в абсолютные адреса функций – пробуем первые из обоих таблиц. Если всё верно, то должны получить точку-входа в соответствующую функцию (указатель на неё). Кстати WinDbg имеет нюанс при арифм.операциях. Когда нам требуется расширение числа со-знаком, то значение обязательно нужно брать в скобки, иначе получим беззнаковое расширение с 32 до 64-бит:
Код:
0: kd> u nt!KiServiceTable + ( 04169700 >>> 4 )
nt!NtMapUserPhysicalPagesScatter:
fffff800`030d6770 48895c2408 mov qword ptr [rsp+8],rbx
fffff800`030d6775 4c89442418 mov qword ptr [rsp+18h],r8
fffff800`030d677a 55 push rbp
fffff800`030d677b 56 push rsi
0: kd> u win32k!W32pServiceTable + ( fff3a740 >>> 4 )
win32k!NtUserGetThreadState:
fffff960`00185974 48895c2408 mov qword ptr [rsp+8],rbx
fffff960`00185979 48896c2410 mov qword ptr [rsp+10h],rbp
fffff960`0018597e 57 push rdi
fffff960`0018597f 4883ec20 sub rsp,20h
А что, если нужно найти в SSDT запись под произвольным номером, например как выше
SSN=9Fh
для NtCreateProcess()? Поскольку размер записей по 4-байта каждая (как на системах х32, так и на х64), значит просто вычисляем индекс произведением 4 * 9f
:
Код:
0: kd> u NtCreateProcess
ntdll!ZwCreateProcess:
00000000`77a7c550 4c8bd1 mov r10,rcx
00000000`77a7c553 b89f000000 mov eax,9Fh <----- Номер SSN
00000000`77a7c558 0f05 syscall
00000000`77a7c55a c3 ret
0: kd> dd nt!KiServiceTable + (9f * 4) L1 <------------ Вычисляем его индекс
fffff800`02cc007c 047be904
0: kd> u nt!KiServiceTable + (047be904 >>> 4) <--------- Формируем указатель
nt!NtCreateProcess:
fffff800`0313bc90 fff3 push rbx
fffff800`0313bc92 4883ec50 sub rsp,50h
fffff800`0313bc96 4c8b9c248800 mov r11,qword ptr [rsp+88h]
fffff800`0313bc9e b801000000 mov eax,1
Осталось разобраться, почему мы сдвигаем значение записи SSDT на 4-бита вправо?
Здесь нужно вспомнить про имеющийся в заголовке указатель на таблицу аргументов. Дело в том, что в целях поддержки совместимости (а может чисто из-за лени), инженеры не стали расширять ArgumentTable на системах х64. Если раньше на х32 в этой таблице каждый байт определял кол-во аргументов функции, то на х64 эта таблица в принципе не нужна, т.к. теперь число аргументов кодируется прямо в младших 4-битах самой записи SSDT. Именно по этой причине мы и удаляем их сдвигом вправо. Рассмотрим это на сл.примере:
Код:
0: kd> dps nt!KeServiceDescriptorTable L4
fffff800`02f0f900 fffff800`02cd3e00 nt!KiServiceTable
fffff800`02f0f908 00000000`00000000
fffff800`02f0f910 00000000`00000191
fffff800`02f0f918 fffff800`02cd4a8c nt!KiArgumentTable
0: kd> dd /c1 nt!KiServiceTable L8
fffff800`02cd3e00 04169700
fffff800`02cd3e04 02f9df00
fffff800`02cd3e08 fff6ec00
fffff800`02cd3e0c 02eb8e05
fffff800`02cd3e10 03217606
fffff800`02cd3e14 0318b505
fffff800`02cd3e18 02bc5c01
fffff800`02cd3e1c 02b56f00
0: kd> db nt!KiArgumentTable L20
fffff800`02cd4a8c 00 00 00 14 18 14 04 00-00 00 00 00 00 04 04 00 ................
fffff800`02cd4a9c 08 00 00 04 08 08 04 04-14 00 0c 00 00 00 04 00 ................
Значит в трёх первых записях KiServiceTable младшие 4-бита равны нулю, и эти-же нули видим в первых трёх байтах таблицы аргументов. Следующим идёт аргумент со-значением
14h
, и если разделить его на 4 (размер регистра в х32), то получим всего 5 аргументов у функции, и эта-же пятёрка нарисовалась в 4 младших битах записи SSDT. Дальше в таблице аргументов лежит значение 18h
, и соответственно в записи наблюдаем 6. Таким образом, таблица аргументов на х64 – это пережиток прошлого, и ей можно принебречь.Заключение
Под занавес хотелось-бы отдать должное инженерам Microsoft, которые не покладая рук стараются повысить безопасность своей системы, ведь в конечном счёте это проецируется и на нас с вами. Один только ASLR с PatchGuard’ом обезоружили не одно поколение малвари, не говоря уже о сотни мелких, но принципиально важных плюшек внутри оси. Актуальные в своё время атаки на таблицы SSDT теперь уже в зародыше обречены на провал, и это делает систему более стабильной. В следующей части поговорим об объектах USER и GDI, а пока на этом всё.