进程与线程

在初级班的学习后我们都会有进程、线程的概念,从操作系统的角度去看进程、线程实际上都是结构体,当创建一个进程或线程,本质上就是分配一块内存来填充对应的结构体。因此我们要摸清楚进程、线程的具体细节,就要对它们的结构体足够了解。

进程结构体

每个Windows进程在0环都有一个对应的结构体:_EPROCESS,该结构体包含了进程所有的重要信息。

我们可以使用Windbg来查看该结构体:

images/download/attachments/1703937/image2022-11-23_15-47-42.png

结构体成员

我们通过Windbg可以看到进程结构体_EPROCESS有很多个成员,目前我们只需要了解一些比较重要的成员,其他的可以等用到时再了解。

_KPROCESS

进程结构体_EPROCESS的第一个成员(即0x0偏移位)也是一个结构体_KPROCESS,我们也可以使用Windbg来查看一下:

images/download/attachments/1703937/image2022-11-24_10-53-30.png

以下就是一些_KPROCESS的成员,我们可以简单了解一下:

偏移

成员

作用

0x0

Header (_DISPATCHER_HEADER)

可等待对象,可以通过WaitForSingleObject函数来使用可等待对象(例如互斥体、事件)。

0x18

DirectoryTableBase ([2] Uint4B)

页目录表基址 ,一个进程都有一个页目录表,在该表里记录着线性地址所引用的物理页,所以修改该成员就可以控制整个进程。

0x38

0x3C

KernelTime (Uint4B)

UserTime (Uint4B)

统计信息,分别记录了一个进程在0环、3环所花费的时间。

0x5C

Affinity (Uint4B)

规定了进程里面的所有线程可以在哪个CPU上运行。如果该成员值为1则这个进程的所有线程只能在0号CPU上运行,也就表示我们可以将该成员值转为二进制数值,第N位为1即表示可以在N号CPU上运行所有线程,举一反三,如果该值为5,则第0、2位为1,也就表示可以在0、2号CPU上运行。

从我们的实验环境上来看该成员仅有4字节(即32位),所以也就只支持32核CPU。

我们了解基本逻辑之后就可以明白,假设某台计算机只有1核的CPU,我们将该成员值设为大于1的数值,那么该进程就没法运行了。

0x62

BasePriority (Char)

基础优先级或最低优先级,规定了该进程中所有线程最基本的优先级。

其他成员

接着我们来了解一下_EPROCESS的其他几个成员:

偏移

成员

作用

0x70

0x78

CreateTime (_LARGE_INTEGER)

ExitTime (_LARGE_INTEGER)

记录信息,分别表示进程创建、退出的时间。

0x84

UniqueProcessId (Ptr32 Void)

进程编号,也就是我们通过任务管理器中所看见的PID。

0x88

ActiveProcessLinks (_LIST_ENTRY)

双向链表,将所有活动的进程都链接成一个表,我们可以通过这个成员找到前一个进程和后一个进程的结构体(需要注意这里找到的并不是结构体的起始位置,而是结构体的0x88偏移位)。

0x90

0x9C

QuotaUsage ([3] Uint4B)

QuotaPeak ([3] Uint4B)

统计信息,与物理页有关。

0xA8

0xAC

0xB0

CommitCharge (Uint4B)

PeakVirtualSize (Uint4B)

VirtualSize (Uint4B)

统计信息,与虚拟内存有关。

0xBC

0xC0

DebugPort (Ptr32 Void)

ExceptionPort (Ptr32 Void)

与调试相关的信息。

0xC4

ObjectTable (Ptr32 _HANDLE_TABLE)

句柄表,存储了当前进程使用的其他内核对象的句柄;我们可以通过遍历其他进程的句柄表,如果表中存在当前进程的进程结构体地址,那就说明当前进程被使用,也就是被调试,我们可以此作为一种反调试的手段。

0x11C

VadRoot (Ptr32 Void)

标识了用户空间(低2G)有哪些地址没被占用。

0x174

ImageFileName ([16] UChar)

当前进程的名字。

0x1A0

ActiveThreads (Uint4B)

当前进程的活动线程数量。

0x1B0

Peb (_PEB)

PEB(Process Environment Block,进程环境块):进程在3环的一个结构体,里面包含了进程的模块列表、是否处于调试状态等信息。

ActiveProcessLinks

如下所示就活动进程双向链表的完整结构,PsActiveProcessHead指向了该链表的表头位置。

images/download/attachments/1703937/image2022-11-24_11-46-10.png

我们可以在Windbg中查看表头,并对应找到其指向的进程结构体:

images/download/attachments/1703937/image2022-11-24_15-56-18.png

线程结构体

每个进程默认都会有一个线程,每启动一个线程,在内存里就会多一个线程结构体,即_ETHREAD。

我们可以使用Windbg来查看该结构体:

images/download/attachments/1703937/image2022-11-24_16-41-32.png

结构体成员

我们通过Windbg可以看到进程结构体_ETHREAD有很多个成员,目前我们只需要了解一些比较重要的成员,其他的可以等用到时再了解。

_KTHREAD

与进程结构体一样,线程结构体的第一个成员(即0x0偏移位)也是一个结构体_KTHREAD,我们可以使用Windbg来查看一下:

