进程与线程
进程与线程
ZEROKO14windows进程与线程的底层实现
更多信息参见《Windows内核原理与实现》第三章。
特别强调:
- 笔记中的内容,与《内核情景分析》《Windows内核原理与实现》均有不同。
- ReactOS是开源免费的Windows NT系列(含NT4.0/2000/XP/2003)克隆操作系统
- WRK 是微软针对教育和学术界开放的 Windows 内核的部分源码
- 而笔记是基于Windows XP SP2/SP3 二进制文件.
- 微软并不开源,很多内核成员的作用需要自己去分析.
整个系统结构:
执行体只维护属性,内核只根据属性做事,内核层比执行体更接近底层
- Ps开头的基本是执行体函数
- KE,Ki开头函数开头的函数都是内核函数(一般KE是导出函数,Ki是不导出函数)
- Nt开头的函数还是ntdll.dll那层
进程结构体
进程结构体_EPROCESS
每个windows进程在0环都有一个对应的结构体:_EPROCESS这个结构体包含了进程所有重要的信息。(PEB是三环关于进程信息的另一个结构体)
_EPROCESS结构体中的成员微软并没有公布作用。(靠逆向分析出来的)
1 | kd> dt _eprocess |
全局变量PsActiveProcessHead指向活动进程链表头中的_EPROCESS的0x88偏移位置
PsActiveProcessHead也是两个值,下一个和前一个,两个四字节地址。
PsActiveProcessHead未导出
任务管理器查的就是这个链表。
PEB
三环进程结构体
三环时,FS:[30]指向这个PEB结构
1 | typedef struct _PEB { // Size: 0x1D8 |
PEB更多信息参见[[windows开发#模块隐藏|windows开发的模块隐藏章节]]
_KRPOCESS结构体
这个就是常说的进程PCB结构体
1 | kd> dt _KPROCESS |
实验
1、用OD附加记事本,然后查看记事本进程的PEB的BeingDebugged成员的值(注意:OD不要使用插件)
OD附加前:
OD附加后:
2、用OD附加记事本,下断点,然后在windbg中清空EPROCESS中的DebugPort中的值,然后单步调试,观察结果.
修改前:
修改后:
单步调试触发:
单步调试直接触发异常,OD卡死
进程断链实现任务管理器隐藏
1 | //目标进程断链函数 |
断链前:
锻炼后:
但是这里还是能看到:
线程结构体
线程结构体_ETHREAD
每个windows线程在0环都有一个对应的结构体:_ETHREAD,这个结构体包含了线程所有重要的信息。
1 | kd> dt _ETHREAD |
_KTHREAD结构体
1 | kd> dt _KTHREAD |
0x1b0:ThreadListEntry,一个进程所有的线程都挂在一个链表中,一共有两个这样的链表
- 线程:上面的圈在_KTHREAD中,下面的圈在_ETHREAD中
- 进程:上面的圈在_KPROCESS中,下面的圈在_EPROCESS中
思考
将线程链中的某个线程断了,如果线程断了还是否可以执行,是不是在od就看不到该线程了?
线程和进程实际上是无法真正隐藏的
逆PspCreateProcess大致思路(暂时无法自己逆向,基础铺垫不够)
1 | //3环 |
三环线程结构体_TEB
1 | kd> dt _TEB |
CPU结构体
_KPCR结构体
KPCR:CPU控制区(Processor Control Region)
CPU在内核中的结构体
_KPCR介绍
- 当线程进入0环时,FS:[0]指向KPCR
- 每个CPU核心都有一个KPCR结构体
- KPCR中存储了CPU本身要用的一些重要数据:GDT,IDT以及线程相关的一些信息。
1 | kd> dt _KPCR |
_NT_TIB结构体
1 | nt!_NT_TIB |
_KPRCB结构体
1 | kd> dt _KPRCB |
KiProcessorBlock全局变量(未导出)
PKPRCB KiProcessorBlock[MAXIMUM_PROCESSORS];
[定 义] wrk\wrk-v1.2\base\ntos\ke\kernldat.c
[初始化] wrk\wrk-v1.2\base\ntos\ex\obinit.c [KiSystemStartup ()]
[引 用]
- KiSwapThread() 线程调度
- KiSetAffinityThread() 设置线程亲和性
[描 述]
系统核心数据结构,与线程调度密切相关。所有KPRCB的指针数组,往前拨偏移可以得到对应KPCR。MAXIMUM_PROCESSORS定义为32。而实际数组的元素个数由上面KeNumberProcessors决定,每个处理器对应一个KPRCB结构体。
指定进程名找进程结构首地址
通过cpu结构,线程结构
1 | ULONG findProcess(char* name) |
对象头结构体
每个内核对象都有对象头结构体。
windbg查看一个进程下的线程,用如下命令:
1 | !process 要找的_EPROCESS结构体首地址 |
_OBJECT_HEADER
1 | kd> dt _OBJECT_HEADER |
_OBJECT_TYPE
1 | kd> dt _OBJECT_TYPE |
实例如下
进程对象头:
线程对象头:
等待链表_调度链表
进程结构体EPROCESS(0x50和0x190)是2个链表,里面圈着当前进程所有的线程。
对进程断链,程序可以正常运行,原因是CPU执行与调度是基于线程的,进程断链只是影响一些遍历系统进程的API,并不会影响程序执行。
对线程断链也是一样的,断链后再Windbg或OD中无法看到被断掉的线程,但并不影响执行(仍然在跑)
CPU到哪里去找线程?
等待链表
1 | dd KiWaitListHead |
KiWaitListHead储存着等待线程双向链表的全局变量,存了两个指针,分别指向的是前一个和后一个_ETHREAD结构体中的_KTHREAD结构体0x60偏移的WaitListEntry成员
线程调用了Sleep()或WaitForSingleObject()等函数时,就挂到这个链表中。
线程有三种状态
- 就绪
- 等待
- 运行
正在运行中的线程存储在KPCR中,就绪和等待的线程全在另外的33个链表中。一个等待链表,32个就绪链表:
这些链表都使用了_KTHREAD(0x60)这个位置,也就是说,线程在某一时刻,只能属于其中一个圈
调度链表(就绪链表)
既然有32个就绪链表,就要有32个链表头。
1 | kd>dd KiDispatcherReadyListHead L70 |
KiDispatcherReadyListHead储存着32个就绪线程双向链表的全局变量,32个8字节的数组,每个链表占8字节。
这个地址存储了32个链表头,分别对应32个优先级的调度链表,地址越高,优先级越高。如果FLink 等于 BLink 等于地址,说明此时链表为空;如果FLink等于BLink不等于地址,说明此时链表中只有一个线程。比如现在我 dd 打印,这时操作系统挂起,所有线程都处于等待状态,全部调度链表都是空的(因为windbg调试的时候,所有线程都会被挂起)
1 | kd> dd KiDispatcherReadyListHead |
全局变量KiReadySummary,32位,如果哪个存在就绪链表,其该位被置1。其从低位到高位就绪链表优先级依次升高,因此需要找从左边数第一个为1的位数,该位作为位数从KiDispatchReadListHead数组中寻找。
版本差异
- XP只有32个调度链表,整个系统(包括多核)只有32个
- 64位系统有64个调度链表,整个系统(包括多核)只有64个
服务器版本又有不同
KiWaitListHead整个系统只有一个,但KiDispatcherReadyListHead这个数组有几个CPU就有几组。
总结
- 正在运行的线程在KPCR中
- 准备运行的线程在32个调度链表中(0~31级),KiDispatcherReadyListHead是个存储了这32个链表头的数组。
- 等待状态的线程存储在等待链表中,KiWaitListHead存储链表头。
- 这些圈都挂一个相同的位置:_KTHREAD(0x60)
线程切换
逆向线程主动切换
Windows的线程切换比较复杂,
为了更好的学习,先了解一份代码:逆向ThreadSwitch
四个参数, ebx:_KPCR esi:新线程 _KTHREAD edi:旧线程 _KTHREAD ecx:WaitIrql
换esp的瞬间就是线程切换的真正本质
不一定切换CR3,仅在跨进程线程切换时才切换CR3
TSS中存储的一定是当前线程的,因为每次线程切换,都会更新TSS中的对应值
线程切换时FS虽然不变,但是fs的段选择子对应的段描述符的baseAddress会被修改为当前线程对应的TEB内存
push的方式将老线程的异常链表(_KPCR.ExceptionList)存到老线程堆栈,改变esp后(即修改线程后),通过pop的方式取出新线程堆栈中的异常链表存到_KPCR.ExceptionList中
空闲线程的_KTHREAD,当KiFindReadyThread找KPCR中的nextThread为空,并且也找不到就绪链表中有线程的时候,就把线程交换给空闲线程。
如图可知,IdleThread的线程开始入口并非放在_ETHREAD中,而是另有地方存
如何找到IdleThread跑的函数:_KPCR._KPRCB.IdleThread.KernelStack,我们知道交换到IdleThread是经过SwapContext线程切换过去的。SwapContext将线程的KernelStack放入esp后的堆栈操作有这些,pop ecx(取出异常链表地址),popf(取出eflags寄存器),retn(取出要执行的地址跳转)。如图:
windbg当前断下的就是IdleThread线程。
因此可知:IdleThread线程入口是KiIdleLoop函数
KiFindReadyThread,先找KPCR中nextThread是否有值,若为空去找调度线程链表找到最高优先级的就绪线程
很多细节没考究
1 | 。。。(上图函数) |
KiSwapThread
1 | .text:004050BF ; _DWORD __cdecl KiSwapThread()//无参 |
KiFindReadyThread
1 | //两个参数,分别是ecx为kpcr的CPU核心编号(单核模式下这个参数是没有用的),edx为0,表示要求的最低优先级,此处是函数开始位置 |
分析感觉不是很准确
该函数做了三件事:
- 解析KiReadySummary,找到从左起第一个为1的位数;
- 用该位获取从KiDispatchReadListHead中的第一个_KTHREAD线程,将其从链表中摘除;
- 如果摘除后该链表为空,则找到相应的KiReadySummary位将其置0。
KiSwapContext
1 | //KiSwapContext只有一个参数,通过ecx传参,[[0FFDFF020h]+8]:_KPRCB中+8偏移的NextThread,即将切换的下一个线程结构体_KTHREAD |
SwapContext
1 | //四个参数 ebx: _KPCR esi: 新线程 _KTHREAD edi: 旧线程 _KTHREAD ecx:旧的WaitIrql |
SwapContext这个函数是Windows线程切换的核心,无论是主动切换还是系统时钟导致的线程切换,最终都会调用这个函数。
KiIdleLoop函数流程:
内核堆栈的结构
_Trap_Frame结构
调用API进0环TSS细节
- 普通调用:通过TSS.ESP0得到0环堆栈
- 快速调用:从MSR得到一个临时0环栈,代码执行后仍然通过TSS.ESP0得到当前线程的0环堆栈
Intel设计TSS的目的是为了任务切换(线程切换),但Windows与Linux并没有使用。而是采用堆栈来保存线程的各种寄存器。
上文逆向线程主动切换部分有SwapContext的具体代码逆向。
调用API进0环FS细节
FS:[0]寄存器在3环时指向TEB,进入0环后FS:[0]指向KPCR
系统中同时存在很多个线程,这就意味着FS:[0]在3环时指向的TEB要有多个(每个线程一份)。
但在实际使用中我们发现,当我们在3环查看不同线程的FS寄存器时,FS的端选择子都是相同的,那是如何实现通过一个FS寄存器指向多个TEB呢?
模拟线程切换
模拟windows的线程切换机制实现用一个线程当n个线程使用
实验代码
ThreadCore.h
1 |
|
ThreadCore.cpp
1 |
|
main.cpp
1 |
|
效果如下
模拟线程切换总结
模拟线程切换总结:
- 线程不是被动切换的,而是主动让出CPU.
- 线程切换并没有使用TSS来保存寄存器,而是使用堆栈.
- 线程切换的过程就是堆栈切换的过程.
时钟中断线程切换
绝大部分系统内核函数都会调用SwapContext函数来进行线程的切换,这种切换是线程主动调用的。
- Q:那么如果当前的线程不去调用系统API,操作系统如何实现线程切换呢?
- A:那就是时钟切换
中断一个正在执行的程序有两种办法
- 异常,比如缺页,或者INT N指令
- 中断,比如时钟中断
系统时钟中断
Windows系统操作系统每隔10~20毫秒触发一次时钟中断。
可使用如下Win32 API,获取当前的时钟中断间隔值:
1 | GetSystemTimeAdjustment |
时钟中断的执行流程
1 | KiStartUnexpectedRange//实际上在windbg下断点根本不断下来 |
HalpClockInterrup函数是时钟中断跳转到的位置
如何避免线程切换
线程切换有几种情况
- 主动调用API函数
- 时钟中断
- 异常处理
如果一个线程不调用API,在代码中屏蔽中断(CLI指令),并且不会出现异常,那么当前线程将永久占有CPU,单核占有率100%,2核也就是50%
硬件时钟中断是没办法屏蔽的
时钟线程切换流程
时钟中断并不一定会导致线程切换,只有两种情况会导致线程切换
- 当前的线程CPU时间片到期
- 有备用线程(KPCR.PrcbData.NextThread)
CPU时间片到期线程切换
当前的线程CPU时间片到期的切换
- 当一个新的线程开始执行时,初始化程序会在_KTHREAD.Quantum赋初始值,该值的大小由_KPROCESS.ThreadQuantum决定
- 有个KeUpdateRunTime函数,该函数每次将当前线程Quantum减少3个单位,如果减到0,则将KPCR.PrcbData.QuantumEnd的值设置为非0
- KiDispatchInterrupt函数判断时间片到期:调用KiQuantumEnd重新设置时间片,内部调用KiFindReadyThread找到要运行的线程
存在备用线程的线程切换
KPCR.PrcbData.NextThread被设置时,即使当前线程的CPU时间片没有到期,仍然会被切换。
线程切换情况总结
当前线程主动调用API:
API函数->KiSwapThread->KiSwapContext->SwapContext
当前线程时间片到期:
KiDispatchInterrupt->KiQuantumEnd->KiSwapContext->SwapContext
有备份线程(KPCR.Prcb.NextThread)
KiDispatchInterrupt->SwapContext
线程优先级(KiFindReadyThread详解)
在KiSwapThread与KiQuantumEnd函数中都是通过KiFindReadyThread来找下一个要切换的线程。
32个调度链表
KiFindReadyThread查找方式:按照优先级别进行查找:31…30…29…28…
在查找中,如果级别31的链表里面有线程,那么就不会查找级别为30的链表
高效查询优化
调度链表有32个,每次都从头开始查找效率太低,所以windows通过一个DWORD类型变量_kiReadySummary来记录:
当向调度链表(32个)中挂入或者摘除某个线程时,会判断当前级别的链表是否为空,为空将_kiReadySummary对应的位清零,否则置1
_kiReadySummary结构
_kiReadySummary中的每一位分别对应调度链表每一个链表中是否有线程,第0位对应的就是KiDispatcherReadyListHead中最低级优先级的调度链表
如果KiDispatcherReadyListHead一个就绪线程也没了,那么切换为空闲线程IdleThread
多CPU会随机寻找KiDispatcherReadyListHead指向的数组中的线程。线程可以绑定某个cpu(使用API:setThreadAffinityMask)
进程挂靠
进程与线程的关系:
- 一个进程可以包含多个线程
- 一个进程至少要有一个线程
进程为线程提供资源,也就是提供Cr3的值,Cr3中存储的是页目录表基址,Cr3确定了,线程能访问的内存也就确定了。
线程代码:
1 | mov eax,dword ptr ds:[0x12345678] |
CPU如何解析0x12345678这个地址呢?
- CPU解析线性地址时要通过页目录表来找对应的物理页,页目录表基址存在Cr3寄存器中
- 当前的Cr3的值来源于当前的进程(_KPROCESS.DiectoryTableBase(+0x018))
线程找进程:(线程结构中有两个成员指向了其进程结构体)
虽然有两个指向进程的结构体,但是他们两个的含义不同。
参考SwapContext的汇编会发现:线程切换的时候,会比较新老线程_KTHREAD结构体0x044处指定的_EPROCESS是否为同一个,如果不是同一个,会将_EPROCESS的DirectoryTableBase的值取出,赋值给Cr3
所以线程需要的Cr3的值来源于线程结构体0x44偏移指向的_EPROCESS。
区别总结
- 0x220 亲身父母:这个线程是哪个进程创建的
- 0x44 养父母:创建后的线程是属于哪个进程的(谁在为这个线程提供资源,也就是提供Cr3)
一般情况下,0x220和0x44指向的是同一个进程。(远线程创建就是这两个值不一样)
进程挂靠
正常情况下,Cr3的值是由养父母提供的,但Cr3的值也可以改成和当前线程毫不相干的其他进程的DirectoryTableBase。
1 | mov cr3,A.DirectoryTableBase; |
将当前Cr3的值改为其他进程,称为“进程挂靠”
分析NtReadVirtualMemory函数
1 | NtReadVirtualMemory |
NtReadVirtualMemory逆向
1 | PAGE:004A72CE ; NTSTATUS __stdcall NtReadVirtualMemory(HANDLE ProcessHandle, PVOID BaseAddress, PVOID Buffer, SIZE_T NumberOfBytesToRead, PSIZE_T NumberOfBytesRead) |
MmCopyVirtualMemory
1 | PAGE:004A1634 ; __stdcall MmCopyVirtualMemory(x, x, x, x, x, x, x) |
MiDoPoolCopy
1 | PAGE:004A180D ; int __stdcall MiDoPoolCopy(ULONG_PTR BugCheckParameter1, int, ULONG_PTR, PVOID Address, SIZE_T Length, char, int) |
KeStackAttachProcess
1 | .text:0041A5A3 ; int __stdcall KeStackAttachProcess(ULONG_PTR BugCheckParameter1, int) |
KiAttachProcess
1 | .text:00413166 ; __stdcall KiAttachProcess(x, x, x, x) |
KiSwapProcess
真正挂靠进程的函数
1 | .text:00404AC4 ; __stdcall KiSwapProcess(x, x) |
进程挂靠总结
可不可以只修改Cr3而不修改养父母?不可以,如果不修改养父母的值,一旦产生线程切换,再切回来的时候,就会变成自己读自己!(因为线程切换就是去0x44取值放入Cr3中,但0x44并未修改,因此线程切换回自己的时候又会把自己的Cr3再取回来)
如果我们自己写代码,在切换Cr3后关闭中断,并且不调用会导致线程切换的API,就可以不用修改养父母的值。
跨进程读写内存
跨进程的本质是“进程挂靠”,正常情况下,A进程的线程只能访问A进程的地址空间,如果A进程的线程想访问B进程的地址空间,就要修改当前的Cr3的值为B进程的页目录表基址(KPROCESS.DirectoryTableBase)
NtReadVirtualMemory流程解析:
- 切换Cr3
- 读取数据复制到高2G
- 切换Cr3
- 从高2G复制到目标位置
NtWriteVirtualMemory流程解析:
- 将数据从目标位置复制到高2G地址
- 切换Cr3
- 从高2G复制到目标位置
- 切换Cr3
跨进程读内存实验:
跨进程写内存实验:
驱动另类通信实验
另类通信,诸如内存映射,门调用等等非常多方法。
inlineHOOK SSDT某一个有输入,有输出的函数,来实现驱动另类通信
内核改PE文件思路?只读不能改?运行不能改?似乎有办法
DPC 延迟过程调用
DPC是“Deferred Procedure Call”的缩写,意为推迟了的过程(函数)调用。这是因为,逻辑上应该放在中断服务程序中完成的操作并非都是那么紧迫,其中有一部分可能相对而言不那么紧迫,而又比较费时间,实际上可以放在开中断的条件下执行。如果把这些操作都放在中断服务程序中,就会使关闭中断的时间太长而引起中断请求的丢失,因为整个中断服务程序通常都是在关中断的条件下执行的。为此,把中断服务程序中不那么紧迫却比较费时,而又不必在关中断条件下执行的操作分割出来,放在另一个函数中,在开中断的条件下加以执行,就可以缩短关中断的时间。这样的函数就是DPC函数。一般而言,中断服务前期的操作是比较紧迫的,并且是必须关中断的,此时可以很快地对外部设备进行操作。此后,剩下的那部分操作便可以稍后在开中断的条件下执行。所以有人曾经把这部分操作称为中断服务的“后半(Bottom Half)”,也有人把这两半分别称为“硬中断”和“软中断”。之所以要把中断服务分成前后两半,是因为一次中断服务的后半不如另一次中断的前半那么紧迫。
为此,内核中要有个DPC请求队列,中断服务程序执行完它的“前半”之后就把一个DPC请求挂入这个队列,要求内核调用相应的DPC函数,然后(形式上)就从中断返回了。接着,如果没有别的中断请求,内核就会扫描这个DPC请求队列,依次在开中断的条件下执行这些DPC函数,直至又发生中断或执行完队列中的所有DPC函数。至于当前线程所要执行的程序,则只有在DPC请求队列为空的时候才会继续得到执行。显然,这里所体现的是“急事急办”的原则,中断是最急的,DPC函数其次,最后才是当前线程。Windows内核的IRQL(即运行级别)就反映了这些活动的轻重缓急,DPC函数是在DISPATCH_LEVEL级别上执行的。
与DPC函数的执行有关的另一个问题是堆栈的使用。我们知道,中断服务程序所使用的堆栈就是当前线程的系统空间堆栈。中断服务程序一般都是比较轻小的,占用一下当前线程的堆栈不至于会有问题;但是DPC函数就不同了,DPC函数有可能是比较大的,如果仍旧占用当前线程的堆栈,在最坏的情况下有可能造成堆栈溢出,所以最好是为DPC函数的执行另外配备一个堆栈。
ISR 中断处理,简单,指令少
重要的部分中断处理
不那么重要的部分,操作系统封装成KDPC里面,等下再执行。
_KPRCB结构体中0x860的DpcListHead就是dpc链表
DPC一定比APC先执行
_KDPC结构
1 | kd> dt _KDPC |
初始化函数KeInitializeDpc
1 | ;参数分别是_KDPC结构体指针,DPC回调函数,DPC上下文 |
插入DPC链表函数KeInsertQueueDpc
IRQL中断执行的优先级
1 | .text:0040C8A2 ; BOOLEAN __stdcall KeInsertQueueDpc(PRKDPC Dpc, PVOID SystemArgument1, PVOID SystemArgument2) |
上面的loc_446240画图展示操作
DPC定时器实验
1 |
|
十秒后打印如下结果:













































