РАЗБОР
Статья
Windows x64 - путешествие из User в Kernel
[ обложка статьи ]
Режим чтения
Переходы из пространства пользователя в ядро - это маршрут, который совершают данные, когда программе требуется доступ к физ.оборудованию. Обычно такие переходы происходят тысячи раз в секунду (например при клике мышкой, вызовах WinAPI, и прочее), а потому сам механизм телепорта должен быть отшлифован до блеска. Но, что мы имеем в современных реалиях, и что предлагает нам ЦП на аппаратном уровне? Давайте разбираться.
Оглавление:
1. Контроль исполняемых потоков процессором
- стек ядра и стек пользователя
- сегмент состояния задачи TSS
2. Асинхронные переходы по прерываниям и исключениям
- структура TRAP_FRAME
3. Синхронные переходы по окончании кванта потока
- структура SWITCH_FRAME
4. Выводы
1. Контроль исполняемых потоков процессором
Чтобы не возникало лишних вопросов, начнём с базовых основ.
Значит всякий процесс в ядре - это просто структура EPROCESS, где перечислены все выделенные ему системные ресурсы. А вот код исполняют уже потоки процесса, которые олицетворяет структура ETHREAD. Что примечательно, ЦП ориентируется исключительно на потоки и даже не знает, какому именно процессу они принадлежат. Далее под терминами "процесс" и "поток" мы будем подразумевать их структуры.
В процессах имеются по 2 кольцевых списка LIST_ENTRY - в первом лежат указатели на следующий и предыдущий процесс в системе (позволяют вести учёт активных), а во втором - линки на их собственные потоки. Вот, что думает на этот счёт отладчик WinDBG:
Код:
0: kd> dt _eprocess
nt!_EPROCESS
+0x000 Pcb : _KPROCESS $$ общая информация (Control Block)
+0x180 UniqueProcessId : Ptr64 $$ PID процесса
+0x188 ActiveProcessLinks : _LIST_ENTRY $$ линк на сл.процесс в системе
+0x308 ThreadListHead : _LIST_ENTRY $$ список потоков данного процесса
+0x328 ActiveThreads : Uint4B $$ всего потоков в списке выше ^^^
+0x338 Peb : Ptr64 _PEB $$ линк на РЕВ в пространстве юзера
........
0: kd>
Всё-бы хорошо, только умиротворённый пейзаж портит системный планировщик. Каждые 0.02 сек (квант 20мс) он подкидывает на конвейер потоки соседних процессов из своей очереди, чтобы эмулировать многозадачность. За этот промежуток ЦП с тактовой частотой 2 ГГц успевает простучать 40 млн раз (2 млрд * 0.02), что равносильно выполнению ~20 млн простых инструкций. Этого более чем достаточно, чтобы утилита среднего звена полностью отработала свой код всего за 1 выделенный ей квант.
Но если поток приложения ожидает диалога с юзером, то 20 милли/сек ему по любому не хватит. Тогда планировщик приостанавливает наш поток, отдаёт управление следующему в очереди, и если нет других, возвращает наш обратно в прежнее состояние. Эту процедуру назвали "Context-Switch", и поскольку она требует переходов в ядро, инженеры стараются макс.оптимизировать её. Рассмотрим, как и при каких именно обстоятельствах это происходит.
1.1. Стек ядра и стек пользователя
Межуровневая миграция потоков в нулевое кольцо и обратно требует обязательной смены стека, иначе любая малварь сможет легко проникнуть на защищённую территорию системы. Поэтому ещё на этапе создания потоков, кроме обычного стека в Ring(3), ОС сразу выделяет каждому из них индивидуальный свой стек и в ядерной памяти, а указатель на него сохраняет в поле "StackBase" их структур KTHREAD.
Код:
0: kd> dt _kthread fffffa8010b96710
nt!_KTHREAD
+0x000 Header : _DISPATCHER_HEADER
+0x030 StackLimit : 0xfffff880`171fd000
+0x038 KernelStack : 0xfffff880`17206370 <--- RSP
+0x278 StackBase : 0xfffff880`17207000
.......
ThreadKrnlStackSize = Base - Limit = 0xA000 = 40 КБ
В доках говорится, что под стек-ядра каждому из юзер-потоков выделяется по 24 КБ, но у себя я вижу 40. При этом если открыть вкладку "Быстродействие" в диспетчере задач, то всего активных потоков 724. Тогда выходит, что на карманные расходы всем потокам ОС выделяет сейчас из своего невыгружаемого пула ~28 МБ памяти, и это только на ядерные стеки, а ведь ещё имеются сами структуры EPROCESS, и у каждой по несколько ETHREAD размером 1200 байт.
В свою очередь, у лог.ядер ЦП имеется личное пространство PCR (Processor Control Region), на которое смотрит их регистр
IA32_GS_BASE - этот регион описывает структура KPCR. Когда система планирует отправить юзер-поток в Kernel, она сначала прописывает указатель на него в поле "CurrentThread" структуры KPCR так, что ядро ЦП всегда знает, по какому именно адресу нужно искать стек вновь прибывшего потока в своём невыгружаемом пуле.У соседнего cpu(2) уже свой регион KPCR, поэтому никаких конфликтов не возникает и планировщик ОС волен тасовать потоки по любым ядрам процессора, в зависимости от их занятности на текущий момент - если первый квант поток отработал на cpu(3), то на втором кванте он может попасть на cpu(1), и т.д. Схематично эта связь представлена ниже:
Кстати размер невыгружаемого в своп NonPagedPool ограничен, и этот фактор влияет на макс.возможное число потоков в системе. В пакете "SysinternalsSuite" есть тулза TestLimit64.exe, и будучи запущенная с ключом(-t) она в цикле порождает треды до тех пор, пока не нарвётся на исключение. Так вот на своей Win7 с 16 ГБ памяти ОЗУ я получил лимит потоков 360.000 штук, при этом невыгружаемый пул ядра динамически увеличился с 75 ровно до 600 МБ (походу это предел). Для справки, на системах х32 этот лимит застрял на отметке всего 2.000.
Пользовательский поток может переходить в ядро по нескольким причинам, которые делятся на синхронные по времени (кратно 20мс = квант), и непредсказуемые асинхронные, например когда происходит какое-нибудь исключение в коде (типа ошибка доступа к памяти), внезапно возникнет прерывание от физ.девайсов клавиатура-мышь-etc, а так-же при вызовах API-функций ядра через шлюз Ntdll.dll. Такая причинно-следственная связь отражается и на самом механизме перехода в ядро.
1.2. Сегмент состояния задачи TSS
На системах х32, за смену уровней привилегий Ring(3-0) полностью отвечал ЦП на аппаратном уровне. Процедура начиналась с того, что в момент исключения все регистры текущего потока система скопом сохраняла в сегмент TSS, а процессор считывал их от туда. Ключевую роль играли поля Esp+Ss0 в структуре TSS, в которых планировщик передавал процессору указатель на стек-ядра переходного потока из его KTHREAD.
Код:
kd> dt _ktss -v
struct _KTSS, 35 elements, 0x20ac bytes
+0x000 Backlink : Uint4B
+0x004 Esp+Ss0 : Uint8B <---- стек потока в Ring(0)
+0x00c Esp+Ss1 : Uint8B <---- стек Ring(1) - резерв
+0x014 Esp+Ss2 : Uint8B <---- стек Ring(2) - резерв
+0x01c CR3 : Uint4B
+0x020 Eip : Uint4B
+0x024 EFlags : Uint4B
+0x028 Eax : Uint4B
+0x02c Ecx : Uint4B
+0x030 Edx : Uint4B
+0x034 Ebx : Uint4B
+0x038 Esp : Uint4B <---- стек Ring(3)
+0x03c Ebp : Uint4B
+0x040 Esi : Uint4B
+0x044 Edi : Uint4B
+0x048 Es : Uint2B
+0x04c Cs : Uint2B
+0x050 Ss : Uint2B <-------- ^^^^^
+0x054 Ds : Uint2B
+0x058 Fs : Uint2B
+0x05c Gs : Uint2B
+0x060 LDT : Uint2B
+0x064 Flags : Uint2B
+0x066 IoMapBase : Uint2B
+0x068 IoMaps : [01] struct _KiIoAccessMap, 2 elements, 0x2024 bytes
+0x208c IntDirectionMap : [32] UChar
kd>
Но сейчас на х64 инженеры отказались от такой схемы, и сегмент TSS играет чисто символическую роль. В нём остались всего 2 актуальных поля - это как и прежде Rsp0, а так-же массив IST из восьми 8-байтных значений. Вообще процессоры х86 поддерживают 4 уровня защиты Ring(0.1.2.3), хотя по факту используют лишь кольца 0 и 3. Поэтому стеки ядра для уровней Rsp1 + Rsp2 в сегменте TSS64 никогда не используются.
В TSS64 появилась новая "Interrupt Stack Table" - IST. Это 7 указателей на резервные стеки для обработки критических аппаратных сбоев, например рекурсивная ошибка DoubleFault(#DF) или MachineCheck(#MC), чтобы процессор мог гарантированно обработать исключение, даже если основной стек ядра был полностью разрушен в труху.
Сегмент TSS имеет свой дескриптор в глобальной таблице GDT, где указывается его база и лимит - система сохраняет их в регистре
TR процессора. Обратите внимание на размер/лимит нового сегмента 0x68=104 байта, хотя раньше был 0x20ac=8.364 байта. Это потому, что огромная карта доступа к портам "IoMap" в хвосте, вместе с картой перенаправления прерываний была полностью удалена, т.к. прежде использовалась в вирт.режиме V86 процессора, который ныне не поддерживается вообще.Когда поток выполняется в Ring(3) и происходит прерывание, исключение или вызов api, ЦП должен переключиться на стек ядра. В этот момент он автоматически считывает поле Rsp0 из TSS и загружает его значение в регистр
RSP. Это обеспечивает безопасный вход в ядро, не затрагивая пользовательский стек. Таким образом, Rsp0 используется только для первоначального переключения стека при входе в Kernel из пользовательского режима.Процессор никогда не записывает никаких значений в TSS, а лишь читает то, что записала система из поля "KernelStack" структуры KTHREAD потока, в момент возникновения исключений/прерываний в нём. У каждого лог.ядра ЦП свой сегмент TSS и свои регистры
TR, т.е. они не делят общий.2. Асинхронные переходы
Когда в исполняемом потоке происходит исключение, срабатывает системная ловушка "Trap" и управление передаётся обработчику форс-мажорного обстоятельства. В этот момент ЦП должен сохранить контекст регистров прерванного потока (чтобы восстановить его работу позже). Но куда сохранять-то, ведь TSS для этих целей уже не используется?
Поэтому ОС бежит здесь вперёд паровоза, заранее подготавливая всё к такой ситуации. Ещё на этапе создания потока (сразу после выделения ему стека-ядра), в поле "TrapFrame" структуры KTHREAD система сохраняет указатель на кадр-ловушки в этом стеке, куда и сбрасываются все регистры потока про запас. На рис.ниже отображён этот фрейм, а значение регистра
RSP указано уже после его заполнения, т.к. изначально находится внизу и двигается вверх:Поскольку вариантов переходов в ядро здесь всего три (исключение, прерывание, системный вызов), ОС обрабатывает их немного по разному, но во всех случаях первым вступает в дело сам ЦП, который на аппаратном уровне сбрасывает во-фрейм ловушки 5 основных регистров - это пары
cs:rip, ss:rsp, и регистр флагов rflags. Процессор начинает пушить их в стек первыми, на каждом шаге двигая rsp всё выше-и-выше к лимиту, поэтому в структуре KTRAP_FRAME эти 5 регистров находятся в самом конце - вот её формат:
Код:
0: kd> dt 0xfffff880`1755eae0 _KTRAP_FRAME -v
struct _KTRAP_FRAME, 63 elements, 0x190 bytes
+0x000 P1Home : 0xfffffa80`0d351b50 ---+
+0x008 P2Home : 0xfffff880`1755eb60 | резерв под первые 4 аргумента
+0x010 P3Home : 0 +--> вызываемой функции
+0x018 P4Home : 0xfffffa80`10b0c070 | rcx, rdx, r8, r9
+0x020 P5 : 0 --------------------+ (пятый push, опционально)
+0x028 PreviousMode : 1 <----- предыдущий режим (от куда вызов: 0=Kernel, 1=User)
+0x029 PreviousIrql : 0
+0x02a NmiMsrIbrs : 0x8
+0x02b ExceptionActive : 0x2
+0x02c MxCsr : 0x1f80
+0x030 Rax : 0x1f
+0x038 Rcx : 0x14
+0x040 Rdx : 0x6fd70
+0x048 R8 : 0x01
+0x050 R9 : 0x6ff00
+0x058 R10 : 0
+0x060 R11 : 0x6fe68
+0x068 GsSwap : 0x7ff`fffde000
+0x070 Xmm0 : _M128A
+0x080 Xmm1 : _M128A
+0x090 Xmm2 : _M128A
+0x0a0 Xmm3 : _M128A
+0x0b0 Xmm4 : _M128A
+0x0c0 Xmm5 : _M128A
+0x0d0 ContextRecord : 0x7746c000
+0x0d8 Dr0 : 0
+0x0e0 Dr1 : 0
+0x0e8 Dr2 : 0
+0x0f0 Dr3 : 0
+0x0f8 Dr6 : 0
+0x100 Dr7 : 0
+0x108 DebugControl : 0
+0x110 LastBranchToRip : 0
+0x118 LastBranchFromRip : 0
+0x120 LastExceptionToRip : 0
+0x128 LastExceptionFromRip : 0
+0x130 SegDs : 0
+0x132 SegEs : 0
+0x134 SegFs : 0
+0x136 SegGs : 0
+0x138 TrapFrame : 0
+0x140 Rbx : 0x6fd70
+0x148 Rdi : 0
+0x150 Rsi : 0
+0x158 Rbp : 1
+0x160 ExceptionFrame : 0x77a02a1c
+0x168 Rip : 0x776e99ca ---+
+0x170 SegCs : 0x33 |
+0x178 EFlags : 0x246 +-> CPU заполняет аппаратно
+0x180 Rsp : 0x6fd18 |
+0x188 SegSs : 0x2b ---------+
+0x18c CodePatchCycle : 0
0: kd>
Теперь рассмотрим, на каких этапах и какими функциями заполняется остальная часть KTRAP_FRAME.
2.1. Системные вызовы "syscall"
Когда поток вызывает api типа CreateFile(), процессор переходит по адресу, который жёстко прошит в MSR-регистре
IA32_LSTAR=0xC0000082. В нём лежит указатель на функцию ядра nt!KiSystemCall64(), которая и получит первой управление от пользовательской syscall. Она сразу резервирует место в стеке под KTRAP_FRAME и вручную заполняет его - вот фрагмент.Здесь на входе встречает нас инструкция
swapgs. Регистр GS под юзером указывает на ТЕВ текущего потока (Thread Environment Block), но в ядре он должен смотреть на структуру KPCR процессора. Так вот swapgs на входе как-раз меняет их значения местами, а на выходе опять восстанавливает. Значение ядра берётся из MSR-регистра IA32_GS_BASE=0xC0000101:
Код:
0: kd> rdmsr 0xc0000082 <------ IA32_LSTAR
msr[c0000082] = fffff800`022abb00
0: kd> ln fffff800`022abb00 <------ проверим адрес
(fffff800`022abb00) nt!KiSystemCall64
0: kd> uf /i KiSystemCall64
393 instructions scanned <---- общее число инструкций
nt!KiSystemCall64:
fffff800`022abb00 0f01f8 swapgs
fffff800`022abb03 65488924251000 mov qword ptr gs:[10h],rsp
fffff800`022abb0c 65488b2425a801 mov rsp,qword ptr gs:[1A8h]
fffff800`022abb15 6a2b push 2Bh
fffff800`022abb17 65ff3425100000 push qword ptr gs:[10h]
fffff800`022abb1f 4153 push r11
fffff800`022abb21 6a33 push 33h
fffff800`022abb23 51 push rcx
fffff800`022abb24 498bca mov rcx,r10
fffff800`022abb27 4883ec08 sub rsp,8
fffff800`022abb2b 55 push rbp
fffff800`022abb2c 4881ec58010000 sub rsp,158h <------ выделение Trap-фрейма!
fffff800`022abb33 488dac24800000 lea rbp,[rsp+80h]
fffff800`022abb3b 48899dc0000000 mov qword ptr [rbp+0C0h],rbx
fffff800`022abb42 4889bdc8000000 mov qword ptr [rbp+0C8h],rdi
fffff800`022abb49 4889b5d0000000 mov qword ptr [rbp+0D0h],rsi
fffff800`022abb50 488945b0 mov qword ptr [rbp-50h],rax
fffff800`022abb54 48894db8 mov qword ptr [rbp-48h],rcx
fffff800`022abb58 488955c0 mov qword ptr [rbp-40h],rdx
.....
fffff800`022abd44 e80726ffff call nt!KiSaveDebugRegisterState (fffff800`0229e350)
.....
2.2. Прерывания и исключения
В целом, программные исключения "Fault" и аппаратные прерывания "Interrupt" обрабатываются одинаково - через таблицу IDT (Interrupt Dispatch Table). Это массив из 256 структур KIDTENTRY64 по 16-байт каждая (итого 4КБ), а его база в памяти лежит в структуре KPCR процессора (система сохраняет в регистре IDTR). Первые 20=0x14 векторов в таблице всегда резервируются под исключения и ловушки, а остальные система раздаёт обработчикам прерываний от физ.устройств.
Раньше можно было прямо в WinDBG узнать адрес обработчиков прерываний ISR от физ.устройств типа мышь, клава, диск, сеть и прочих. Но теперь появилась защита от уязвимостей Meltdown и Spectre, в результате чего доступ только через затенённое пространство ShadowSpace (см.жёлтый блок выше). В отладчике есть и окольные пути, например чз объекты драйверов, но сейчас речь не о том, т.к. сути это не меняет.
Когда поток нарывается на исключение/прерывание, система передаёт управление на вектор из таблицы IDT, а функции его обработки формируют уже KTRAP_FRAME для фиксации состояния прерванного потока. Вот пролог самой первой процедуры из списка IDT (в остальных аналогично) - как видим, в нём много общего с предыдущей KiSystemCall64():
Код:
0: kd> uf /i nt!KiDivideErrorFaultShadow
670 instructions scanned
nt!KiDivideErrorFault:
fffff800`022a6f00 4883ec08 sub rsp,8
fffff800`022a6f04 55 push rbp
fffff800`022a6f05 4881ec58010000 sub rsp,158h
fffff800`022a6f0c 488dac24800000 lea rbp,[rsp+80h]
fffff800`022a6f14 c645ab01 mov byte ptr [rbp-55h],1
fffff800`022a6f18 488945b0 mov qword ptr [rbp-50h],rax
fffff800`022a6f1c 48894db8 mov qword ptr [rbp-48h],rcx
fffff800`022a6f20 488955c0 mov qword ptr [rbp-40h],rdx
fffff800`022a6f24 4c8945c8 mov qword ptr [rbp-38h],r8
fffff800`022a6f28 4c894dd0 mov qword ptr [rbp-30h],r9
fffff800`022a6f2c 4c8955d8 mov qword ptr [rbp-28h],r10
fffff800`022a6f30 4c895de0 mov qword ptr [rbp-20h],r11
fffff800`022a6f34 f685f000000001 test byte ptr [rbp+0F0h],1
fffff800`022a6f3b 752d jne nt!KiDivideErrorFault+0x6a (fffff800`022a6f6a)
......
2.3. Волатильные регистры
В отличии от х32, сейчас Win не сохраняют буквально все регистры процессора в TRAP_FRAME. Неволатильные
RBX,RDI,RSI,RBP,R12-R15 намеренно опускаются для оптимизации скорости. Если ядру в процессе обработки прерывания всё же потребуется их изменить, оно сохраняет их отдельно. Но здесь возникает вопрос, что означают термины Volatile и NonVolatile?Это деление определяет, кто несёт ответственность за сохранение регистра при вызове функций, и какие из них попадают в KTRAP_FRAME при входе в ядро.
1. Волатильные:
RAX,RCX,RDX,R8,R9,R10,R11,XMM0–15Это регистры, которые вызываемая WinAPI имеет право перезаписать без предварительного сохранения. Если нам как вызывающим значения этих регистров ещё нужны, придётся самостоятельно сохранить их до вызова функции. В _fastcall первые 4 аргумента передаются в
RCX,RDX,R8,R9, а в регистре RAX функция возвращает результат. Пара R10-11 черновики "scratch", которые api может использовать для временных вычислений.2. Не волатильные:
RBX,RBP,RSI,RDI,RSP,R12–R15Их вызываемая функция обязана сохранить и восстановить перед возвратом управления. Они часто используются для хранения всякого рода переменных (указателей на структуры, счетчиков циклов и т.д.). Если-бы любая функция могла их безнаказанно рипать, нам пришлось-бы сохранять их вручную перед каждым вызовом, что крайне неудобно.
3. Синхронные переходы по окончании кванта потока
Ну и последний вариант - это переход юзер-потока в ядро при переключение планировщиком. Здесь уже контекст сохраняется не в прежнюю структуру KTRAP_FRAME, а в недокументированную KSWITCH_FRAME, которой нет даже в публичных символах *.pdb отладчика WinDBG. После входа потока в ядро, структура заполняется вполне прозрачной ассемблерной процедурой nt!KiSwapContext(), а потому дизассемблировав последнюю можно собрать KSWITCH_FRAME самим:
Код:
dt nt!_KSWITCH_FRAME
+0x000 P5Home : Uint8B ; Зарезервировано под домашние регистры (Home registers)
+0x008 P6Home : Uint8B ; согласно соглашению вызовов x64 fastcall
+0x010 ApcBypass : UChar ; Флаг обхода APC прерываний
+0x011 Rgb : UChar
+0x012 Status : UChar
+0x018 Rbp : Uint8B ; Сохраненный указатель кадра стека (Frame Pointer)
+0x020 NonVolatileRegs : _KNONVOLATILE_CONTEXT_POINTERS ; Указатели на неволатильные регистры
+0x028 Rbx : Uint8B ; \
+0x030 Rdi : Uint8B ; | Сохраненные неволатильные регистры общего назначения
+0x038 Rsi : Uint8B ; | (сохраняются, так как они должны пережить вызов функции)
+0x040 R12 : Uint8B ; |
+0x048 R13 : Uint8B ; |
+0x050 R14 : Uint8B ; |
+0x058 R15 : Uint8B ; /
+0x060 Xmm6 : _M128A ; \ Сохраненные неволатильные регистры SSE
+0x070 Xmm7 : _M128A ; / (используются для мультимедиа и точных вычислений)
+0x080 ReturnAddress : Uint8B ; Адрес возврата, куда передать управление после переключения
Код:
0: kd> uf /i KiSwapContext
44 instructions scanned
nt!KiSwapContext:
fffff800`022a02e0 4881ec38010000 sub rsp,138h <--- фрейм в ядерном стеке потока
fffff800`022a02e7 488d8424000100 lea rax,[rsp+100h]
fffff800`022a02ef 0f29742430 movaps xmmword ptr [rsp+30h],xmm6
fffff800`022a02f4 0f297c2440 movaps xmmword ptr [rsp+40h],xmm7
fffff800`022a02f9 440f29442450 movaps xmmword ptr [rsp+50h],xmm8
fffff800`022a02ff 440f294c2460 movaps xmmword ptr [rsp+60h],xmm9
fffff800`022a0305 440f29542470 movaps xmmword ptr [rsp+70h],xmm10
fffff800`022a030b 440f295880 movaps xmmword ptr [rax-80h],xmm11
fffff800`022a0310 440f296090 movaps xmmword ptr [rax-70h],xmm12
fffff800`022a0315 440f2968a0 movaps xmmword ptr [rax-60h],xmm13
fffff800`022a031a 440f2970b0 movaps xmmword ptr [rax-50h],xmm14
fffff800`022a031f 440f2978c0 movaps xmmword ptr [rax-40h],xmm15
fffff800`022a0324 488918 mov qword ptr [rax+00h],rbx
fffff800`022a0327 48897808 mov qword ptr [rax+08h],rdi
fffff800`022a032b 48897010 mov qword ptr [rax+10h],rsi
fffff800`022a032f 4c896018 mov qword ptr [rax+18h],r12
fffff800`022a0333 4c896820 mov qword ptr [rax+20h],r13
fffff800`022a0337 4c897028 mov qword ptr [rax+28h],r14
fffff800`022a033b 4c897830 mov qword ptr [rax+30h],r15
fffff800`022a033f 65488b1c252000 mov rbx,qword ptr gs:[20h]
fffff800`022a0348 488bf9 mov rdi,rcx
fffff800`022a034b 488bf2 mov rsi,rdx
fffff800`022a034e 0fb68f66010000 movzx ecx,byte ptr [rdi+166h]
fffff800`022a0355 e8a6020000 call nt!SwapContext (fffff800`022a0600)
fffff800`022a035a 488d8c24000100 lea rcx,[rsp+100h]
fffff800`022a0362 0f28742430 movaps xmm6, xmmword ptr [rsp+30h]
fffff800`022a0367 0f287c2440 movaps xmm7, xmmword ptr [rsp+40h]
fffff800`022a036c 440f28442450 movaps xmm8, xmmword ptr [rsp+50h]
fffff800`022a0372 440f284c2460 movaps xmm9, xmmword ptr [rsp+60h]
fffff800`022a0378 440f28542470 movaps xmm10,xmmword ptr [rsp+70h]
fffff800`022a037e 440f285980 movaps xmm11,xmmword ptr [rcx-80h]
fffff800`022a0383 440f286190 movaps xmm12,xmmword ptr [rcx-70h]
fffff800`022a0388 440f2869a0 movaps xmm13,xmmword ptr [rcx-60h]
fffff800`022a038d 440f2871b0 movaps xmm14,xmmword ptr [rcx-50h]
fffff800`022a0392 440f2879c0 movaps xmm15,xmmword ptr [rcx-40h]
fffff800`022a0397 488b19 mov rbx,qword ptr [rcx]
fffff800`022a039a 488b7908 mov rdi,qword ptr [rcx+8]
fffff800`022a039e 488b7110 mov rsi,qword ptr [rcx+10h]
fffff800`022a03a2 4c8b6118 mov r12,qword ptr [rcx+18h]
fffff800`022a03a6 4c8b6920 mov r13,qword ptr [rcx+20h]
fffff800`022a03aa 4c8b7128 mov r14,qword ptr [rcx+28h]
fffff800`022a03ae 4c8b7930 mov r15,qword ptr [rcx+30h]
fffff800`022a03b2 4881c438010000 add rsp,138h
fffff800`022a03b9 c3 ret
0: kd>
В отличие от KTRAP_FRAME, которую частично заполняет cpu, KSWITCH_FRAME создаётся исключительно программно. Обратите внимание, что здесь сохраняются только неволатильные регистры. В зависимости от причины переключения контекста, волатильные сохраняются в разных местах и разными компонентами. Здесь есть всего 2 сценария:
1. Добровольное переключение
Если поток сам решает уступить время, например вызывает Sleep() или WaitForSingleObject(), код потока просто вызывает функцию ядра чз трамплин Ntdll.dll. Зная правила соглашения о вызовах, компилятор системных DLL автоматически генерирует код для сохранения всех волатильных регистров в стек пользовательского потока задолго до того, как передать управление дальше. То-есть когда функция ядра KiSwapContext() получает управление, волатильные регистры уже сохранены.
2. Принудительное переключение
Если поток выполнялся, и вдруг закончился выделенный ему планировщиком квант 0.02 сек, срабатывает аппаратное прерывание от таймера, и в поисках его обработчика ЦП лезет в таблицу диспетчеризации IDT. Этот сценарий уже рассматривался выше на примере функции KiDivideErrorFaultShadow(). Только после этого вызывается api планировщика KiDispatchInterrupt(), которая тянет за собой KiSwapContext(). Когда управление вернется в этот поток, обработчик прерывания восстановит абсолютно все регистры из KTRAP_FRAME, и поток даже не заметит, что его прерывали.
В среднем, на современных ЦП вся процедура "Context Switch" занимает до 5 мкс = 0.000005 сек, что эквивалентно нескольким тысячам тактов процессора. Точное время зависит от типа переключения (внутри одного процесса или между разными) и архитектуры ОС. Входит-ли время смены контекста в выделенный потоку квант? Ответ - нет! ..поскольку квант это чистая продолжительность работы потока. Алгоритм выглядит примерно так:
1. Поток(A) заканчивает свой квант, а таймер прерываний фиксирует это.
2. Системный планировщик приостанавливает поток(A) и запускает процедуру Switch.
3. В этот момент счётчик кванта потока(A) уже остановлен, а для потока(B) ещё не запущен.
4. Как только контекст потока(B) будет полностью загружен в регистры цп, планировщик отдаст ему руль, и начнётся отсчёт нового кванта для потока(B).
Таким образом, смена контекста - это чистый оверхед системы с точки зрения выполнения полезных задач, и в то-же время необходимый компромисс для поддержки многозадачности. Из-за аппаратных факторов (типа кэш и буферы TLB), переключение между потоками внутри одного процесса всегда обходится дешевле, чем между разными процессами.
4. Выводы
Зачем это знать исследователю?
Ну во-первых отладка ядра. При анализе дампов памяти BSOD, вызовы KiSwapContext() часто находятся на макушке стека потоков, находящихся в ожидании Waiting. Понимание фреймов ловушек Trap и Switch помогает восстановить состояние регистров спящих потоков. А во-вторых разработка драйверов и руткитов. Мониторинг или подмена данных в структурах контекста позволяет низкоуровневому коду осуществлять перехват управления потоками ядра, или скрывать своё присутствие. В общем были-бы знания, а применение им обязательно найдётся. Всем удачи, пока!
Ссылки по теме:
https://medium.com/windows-os-internals/windows-internals-thread-management-part-1-9f4227a9e17c
Exploiting WRMSR in vulnerable drivers
AI-выжимка
по разделам
Тезисы статьи скоро
Статья читается полностью. Тезисы со ссылками на разделы появятся позже.
Содержание
Codeby Academy
Практика и мастерство
От основ до продвинутого — программы для практиков Codeby.
Перейти к курсу →
Продолжить чтение
Следующий разбор
Fuzzilli против BigSleep: кто ищет баги в V8
Комментарии
0