images/download/attachments/1703937/image2022-11-25_13-47-27.png

我们简单了解一下它的几个成员:

偏移

成员

作用

0x0

Header (_DISPATCHER_HEADER)

可等待对象,与进程结构体的_KPROCESS中的Header成员是一样的。

0x18

0x1C

0x28

InitialStack (Ptr32 Void)

StackLimit (Ptr32 Void)

KernelStack (Ptr32 Void)

与线程切换有关的成员。

0x20

Teb (Ptr32 Void)

TEB(Thread Environment Block,线程环境块),线程在3环的一个结构体,里面包含了线程的相关信息。我们在3环可以通过FS:[0]来找到TEB。

0x2C

DebugActive (UChar)

如果该值为-1则表示不能使用调试寄存器:Dr0-Dr7,如果当前线程正在被调试则该值就不是-1。

0x34

0xE8

0x138

0x14C

ApcState (_KAPC_STATE)

ApcQueueLock (Uint4B)

ApcStatePointer ([2] Ptr32 _KAPC_STATE)

SavedApcState (_KAPC_STATE)

与APC相关的成员。

0x2D

State (Uchar)

表示线程状态:准备就绪、等待、正在执行。

0x6C

BasePriority (Char)

基础优先级或最低优先级,它的初始值就是所属进程的结构体的BasePriority值,如果你想要修改可以通过KeSetBasePriorityThread函数进行重新设定。

0x70

WaitBlock ([4] _KWAIT_BLOCK)

如果当前线程执行了WaitForSingleObject函数,那么当前线程结构体中_KTHREAD的WaitBlock成员就会记录等待的对象。

0xE0

ServiceTable (Ptr32 Void)

指向系统服务表的基址。

0x134

TrapFrame (Ptr32 _KTRAP_FRAME)

这是一个结构体,用于进0环时保存环境。

0x140

PreviousMode (Char)

先前模式,某些内核函数会判断程序调用它时是0环还是3环,就是通过该成员去判断。

0x1B0

ThreadListEntry (_LIST_ENTRY)

双向链表,一个进程的所有线程都链入这个表中了。

其他成员

除了_KTHREAD以外,我们来看一下其他几个在_ETHREAD结构体中的成员:

偏移

成员

作用

0x1EC

Cid (_CLIENT_ID)

进程有编号,线程也有自己的编号,也就是这里的CID,需要注意的是这里的CID不光是线程ID,也包含了进程ID。即_CLIENT_ID[0]为进程ID,_CLIENT_ID[4]为线程ID。

0x220

ThreadsProcess (Ptr32 _EPROCESS)

指向当前线程所属进程的_EPROCESS结构体地址。

0x22C

ThreadListEntry (_LIST_ENTRY)

双向链表,一个进程的所有线程都链入这个表中了。

_KPCR

如果要逆向分析操作系统内核代码,需要具备两个前置知识:1.段、页相关代码能够理解;2.至少要知道三个结构体:_EPROCESS、_ETHREAD、_KPCR。因此本章节我们需要了解_KPCR结构体。

介绍

每个进程或线程都一个结构体来描述本身,同样CPU也有这样一个结构体来描述自己,即_KPCR。

当线程进入0环时,FS:[0]指向的就不再是TEB了,而变成了_KPCR;每个CPU(每核)都有一个_KPCR结构体,该结构体中存储了CPU本身需要用到的一些重要数据:如GDT、IDT以及线程相关的一些信息。

我们同样可以在Windbg中查看_KPCR结构体:

images/download/attachments/1703937/image2022-11-28_14-49-51.png

成员

_NT_TIB

在_KPCR结构体中的0x0偏移位成员,它是一个结构体_NT_TIB。

images/download/attachments/1703937/image2022-11-28_14-51-36.png

该结构体几个重要的成员如下:

偏移

成员

作用

0x0

ExceptionList (Ptr32 _EXCEPTION_REGISTRATION_RECORD)

表示当前线程内核异常链表(SEH)。

0x4

0x8

StackBase (Ptr32 Void)

StackLimit (Ptr32 Void)

表示当前线程内核栈的基址和大小。

0x18

Self (Ptr32 _NT_TIB)

这是一个指针,指向了当前_NT_TIB结构体,也就是_KPCR结构体(便于查找)。

其他

_KPCR结构体其他几个成员的信息如下:

偏移

成员

作用

0x1C

SelfPcr (Ptr32 _KPCR)

这是一个指针,指向了当前_KPCR结构体。

0x20

Prcb (Ptr32 _KPRCB)

这是一个指针,指向了_KPRCP结构体,即_KPCR结构体0x120偏移位的成员。这里也是为了方便找到结构体,防止_KPCR结构体发生了变化,就可以通过该成员找到_KPRCP结构体。

0x38

IDT (Ptr32 _KIDTENTRY)

这是一个指针,指向了 中断描述符表IDT。

0x3C

GDT (Ptr32 _KGDTENTRY)

这是一个指针,指向了全局 描述符表GDT。

0x40

TSS (Ptr32 _KTSS)

这是一个指针,指向了任务段TSS,每个CPU都有一个TSS。

