保护模式
x86 CPU的三个模式: 实模式、保护模式和虚拟8086模式
一开机大多数都是实模式, 后期才进入保护模式, 后期如何写代码从实模式进入保护模式
保护模式有什么特点?
保护模式保护以下两种机制:段机制
、页机制
, 进入段进行权限检查,页的读写检查读写保护等
保护模式保护的是寄存器, CR0~CR4等控制寄存器,
段寄存器
1、什么是段寄存器?有哪些?
当我们用汇编读写某一个地址时:mov dword ptr ds:[0x123456],eax
我们真正读写的地址是:
ds.base + Ox123456
ES CS SS DS FS GS LDTR TR
共8个
1 | EDI写ES |
段寄存器结构
段寄存器的读写
读段寄存器
1 | 比如:MOV AX,ES 只能读16位的可见部分 |
写段寄存器1
比如:MOV DS,AX 写时是写96位
段寄存器属性
段寄存器有96位:
1 | Selector //16位 |
但我们只能看见16位,那如果证明Attribute、Base、Limit的存在呢?
探测Attribute
1 | int var = 0; |
探测Base
1 | int var = 1; |
探测Limit
1 | int var = 1; |
段描述符与段选择子
1、GDT(全局描述符表) LDT(局部描述符表)
当我们执行类似MOV DS,AX指令时,CPU会查表,根据AX的值来决定查找GDT还是LDT,查找表的什么位置,查出多少数据.
段选择子是一个16位的段描述符,该描述符指向了定义该段的段描述符.
RPL:请求特权级别
TI:
- TI=0 查GDT表
- TI=1 查LDT表
Index:
处理器将索引值乘以8在加上GDT或者LDT的基地址,就是要加载的段描述符
加载段描述符至段寄存器
除了MOV指令,我们还可以使用LES、LSS、LDS、LFS、LGS指令修改寄存器.
CS不能通过上述的指令进行修改,CS为代码段,CS的改变会导致EIP的改变,要改CS,必须要保证CS与EIP一起改,后面会讲.
1 | char buffer[6]; |
段描述符属性P位与G位
P = 1 段描述符有效
P = 0 段描述符无效
段描述符属性S位与Type域
S = 1 代码段或者数据段描述符
S = 0 系统段描述符
系统段描述符
当S=0时,该段描述符为系统描述符
数据段、代码段描述符
- A 访问位,表示该位最后一次被操作系统清零后,该段是否被访问过.每当处理器将该段选择符置入某个段寄存器时,就将该位置1.
- W 是否可写
- E 扩展方向
段描述符DB位
情况一:对CS段的影响
- D = 1 采用32位寻址方式
- D = 0 采用16位寻址方式
- 前缀67 改变寻址方式
情况二:对SS段的影响
- D = 1 隐式堆栈访问指令(如:PUSH POP CALL) 使用32位堆栈指针寄存器ESP
- D = 0 隐式堆栈访问指令(如:PUSH POP CALL) 使用16位堆栈指针寄存器SP
情况三:向下拓展的数据段
- D = 1 段上线为4GB
- D = 0 段上线为64KB
段权限检查
段权限检查MOV DS,AX
CPL(Current Privilege Level) :当前特权级
如何查看程序处于几环?
CS和SS中存储的段选择子后2位.
DPL(Descriptor Privilege Level) 描述符特权级别
DPL存储在段描述符中,规定了访问该段所需要的特权级别是什么.
通俗的理解:
如果你想访问我,那么你应该具备什么特权.
举例说明:mov DS,AX
如果AX指向的段DPL = 0 但当前程序的CPL = 3 这行指令是不会成功的!
RPL(Request Privilege Level) 请求特权级别
举例说明:1
2Mov ax,0008 与 Mov ax,000B //段选择子
Mov ds,ax Mov ds,ax //将段描述
指向的是同一个段描述符,但RPL是不一样的.
数据段的权限检查
参考如下代码:
比如当前程序处于0环,也就是说CPL=01
2
3Mov ax,000B //1011 RPL = 3
Mov ds,ax //ax指向的段描述符的DPL = 0
数据段的权限检查:CPL <= DPL 并且 RPL <= DPL (数值上的比较)
注意:
代码段和系统段描述符中的检查方式并不一样,具体参加后面课程.
总结
- CPL CPU当前的权限级别
- DPL 如果你想访问我,你应该具备什么样的权限
- RPL 用什么权限去访问一个段
为啥要有RPL?
我们本可以用“读 写”的权限去打开一个文件,但为了避免出错,有些时候我们使用“只读”的权限去打开。
代码跨段流程
段寄存器:
ES,CS,SS,DS,FS,GS,LDTR,TR
段寄存器读写:
除CS外,其他的段寄存器都可以通过MOV,LES,LSS,LDS,LFS,LGS指令进行修改
CS为什么不可以直接修改呢?
CS的改变意味着EIP的改变,改变CS的同时必须修改EIP,所以我们无法使用上面的指令来进行修改.
代码间的跳转
(段间跳转 非调用门之类的)
段间跳转,有2种情况,即要跳转的段是一致代码段还是非一致代码段(如何区分参见之前视频)
同时修改CS与EIP的指令
JMP FAR / CALL FAR / RETF / INT /IRETED
只改变EIP的指令
JMP / CALL / JCC / RET
代码间的跳转执行流程
JMP 0x20:0x004183D7
CPU如何执行这行代码?
(1) 段选择子拆分
1
2
3
40x20 对应二进制形式 0000 0000 0010 0000
RPL = 00
TI = 0
Index = 4(2) 查表得到段描述符
1
2
3TI = 0 所以查GDT表
Index = 4 找到对应的段描述符
四种情况可以跳转:代码段、调用门、TSS任务段、任务门(3) 权限检查
1
2如果是非一致代码段,要求:CPL == DPL 并且 RPL <= DPL
如果是一致代码段,要求:CPL >= DPL(4) 加载段描述符
通过上面的权限检查后,CPU会将段描述符加载到CS段寄存器中.- (5) 代码执行
CPU将 CS.Base + Offset 的值写入EIP 然后执行CS:EIP处的代码,段间跳转结束.
直接对代码段进行JMP 或者 CALL的操作
无论目标是一致代码段还是非一致代码段,CPL都不会发生改变.如果要提升CPL的权限,只能通过调用门.
长调用与短调用
我们通过JMP FAR可以实现段间的跳转,如果要实现跨段的调用就必须要
学习CALL FAR,也就是长调用.
CALL FAR 比JMP FAR要复杂,JMP并不影响堆栈,但CALL指令会影响.
短调用
指令格式:CALL 立即数/寄存器/内存
发生改变的寄存器:ESP EIP
长调用(跨段不提权)
指令格式:CALL CS:EIP(EIP是废弃的)
与JMP跨段不一样, JMP FAR CS:EIP中的EIP会跳到指定地址, 但是CALL的EIP是废弃的
修改CS就为跨段, 根据段选择子拿到的段描述符DPL是3,如果CSCPL一直是3环, 则是不提权跨段
不提权用的堆栈还是原来的堆栈SS
如果用普通的RET只是将EIP指向返回地址, 但是CS不会回到原址,而RETF则会刷入原来的CS寄存器
发生改变的寄存器:ESP EIP CS
长调用(跨段并提权)
指令格式:CALL CS:EIP(EIP是废弃的)
这次段选择子找到的DPL是0,那就是提权跨段调用
CS一旦提权了, SS也得提权, CS和SS是一对的
ESP栈顶指针寄存器也得提权, 从3环换成0环
SS和ESP从哪里来, 一旦看到CS提权之后, 就会去TSS那边要这两个
发生改变的寄存器:ESP EIP CS SS
- 1) 跨段调用时,一旦有权限切换,就会切换堆栈.
- 2) CS的权限一旦改变,SS的权限也要随着改变,CS与SS的等级必须一样.
- 3) JMP FAR 只能跳转到同级非一致代码段,但CALL FAR可以通过调用门
提权,提升CPL的权限.
调用门
调用门描述符
TYPE: 1100代表的是TSS1
真正存入CS寄存器的是门里面的段选择子指向的段描述符决定
1、调用门执行流程
指令格式:CALL CS:EIP(EIP是废弃的)
执行步骤:
- 1) 根据CS的值 查GDT表,找到对应的段描述符 这个描述符是一个调用门.(注意当前CS的CPL一定要和调用门的DPL一致,否则敲门的权力都没有)
- 2) 在调用门描述符中存储另一个代码段段的选择子.(在代码段上就可以指定是0环的RPL还是3环的RPL)
- 3) 选择子指向的段 段.Base + 偏移地址 就是真正要执行的地址.
无参
1 | void __declspec(naked) NoArgs(){ |
打印一下函数地址NoArg:0x00401030
,HasArg:0x00401040
根据ox4b
可以找到第0100 1011
RPL:3环
TI:0
Index:9
从而修改GDT第十项:0040ec00, 00081030
, 跳到index2的段描述符提权
有参
1 |
|
打印一下函数地址NoArg:0x00401030
,HasArg:0x00401040
根据ox4b
可以找到第0100 1011
RPL:3环
TI:0
Index:9
从而修改GDT第十项:0040ec03, 00081030
, 跳到index2的段描述符提权,并且参数为3个
根据esp查内存地址, 分以下结构
- 第三个参数
- 第二个参数
- 第一个参数
- ESP
- SS
中断门
Windows没有使用调用门,但是使用了中断门:
- <1> 系统调用1>
- <2> 调试2>
IDT
IDT即中断描述符表,同GDT一样,IDT也是由一系列描述符组成的,每个
描述符占8个字节。但要注意的是,IDT表中的第一个元素不是NULL。
1 | CPU遇到INT指令会查IDT表 |
GDT表大约有两大类, 系统段、代码&数据段
IDT表是任务门, 中断门, 陷阱门
中断门: P为1, S为0, TYPE=1110
任务门: P为1, S为0, TYPE=0101
陷阱门: P为1, S为0, TYPE=1111
这个中断描述符跟调用门结构很像, 只是无参
注意,这结构里面的段选择子查的还是会走GDT, 虽然是从IDT拿到的中断描述符
但是中断描述符的段选择子还是会走GDT的数据
INT X
X是索引,X*8+IDT
的基址
中断返回
INT N指令
1:在没有权限切换时, 会向堆栈push 3个值, 分别是:
CS EFLAG EIP
(返回地址)
2:在有权限切换时, 会向堆栈push5个值, 分别是:
SS ESP EFLAG CS EIP
在中断门中, 不能用RETF返回, 而是应该通过IRET/IRETD指令返回
调用门与中断门的区别
- 1)调用门通过CALL FAR指令执行但中断门通过INT指令
- 2)调用门查询GDT表,中断门查询IDT表(根据段选择子还查GDT)
- 3)CALL CS:EIP中的CS是段选择子,由3部分组成但INTN指令中的N只是索引,中断门不检查RPL,只检查CPL
- 4)调用门可以有参数,但中断门没有参数
陷阱门
构造一个陷阱门
- 1)构造一个陷阱门(
0040EFO0 00081030
) - 2)写入到IDT表中
eq 8003f500 0040EFO0 00081030
- 3)执行陷阱门
陷阱门与中断门的区别
通过中断门与陷阱门打印EFLAG寄存器的值
- 执行前:216
- 执行中:
- 陷阱门:216
- 中断门:16
中断门会清除掉EFL寄存器的IF位,而陷阱门不会
EFLAG寄存器
IF标志用于控制处理器对可屏蔽中断请求的响应。
置1以响应可屏蔽中断,反之则禁止可屏蔽中断。
IF标志只对不可屏蔽中断没有影响。
不可屏蔽中断?
int 3 == 0xcc
任务段
1 | 在调用门、中断门与陷阱门中,一旦出现权限切换,那么就会有堆栈的切换。 |
这张表里很重要的一点就是LDT的段选择子, 这也说明了一个任务会带一个LDT, 而GDT只会有一个
LDT的段选择子, 衍生出了LDTR寄存器, 那么GDT知道去哪里找, LDT也就知道了从LDTR寄存器里面找
CPU如何找到TSS
TR段寄存器
由GDT表中的TSS段描述符加载到TR段寄存器, TR段寄存器里面指向了TSS内存内容
1 | Windows只有了ESP0,SS0, 其他都没有用到 |
TR寄存器读写
- 1) 将TSS段描述符加载到TR寄存器
1 | 指令:LTR |
- 2) 读TR寄存器
1 | 指令:STR |
具体案例
构造TSS段描述符公式:XX00e9XX XXXX0068
eq 8003f0c0 0000e912 fDCCo068
这些xxx就是base地址,即TSS的内存base
怎么替换呢?
- 修改TR寄存器
- 1)在Ring0我们可以通过LTR指令去修改TR寄存器
- 2)在Ring3我们可以通过CALL FAR或者JMP FAR指令来修改
- 用JMP去访问一个代码段的时候,改变的是CS和EIP:
- JMP 0x48:0x123456如果Ox48是代码段
- 执行后:CS–>0x48 EIP–>0x123456
- 用JMP去访问一个任务段的时候:
- 如果Ox48是TSS段描述符,先修改TR寄存器,在用TR.Base指向的TSs中的值修改当前的寄存器
1 | 这里jmp和call是有区别的, Previous Task Link用call会填上原来的TSS, JMP则为0 |
任务门
虽然windows没有用到TSS大量的知识,好像不需要任务门
但是IDT表里面有任务门,INT 8, 双重错误(比如进行一个任务出错则进入0号中断, 0号中断的处理又出错就进入8号中断)
1 | 在上一节中讲解了如何通过CALL、JMP指令访问任务段 |
IDT中断描述符
IDT表可以包含3种门描述符:
- 任务门描述符
- 中断门描述符
- 陷阱门描述符
任务门指向流程
任务门执行过程:
- INT N
- 查IDT表,找到任务门描述符
- 通过任务门描述符,查GDT表,找到TSS段描述符
- 使用TSS段中的值修改TR寄存器
- IRETD返回
10-10-12分页
4GB内存空间
物理地址
线性地址、有效地址、物理地址
如下指令:
MOV eax,dword ptr ds:[0x12345678]
其中0x12345678 是有效地址
ds.Base + 0x12345678
是线性地址
所有在汇编里面的地址都是假的线性地址, 还有一个地址叫”物理地址”
1 | 上面MOV eax,dword ptr ds:[0x12345678]想要执行成功, 首先过段的机制,再过页的机制 |
物理在哪里
每个进程都有一个CR3,(准确的说是都一个CR3的值,CR3本身是
个寄存器,一个核,只有一套寄存器)
CR3指向一个物理页,一共4096字节,如图:
1 | CPU保护模式下只有CR3存的base是物理地址,其他都是线性地址 |
设置分页的方式
将noexecute 改成 execute
物理页
PDE_PTE
从这开始学习页的机制.
CR3首地址
PDT 页目录表(4096个字节 4kb大小)
- PDE 页目录项(4字节, 4字节32位,高20位是物理地址低12位是属性)
- PTT(页表)
- PTE(页表项: 4字节, 32位, 高20位是物理地址[这里指向具体4kb大小的物理页], 低12位是属性)
- .... 1024个PDE
既然有1024项, 则是2的10次方分布
10-10-12
1024个PDE, 1024个PTE 4k物理页
1024*1024*4KB = 4G
PDE,PTE属性
物理页的属性 = PDE属性 & PTE属性
1 | 这里提一嘴, 当内存不够的时候会有页换出 |
P位
线性地址0 为什么不能访问呢?
没有指定物理页,指定物理页就一定能访问吗?
先看PDE与PTE的P位 P=1 才是有效的物理页
R/W 位
R/W = 0 只读
R/W = 1 可读可写
实验
定义一个只读类型的变量,再另一个线性地址指向相同的物理页,通过修改PDE/PTE属性,实现可写。
U/S位
U是用户 1
S是系统 0
0-7FFFFFFF: U/S = 1
80000000 - FFFFFFFF: U/S = 0
1 | mov dword ptr ds:[12345678],0 |
如果当前在应用层写代码访问高2g的内存地址
- 提权,突破PDE、PTE的U/S位限制
- 改PDE、PTE的U/S位限制(也得提权)
A 位
是否被访问(读或者写)过,访问过置1
即使只访问一个字节也会导致PDE PTE置1
D 位
脏位:是否被写过,0没有被写过,1被写过
P/S位
只对PDE有意义,PS == PageSize的意思
当PS==1的时候 PDE直接指向物理页,无PTE,低22位是页内偏移。
线性地址只能拆成2段:大小为4MB,俗称“大页”
G位 PWT位 PCD位
学完控制寄存器与TLB才能讲 此处略过。。。
代码挂物理页
- 低2G(0-7FFFFFFF) 几乎不同
- 高2G(80000000-FFFFFFFF) 几乎相同
- 0-7FFFFFFF的前后64k都是没有映射
让其0线性地址可用挂上物理页(类似VirtualAlloc分配内存), 准确来讲挂PDE/PTE, 访问PDE/PTE,就得能拿到PDT/PTT
页目录基址
如果系统要保证某个线性地址是有效的,
那么必须为其填充正确的PDE与PTE,如果我们想填充PDE与PTE那么必须能够访问
PDT与PTT,那么存在2个问题:
- 1、一定已经有“人”为我们访问PDT与PTT挂好了PDE与PTE,我们只有找到这个线性地址就可以了。
- 2、这个为我们挂好PDE与PTE的“人”是谁?
1 | 注意:CR3中存储的是物理地址,不能在程序中直接读取的。 |
拆分C0300000
(参加实验)
结论:C0300000存储的值就是PDT
如果我们要访问第N个PDE,那么有如下公式:
0xC0300000 + N*4
1 | // mov dword ptr ds:[0x123], 1 // CPU会拿(123->CR3),所以CR3是物理地址,不能在汇编里面用 |
通过0xc0300000
地址找到PDT
1100 0000 0011 00000 0000 00000 0000 0000
10-10-24拆
1100 0000 00 = 300 4 = c00
1100 0000 00 = 300 4 = c00
00000 0000 0000 = 0 = 0
可以看到拆0xc0300000
就会有如上的输出
但是如果直接看CR3的物理地址: !dd 0x10df8000
那么直接查CR3的物理地址和查0xC0300000
是一个PDT
那0xC030000
是可以在汇编上动手脚找到PDT
那么接下来的语句即可执行
1 | // 0xc0300000 |
页目录表基址
Xp系统 10-10-12分页模式
总结
- 1、通过0xC0300000找到的物理页就是页目录表
- 2、这个物理页即是页目录表本身也是页表
- 3、0xC030页目录表是一张特殊的页表,每一项PTE指向的不是普通的物理页,而是指向其他的页表.
- 4、如果我们要访问第N个PDE,那么有如下公式:
0xC0300000 + N*4
页表基址
上一节我们讲解了页目录表基址,也就是说通过0xC0300000这个线性地址
可以访问一张表,这张表就是页目录表,有了这个地址,我们可以任意修改页目录表。
但如果我们要设置某个线性地址PDE和PTE那么还要能够访问PTT,如何访问呢?
1 | 0xc0300000得到一个物理页 |
拆分C0000000 / C0001000
总结:
- 1、页表被映射到了从0xC0000000到0xC03FFFFF的4M地址空间
- 2、在这1024个表中有一张特殊的表:页目录表
- 3、页目录被映射到了0xC0300000开始处的4K地址空间
有了0xC0300000和0xC0000000能做什么
掌握了这两个地址,就掌握了一个进程所有的物理内存读写权限。
1、什么是PDI与PTI
10-10-12
第一个10为PDI
第二个10为PTI2、访问页目录表的公式:
0xC0300000 + PDI*4
3、访问页表的公式:
0xC0000000 + PDI*4096 + PTI*4
MnIsAddressValid
逆向分析该函数是否有效(10-10-12)
TLB
地址解析
- 1)通过一个线性地址访问一个物理页。比如:一个DWORD,其实未必真正读的是4个字节,我们先读的PDE再读PTE 最后才读的4个字节的页。
- 2)在2-9-9-12会读24个字节 如果跨页可能更多。
为了提高效率,只能做记录。
CPU内部做了一个表,来记录这些东西,这个表格是CPU内部的,和寄存器一样快,这个表格:TLB(Translation Lookaside Buffer)。
TLB结构
说明:
- 1) ATTR(属性):属性是PDPE PDE PTE三个属性AND起来的. 如果是10-10-12 就是PDE and PTE
- 2) 不同的CPU 这个表的大小不一样.
- 3) 只要Cr3变了,TLB立马刷新,一核一套TLB.
操作系统的高2G映射基本不变,如果Cr3改了,TLB刷新 重建高2G以上很浪费。
所以PDE和PTE中有个G标志位,如果G位为1刷新TLB时将不会刷新 PDE/PTE的
G位为1的页,当TLB满了,根据统计信息将不常用的地址废弃,最近最常用的保留.
TLB种类
TLB在X86体系的CPU里的实际应用最早是从Intel的486CPU开始的,在X86体系
的CPU里边,一般都设有如下4组TLB:
- 第一组:缓存一般页表(4K字节页面)的指令页表缓存(Instruction-TLB);
- 第二组:缓存一般页表(4K字节页面)的数据页表缓存(Data-TLB);
- 第三组:缓存大尺寸页表(2M/4M字节页面)的指令页表缓存(Instruction-TLB);
- 第四组:缓存大尺寸页表(2M/4M字节页面)的数据页表缓存(Data-TLB)
驱动
第一个驱动程序
驱动的开发流程
- 编写代码
- 生成.sys文件
- 部署
- 启动
- 停止
- 卸载
基本代码
1 |
|
编译环境
- Drvier Settings: TargetOsVersion Windows7 TargetPlatform Desktop
- Inf2Cat: Windows Version List 10_${DDKPlatForm},
- C/C++-常规:警告等级 等级3, 将警告视为错误 否
内核编程基础
内核API的使用
- <1> 在应用层编程我们可以使用WINDOWS提供的各种API函数,只要导入头文件
<windows.h>就可以了,但是在内核编程的时候,我们不能像在Ring3那样直接使用。微软为内核程序提供了专用的API,只要在程序中包含相应的头文件就可以使用了,如:#include <ntddk.h> (假设你已经正确安装了WDK)1> - <2> 在应用层编程的时候,我们通过MSDN来了解函数的详细信息,在内核编程的时候,要使用WDK自己的帮助文档。2>
未导出函数的使用
WDK说明文档中只包含了内核模块导出的函数,对于未导出的函数,则不能直接使用。
如果要使用未导出的函数,只要自己定义一个函数指针,并且为函数指针提供正确的函数地址就可以使用了。有两种办法都可以获取为导出的函数地址:
- <1> 特征码搜索1>
- <2> 解析内核PDB文件2>
基本数据类型
<1> 在内核编程的时候,强烈建议大家遵守WDK的编码习惯,不要这样写:1>
1 | unsigned long length; |
<2> 习惯使用WDK自己的类型:2>
1 | ULONG(unsigned long) PULONG(unsigned long *) |
返回值
大部分内核函数的返回值都是NTSTATUS类型,如:
1 | NTSTATUS PsCreateSystemThread(); |
这个值能说明函数执行的结果,比如:
1 | STATUS_SUCCESS 0x00000000 成功 |
当你调用的内核函数,如果返回的结果不是STATUS_SUCCESS,就说明函数执行中遇到了问题,具体是什么问题,可以在ntstatus.h文件中查看。
内核中的异常处理
在内核中,一个小小的错误就可能导致蓝屏,比如:读写一个无效的内存地址。为了让自己的内核程序更加健壮,强烈建议大家在编写内核程序时,使用异常处。
Windows提供了结构化异常处理机制,一般的编译器都是支持的,如下:1
2
3
4
5
6__try{
//可能出错的代码
}
__except(filter_value) {
//出错时要执行的代码
}
出现异常时,可根据filter_value的值来决定程序该如果执行,当filter_value的值为:
- EXCEPTION_EXECUTE_HANDLER(1),代码进入except块
- EXCEPTION_CONTINUE_SEARCH(0),不处理异常,由上一层调用函数处理
- EXCEPTION_CONTINUE_EXECUTION(-1),回去继续执行错误处的代码
常用的内核内存函数
对内存的使用,主要就是:申请、设置、拷贝以及释放。
内核字符串种类
1 | CHAR(char)/WCHAR(wchar_t)/ANSI_STRING/UNICODE_STRING |
内核字符串常用函数
字符串常用的功能无非就是:
创建、复制、比较以及转换等等
内核空间与内核模块
内核空间
内核模块定义全局变量:在不同进程中查看
内核模块
<1> 硬件种类繁多,不可能做一个兼容所有硬件的内核,所以,微软提供规定的接口格式,让硬件驱动人员安装规定的格式编写“驱动程序” 。1>
<2> 这些驱动程序每一个都是一个模块,称为“内核模块”,都可以加载到内核中,都遵守PE结构。但本质上讲,任意一个.sys文件与内核文件没有区别。2>
DRIVER_OBJECT
每个内核模块都有一个对应的结构体,来描述这个模块在内核中的:
位置、大小、名称等等。
1 | kd> dt _DRIVER_OBJECT |
打印DRIVER_OBJECT地址
1 | DbgPrint("DRIVER_OBJECT对象地址:%x \r\n",driver); |
通过DRIVER_OBJECT找到当前模块信息:
1 | DbgPrint("驱动名称:%ws \r\n",driver->DriverName.Buffer); |
遍历内核模块
- <1> dt _DRIVER_OBJECT (地址)1>
- <2> dt _LDR_DATA_TABLE_ENTRY (DriverSection)2>
- <3>
dt _LDR_DATA_TABLE_ENTRY (InLoadOrderLinks.Flink)
3>
1 | kd> dt _LDR_DATA_TABLE_ENTRY |
0环与3环通信(常规方式)
设备对象
我们在开发窗口程序的时候,消息被封装成一个结构体:MSG,在内核开发时,消息被封装成另外一个结构体:IRP(I/O Request Package)。
在窗口程序中,能够接收消息的只能是窗口对象。在内核中,能够接收IRP消息的只能是设备对象。
创建设备对象
1 | //创建设备名称 |
设置交互数据的方式
1 | pDeviceObj->Flags |= DO_BUFFERED_IO; |
- 缓冲区方式读写(DO_BUFFERED_IO) :操作系统将应用程序提供缓冲区的数据复制到内核模式下的地址中。
- 直接方式读写(DO_DIRECT_IO) :操作系统会将用户模式下的缓冲区锁住。然后操作系统将这段缓冲区在内核模式地址再次映射一遍。这样,用户模式的缓冲区和内核模式的缓冲区指向的是同一区域的物理内存。缺点就是要单独占用物理页面。
- 其他方式读写(在调用IoCreateDevice创建设备后对pDevObj->Flags即不设置DO_BUFFERED_IO也不设置DO_DIRECT_IO此时就是其他方式) :
在使用其他方式读写设备时,派遣函数直接读写应用程序提供的缓冲区地址。在驱动程序中,直接操作应用程序的缓冲区地址是很危险的。只有驱动程序与应用程序运行在相同线程上下文的情况下,才能使用这种方式。
创建符号链接
1 | //创建符号链接名称 |
特别说明:
1、设备名称的作用是给内核对象用的,如果要在Ring3访问,必须要有符号链接
其实就是一个别名,没有这个别名,在Ring3不可见。2、内核模式下,符号链接是以“\??\”开头的,如C 盘就是“\??\C:”
3、而在用户模式下,则是以“\.\”开头的,如C 盘就是“\.\C:
IRP与派遣函数
IRP的类型
<1> 当应用层通过CreateFile,ReadFile,WriteFile,CloseHandle等函数打开、从设备读取数据、向设备写入数据、关闭设备的时候,会使操作系统产生出IRP_MJ_CREATE,IRP_MJ_READ,IRP_MJ_WRITE,IRP_MJ_CLOSE等不同的IRP。1>
<2> 其他类型的IRP2>
派遣函数在哪里注册呢?
1 | kd> dt _DRIVER_OBJECT |
注册派遣函数
1 | NTSTATUS DriverEntry( 。。。。) |
派遣函数的格式
1 | //派遣函数的格式: |
通过IRP_MJ_DEVICE_CONTROL交互数据
应用层调用DeviceControl函数会产生此IRP.
系统调用
kernal32!ReadProcessMemory -> ntddl!NtReadVirtualMemory ->
1 | mov eax,0BA |
7FFE0300
地址下的函数位ntddl!KiIntSystemCall函数
1 | lea edx [esp + arg_4] |
至此进入idt的中断表, 寻找2e位置的函数偏移地址, 以及刷新cs,ss,eip,esp
等信息在Tss存取
3环进0环 上
_KUSER_SHARED_DATA
1) 在 User 层和 Kernel 层分别定义了一个_KUSER_SHARED_DATA
结构区域,用于 User 层和 Kernel 层共享某些数据
2) 它们使用固定的地址值映射,_KUSER_SHARED_DATA
结构区域在 User 和 Kernel 层地址分别为:
User 层地址为:0x7ffe0000
Kernnel 层地址为:0xffdf0000
特别说明:
虽然指向的是同一个物理页,但在User 层是只读的,在Kernnel层是可写的.
0x7FFE0300到底存储的是什么
实验:是否支持快速调用
当通过eax=1来执行cpuid指令时,处理器的特征信息被放在ecx和edx寄存器中,其中edx包含了一个SEP位(11位),该位指明了当前处理器知否支持sysenter/sysexit指令
支持:
ntdll.dll!KiFastSystemCall()
不支持:
ntdll.dll!KiIntSystemCall()
进0环需要更改哪些寄存器?
1) CS的权限由3变为0 意味着需要新的CS
2) SS与CS的权限永远一致 需要新的SS
3) 权限发生切换的时候,堆栈也一定会切换,需要新的ESP
4) 进0环后代码的位置,需要EIP
中断门进0环
快速调用进0环
为什么叫快速调用?
中断门进0环,需要的CS、EIP在IDT表中,需要查内存(SS与ESP由TSS提供)
而CPU如果支持sysenter指令时,操作系统会提前将CS/SS/ESP/EIP的值存储在MSR寄存器中,sysenter指令执行时,CPU会将MSR寄存器中的值直接写入相关
寄存器,没有读内存的过程,所以叫快速调用,本质是一样的!
3环进0环 下
在上一节中讲到了,如果CPU支持sysenter指令,那么API调用时
会通过sysenter指令进入0环,如果不支持sysenter指令,通过中断门进0环,默认
的中断号:0x2E(IDT表)
INT 0x2E进0环
- 步骤一:在IDT表中找到0x2E号门描述符
- 步骤二:分析CS/SS/ESP/EIP的来源
- 步骤三:分析EIP是什么
sysenter进0环
在执行sysenter指令之前,操作系统必须指定0环的CS段、SS段、EIP以及ESP.
可以通过RDMSR/WRMST来进行读写(操作系统使用WRMST写该寄存器):
1 | kd> rdmsr 174 //查看CS |
参考:Intel白皮书第二卷(搜索sysenter)
总结
API通过中断门进0环:
- 1) 固定中断号为0x2E
- 2) CS/EIP由门描述符提供 ESP/SS由TSS提供
- 3) 进入0环后执行的内核函数:NT!KiSystemService
API通过sysenter指令进0环:
- 1) CS/ESP/EIP由MSR寄存器提供(SS是算出来的)
- 2) 进入0环后执行的内核函数:NT!KiFastCallEntry
内核模块:ntoskrnl.exe/ntkrnlpa.exe
1 | IDA pro直接找_IDT, 然后找_IDT的具体偏移位置的函数 |
3环进0环从Tss那边拿了esp0, ss, esp
只要我们3环进0环, 就要把我们的寄存器保存下来, 保存的结构体就是_KTRAP_FRAME
保存现场
在上一节中,我们讲到了API进入0环后会调用的函数:
KiSystemService 或者 KiFastCallEntry
上一节课给大家留的课后作业:
- 1) 进0环后,原来的寄存器存在哪里?
- 2) 如何根据系统服务号(eax中存储)找到要执行的内核函数?
- 3) 调用时参数是存储到3环的堆栈,如何传递给内核函数?
- 4) 2种调用方式是如何返回到3环的?
_Trap_Frame
结构
1 | kd> dt _KTrap_Frame |
esp0指向了kTrapFrame结构体的+0x078 HardwareSegSs : Uint4B
所以操作系统在3环进0环的时候往这个结构体放入四个值就是
1 | +0x068 Eip : Uint4B |
每个线程都有自己的一个TrapFrame, esp0是当前线程0环堆栈的起始位置
KPCR
每个线程FS段寄存器上3环存储的是TEB, 进入0环就会变成KPCR
KPCR 叫CPU控制区(Processor Control Region)
CPU也有自己的控制块,每一个CPU有一个,叫KPCR
查看当前有几个核
1 | kd> dd KeNumberProcessors |
查看每个核对应的KPCR是什么
1 | kd> dd KiProcessorBlock L2 |
ffdff120 - 120就是KPCR的地址
查看KPCR是什么
1 | kd> dt _KPCR |
对应的地址KPCR是什么?
1 | kd> dt _KPCR ffdff000 |
ffdff000 其实这个地址想拿到, 更容易的方式就是
当进入0环之后, 有mov fx,bx
这样的段选择子, 解析出来最后的地址就是0xffdff000
接下来的汇编代码, 是保存老的KPCR里面的ExceptionList
KPCR的第一个成员NT_TIB
1 | kd> dt _NT_TIB |
这个链表是异常处理函数+0x000 ExceptionList : Ptr32 _EXCEPTION_REGISTRATION_RECORD
会去遍历这个异常链表, 选出来进行处理
接下来又给esi, 放置了KPCR+124的偏移, 这个为_KTHREAD
, 其实这个就是_ETHREAD的结构体
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93kd> dt _KPRCB
ntdll!_KPRCB
+0x000 MinorVersion : Uint2B
+0x002 MajorVersion : Uint2B
+0x004 CurrentThread : Ptr32 _KTHREAD //当前CPU所执行线程的_ETHREAD
+0x008 NextThread : Ptr32 _KTHREAD //下一个_ETHREAD
+0x00c IdleThread : Ptr32 _KTHREAD //当所以线程都执行完了CPU就可以执行这个
+0x010 Number : Char //CPU编号
+0x011 Reserved : Char
+0x012 BuildType : Uint2B
+0x014 SetMember : Uint4B
+0x018 CpuType : Char
+0x019 CpuID : Char
+0x01a CpuStep : Uint2B //CPU子版本号
+0x01c ProcessorState : _KPROCESSOR_STATE //CPU状态
+0x33c KernelReserved : [16] Uint4B
+0x37c HalReserved : [16] Uint4B
+0x3bc PrcbPad0 : [92] UChar
+0x418 LockQueue : [16] _KSPIN_LOCK_QUEUE
+0x498 PrcbPad1 : [8] UChar
+0x4a0 NpxThread : Ptr32 _KTHREAD //Npx浮点处理器 最后一次用过浮点的线程
+0x4a4 InterruptCount : Uint4B //中断计数 统计信息 没什么实际意义 自己调试用的
+0x4a8 KernelTime : Uint4B //统计信息
+0x4ac UserTime : Uint4B //统计信息
+0x4b0 DpcTime : Uint4B //统计信息
+0x4b4 DebugDpcTime : Uint4B //统计信息
+0x4b8 InterruptTime : Uint4B //统计信息
+0x4bc AdjustDpcThreshold : Uint4B
+0x4c0 PageColor : Uint4B
+0x4c4 SkipTick : Uint4B
+0x4c8 MultiThreadSetBusy : UChar
+0x4c9 Spare2 : [3] UChar
+0x4cc ParentNode : Ptr32 _KNODE
+0x4d0 MultiThreadProcessorSet : Uint4B
+0x4d4 MultiThreadSetMaster : Ptr32 _KPRCB
+0x4d8 ThreadStartCount : [2] Uint4B
+0x4e0 CcFastReadNoWait : Uint4B
+0x4e4 CcFastReadWait : Uint4B
+0x4e8 CcFastReadNotPossible : Uint4B
+0x4ec CcCopyReadNoWait : Uint4B
+0x4f0 CcCopyReadWait : Uint4B
+0x4f4 CcCopyReadNoWaitMiss : Uint4B
+0x4f8 KeAlignmentFixupCount : Uint4B
+0x4fc KeContextSwitches : Uint4B
+0x500 KeDcacheFlushCount : Uint4B
+0x504 KeExceptionDispatchCount : Uint4B
+0x508 KeFirstLevelTbFills : Uint4B
+0x50c KeFloatingEmulationCount : Uint4B
+0x510 KeIcacheFlushCount : Uint4B
+0x514 KeSecondLevelTbFills : Uint4B
+0x518 KeSystemCalls : Uint4B
+0x51c SpareCounter0 : [1] Uint4B
+0x520 PPLookasideList : [16] _PP_LOOKASIDE_LIST
+0x5a0 PPNPagedLookasideList : [32] _PP_LOOKASIDE_LIST
+0x6a0 PPPagedLookasideList : [32] _PP_LOOKASIDE_LIST
+0x7a0 PacketBarrier : Uint4B
+0x7a4 ReverseStall : Uint4B
+0x7a8 IpiFrame : Ptr32 Void
+0x7ac PrcbPad2 : [52] UChar
+0x7e0 CurrentPacket : [3] Ptr32 Void
+0x7ec TargetSet : Uint4B
+0x7f0 WorkerRoutine : Ptr32 void
+0x7f4 IpiFrozen : Uint4B
+0x7f8 PrcbPad3 : [40] UChar
+0x820 RequestSummary : Uint4B
+0x824 SignalDone : Ptr32 _KPRCB
+0x828 PrcbPad4 : [56] UChar
+0x860 DpcListHead : _LIST_ENTRY
+0x868 DpcStack : Ptr32 Void
+0x86c DpcCount : Uint4B
+0x870 DpcQueueDepth : Uint4B
+0x874 DpcRoutineActive : Uint4B
+0x878 DpcInterruptRequested : Uint4B
+0x87c DpcLastCount : Uint4B
+0x880 DpcRequestRate : Uint4B
+0x884 MaximumDpcQueueDepth : Uint4B
+0x888 MinimumDpcRate : Uint4B
+0x88c QuantumEnd : Uint4B
+0x890 PrcbPad5 : [16] UChar
+0x8a0 DpcLock : Uint4B
+0x8a4 PrcbPad6 : [28] UChar
+0x8c0 CallDpc : _KDPC
+0x8e0 ChainedInterruptList : Ptr32 Void
+0x8e4 LookasideIrpFloat : Int4B
+0x8e8 SpareFields0 : [6] Uint4B
+0x900 VendorString : [13] UChar
+0x90d InitialApicId : UChar
+0x90e LogicalProcessorsPerPhysicalProcessor : UChar//每个物理处理器有几个逻辑处理器
+0x910 MHz : Uint4B//频率
+0x914 FeatureBits : Uint4B
+0x918 UpdateSignature : _LARGE_INTEGER
+0x920 NpxSaveArea : _FX_SAVE_AREA
+0xb30 PowerState : _PROCESSOR_POWER_STATE
接下来又将_ETHREAD
的结构体里面偏移push dword ptr [esi+140]
保存老的先前模式
1 | kd> dt _EThread |
从第一个结构溯源到_KTHREAD
1 | kd> dt _KTHREAD |
最后溯源到了PreviouseModel, 这个有什么功能和意义呢?
这个叫先前模式, 0环的程序可能是0环或者3环直接调用的, 但是0环和3环调用的实现功能是不一样的, 所以记录一下先前是什么模式的
接下来继续分析汇编代码, sub esp, 48
, -48等于堆栈上清到KTrap_Frame
最上面
继续回到KTrap_Frame
1 | kd> dt _KTrap_Frame |
这样esp就指向了TrapFrame的头部了
系统服务表
在上一节中,讲到进0环后,3环的各种寄存器都会保留到_Trap_Frame
结构体中,这节我们来讲解:
如何根据系统服务号(eax中存储)找到要执行的内核函数?
调用时参数是存储到3环的堆栈,如何传递给内核函数?
eax, edx 这两个分别存储调用服务的编号, 以及调用的esp堆栈指针
SystemServiceTable 系统服务表
SystemServiceTable 系统服务表在哪
判断要调用的函数在哪个表
找到要执行的函数与参数个数
SSDT(API函数的调用过程)
在上一节课中,我们讲到系统服务表的结构,以及如何找到系统服务
表(KTHREAD 0xE0偏移).
这一节课,我们通过其他方式来访问系统服务表.
SystemServiceTable 系统服务表
如何访问系统服务表
SSDT 的全称是 System Services Descriptor Table,系统服务描述符表
kd> dd KeServiceDescriptorTable(SSDT)
导出的 声明一下就可以使用了
kd> dd KeServiceDescriptorTableShadow(SSDT Shadow)
未导出 需要用其他的方式来查找
进程线程
Ps开头的都是执行体函数
Ke(导出函数)、Ki(未导出函数)开头的都是内核函数
进程结构体EPROCESS
从这一节开始学习进程、线程相关的知识。
进程线程的知识点很多,如果我们想了解问题的本质,就要从一些关键的结构体学起,这节课的内容是介绍一个与进程密切相关的结构体:
EPROCESS
进程结构体EPROCESS
每个windows进程在0环都有一个对应的结构体:EPROCESS 这个结构体包含了进程所有重要的信息。
(在winbbg中查看EPROCESS结构体)
KPROCESS主要成员介绍
1) +0x000 Header :
_DISPATCHER_HEADER
“可等待”对象,比如Mutex互斥体、Event事件等(WaitForSingleObject)2) +0x018 DirectoryTableBase : [2] Uint4B
页目录表的基址3) +0x020 LdtDescriptor :
_KGDTENTRY
+0x028 Int21Descriptor : `_KIDTENTRY`
历史遗留,16位Windows 段选择子不够 每个进程都有一个LDT表
Int21Descriptor 是 DOS下要用的4) +0x038 KernelTime : Uint4B, +0x03c UserTime : Uint4B
统计信息 记录了一个进程在内核模式/用户模式下所花的时间5) +0x05c Affinity : Uint4B
规定进程里面的所有线程能在哪个CPU上跑,如果值为1,那这个进程的所以线程只能在0号CPU上跑(00000001)
如果值为3,那这个进程的所以线程能在0、1号CPU上跑(000000011)
如果值为4,那这个进程的所以线程能在2号CPU上跑(000000100)
如果值为5,那这个进程的所以线程能在0,2号CPU上跑(000000101)
4个字节共32位 所以最多32核 Windows64位 就64核
如果只有一个CPU 把这个设置为4 那么这个进程就死了6) +0x062 BasePriority : Char
基础优先级或最低优先级 该进程中的所有线程最起码的优先级.
EPROCESS其他成员
- 1) +0x070 CreateTime :
_LARGE_INTEGER
+0x078 ExitTime :_LARGE_INTEGER
进程的创建/退出时间 - 2) +0x084 UniqueProcessId : Ptr32 Void
进程的编号 任务管理器中的PID - 3) +0x088 ActiveProcessLinks :
_LIST_ENTRY
双向链表 所有的活动进程都连接在一起,构成了一个链表
PsActiveProcessHead指向全局链表头
4) +0x090 QuotaUsage : [3] Uint4B +0x09c QuotaPeak : [3] Uint4B
物理页相关的统计信息5) +0x0a8 CommitCharge : Uint4B
+0x0ac PeakVirtualSize : Uint4B +0x0b0 VirtualSize : Uint4B
虚拟内存相关的统计信息
6) +0x11c VadRoot : Ptr32 Void
标识0-2G哪些地址没占用了7) +0x0bc DebugPort : Ptr32 Void
+0x0c0 ExceptionPort : Ptr32 Void
调试相关8) +0x0c4 ObjectTable : Ptr32
_HANDLE_TABLE
句柄表9) +0x174 ImageFileName : [16] UChar
进程镜像文件名 最多16个字节10) +0x1a0 ActiveThreads : Uint4B
活动线程的数量11) +0x1b0 Peb : Ptr32
_PEB
PEB((Process Environment Block 进程环境块):进程在3环的一个结构体,里面包含了进程的模块列表、是否处于调试状态等信息。
关于PEB或者其他成员更加详细的说明:
参考 潘爱民老师《Windows内核原理与实现》 中的第3章
线程结构体ETHREAD
线程结构体ETHREAD
每个windows线程在0环都有一个对应的结构体:ETHREAD 这个结构体包含了线程所有重要的信息。
(在winbbg中查看ETHREAD结构体)
KTHREAD主要成员介绍
1) +0x000 Header :
_DISPATCHER_HEADER
“可等待”对象,比如Mutex互斥体、Event事件等(WaitForSingleObject)2) +0x018 InitialStack : Ptr32 Void
+0x01c StackLimit : Ptr32 Void +0x028 KernelStack : Ptr32 Void
线程切换相关(其中KernelStack是操作系统用这个值, 填充到了Tss的esp0,这样的情况下CPU切换线程就能直接拿到这个ESP0)
当然线程切走的时候, 操作系统又会把esp0填充到kernelStack上去- 3) +0x020 Teb : Ptr32 Void
TEB,Thread Environment Block,线程环境块。
大小4KB,位于用户地址空间。
FS:[0] -> TEB(3环时 0环时FS执行KPCR) - 4) +0x02c DebugActive : UChar
如果值为-1 不能使用调试寄存器:Dr0 - Dr7 - 5) +0x034 ApcState :
_KAPC_STATE
APC相关+0x0e8 ApcQueueLock : Uint4B +0x138 ApcStatePointer : [2] Ptr32 `_KAPC_STATE` +0x14c SavedApcState : `_KAPC_STATE`
- 6) +0x02d State : UChar
线程状态:就绪、等待还是运行 - 7) +0x06c BasePriority : Char
其初始值是所属进程的BasePriority值(KPROCESS->BasePriority),以后可以通过KeSetBasePriorityThread()函数重新设定 - 8) +0x070 WaitBlock : [4]
_KWAIT_BLOCK
等待哪个对象(WaitForSingleObject) - 9) +0x0e0 ServiceTable : Ptr32 Void
指向系统服务表基址 - 10) +0x134 TrapFrame
进0环时保存环境 - 11) +0x140 PreviousMode : Char
某些内核函数会判断程序是0环调用还是3环调用的 - 12) +0x1b0 ThreadListEntry :
_LIST_ENTRY
双向链表 一个进程所有的线程 都挂在一个链表中 挂的就是这个位置
一共有两个这样的链表
ETHREAD其他成员介绍
- 1) +0x1ec Cid :
_CLIENT_ID
进程ID、线程ID - 2) +0x220 ThreadsProcess : Ptr32
_EPROCESS
指向自己所属进程 - 3) +0x22c ThreadListEntry :
_LIST_ENTRY
双向链表 一个进程所有的线程 都挂在一个链表中 挂的就是这个位置
一共有两个这样的链表
KPCR(CPU控制区(Processor Control Region))
进程在内核中对应结构体:EPROCESS
线程在内核中对应结构体:ETHREAD
CPU在内核中也有一个对应的结构体:KPCR
KPCR介绍
- 1) 当线程进入0环时,FS:[0]指向KPCR(3环时FS:[0] -> TEB)
- 2) 每个CPU都有一个KPCR结构体(一个核一个)
- 3) KPCR中存储了CPU本身要用的一些重要数据:GDT、IDT以及线程相关的一些信息。
(在winbbg中查看KPCR结构体)
_NT_TIB
主要成员介绍
- 1) +0x000 ExceptionList : Ptr32_EXCEPTION_REGISTRATION_RECORD
当前线程内核异常链表(SEH) - 2) +0x004 StackBase : Ptr32 Void
当前线程内核栈的基址和大小+0x008 StackLimit : Ptr32 Void
- 3) +0x018 Self : Ptr32
_NT_TIB
指向自己(也就是指向KPCR结构) 这样设计的目的是为了查找方便.
KPCR的其他成员介绍
- 1) +0x01c SelfPcr : Ptr32
_KPCR
指向自己,方便寻址 - 2) +0x020 Prcb : Ptr32
_KPRCB
指向拓展结构体PRCB - 3) +0x038 IDT : Ptr32
_KIDTENTRY
IDT表基址 - 4) +0x03c GDT : Ptr32
_KGDTENTRY
GDT表基址
PRCB成员介绍
1 | +0x004 CurrentThread : Ptr32 _KTHREAD |
当前线程
即将切换的下一个线程
空闲线程
特别强调
我们课程里面讲解的内容,与《内核情景分析》《Windows内核原理与实现》均有不同。
ReactOS是开源免费的Windows NT系列(含NT4.0/2000/XP/2003)克隆操作系统
WRK 是微软针对教育和学术界开放的 Windows 内核的部分源码
而我们的课程是基于Windows XP SP2/SP3 二进制文件.
微软并不开源,很多内核成员的作用需要自己去分析.
等待链表/调度链表
内容回顾
进程结构体EPROCESS(0x50和0x190)是2个链表,里面圈着当前进程所有的线程。
对进程断链,程序可以正常运行,原因是CPU执行与调度是基于线程的,进程断链只是影响一些遍历系统进程的API,并不会影响程序执行。
对线程断链也是一样的,断链后在Windbg或者OD中无法看到被断掉的线程,但并不影响其执行(仍然再跑)。
33个链表
线程有3种状态:就绪、等待、运行
正在运行中的线程存储在KPCR中,就绪和等待的线程全在另外的33个链表中。
一个等待链表,32个就绪链表:
这些链表都使用了_KTHREAD(0x060)这个位置,也就是说,线程在某一时刻,只能属于其中一个圈。
等待链表
1 | kd> dd KiWaitListHead |
比如:线程调用了Sleep() 或者 WaitForSingleObject()或者SuspendThread()等函数时,就挂到这个链表(查看等待线程)
调度链表
调度链表有32个圈,就是优先级:0 - 31 0最低 31最高 默认优先级一般是8
改变优先级就是从一个圈里面卸下来挂到另外一个圈上
这32个圈是正在调度中的线程:包括正在运行的和准备运行的
比如:只有一个CPU但有10个线程在运行,那么某一时刻,正在运行的线程在KPCR中,其他9个在这32个圈中。
查看调度链表
既然有32个链表,就要有32个链表头。1
kd> dd KiDispatcherReadyListHead L70
版本差异
XP只有一个33个圈,也就是说上面这个数组只有一个,多核也只有一个.
Win7也是一样的只有一个圈,如果是64位的,那就有64个圈.
服务器版本:KiWaitListHead整个系统只有一个,但KiDispatcherReadyListHead这个数组有几个CPU就有几组
总结
- 1、正在运行的线程在KPCR中
- 2、准备运行的线程在32个调度链表中(0 - 31级),KiDispatcherReadyListHead 是个数组存储了这32个链表头.
- 3、等待状态的线程存储在等待链表中,KiWaitListHead存储链表头.
- 4、这些圈都挂一个相同的位置:
_KTHREAD(0x060)
模拟线程切换
内容回顾
在之前课程里面讲到了线程的等待链表和调度链表
这节课我们开始学习Windows的线程切换,线程切换比较复杂
为了更好的学习,我们要先读一份代码:
模拟Windows线程切换(ThreadSwitch)
关键结构体
1 | //线程结构体(仿EHREAD) |
调度链表
1 | //线程结构体数组 |
初始化线程堆栈
线程切换
模拟线程切换总结:
- 1) 线程不是被动切换的,而是主动让出CPU.
- 2) 线程切换并没有使用TSS来保存寄存器,而是使用堆栈.
- 3) 线程切换的过程就是堆栈切换的过程.
Windows线程切换_主动切换
在之前课程里面讲到了模拟Windows线程切换(ThreadSwitch)
在这个项目里面我们介绍了一个重要的函数:SwitchContext只有调用这个函数,就会导致线程切换
Windows也有类似的函数:KiSwapContext
KiSwapContext函数
SwapContext函数
关键函数
总结
- 1) Windows中绝大部分API都调用了SwapContext函数, 也就是说,当线程只要调用了API,就是导致线程切换。
- 2) 线程切换时会比较是否属于同一个进程,如果不是,切换Cr3,Cr3换了,进程也就切换了。
如果不调用API,就可以一直占用CPU吗?
Windows线程切换_时钟中断切换
在上一节中我们讲过了,绝大部分系统内核函数都会调用SwapContext
函数,来实现线程的切换,那么这种切换是线程主动调用的。
那如果当前的线程不去调用系统API,操作系统如何实现线程切换呢?
如何中断一个正在执行的程序?
- 1) 异常 比如缺页,或者INT N指令
- 2) 中断 比如时钟中断
系统时钟
Windows系列操作系统:10 - 20 毫秒
如要获取当前的时钟间隔值,可使用Win32 API:
GetSystemTimeAdjustment
时钟中断的执行流程
总结
线程切换的几种情况:
- 1) 主动调用API函数
- 2) 时钟中断
- 3) 异常处理
如果一个线程不调用API,在代码中屏蔽中断(CLI指令),并且不会出现异常,那么当前线程将永久占有CPU,单核占有率100% 2核就是50%
Windows线程切换_时间片
在上一节中我们讲过了,时钟中断会导致线程进行切换,但并不是说
只要有时钟中断就一定会切换线程,时钟中断时,两种情况会导致线程切换:
- 1、当前的线程CPU时间片到期
- 2、有备用线程(KPCR.PrcbData.NextThread)
关于时间片
- 1) 当一个新的线程开始执行时,初始化程序会在_KTHREAD.Quantum赋初始值,该值的大小由_KPROCESS.ThreadQuantum决定(观察ThreadQuantum大小)
- 2) 每次时钟中断会调用KeUpdateRunTime函数,该函数每次将当前线程Quantum减少3个单位,如果减到0,则将KPCR.PrcbData.QuantumEnd的值设置为非0。
- 3) KiDispatchInterrupt判断时间片到期:用KiQuantumEnd(重新设置时间片、找到要运行的线程)
存在备用线程(NextThread)
这个值被设置时,即使当前线程的CPU时间片没有到期,仍然会被切换.
参见KiDispatchInterrupt代码
线程切换的三种情况
1 | (1)、当前线程主动调用API: |
Windows线程切换_TSS
SwapContext这个函数是Windows线程切换的核心,无论是主动切换还是系统时钟导致的线程切换,最终都会调用这个函数。
在这个函数中除了切换堆栈意外,还做了很多其他的事情,了解这些细节对我们学习操作系统至关重要。
这节课我们讲一下线程切换与TSS的关系。
内核堆栈
内核堆栈的结构
调用API进0环
普通调用:通过TSS.ESP0得到0环堆栈
快速调用:从MSR得到一个临时0环栈,代码执行后仍然
通过TSS.ESP0得到当前线程0环堆栈。
TSS
Intel设计TSS的目的是为了任务切换(线程切换),但Windows与Linux并没有使用。而是采用堆栈来保存线程的各种寄存器。
一个CPU只有一个TSS,但是线程很多,如何用一个TSS来保存所有线程的ESP0呢?
通过KPCR.TSS+4的位置ESPO0替换为ETHREAD.Tcb.InitialStack - 210, 就成功替换掉了目标线程的ESP值
并且这个ETHREAD.Tcb.InitialStack - 210就是TrapFrame的数据块了
TSS不仅存储了ESP0还存储了CR3
Windows线程切换_FS
FS:[0]寄存器在3环时指向TEB,进入0环后FS:[0]指向KPCR
系统中同时存在很多个线程,这就意味着FS:[0]在3环时指向的TEB要有多个(每个线程一份)。
但在实际的使用中我们发现,当我们在3环查看不同线程的FS寄存器时,FS的段选择子都是相同的,那是如何实现通过一个FS寄存器指向多个TEB呢?
SwapContext代码分析
我们可以看到FS不用其他段选择子改掉KPCR和TEB的方式就是直接改段描述符的基址, 用原来的段选择子
Windows线程切换_线程优先级
之前的课程讲过了,有三种情况会导致线程切换:
1 | (1)、当前线程主动调用API: |
在KiSwapThread与KiQuantumEnd函数中都是通过KiFindReadyThread来找下一个要切换的线程,KiFindReadyThread是根据什么条件来选择下一个要执行的线程呢?
调度链表(32个)
1 | kd> dd KiDispatcherReadyListHead |
KiFindReadyThread查找方式:
按照优先级别进行查找:31..30..29..28.....
也就是说,在本次查找中,如果级别31的链表里面有线程,那么就不会查找级别为30的链表!
如何高效查找
调度链表有32个,每次都从头开始查找效率太低,所以Windows都过一个
DWORD类型变量的变量来记录:
当向调度链表(32个)中挂入或者摘除某个线程时,会判断当前级别的链表是否为空,为空将DWORD变量对应位置0,否则置1。
如下图:
这个变量:_kiReadySummary
多cpu会随机寻找KiDispatcherReadyListHead指向的数组中的线程。线程可以绑定某个cpu(使用api:setThreadAffinityMask)
如果没有就绪线程怎么办
1 | PrcbData: |
那么就会转向到IdleThread的线程上去
进程挂靠
进程与线程的关系:
- 一个进程可以包含多个线程
- 一个进程至少要有一个线程
进程为线程提供资源,也就是提供Cr3的值,Cr3中存储的是页目录表基址,Cr3确定了,线程能访问的内存也就确定了。
进程与线程的关系
线程代码:
mov eax,dword ptr ds:[0x12345678]
CPU如何解析0x12345678这个地址呢?
- 1) CPU解析线性地址时要通过页目录表来找对应的物理页,页目录表基址存在Cr3寄存器中。
- 2) 当前的Cr3的值来源于当前的进程(
_KPROCESS.DirectoryTableBase(+0x018)
)。
线程与进程如何关联
ETHREAD结构体:1
2
3
4
5
6
7
8+0x034 ApcState
+0x000 ApcListHead
+0x010 Process
+0x014 KernelApcInProgress
+0x015 KernelApcPending
+0x016 UserApcPending
+0x220 ThreadsProcess
+0x220 ThreadsProcess 线程所属的进程
Kthread里面还有一个KAPC_STATE#10位置就是一个Kprocess
同样的线程结构体两份进程指针
养父母负责提供Cr3
线程切换的时候,会比较_KTHREAD结构体0x044处指定的EPROCESS是否为同一个,如果不是同一个,会将0x044处指定的EPROCESS的DirectoryTableBase的值取出,赋值给Cr3。
所以线程需要的Cr3的值来源于0x044处偏移指定的EPROCESS.
总结:1
2
30x220 亲生父母:这个线程谁创建的
0x044 养父母:谁在为这个线程提供资源(也就是提供Cr3)
一般情况下,0x220与0x44指向的是同一个进程
Cr3的值可以随便改吗?
正常情况下,Cr3的值是由养父母提供的,但Cr3的值也可以改成和当前线程毫不相干的其他进程的DirectoryTableBase。
线程代码:
1 | mov cr3,A.DirectoryTableBase |
将当前Cr3的值改为其他进程,称为“进程挂靠”。
分析NtReadVirtualMemory函数
可不可以只修改Cr3而不修改养父母?不可以,如果不修改养父母的值,一旦产生线程切换,就会变成自己读自己!
如果我们自己来写这个代码,在切换Cr3后关闭中断,并且不调用会导致线程切换的API,就可以不用修改养父母的值。
总结
正常情况下,当前线程使用的Cr3是由其所属进程提供的(ETHREAD 0x44偏移处指定的EPROCESS),正是因为如此,A进程中的线程只能访问A的内存。
如果要让A进程中的线程能够访问B进程的内存,就必须要修改Cr3的值为B进程的页目录表基址(B.DirectoryTableBase),这就是所谓的“进程挂靠”。
跨进程读写内存
跨进程的本质是“进程挂靠”,正常情况下,A进程的线程只能访问A进程的地址空间,如果A进程的线程想访问B进程的地址空间,就要修改当前的Cr3的值为B进程的页目录表基值(KPROCESS.DirectoryTableBase)。
即:mov cr3,B.DirectoryTableBase
跨进程操作
A进制中的线程代码:
1 | mov cr3,B.DirectoryTableBase //切换Cr3的值为B进程 |
- 此时0x00401234中的数据还有吗?
- 如何将数据传递给A进程的变量呢?
NtReadVirtualMemory流程解析
- 1、切换Cr3
- 2、将数据读复制到高2G
- 3、切换Cr3
- 4、从高2G复制到目标位置
NtWriteVirtualMemory流程解析
- 1、将数据从目标位置复制到高2G地址
- 2、切换Cr3
- 3、从高2G复制到目标位置
- 4、切换Cr3
句柄
句柄表
什么是句柄(内核对象)
当一个进程创建或者打开一个内核对象时,将获得一个句柄,通过这个句柄可以访问内核对象。
如:
1 | HANDLE g_hMutex = ::CreateMutex(NULL,FALSE, "XYZ"); |
为什么要有句柄?
句柄存在的目的是为了避免在应用层直接修改内核对象。
HANDLE g_hEvent = CreateEvent(NULL, TRUE, FALSE, NULL);
如果g_hEvent存储的就是EVENT内核对象的地址,那么就意味着我们可以在应用层修改这个地址,一旦指向了无效的内核内存地址就会蓝屏。
句柄表在哪?
1 | kd> dt _EPROCESS |
TableCode就是句柄表
涉及到句柄表的有以下这些概念:
HANDLE_TABLE
HANDLE_TABLE结构体中的TableCode变量
实际上啊,TableCode是指向句柄表项第一个句柄表项的指针(NULL句柄表项),TableCode就是HANDLE_TABLE_ENTRY的指针。
但是,当有两级以上表时,这个时候就不是了,先来搞定最简单的。
HANDLE_TABLE_ENTRY:句柄表项
对象头_OBJECT_HANDLE
EXHANDLE:这个就是提供给用户使用的句柄值
HANDLE_TABLE_ENTRY是一个8个字节的结构体。它包括:
指向对象头的指针
32位的访问掩码
因为对象头_OBJECT_HANDLE的大小是0x18h,是8的倍数,因为对象头总是按照8个字节对齐。
所以对象头的地址低3位肯定是0。所以HANDLE_TABLE_ENTRY的对象头的指针的低3位为0,低三位被用作一些访问标志。
句柄值:通过查看句柄值可以发现,句柄值总是4的倍数。例如:
1 | 0x0004 |
所以,句柄值低2位总是0,所以低2位可以被用作标志位。
句柄值的结构类型是EXHANDLE,在EXHANDLE中低2位为标志位。
0x0000这一个句柄值被用来做位NULL无效句柄。提供给用户使用:
1 | if(Handle == NULL/ *0x0000*/ ) |
如何知道HANDLE_TABLE和HANDLE_TABLE_ENTRY的关系,可以参考ExpAllocateHandleTable。
ExpAllocateHandleTable用来为每个进程分配句柄表,并初始化句柄表。第一次只分配0级的句柄(保存真正句柄项)。
我不知道该怎么写才顺,我就按照我的分析流程写吧。
1 | !Process 查看当前进程 |
其中EPROCESS的地址在87ca7cf8,ObjectTable的地址在e2d11b78 。
ObjectTable是HANDLE_TABLE结构变量,它保存在EPROCESS中。1
2
3
4
5dt _EPROCESS 87ca7cf8 来查看EPROCESS结构体的值。
.....
+0x0c4 ObjectTable : 0xe2d11b78_HANDLE_TABLE
......
ObjectTable在EPROCESS偏移0x0c4处。
ObjectTable保存着关于句柄表的信息。
我们使用下面的命令来查看HANDLE_TABLE内容:1
2
3
4
5dt _HANDLE_TABLE 0xe2d11b78
+0x000 TableCode : 0xe4702000
......
其中偏移0为TableCode,它实际上是句柄项(_HANDLE_TABLE_ENTRY
)的指针。
句柄项(_HANDLE_TABLE_ENTRY
)是用来保存真正的句柄信息的结构体。
(注意,当TableCode低2位为0时,TableCode才指向_HANDLE_TABLE_ENTRY,至于如何给TableCode分配值,还是需要分析一下源码。)
_HANDLE_TABLE_ENTRY
是两个32位的结构体:一个指向对象头的指针;一个是32位的标志。
1 | 注意,这个32位的对象头的指针并不是全部有效。因为对象头为0x18个字节,所以windows能保证对象头的分配地址总是8的倍数。 |
我们使用dt _HANDLE_TABLE_ENTRY
0xe4702000 来查看其值。1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19lkd> dt _HANDLE_TABLE_ENTRY 0xe4702000
nt!_HANDLE_TABLE_ENTRY
+0x000 Object : (null)
+0x000 ObAttributes : 0
+0x000 InfoTable : (null)
+0x000 Value : 0
+0x004 GrantedAccess : 0xfffffffe
+0x004 GrantedAccessIndex : 0xfffe
+0x006 CreatorBackTraceIndex : 0xffff
+0x004 NextFreeTableEntry : -2
但是得到的值好像是无效的。
这就对了,因为句柄表的第一个值不被使用,做为一个无效值,用来提供给程序员做错误处理使用。
下面该怎么办呢?句柄表,句柄表肯定是一个连续的数组,连续的保存一些句柄表项。
我们使用dd 0x4702000来查看这块地址的一些值1
2
3
4
5
6
7lkd> dd 0xe4702000
e4702000 00000000 fffffffe e1008719 000f0003
e4702010 e1858019 00000003 87d68f13 00100020
......
看值出来了,因为一个句柄表项_HANDLE_TABLE_ENTRY
占8个字节。所以,前两个值看似一个无效的值。但是红色标示的却象个有用的句柄项。
注意了,_HANDLE_TABLE_ENTRY
是2个32位组成的64位结构体。前面说过了,低32位是对象头的指针。但是要得到对象头的指针,我们必须将值&0xFFFFFFF8,将低3位置0。1
e1008719 & 0xFFFFFFF8 = e1008718
我们还必须加上0x18才能得到真正的内核对象。因为内核对象在对象头的后面,对象头的大小是0x18。1
2
3
4
5
6dt _object_header
lkd> dt _object_header
nt!_OBJECT_HEADER
+0x000 PointerCount : Int4B
...
+0x018 Body : _QUAD
用刚才的 e1008718 + 0x018 = e1008730 ,然后我们使用!Object e1008730查看。1
2
3
4
5
6
7
8
9lkd> !object e1008730
Object: e1008730 Type: (89bddad0) KeyedEvent
ObjectHeader: e1008718 (old version)
HandleCount: 48 PointerCount: 49
Directory Object: e1000270 Name: CritSecOutOfMemoryEvent
哈哈,挺像的 。使用!Handle列出当前句柄来测试一把。1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27lkd> !handle
processor number 0, process 87ca7cf8
PROCESS 87ca7cf8 SessionId: 0 Cid: 0cb0 Peb: 7ffd8000 ParentCid: 03c4
DirBase: 3f13d000 ObjectTable: e2d11b78 HandleCount: 99.
Image: windbg.exe
Handle table at e4702000 with 99 Entries in use
0004: Object: e1008730 GrantedAccess: 000f0003 Entry: e4702008
Object: e1008730 Type: (89bddad0) KeyedEvent
ObjectHeader: e1008718 (old version)
HandleCount: 48 PointerCount: 49
Directory Object: e1000270 Name: CritSecOutOfMemoryEvent
0008: Object: e1858030 GrantedAccess: 00000003 Entry: e4702010
Object: e1858030 Type: (89c153b0) Directory
......
对比一下,成功,我们已经找到了句柄值为0004的内核对象。
OK,冒出了句柄值,那么句柄值是如何被关联起来呢?想下0004,它对应_HANDLE_TABLE_ENTRY
表项的低几个啊?
句柄值为0x0000代表是NULL,刚好_HANDLE_TABLE_ENTRY的第0个表项为无效值
句柄值为0x0004有效,刚好指的是_HANDLE_TABLE_ENTRY的第1个表项。
那句柄值为0x0008了?
原来句柄值总是4的倍数。值/4就代表句柄表项数组_HANDLE_TABLE_ENTRY的索引啊。
这时,句柄值的低两位永远是0啦,为啥呢?是4的倍数,第2为不就为0?自己算算。
0x00,0x04,0x08,0x10,0x14等等的二进制
既然第2位永远为0,那么微软就利用了这两位做一个标志位,用来指示当前句柄值所代表的内核对象到那个表项数组中找到?
什么意思呢?
句柄表实际上是分级的,分3级,我们可以像理解分页一样。
分页分为:页目录、页表、物理页。
每个页目录保存1024个页表,每个页表保存着1024个物理页,每个页为4k。
句柄表可以这样分:
4K的目录,保存1K个表指针(指针为一项,占4个字节吗,总共是1024个项)。
每个4K的表,保存着1K个_HANDLE_TABLE_ENTRY数组指针。
每个4K的_HANDLE_TABLE_ENTRY数组保存着512个_HANDLE_TABLE_ENTRY,咋是512个?因为每一个_HANDLE_TABLE_ENTRY是8个字节。
每个_HANDLE_TABLE_ENTRY指向真正的内核对象前的对象头。(第1个表项除外,代表空。)
哈哈,句柄表就这么简单,就是太多繁琐的东西。记不住。
记下笔记备忘之。
这两天试着写写访问句柄表信息的小驱动。写完后配合代码和WRK再整理。
句柄表结构
总结
- 1、一个进程可以创建、打开很多内核对象,这些内核对象的地址存储在当前进程的句柄表中。我们在应用层得到的句柄值,实际上就是当前进程句柄表的索引。
- 2、同一个内核对象可以被不同的进程所引用,但句柄的值可能一样也可能不一样。
全局句柄表
在进程中可以创建、打开很多内核对象,这些内核对象的地址都存储在当前进程的句柄表中。我们在应用层得到的句柄实际上就是句柄表的索引。
进程的句柄表是私有的,每个进程都有一个自己的句柄表。除此之外,系统还有一个全局句柄表:PsdCidTable
全局句柄表
1) 所有的进程和线程无论无论是否打开,都在这个表中。
2) 每个进程和线程都有一个唯一的编号:PID和CID 这两个值其实就是全局句柄表中的索引。
进程和线程的查询,主要是以下三个函数,按照给定的PID或CID从PspCidTable从查找相应的进线程对象:
1 | PsLookupProcessThreadByCid() |
全局句柄表结构
观察句柄表
通过PID的值,在PspCidTable中找到内核对象.
先找到计算器的pid是992, 得到十六进制是F8
winDbg找PspCidTable
, 找到第一项为HandleTable, 其中第一个0x000位置就是TableCode
TableCode的位置 +F8*8
的偏移就得到具体的地址
但是这里不需要加18, 私有的句柄表是指向了ObjectHead,并不是直接指向了EProcess所有这里, 但是全局句柄表直接指向了EProcess, 但是这个TableCode的位置后三位是因为属性所以要清0, 这点还是跟私有句柄表一致
1 | dt _EProcess 860b2980 |
如果是二级的TableCode该怎么找呢?
假如现在超过了512个句柄, 如何找呢?
可以看到现在末位是1了
那么现在进入了两级,
看我们现在3FC是第几个512, 然后选择具体的第几项
3FC - (需要转十六进制的512) = 1FC
最后数值再末尾属性位清零
dt _EPROCESS 860b2968+18
多核同步
临界区
补充知识
并发是指多个线程在同时执行:
- 单核(是分时执行,不是真正的同时)
- 多核(在某一个时刻,会同时有多个线程再执行)
同步则是保证在并发执行的环境中各个线程可以有序的执行
演示代码
1 | DWORD dwVal = 0; //全局变量 |
LOCK指令
1 | INC DWORD PTR DS:[0x12345678] //一行汇编代码,安全吗? |
多行代码原子操作
1 | 关键代码A //N行代码要求原子操作 |
临界区
一次只允许一个线程进入直到离开
1 | DWORD dwFlag = 0; //实现临界区的方式就是加锁 |
自己实现临界区
全局变量:Flag = 0
进入临界区
1 | Lab: |
离开临界区
1 | lock dec [Flag] |
自旋锁
上一节课我们讲解了什么是临界区,并且自己实现了一个临界区
全局变量:Flag = 0
进入临界区1
2
3
4
5
6
7
8
9Lab:
mov eax,1
lock xadd [Flag],eax
cmp eax,0
jz endLab
dec [Flag]
//线程等待Sleep..
endLab:
ret
离开临界区1
lock dec [Flag]
不同版本的内核文件
单核:1
2
3ntkrnlpa.exe 2-9-9-12分页
ntoskrnl.exe 10-10-12分页
多核:1
2
3ntkrnlpa.exe 2-9-9-12分页
ntoskrnl.exe 10-10-12分页
Windows自旋锁
参考:KeAcquireSpinLockAtDpcLevel
关键代码:1
lock bts dword ptr [ecx], 0
LOCK是锁前缀,保证这条指令在同一时刻只能有一个CPU访问
BTS指令:设置并检测 将ECX指向数据的第0位置1
如果[ECX]原来的值==0 那么CF=1 否则CF=0
总结
1、自旋锁只对多核有意义。
(查看不同版本的KeAcquireSpinLockAtDpcLevel函数)2、自旋锁与临界区、事件、互斥体一样,都是一种同步机制,都可以让当前线程处于等待状态,区别在于自旋锁不用切换线程。
线程等待与唤醒
要点回顾
我们在之前的课程里面讲解了如何自己实现临界区以及什么是Windows自旋锁,这两种同步方案在线程无法进入临界区时都会让当前线程进入等待状态,
一种是通过Sleep函数实现的,一种是通过让当前的CPU”空转”实现的,但这两种等待方式都有局限性:
- 1) 通过Sleep函数进行等待,等待时间该如何确定呢?
- 2) 通过“空转”的方式进行等待,只有等待时间很短的情况下才有意义,否则对CPU资源是种浪费。而且自旋锁只能在多核的环境下才有意义。
有没有更加合理的等待方式呢?只有在条件成熟的时候才将当前线程唤醒?
等待与唤醒机制
在Windows中,一个线程可以通过等待一个或者多个可等待对象,从而进入等待状态,另一个线程可以在某些时刻唤醒等待这些对象的其他线程。
可等待对象
在Windbg中查看如下结构体:
1 | dt _KPROCESS 进程 |
可等待对象的差异
一个线程等待一个对象
一个线程等待多个对象
等待网
总结
- 1、等待中的线程,一定在等待链表中(KiWaitListHead),同时也一定在这张网上(KTHREAD +5C的位置不为空)。
- 2、线程通过调用WaitForSingleObject/WaitForMultipleObjects函数将自己挂到这张网上。
- 3、线程什么时候会再次执行取决于其他线程何时调用相关函数,等待对象不同调用的函数也不同。
WaitForSingleObject函数分析
要点回顾
无论可等待对象是何种类型,线程都是通过:
- WaitForSingleObject
- WaitForMultipleObjects
进入等待状态的,这两个函数是理解线程等待与唤醒进制的核心
WaitForSingleObject参数说明
1 | _DISPATCHER_HEADER |
WaitForSingleObject参数说明
WaitForSingleObject对应的内核函数:
1 | NTSTATUS __stdcall NtWaitForSingleObject( |
- Handle 用户层传递的等待对象的句柄(具体细节参加句柄表专题)
- Alertable 对应KTHREAD结构体的Alertable属性 如果为1 在插入用户APC时,该线程将被吵醒
- Timeout 超时时间
NtWaitForSingleObject
- 1) 调用ObReferenceObjectByHandle函数,通过对象句柄找到等待对象结构体地址。
- 2) 调用KeWaitForSingleObject函数,进入关键循环。
KeWaitForSingleObject:上半部分
- 1) 向_KTHREAD(+70)位置的等待块赋值。
- 2) 如果超时时间不为0,KTHREAD(+70)第四个等待块与第一个等待块关联起来:
第一个等待块指向第四个等待块,第四个等待块指向第一个等待块。 - 3) KTHREAD(+5C)指向第一个_KWAIT_BLOCK。
- 4) 进入关键循环
关键循环
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16 while(true)//每次线程被其他线程唤醒,都要进入这个循环
{
if(符合激活条件)//1、超时 2、等待对象SignalState>0
{
//1) 修改SignalState
//2) 退出循环
}
else
{
if(第一次执行)
将当前线程的等待块挂到等待对象的链表(WaitListHead)中;
//将自己挂入等待队列(KiWaitListHead)
//切换线程...再次获得CPU时,从这里开始执行
}
}
- 1) 线程将自己+5C位置清0
- 2) 释放_KWAIT_BLOCK所占内存
总结
不同的等待对象,用不同的方法来修改_DISPATCHER_HEADER(SignalState)
比如如果可等待对象是EVENT,其他线程通常使用SetEvent来设置SignalState = 1
并且将正在等待该对象的其他线程唤醒,也就是从等待链表(KiWaitListHead)中摘出来。但是,SetEvent函数并不会将线程从等待网上摘下来,是否要下来,由当前线程自己来决定。
symchk.exe /r C:\Users\Coderss\Desktop\x86os /s SRV*C:\Users\Coderss\Desktop\x86os\pdb\*http://msdl.microsoft.com/download/symbols
Event
要点回顾
在之前的课程里面讲过,线程在进入临界区之前会调用WaitForSingleObject
或者WaitForMultipleObjects,此时如果有信号,线程会从函数中退出并进入临界区,如果没有信号那么线程将自己挂入等待链表,然后将自己挂入等待网,最后切换线程。
其他线程在适当的时候,调用方法修改被等待对象的SignalState为有信号(不同的等待对象,会调用不同的函数),并将等待该对象的其他线程从等待链表中摘掉,这样,当前线程便会在WaitForSingleObject或者WaitForMultipleObjects恢复执行(在哪切换在哪开始执行),如果符合唤醒条件,此时会修改SignalState的值,并将自己从等待网上摘下来,此时的线程才是真正的唤醒。
创建事件对象:信号
1 | CreateEvent(NULL, TRUE, FALSE, NULL); |
创建事件对象:类型
1 | CreateEvent(NULL, TRUE, FALSE, NULL); |
SetEvent函数分析
SetEvent对应的内核函数:KeSetEvent
- 1) 修改信号值SignalState为1
- 2) 判断对象类型
- 3) 如果类型为通知类型对象(0) 唤醒所有等待该状态的线程
- 4) 如果类型为事件同步对象(1) 从链表头找到第一个
WaitForSingleObject
关键循环
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16 while(true)//每次线程被其他线程唤醒,都要进入这个循环
{
if(符合激活条件)//1、超时 2、等待对象SignalState>0
{
//1) 修改SignalState
//2) 退出循环
}
else
{
if(第一次执行)
将当前线程的等待块挂到等待对象的链表(WaitListHead)中;
//将自己挂入等待队列(KiWaitListHead)
//切换线程...再次获得CPU时,从这里开始执行
}
}
- 1) 如果类型为通知类型对象(0) 不修改SignalState
- 2) 如果类型为事件同步对象(1) SignalState减1
SEMAPHORE
要点回顾
在上一节课我们讲到了事件(EVENT)对象,线程在进入临界区之前会通过调用WaitForSingleObject或者WaitForMultipleObjects来判断当前的事件对象是否有信号(SignalState>0),只有当事件对象有信号时,才可以进入临界区(只允许一个线程进入直到退出的一段代码,不单指用EnterCriticalSection() 和 LeaveCriticalSection() 而形成的临界区)。
通过我们对EVENT对象相关函数的分析,我们发现,EVENT对象的SignalState值只有2种可能:
- 1 初始化时 或者调用 SetEvent
- 0 WaitForSingleObject、WaitForMultipleObjects、ResetEvent
事件(EVENT)
信号量(SEMAPHORE)
为什么要使用信号量
创建信号量对象
1 | HANDLE CreateSemaphore( |
ReleaseSemaphore函数分析
- 1) 设置SignalState = SignalState + N(参数)
- 2) 通过WaitListHead找到所有线程,并从等待链表中摘掉。
互斥体
为什么要有互斥体:等待对象被遗弃
互斥体(MUTANT)与事件(EVENT)和信号量(SEMAPHORE)一样,都可以用来进行线程的同步控制。
但需要指出的是,这几个对象都是内核对象,这就意味着,通过这些对象可以进行跨进程的线程同步控制,比如:
1 | A进程中的X线程 |
极端情况
如果B进程的Y线程还没有来得及调用修改SignalState的函数(如SetEvent)
那么等待对象Z将被遗弃,这也就以为者X线程将永远等下去!
为什么要有互斥体:重入
1 | WaitForSingleObject(A) |
死锁
MUTANT结构体介绍
1 | _KMUTANT |
MutantListEntry
拥有互斥体线程(KTHREAD+0x010 MutantListHead)是个链表头 圈着所有互斥体
OwnerThread
正在拥有互斥体的线程
Abandoned
是否已经被放弃不用
ApcDisable
是否禁用内核APC
CreateMutex函数
1 | HANDLE CreateMutex( |
初始化MUTANT结构体:1
2
3
4
5MUTANT.Header.Type=2;
MUTANT.Header.SignalState=bInitialOwner?0:1;
MUTANT.OwnerThread=当前线程 or NULL;
MUTANT.Abandoned=0;
MUTANT.ApcDisable=0;
bInitialOwner==TRUE 将当前互斥体挂入到当前线程的互斥体链表
(KTHREAD+0x010 MutantListHead)
ReleaseMutex函数
1 | BOOL WINAPI ReleaseMutex(HANDLE hMutex); |
正常调用时:1
MUTANT.Header.SignalState++;
如果SignalState=1 说明其他进程可用了 将该互斥体从线程链表中移除。
如何解决重入问题
1 | _KMUTANT |
OwnerThread:
正在拥有互斥体的线程
(参见KeWaitForSingleObject函数)
如何解决等待对象被遗弃问题
1 | _KMUTANT |
MutantListEntry:
拥有互斥体线程(KTHREAD+0x010 MutantListHead)是个链表头 圈着所有互斥体
Abandoned:
是否已经被放弃不用
1 | MmUnloadSystemImage ----> KeReleaseMutant(X,Y,Abandon,Z) //是否被丢弃 |
(参见KeReleaseMutant函数)
禁用内核APC
1 | _KMUTANT |
(参见KeWaitForSingleObject函数)
异常
概述
CPU 记录. 软件模拟
执行指令的时候没有识别或者运输出错称为异常
中断外部打断的,异常是自己触发
x86的异常有三种: 错误类、陷阱类、终止
除0异常找idt的处理例程
陷阱类对错误类的区别是修复的地址是下一句的,而错误类是直接修复当前的地址
异常的类别
- (1)中断:中断是异步发生的,来自处理器外部IO设备的信号(区别于同步异常:执行一条指令的结果),它不是由任何一条专门的指令造成的。例如网络适配器、磁盘控制器通过向处理器芯片上的一个管脚发信号,并将异常号放在系统总线上,来触发中断,这个异常号标识了引起中断的设备。中断处理程序总是返回到当前指令的下一条指令。
- (2)陷阱:陷阱是同步异常,是执行一条指令的结果。陷阱最重要的用途是在用户程序和内核之间提供系统调用接口。陷阱总返回到当前指令的下一条指令。
大家都知道,现代的CPU都是有优先级概念的,用户程序运行在低优先级,操作系统运行在高优先级。高优先级的一些指令低优先级无法执行。有一些操作只能由操作系统来执行,用户想要执行这些操作的时候就要通知操作系统,让操作系统来执行。用户态的程序就是用这种方法来通知操作系统的。- (3)故障:故障由错误引起,它可能被故障处理程序修正,如果修正成功,将返回到当前正在执行的指令,重新执行。否则处理程序返回到内核的abort历程,将终止故障程序。故障的一个典型是缺页异常。
- (4)终止:由不可恢复的知名错误造成的结果,处理程序将返回到内核中的abort例程,终止应用程序。
0xcc == 0xcd 0x03
ExceptionRecord.ExceptionFlags
如果为1软件模拟异常,如果为0是CPU异常;
如果在throw则为软件模拟异常,如果遇到除0类的异常则为cpu记录异常
…
CPU异常的产生
1 | CPU指令检测到异常(如除0) |
CommonDispatchException
函数分析
该函数构造了一个_EXCEPTION_RECORD结构体
1 | kd> dt _EXCEPTION_RECORD |
软件模拟异常的产生
代码内部直接
1 | throw 1; |
抛出异常
CxxThrowException
->kernel32.RaiseException
->Ntdll.RtlRaiseException
->Ntdll.ZwRaiseException
->Nt.NtRaiseException
->Nt.KiRaiseException
->KiDispatchException
异常处理流程
这里其实看张银奎的那本软件调试更加方便
VEH
RtlAddVectoredExceptionHandler
1 | // VEHXXXX.cpp : Defines the entry point for the console application. |
SEH
VEH是全局的, SEH是挂在KPCR,FS:[0]是跟CPU的任务有关,每个任务都有一个SEH, 所以间接得出SEH是跟线程有关
当前产出SEH只处理当前线程的异常, 而不是像VEH一样全局性的去处理异常
汇编塞入
1 |
|
try__except 逆向
分析try异常处理函数
所有的C++代码里面的try except编译器都很好的兼容了SEH的异常链处理函数
他的做法就是讲FS:[0]里面的EXCEPTION_REGISTRATION拿出来, 然后将当前函数栈ebp中的造一个_except_handler函数,并且把ebp-4的位置留做一个prev指针
等到时候三环的Ntdll.RtlDispatchException中的ExecuteHandler2再去处理这个函数栈中的_except_handler
编译器扩展的
1 | struct _EXCEPTION_REGISTRATION{ |
系统原生的
1 | ntdll!_NT_TIB |
scopetable
1 | struct scopetable_entry{ |
except_handler
CPU检测到异常, 查中断表执行处理函数CommonDispatchException->KiDispatchException -> KiUserExceptionDispatch -> RtlDispatchException VEH SEH
执行
_except_handler
- 根据tryLevel选择scopetable数组
- 调用scopetable数组中对应的lpfnFilter函数
- EXCEPTION_EXECUTE_HANDLER(1) 执行except代码
- EXCEPTION_CONTINUE_SEARCH(0) 寻找下一个
- EXCEPTION_CONTINUE_EXECUTION(-1) 重新执行
try__finally
编译器还提供了另一种程序块
1 | __try{ |
编译器如何实现的呢?
它如果出异常还是走对应scopetable里面,并且scopeentry里面的第二个为空就是过滤表达式为空, 那么处理函数从except转变为finally的代码块
它如果不出异常,那么会有一个局部展开当出现Continue,Break,Return
的函数强制走一下finally
局部展开
局部展开, 当__try__finally
中的__try代码提前退出流程代码块时会产生
比如Continue
,Break
,Return
等操作的提前退出流程语句
所以再不出异常的情况下, 就是直接call 一个局部展开
, 所以我们finally能够执行是因为我们使用了这个局部展开的函数
全局展开
1 | __try |
当tryLevel发现这里的过滤函数为空就会继续往外找, 当找到except(1), 当他找到这里的时候, 之前语义约定finally必须执行, 这个时候编译器会怎么做?
那么当except(1)返回为1,在exceptHandler的函数内执行异常代码之前, 会逐个寻找到到之前内部finally代码块全部执行
调试
调试对象
1 | typedef struct _DEBUG_OBJECT |
如何在调试器与被调试程序之间建立联系?
先介绍两种附加模式
- CreateProcess: 创建调试进程
- DebugActiveProcess: 两个进程都已存在,利用Attach进行附加调试
具体建立流程
先创建DebugObject这个0环对象, 它的句柄对象内容放到调试器进程的TEB中0xF24的地方
再存放被调试进程EPROCESS的DebugPort的位置,并且存储的是句柄对象的地址
DebugActiveProcess执行流程
kernel32!DebugActiveProcess执行流程
kernel32!DbgUiConnectToDbg()
- ntdll!DbgUiConnectToDbg()
- ntdll!ZwCreateDebugObject()
- nt!NtCreateDebugObject() –> 创建调试对象,并且把内核调试对象的句柄存放到调试进程的TEB偏移0xF24的位置上达到与调试器建立连接
kernel32!DbgUiDebugActiveProcess(被调试进程句柄)
- ntdll!DbgUiDebugActiveProcess(被调试进程句柄)
- ntdll!NtDebugActiveProcess(被调试进程句柄,调试器进程TEB+0xF24)
- nt!NtDebugActiveProcess(HANDLE ProcessHandle, HANDLE DebugObjectHandle)
调试事件的采集
调试事件的种类
调试事件的种类
1 | typedef enum _DBGKM_APINUMBER{ |
调试事件采集函数
创建进程、线程必经之路
PspUserThreadStartip
DbgkCreateThread
DbgkpSendApiMessage(x,x)
退出线程、进程必经之路
PspExitThread
DbgkExitThread
/DbgkExitProcess
DbgkpSendApiMessage(x,x)
加载模块的必经之路
NtMapViewOfSection
DbgkMapViewOfSection
DbgkpSendApiMessage(x,x)
卸载模块的必经之路
NtUnMapViewOfSection
DbgkUnMapViewOfSection
DbgkpSendApiMessage(x,x)
异常的必经之路
KiDispatchException
DbgkForwardException
DbgkpSendApiMessage(x,x)
调试事件写入函数
DbgkpSendApiMessasge(x,x)参数说明
- 第一个参数: 消息结构, 每种消息都有自己的消息结构共有7种类型
- 第二个参数: 要不要把本进程内除了自己之外的其他线程挂起
有些消息需要把其他线程挂起,比如CC有些消息不需要把线程挂起,比如模块加载
DbgkSendApiMessage是调试事件收集的总入口, 如果在这里挂钩子, 调试器将无法调试
写入的DebugEvent结构如下
1 | typedef struct _DEBUG_EVENT { |
调试事件的处理
创建进程的方式
- 第一步: 关联,通过调试对象进行关联
调试器
与被调试进程
- 第二部: 调试循环
1 | BOOL bRet = TRUE; |
取到的DebugEvent的信息如下
1 | typedef struct _EXCEPTION_DEBUG_INFO{ |
这个当一旦运行就会触发异常断点, 这个异常断点来源于系统断点int3
系统断点的由来如下
1 | LdrInitializeThunk |
附加进程的方式
1 | BOOL bRet = TRUE; |
当附加一个进程的时候, 会发送杜撰的假消息
你会看到创建进程、创建线程、以及dll的加载各种调试信息都会出现
这个消息都是来源于DbgkpPostFakeProcessCreateMessage(发送假的进程创建消息、假的模块加载的消息[遍历了PEB的LDR模块列表]、假的线程创建消息)
这样保证调试器无论是新创建进程的方式, 还是附加进程的方式, 调试器都想得到这些信息
这样调试器都能知道进程加载了多少模块, 调试事件就表明了多少模块, 这样写的目的就是给调试器提供必要的信息, 形成完整的调试形态
异常的处理流程
异常处理的大概
如果有调试器的话,一旦有异常, 结果对象不是异常流程而是调试流程
1 |
|
上面这个程序以调试的状态在运行, 会先抛给调试器, 而不是异常处理代码
如果调试器ignore忽略掉这个除0异常, 那么调试器对应的代码Api就是ContinueDebugEvent
,代表这个异常会继续找异常处理函数, 如果异常处理函数没有, 则会找到SEH的默认UnhandledExceptionFilter
去执行
DBG_EXCEPTION_NOT_HANDLE
:调试器没有处理该异常,转会到用户态中执行了, 寻找可以处理该异常的异常处理器
DBG_CONTINUE
:表示调试器已处理了该异常
UnhandledExceptionFilter的执行流程
通过NtQueryInformationProcess查询当前进程是否正在被调试, 如果是, 返回EXCEPTION_CONTINUE_SEARCH, 此时会进入第二轮分发
如果没有被调试
1
2
3查询是否通过SetUnhandledExceptionFilter注册处理函数, 如果有就调用
如果没有通过SetUnhandledExceptionFilter注册处理函数, 弹出窗口让用户选择终止程序还是启动即时调试器
如果用户没有启用即时调试器,那么该函数返回EXCEPTION_EXECUTE_HANDLER
软件断点
一旦被调试程序触发异常, 就会向DebugObject发送调试事件, 这就是调试的本质
内存断点和硬件断点无非就是在被调试进程里面想方设法出现异常,所以调试就是异常触发和处理的过程
如果我们在调试器中下断点, 它具体实现的流程是怎么样的?
当在某个代码按F2,就是把代码的汇编改成0xCC
就是int 3
, 但是调试器为了使用体验更好, 不会在代码上展现出0xCC
的汇编指令, 但是内存的确是int3的汇编指令了
调试器进程 | 被调试进程 |
---|---|
1:循环判断 | 1:CPU检测到INT 3指令 |
2:取出调试事件 | 2:查IDT表中对应的中断处理函数 |
3:列出信息 寄存器 内存 |
3:CommonDispatchException |
4:用户处理 | 4:KiDispatchException |
_ | 5:DbgkForwardException搜集并发送调试事件 DbgkpSendApiMessage(x,x) 第一个参数:消息结构,每种消息都有自己的消息结构共有7种类型 第二个参数:要不要把本进程内除了自己之外的其他线程挂起,有些消息需要把其他线程挂起,比如异常 |
内存断点
这个数据进行内存访问的时候, 让程序停下来, 这个时候会用到内存断点
触发异常-调试器接管异常的过程
内存断点
1 | BOOL VritualProtectEx( |
将被调试进程的某内存属性修改为
- PAGE_NOACCESS(PTE)
- PAGE_EXECUTE_READ
国内查看评论需要代理~