Статья ASM. В ядре Windows(2) – драйвер Win32k.sys и таблица SSDT

Marylin

Mod.Assembler
Red Team
05.06.2019
326
1 451
BIT
696
Статья затрагивает такие понятия как подсистема Win32 пользователя, поддержка её на строне ядра в лице драйвера win32k.sys, способы программного перехода из юзера в кернел с использованием таблиц SSDT, ну и всё-что с ними связано. Основной упор делается на изменения, которые принесла с собой современная архитектура х86-64. Чтобы исключить всякого рода подозрения, вся теория сопровождается практикой в отладчике WinDbg. Поскольку это логическое продолжение первой части, она рекомендуется к прочтению.


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.

WinArch_2.png

Изначально в 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 внутренняя:

NtPrefix.png


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
(.....)

MSR.png

Осуществив переход в ядро код должен знать, что делать дальше – эту информацию система передаёт инструкции 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.png

Как видим, каждая из таблиц 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, а пока на этом всё.
 
Мы в соцсетях:

Обучение наступательной кибербезопасности в игровой форме. Начать игру!