0x51

Number (UChar)

表示CPU的编号:1、2、3、4...

0x120

PrcbData (_KPRCB)

拓展结构体。

_KPRCB

_KPRCB是_KPCR结构体的拓展结构体,我们先了解几个成员,后续用到了再逐个去了解。

偏移

成员

作用

0x4

CurrentThread (Ptr32 _KTHREAD)

这是一个指针,指向当前线程的结构体。

0x8

NextThread (Ptr32 _KTHREAD)

这是一个指针,指向即将切换的下一个线程的结构体。

0xC

IdleThread (Ptr32 _KTHREAD)

这是一个指针,指向了一个空闲的线程的结构体。

33个链表

线程有3种状态:就绪、等待、运行。正在运行中的线程存储在_KPCR结构体中,就绪和等待的线程全在另外的33个链表中。这33个链表有1个等待链表核32个就绪链表(我们也称之为调度链表)。

等待链表

所谓等待链表,即当线程调用了Sleep、WaitForSingleObject等函数时,就会被链入该表。

等待链表是一个双向链表,因此我们可以通过KiWaitListHead来遍历这张表,找到所有的等待线程。如下图所示我们可以通过KiWaitListHead这个全局变量,在Windbg中找到等待链表头,它的两个成员分别指向了前、后的等待线程。

images/download/attachments/1703937/image2022-11-28_15-33-18.png

如果我们想要找到它们对应的线程结构体需要在地址上减去0x60,因为这里记录的地址实际上是_ETHREAD(_KTHREAD)中的0x60偏移位成员WaitListEntry。

images/download/attachments/1703937/image2022-11-28_15-50-2.png

调度链表

调度链表有32个,也就有32个表头,我们可以通过全局变量KiDispatcherReadyListHead找到。在这32个链表中,每个链表链入的线程的优先级是不一样的。

images/download/attachments/1703937/image2022-11-28_15-58-17.png

同样,在链表中被链入的地址为_ETHREAD(_KTHREAD)中的0x60偏移位成员SwapListEntry。(0x60偏移位有两个含义)

images/download/attachments/1703937/image2022-11-28_16-5-3.png

线程切换

模拟线程切换

Windows下的线程切换是比较复杂的,为了更好的学习我们需要读一份代码,这份代码是用来进行模拟Windows线程切换的。

images/inline/8e5f22a30e18a66fdf98b8fe13b8096216a133029af2e40161a4d9b6726f8f23.png

模拟线程结构体

模拟代码的第一部分就是要模拟线程结构体,我们保留最重要的成员进行模拟:

//线程信息的结构
typedef struct
{
const char* name; // 线程名
int Flags; // 线程状态
int SleepMillsecondDot; // 休眠时间
void* initialStack; // 线程堆栈起始位置
void* StackLimit; // 线程堆栈界限
void* KernelStack; // 线程堆栈当前位置,也就是ESP
void* lpParameter; // 线程函数的参数
void(*func)(void* lpParameter); // 线程函数
}GMThread_t;

调度链表

在代码中有一个结构体数组,即线程结构体数组:

extern GMThread_t GMThreadList[MAXGMTHREAD];

我们知道线程有三种不同的状态,这些不同状态的线程根据优先级存储在不同的链表里,在这里我们模拟的没有那么复杂,只使用一个链表来存储所有状态的线程。

所谓创建线程,本质就是创建一个结构体,然后将结构体存到这个结构体数组中,我们可以根据线程结构体的Flags成员来判断线程的状态。

images/download/attachments/1703937/image2022-12-5_14-0-33.png

结构体存储的时候是从下标为1的索引中开始的,下标为0的索引中存储的是当前函数的线程信息。

初始化线程

我们创建一个线程,就是创建结构体,接着我们初始化线程,就是要准备内存空间,并将结构体成员的值填充好,如下代码就完成了这些操作:

// 初始化线程的信息
void initGMThread(GMThread_t* GMThreadp, const char* name, void(*func)(void* lpParameter), void* lpParameter)
{
unsigned char* StackPages;
unsigned int* StackDWordParam;
// 结构体初始化赋值:状态、名称、函数地址、函数参数
GMThreadp->Flags = GMTHREAD_CREATE;
GMThreadp->name = name;
GMThreadp->func = func;
GMThreadp->lpParameter = lpParameter;
// 申请内存空间,大小为GMTHREADSTACKSIZE = 0x8000
StackPages = (unsigned char*)VirtualAlloc(NULL, GMTHREADSTACKSIZE, MEM_COMMIT, PAGE_READWRITE);
// 空间清零
ZeroMemory(StackPages, GMTHREADSTACKSIZE);
// 由于堆栈的操作是从高地址往低地址的,所以为了模拟这一效果,我们将将申请的内存空间地址加上初始化的大小就得到了线程的内存空间初始化地址
GMThreadp->initialStack = StackPages + GMTHREADSTACKSIZE;
StackDWordParam = (unsigned int*)GMThreadp->initialStack;
// 入栈
PushStack(&StackDWordParam, (unsigned int)GMThreadp); // 线程结构体,用于线程函数地址、函数参数地址的寻找
PushStack(&StackDWordParam, (unsigned int)9); // 平衡堆栈
PushStack(&StackDWordParam, (unsigned int)GMThreadStartup); // 启动线程的函数,用于调用线程函数
// 压入的对应寄存器初始值
PushStack(&StackDWordParam, (unsigned int)5); // ebp
PushStack(&StackDWordParam, (unsigned int)7); // edi
PushStack(&StackDWordParam, (unsigned int)6); // esi
PushStack(&StackDWordParam, (unsigned int)3); // ebx
PushStack(&StackDWordParam, (unsigned int)2); // ecx
PushStack(&StackDWordParam, (unsigned int)1); // edx
PushStack(&StackDWordParam, (unsigned int)0); // eax
// 设置当前线程的栈顶
GMThreadp->KernelStack = StackDWordParam;
// 设置线程状态为准备
GMThreadp->Flags = GMTHREAD_READY;
return;
}

这段代码执行完成之后的内存空间分布就如下:

images/download/attachments/1703937/image2022-12-6_14-59-26.png

线程切换

初始化线程之后,也就是执行完RegisterGMThread函数,就进入了线程切换运行,即Scheduling函数。该函数遍历调度链表,找到其中的线程结构体,并根据Flags成员判断线程是否为准备状态,获取对应的线程结构体通过SwitchContext函数进行线程切换。

void Scheduling(void)
{
int i;
int TickCount;
GMThread_t* SrcGMThreadp;
GMThread_t* DstGMThreadp;
TickCount = GetTickCount();
SrcGMThreadp = &GMThreadList[CurrentThreadIndex];
DstGMThreadp = &GMThreadList[0];
 
for (i = 1; GMThreadList[i].name; i++) {
// 判断休眠状态的线程休眠时间是否小于程序启动至今的时间,如果是则将线程状态调整为等待
if (GMThreadList[i].Flags & GMTHREAD_SLEEP) {
if (TickCount > GMThreadList[i].SleepMillsecondDot) {
GMThreadList[i].Flags = GMTHREAD_READY;
}
}
if (GMThreadList[i].Flags & GMTHREAD_READY) {
DstGMThreadp = &GMThreadList[i]; // 获取准备状态的线程结构体
break;
}
}
 
CurrentThreadIndex = DstGMThreadp - GMThreadList;
SwitchContext(SrcGMThreadp, DstGMThreadp);
return;
}

接着我们来看线程切换的函数SwitchContext,一开始进行堆栈的提升然后将当前线程用到的寄存器入栈,接着分别给ESI、EDI存入当前和要切换的线程结构体地址,并将当前的栈顶即ESP寄存器给到当前线程结构体的KernelStack成员,然后就是经典的线程切换操作,将当前的栈顶指向要切换的线程结构体成员KernelStack,再将其对应存入的结构体进行弹出,最后RET指令最为精巧,将启动线程的函数地址给到EIP,这样下一次要执行的函数就是启动线程的函数

// 切换线程
__declspec(naked) void SwitchContext(GMThread_t* SrcGMThreadp, GMThread_t* DstGMThreadp)
{
__asm {
// 提升堆栈
push ebp
mov ebp, esp
// 当前线程用到的寄存器入栈
push edi
push esi
push ebx
push ecx
push edx
push eax
// 将当前线程结构体和要切换的线程结构体分别存入ESI和EDI寄存器
mov esi, SrcGMThreadp
mov edi, DstGMThreadp
// 把当前线程的栈顶存入到结构体的KernelStack成员中
mov [esi + GMThread_t.KernelStack], esp
// 经典线程切换,另外一个线程复活
// 将要切换的线程结构体成员KernelStack给到ESP寄存器(栈顶),也就是栈的切换
mov esp, [edi + GMThread_t.KernelStack]
// 分别弹出要切换的线程结构体初始化的寄存器值
pop eax
pop edx
pop ecx
pop ebx
pop esi
pop edi
pop ebp
ret // 将启动线程的函数地址给到EIP
}
}

images/download/attachments/1703937/image2022-12-6_16-16-49.png

启动线程的函数如下,我们可以看到它使用线程结构体的func成员函数,传递参数为lpParameter成员,这样我们就可以顺利的去执行对应线程的函数,最后将线程的状态设为退出状态,再进入Scheduling函数进行线程切换:

void GMThreadStartup(GMThread_t* GMThreadp)
{
GMThreadp->func(GMThreadp->lpParameter);
GMThreadp->Flags = GMTHREAD_EXIT;
Scheduling();
 
return;
}

这里实际上有一个细节,虽然当RET指令将启动线程的函数地址弹给了EIP,但是启动线程函数是有参数的,这个参数就是一个线程结构体,而这里我们并没有进行参数的传递,那么它是如何找到参数的呢?实际上我们可以通过反汇编来看一下,通过反汇编我们可以看到即使你没有传递参数,但是在反汇编的指令层它会通过[EBP+8]的方式去取参数。

images/download/attachments/1703937/image2022-12-6_16-11-25.png

所以我们在初始化线程的代码中,向堆栈中随便压入了一个值,用于平衡堆栈,这样就可以确保当执行该函数时能顺利的通过[EBP+8]的方式来取到线程结构体,然后再找到对应的成员函数、函数参数进行调用。

PushStack(&StackDWordParam, (unsigned int)9); // 平衡堆栈

总结

  1. 线程不是被动切换的,而是主动的,这点我们在线程函数的实现代码上有体现,即每个线程函数都调用了线程切换的函数

    • images/download/attachments/1703937/image2022-12-6_16-21-13.png
  2. 线程切换并没有通过TSS来保护寄存器,而是通过堆栈的方式;

  3. 线程的切换过程本质上就是堆栈的切换过程。

主动切换

在线程模拟的代码中,有一个重要的函数SwitchContext,它是用于线程切换的,而对应在Windows上有着类似功能的函数就是KiSwapContext。

我们可以通过IDA打开Ntoskrnl.exe内核模块找到该函数,来看一下它的作用,根据反汇编我们可以看见,该函数一开始就是保存当前线程所使用的寄存器,然后根据_KPCR取出_KTHREAD结构体,并且从父函数传递的参数ECX中拿到要切换的线程结构体地址,将该地址替换至_KPCR结构体中用于当前线程的_KTHREAD结构体成员的位置

images/download/attachments/1703937/image2022-12-7_14-20-16.png

从父函数传递的参数ECX,我们可以跟进看一下KiSwapThread函数,如下图所示ECX是通过EAX得来的,而EAX是通过KiFindReadyThread函数返回的,该函数从字面意思就是寻找准备就绪的线程,返回的自然就是一个线程结构体了:

images/download/attachments/1703937/image2022-12-7_14-23-37.png

接着我们回到KiSwapContext,向下走就会发现真正的线程切换是在SwapContext函数内,跟进该函数我们可以先忽略其他的一些细节,关注最重要的堆栈切换那一部分,我们可以清晰的看见这里的操作与之前的模拟线程切换一样,将当前的ESP存入当前线程的结构体中,并且将要切换线程的KernelStatck成员给到当前ESP。

images/download/attachments/1703937/image2022-12-7_14-55-4.png

总结

其实在内核函数中大多都用到了线程切换,我们可以根据IDA的XREF来看调用链,最终看见有很多个内核函数调用了线程切换:

images/download/attachments/1703937/image2022-12-7_15-8-56.png

在线程切换时会比较是否属于同一个进程,如果不是同一个进程,就会切换Cr3,这样对应进程也就切换了,因此进程的切换实际上也是线程的切换

时钟中断切换

之前我们了解到大多数内核函数都最终会调用到SwapContext来进行线程切换,但如果有线程没有去调用系统API是否就不会进行线程切换呢?其实不然,时钟中断也会导致线程切换。

如下就是系统时钟中断对应的信息,在Windows操作系统中,每10~20毫秒便会触发一次时钟中断,要想获取当前版本Windows时钟间隔值,可使用GetSystemTimeAdjustment函数:

(IDT表)中断号

IRQ号

说明

0x30

IRQ0

时钟中断

执行流程

我们可以通过IDA打开Ntoskrnl.exe内核模块,找到IDT表中0x30中断号对应的处理函数,也就是_KiStartUnexpectedRange函数:

images/download/attachments/1703937/image2022-12-7_16-2-17.png

我们跟进这个函数发现有两个跳转,先是走到_KiEndUnexpectedRange函数,最终是走到了_KiUnexpectedInterruptTail函数:

images/download/attachments/1703937/image2022-12-7_16-6-2.png

在这个函数里,我们可以看见它在函数内又调用了一个导入表的函数HalBeginSystemInterrupt和HalEndSystemInterrupt:

images/download/attachments/1703937/image2022-12-7_16-27-4.png

images/download/attachments/1703937/image2022-12-7_16-40-8.png

我们可以在IDA的Imports导入表窗口中找到该导入函数对应的模块,也就是如下图所示的HAL模块:

images/download/attachments/1703937/image2022-12-7_16-28-40.png

images/download/attachments/1703937/image2022-12-7_16-36-41.png

跟进HAL模块(HAL.DLL文件在C:/Windows/System32目录下)在HalEndSystemInterrupt函数内,我们可以看到它又调用了KiDispatchInterrupt函数:

images/download/attachments/1703937/image2022-12-8_15-23-47.png

再回到Ntoskrnl.exe内核模块,跟进KiDispatchInterrupt函数你会发现它最终又调用了SwapContext函数,因此也就证明了时钟中断也会导致线程切换:

images/download/attachments/1703937/image2022-12-7_16-39-27.png

我们简单梳理一下,系统时钟中断的执行流程图如下:

images/download/attachments/1703937/image2022-12-7_16-40-37.png

总结

线程切换的几种情况:

  1. 主动调用API函数(SwapContext)

  2. 时钟中断

  3. 异常处理(缺页、INT N指令)

如果一个线程不调用API,在代码中屏蔽中断(CLI指令),并且不会出现异常,那么当前线程将永久占有CPU,单核占有率100% ,2核就是50%。

时间片管理与备用线程

时钟中断导致线程进行切换也是有前置条件的,第一是当前的线程CPU时间片到期,第二是有备用的线程(即_KPCR.PrcbData.NextThread存储了另外的线程地址),以上两种情况时候发生的时间中断才会进行线程切换。

CPU时间片到期

时间片就是CPU分配给各个程序的时间,每个进程被分配一个时间段,称作它的时间片,表示该进程允许运行的时间,使各个程序从表面上看是同时进行的。 如果在时间片结束时进程还在运行,则CPU将被剥夺并分配给另一个进程。如果进程在时间片结束前阻塞或结束,则CPU当即进行切换,这样就不会造成CPU资源浪费。

当一个新的线程开始执行,初始化程序会给_KTHREAD结构体的Quantum成员赋予初始值,该值的大小由_KPROCESS结构体的ThreadQuantum成员决定。如下图所示我们找一个进程来看下结构体的ThreadQuantum成员,它的值为6,也就表示当该进程的线程开始执行,初始化程序会将该值赋予_KTHREAD结构体的Quantum成员,该值就表示当前线程时间片的大小。

images/download/attachments/1703937/image2022-12-7_16-55-53.png

每次时钟中断会调用KeUpdateRunTime函数,该函数每次执行就会将当前线程结构体成员Quantum的值减少3个单位,如果减到0则将_KPCR.PrcbData.QuantumEnd的值设置为非0来表示当前时间片已经到期了,我们可以通过IDA来看到这一过程。

images/download/attachments/1703937/image2022-12-7_17-14-58.png

系统时钟执行完毕之后都会去调用KiDispatchInterrupt函数,这个函数用于判断当前线程的时间片是否到期,我们可以来看一下该函数的执行流程,先判断_KPCR.PrcbData.QuantumEnd的值,当值为非0时进行跳转,接着修改该值为0继而调用_KiQuantumEnd函数,在这个函数里就是重新设置_KTHREAD结构体的Quantum成员为6,然后进入到KiFindReadThread函数寻找下一个就绪状态的线程。

images/download/attachments/1703937/image2022-12-8_15-0-1.png

以上流程结束之后就返回到KiDispatchInterrupt函数了,接着跟进发现将_KPCR.PrcbData.CurrentThread设为下一个就绪状态的线程,并且把当前线程挂到调度链表中,最终进行堆栈的切换(线程切换)

images/download/attachments/1703937/image2022-12-8_15-38-34.png

以上就是大致的CPU时间片到期的执行流程。

备用线程

除了CPU时间片到期的情况,还有存在备用线程的情况也会进行线程切换,我们可以继续分析KiDispatchInterrupt这个函数,回到最开始,我们可以看到即使你的CPU时间片没有过期,但如果_KPCR.PrcbData.NextThread不为0,也就是存在备用线程,也会进行线程切换。

images/download/attachments/1703937/image2022-12-8_15-55-7.png

线程切换与TSS的关系

SwapContext这个函数是Windows线程切换的核心,无论是主动切换还是系统时钟导致的线程切换,最终都会调用这个函数。在这个函数中除了切换堆栈以外,还做了很多其他的事情,了解这些细节对我们学习操作系统至关重要。这节课我们讲一下线程切换与TSS的关系。

内核堆栈

每一个线程都有一个内核堆栈,在系统调用章节时我们了解到使用API从3环进0环都要进行一个堆栈的切换,进0环所切换的堆栈就是内核堆栈。在线程结构体_KTHREAD中有关于内核堆栈的相关信息(栈顶、栈底、栈边界)。

images/download/attachments/1703937/image2022-12-8_16-2-45.png

内核堆栈的结构大致分为两个部分。第一部分就是从InitialStack开始一共0x210个字节,存储的就是浮点寄存器的值;剩下的第二部分就是我们之前了解过的_Trap_Frame结构体。

images/download/attachments/1703937/image2022-12-9_10-45-44.png

_Trap_Frame结构体在系统调用章节已经学过了,它的结构成员如下:

images/download/attachments/1703937/image2022-12-9_10-48-44.png

调用API进0环

API进0环有两种方式,分别为普通调用和快速调用。普通调用通过API进0环后会从TSS的ESP0得到0环的堆栈;快速调用则是先从MSR得到一个临时的0环栈来提供代码执行的环境,但是这段代码的执行实际上就是从TSS的ESP0得到0环的堆栈。

我们可以通过IDA来看一下_KiFastCallEntry函数,它先通过_KPCR找到TSS,而后通过TSS的ESP0得到0环的堆栈:

images/download/attachments/1703937/image2022-12-9_11-25-34.png

所以我们需要明白一个事情,无论是什么调用方式进入0环都是要通过TSS来切换堆栈的。

TSS

Intel设计TSS的目的是为了任务切换(线程切换),但是Windows和Linux都没有使用,而是采用堆栈来保护线程的各种寄存器。

一个CPU只有一个TSS,但是线程是有很多个的,每个线程在0环对应的堆栈是不一样的,那么到底是什么样的方式能让一个TSS来保存所有线程的ESP0呢?这里的细节在线程切换中,因此我们需要分析SwapContext函数。

SwapContext分析

我们可以通过IDA来分析一下SwapContext函数,重点关注TSS部分,首先是取出TSS然后将EAX给到TSS的ESP0。

images/download/attachments/1703937/image2022-12-9_16-21-39.png

那么这个EAX又是从何而来呢,我们可以向上找一下,EAX的值是目标线程的栈底,并且将堆栈中存储的浮点寄存器和_Trap_Frame结构体中用于虚拟8086模式下的成员去除,也就是进行偏移位的修正。

images/download/attachments/1703937/image2022-12-9_16-26-43.png

从_Trap_Frame结构来看,目标线程的栈底(EAX)就指向了HardwareSegSs成员的位置,当然这是快速调用时所指向的位置,如果是普通调用(通过中断门的方式)由于CPU会帮我们压入5个寄存器值,所以栈底是指向ErrCode成员的。

除了ESP0之外,还有两个TSS的成员会被使用到。一个是TSS的Cr3会被修改为目标进程的Cr3;另外一个是之前学习中没有提到的IO权限位图,是将当前线程的IO权限位图存到TSS的0x66偏移位成员,但是这个在Windows2000以后已经不再使用了。(自行了解IO权限位图

images/download/attachments/1703937/image2022-12-9_16-36-15.png

那么通过以上的分析,我们发现虽然Intel的初衷是希望操作系统用TSS去存储更多内容,但是在Windows的实际实现中只用到了TSS的ESP0、Cr3、IO权限位图这3个成员(实际对于当前的Windows系统来说只有2个)来进行线程切换。

线程切换与FS寄存器的关系

我们知道在3环下FS:[0]指向的是TEB,而在0环下FS:[0]指向的是_KPCR。在系统中同时会有很多个线程运行,这就意味着FS:[0]需要存储的不同线程的相关信息,但是在实际的使用中发现3环下不同线程的FS寄存器(段选择子)都是一样的值,那么到底是什么样的一个实现能让同一个寄存器指向多个不同的TEB呢,要了解其中的细节我们还是需要来分析SwapContext这个函数的代码。

如下我们找到与FS寄存器有关系的代码段,可以看出它显示取出目标线程的TEB地址,接着取出GDT表,并且向表中的成员写入值。我们知道FS在Windows XP下的段选择子是0x3B,也就表示它的段描述符是在GDT表的第7个,即0x38偏移位,再结合如下的代码,我们可以根据段选择子的结构体,发现它实际上就是将目标线程的TEB地址写入到FS段描述符的Base Address成员位中

images/download/attachments/1703937/image2022-12-12_16-35-40.png

images/download/attachments/1703937/image2022-6-15_15-14-33.png

综上所示,也就解答了我们的困惑,在多个线程中,FS选择子不变,依旧可以获取对应线程的信息。

线程优先级

我们发现在主动调用、时间片到期的情况下导致的线程切换,它们所用到的API都会通过KiFindReadyThread函数来找到下一个要切换的线程,那么这时候就抛出了一个问题:该函数是根据什么样的条件取寻找下一个切换的线程呢?

KiFindReadyThread就是根据调度链表去查询的线程,它的查询优先级就是根据调度链表的级别(32个调度链表),按线程级别大小从高到低查找:31、30、29、28...,如果在线程级别31的链表中找到了线程,那么查找就结束了。

但是如果每次都是按照这个从高到低的优先级寻找,从效率和性能来看是很差的,所以Windows就通过一个DWORD类型的变量(_KiReadySummary)来记录,当向调度链表中挂入或取出某个线程时,会先判断当前级别的链表是否为空,为空则在DWORD变量的对应偏移位的值设为0,反之不为空就设为1。

如下图所示,就意味着线程级别为30、29的链表中是有线程的,其他链表中没有线程。

images/download/attachments/1703937/image2022-12-12_16-57-39.png

判断链表是否为空可以通过KiDispatcherReadyListHead全局变量找到调度链表,然后根据它的链表成员判断是否前后指向的地址都是同一个,且该地址与链表的成员地址一致,如果都是一致的则表示该链表为空。

images/download/attachments/1703937/image2022-12-12_17-8-57.png

假设调度链表中都没有就绪状态的线程了,CPU就会取_KPCR.PrcbData.IdleThread即空闲线程去运行,这一点我们可以通过IDA来看一下KSwapThread调用KiFindReadyThread的前后代码来论证。

先是给了ESI一个_KPRCB的地址,接着调用KiFindReadyThread,比较返回结果是否为空,如果为空则进行跳转。

images/download/attachments/1703937/image2022-12-12_17-27-57.png

跟进跳转我们就能看见将空闲线程的值就赋值给了EAX,然后跳回之前的位置:

images/download/attachments/1703937/image2022-12-12_17-32-3.png


进程挂靠

进程为线程提供资源,也就是提供Cr3的值,Cr3中存储的就是页目录表的基址,我们有了Cr3也就表示知道了线程能访问的内存。

进程与线程的关系

在Win32的课程学习中,我们了解了进程的创建,知道真正运行代码的实际上是线程而不是进程。如下这个汇编指令就是线程运行的,当运行这段代码时CPU是如何解析0x12345678这个地址的呢?

MOV EAX, DWORD PTR DS:[0x12345678]

0x12345678是一个线性地址,CPU要解析这个线性地址就需要通过页目录表找到对应的物理页。而要找到页目录表就需要通页目录表基址,也就表示我们需要Cr3寄存器,Cr3寄存器值来源于当前的进程结构体(_KPROCESS.DirectoryTableBase)。因此我们可以理解为进程提供给线程运行的空间环境(界定了哪些内存可以访问)。

既然进程给线程提供了环境信息,线程也要能找到进程才能知道这些信息,因此在线程结构体中实际上也有成员是指向了进程结构体的。

在_ETHREAD结构体中有两处位置都指向了进程结构体,分别是_ETHREAD.ThreadsProcess(0x220偏移位)和_ETHREAD.Tcb.ApcState.Process(0x44偏移位)。

images/download/attachments/1703937/image2022-12-13_10-29-25.png

那么在实际的线程切换当中用到的到底是0x220偏移位成员还是0x44偏移位成员呢,我们可以来看一下SwapContext函数的实现。

从实现来看SwapContext使用的是0x44偏移位成员,即_ETHREAD.Tcb.ApcState.Process来进行线程间的所属进程比对。

images/download/attachments/1703937/image2022-12-13_13-51-3.png

如果所属进程不是同一个,则会从进程结构体中找到Cr3取出,最后进行Cr3切换。

images/download/attachments/1703937/image2022-12-13_13-57-31.png

综上所述,我们就知道线程所需要的Cr3寄存器值是来源于_ETHREAD线程结构体0x44偏移位的成员。那么是否就表示0x220是多余的呢,其实不然,0x220偏移位成员用于表示的是当前线程是哪个进程所创建,而0x44偏移位成员用于表示当前线程的资源(Cr3)是哪个进程所提供,在一般情况下这两个偏移位成员都指向同一个进程。

Cr3的修改

正常情况下,Cr3的值是由_ETHREAD.Tcb.ApcState.Process提供,但Cr3的值也可以改成和当前线程毫不相干的其他进程的DirectoryTableBase,这样的手法我们称之为进程挂靠

我们可以通过如下汇编指令来修改Cr3寄存器的值:

mov cr3, A.DirectoryTableBase
mov eax, dword ptr ds:[0x12345678] // A进程的0x12345678内存
mov cr3, B.DirectoryTableBase
mov eax, dword ptr ds:[0x12345678] // B进程的0x12345678内存
mov cr3, C.DirectoryTableBase
mov eax, dword ptr ds:[0x12345678] // C进程的0x12345678内存

进程挂靠的目的就是让当前线程可以访问其他进程的内存空间。

NtReadVirtualMemory函数分析

直接修改Cr3的方法如果遇到了线程切换就会变回去,我们可以来看一下Windows下的函数NtReadVirtualMemory,该函数的作用就是读取其他进程的内存,我们看一下它是如何实现的。

这个函数很复杂,我们只需要找关键的部分,也就是切换Cr3的那一步,追踪它的调用关系,最终在_KiSwapProcess函数找到了关键部分,调用链如下:

_MmCopyVirtualMemory -> _MiDoPoolCopy -> _KeStackAttachProcess -> _KiAttachProcess -> _KiSwapProcess

在_KiSwapProcess函数中,我们发现它就是将Cr3修改为目标进程的DirectoryTableBase:

images/download/attachments/1703937/image2022-12-13_14-59-23.png

但是在这个函数调用之前,又将_ETHREAD.Tcb.ApcState.Process的值修改为要读取的进程结构体_KPROCESS地址,这样就避免了线程切换时导致的Cr3值还原回去:

images/download/attachments/1703937/image2022-12-13_15-4-40.png

如果你不想要使用这个函数来跨进程读取内存,自己实现的话就需要切换Cr3后来关闭中断,并且在程序中不再使用导致线程切换的Windows API。

总结

正常情况下,当前线程使用的Cr3是由其所属进程提供的(_ETHREAD,0x44偏移位指定的_EPROCESS),正是因为如此,A进程中的线程只能访问A的内存。如果要让A进程中的线程能够访问B进程的内存,就必须要修改Cr3的值为B进程的页目录表基址(B.DirectoryTableBase),这就是所谓的进程挂靠。

跨进程读写

如下这段汇编指令是向通过A进程的线程执行,获取B进程的0x12345678的值再存入到0x00401234,这样操作很明显是无意义的,因为你操作了半天都只是将数据存入到了B进程的0x00401234,而A进程的0x00401234对应值并没有数据。

mov cr3, B.DirectoryTableBase // 切换Cr3的值为B进程
mov eax, dword ptr ds:[0x12345678] // 将B进程0x12345678的值存的eax中
mov dword ptr ds:[0x00401234], eax // 将数据存储到0x00401234中
mov cr3, A.DirectoryTableBase // 切换回Cr3的值

如果你还清晰的记得保护模式相关的内容就会想到一个进程有4GB内存空间,低2G是自己的,高2G是共享的,就可以利用高2G的内存空间来存储数据。

在Windows下的NtReadVirtualMemory、NtWriteVirtualMemory函数来跨进程读写就是利用的高2G内存空间来进行读写,具体流程如下:

images/download/attachments/1703937/image2022-12-13_15-20-5.png

images/download/attachments/1703937/image2022-12-13_15-20-44.png