Windows 内核技术详解笔记

保护模式

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
2
3
EDI写ES
EBP,ESP写SS
立即数写DS


段寄存器结构

段寄存器的读写

读段寄存器

1
2
3
4
5
比如:MOV AX,ES    只能读16位的可见部分

读写LDTR的指令为:SLDT/LLDT

读写TR的指令为:STR/LTR

写段寄存器

1
比如:MOV DS,AX   写时是写96位


段寄存器属性

段寄存器有96位:

1
2
3
4
Selector  //16位
Atrribute //16位
Base //32位
Limit //32位

但我们只能看见16位,那如果证明Attribute、Base、Limit的存在呢?

探测Attribute

1
2
3
4
5
6
7
int var = 0;					
__asm
{
mov ax,ss //cs不行 cs是可读 可执行 但不可写
mov ds,ax
mov dword ptr ds:[var],eax
}

探测Base

1
2
3
4
5
6
7
8
9
10
int var = 1;					
__asm
{
mov ax,fs
mov gs,ax
mov eax,gs:[0] //不要用DS 否则编译不过去
mov dword ptr ds:[var],eax

//mov edx,dword ptr ds:[0x7FFDF000]
}

探测Limit

1
2
3
4
5
6
7
8
9
10
int var = 1;					
__asm
{
mov ax,fs
mov gs,ax
mov eax,gs:[0] //不要用DS 否则编译不过去
mov dword ptr ds:[var],eax

//mov edx,dword ptr ds:[0x7FFDF000]
}


段描述符与段选择子

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
2
3
4
5
6
7
char buffer[6];					
__asm
{
les ecx,fword ptr ds:[buffer] //高2个字节给es,低四个字节给ecx
}

注意:RPL<=DPL(在数值上)


段描述符属性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
2
Mov ax,0008	与	Mov ax,000B 		//段选择子  		
Mov ds,ax Mov ds,ax //将段描述

指向的是同一个段描述符,但RPL是不一样的.

数据段的权限检查
参考如下代码:
比如当前程序处于0环,也就是说CPL=0

1
2
3
Mov 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:0x004183D7CPU如何执行这行代码?

  • (1) 段选择子拆分

    1
    2
    3
    4
    0x20 对应二进制形式 0000 0000 0010 0000	
    RPL = 00
    TI = 0
    Index = 4
  • (2) 查表得到段描述符

    1
    2
    3
    TI = 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代表的是TSS

1
真正存入CS寄存器的是门里面的段选择子指向的段描述符决定

1、调用门执行流程

指令格式:CALL CS:EIP(EIP是废弃的)

执行步骤:

  • 1) 根据CS的值 查GDT表,找到对应的段描述符 这个描述符是一个调用门.(注意当前CS的CPL一定要和调用门的DPL一致,否则敲门的权力都没有)
  • 2) 在调用门描述符中存储另一个代码段段的选择子.(在代码段上就可以指定是0环的RPL还是3环的RPL)
  • 3) 选择子指向的段 段.Base + 偏移地址 就是真正要执行的地址.

无参

1
2
3
4
5
6
7
8
9
10
11
12
13
void __declspec(naked) NoArgs(){
__asm{
int 3
retf
}
}

int main(){
SegVal segVal = {0, 0x4b};
__asm{
call fword ptr[segVal]
}
}

打印一下函数地址NoArg:0x00401030,HasArg:0x00401040

根据ox4b可以找到第0100 1011
RPL:3环
TI:0
Index:9

从而修改GDT第十项:0040ec00, 00081030, 跳到index2的段描述符提权

有参

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

struct SegVal {
long val;
short seg;
};

void __declspec(naked) HasArgs(){
__asm{
int 3
pushad
pushfd
mov eax, dword ptr[esp+0x24+0x8+0x8]
mov esi, dword ptr[esp+0x24+0x8+0x4]
mov edi, dword ptr[esp+0x24+0x8] // 取三个参数
popfd
popad
retf 0xc
}
}

int main(){
SegVal segVal = {0, 0x4b};
__asm{
push 1
push 2
push 3
call fword ptr [segVal]
}
}

打印一下函数地址NoArg:0x00401030,HasArg:0x00401040

根据ox4b可以找到第0100 1011
RPL:3环
TI:0
Index:9

从而修改GDT第十项:0040ec03, 00081030, 跳到index2的段描述符提权,并且参数为3个

根据esp查内存地址, 分以下结构

  • 第三个参数
  • 第二个参数
  • 第一个参数
  • ESP
  • SS




中断门

Windows没有使用调用门,但是使用了中断门:

  • <1> 系统调用
  • <2> 调试

IDT

IDT即中断描述符表,同GDT一样,IDT也是由一系列描述符组成的,每个
描述符占8个字节。但要注意的是,IDT表中的第一个元素不是NULL。

1
2
3
CPU遇到INT指令会查IDT表
INT指令后面跟的就是索引
中断门没有RPL了,所以中断门只查CPL

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
2
3
4
5
6
在调用门、中断门与陷阱门中,一旦出现权限切换,那么就会有堆栈的切换。
而且由于CS的CPL发生改变,也导致了SS也必须要切换。

切换时,会有新的ESP和SS(CS是由中断门或者调用门指定)这2个值从哪里来的呢?

答案:TSS (Task-state segment ),任务状态段.

这张表里很重要的一点就是LDT的段选择子, 这也说明了一个任务会带一个LDT, 而GDT只会有一个

LDT的段选择子, 衍生出了LDTR寄存器, 那么GDT知道去哪里找, LDT也就知道了从LDTR寄存器里面找

CPU如何找到TSS

TR段寄存器

由GDT表中的TSS段描述符加载到TR段寄存器, TR段寄存器里面指向了TSS内存内容

1
2
Windows只有了ESP0,SS0, 其他都没有用到
所以EAX,EBX.....很多进行TSS任务切换, 如线程切换的时候, 不会替换其他寄存器里面的内容

TR寄存器读写

  • 1) 将TSS段描述符加载到TR寄存器
1
2
3
4
5
6
指令:LTR
说明:
用LTR指令去装载的话 仅仅是改变TR寄存器的值(96位)
并没有真正改变TSS
LTR指令只能在系统层使用
加载后TSS段描述符会状态位会发生改变
  • 2) 读TR寄存器
1
2
指令:STR
说明:如果用STR去读的话,只读了TR的16位 也就是选择子

具体案例

构造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
2
3
4
这里jmp和call是有区别的, Previous Task Link用call会填上原来的TSS, JMP则为0
并且call使用的时候NT为变1, JMP则不会变
NT位如果为0, IRET的返回值从堆栈取
NT位如果为1, IRET的返回值会从TSS的Previous Task LinK找




任务门

虽然windows没有用到TSS大量的知识,好像不需要任务门
但是IDT表里面有任务门,INT 8, 双重错误(比如进行一个任务出错则进入0号中断, 0号中断的处理又出错就进入8号中断)

1
2
3
在上一节中讲解了如何通过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
2
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
2
3
4
5
6
7
8
9
这里提一嘴, 当内存不够的时候会有页换出
线性地址原先的物理内存被放到磁盘, 这里的物理内存就会被其他进程的线性地址占用
那么原先的线性地址就把P位置为0不可用
如果下次当前进程在读到这个线性地址的时候会进入异常
这个时候CPU会走IDT的E号中断

那CPU如何判断E号中断, 是因为页换出, 还是真的不可用
00401234(PTE p=0) -> 10, 000->PTE(p=0)
这个时候9~11位的数据就有辨别作用了,具体操作空间给操作系统

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
2
3
4
5
6
7
mov dword ptr ds:[12345678],0
// 低2g U/S=1, CPL=3, CPL=0都是可以访问的, PDE&PTE的RW位也要有写权限


mov dword ptr ds:[81234567],0
// 高2g 系统位内存段 U/S=0, 要想保证访问成功, DS段要权限, PDE&PTE的RW位也要有写权限
// 如果当前的CPL为3的话, 写不了, 需要提权

如果当前在应用层写代码访问高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
2
3
注意:CR3中存储的是物理地址,不能在程序中直接读取的。
如果想读取,也要把CR3的值挂到PDT和PTT中才能访问
那么怎么通过线性地址访问PDT和PTT呢?

拆分C0300000

(参加实验)

结论:C0300000存储的值就是PDT

如果我们要访问第N个PDE,那么有如下公式:

0xC0300000 + N*4

1
2
3
4
5
// mov dword ptr ds:[0x123], 1 // CPU会拿(123->CR3),所以CR3是物理地址,不能在汇编里面用


// 0xc0300000
mov dword ptr ds:[PDT], 0x0000167

通过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
2
// 0xc0300000 
mov dword ptr ds:[0xc0300000], 0x0000167

页目录表基址

Xp系统 10-10-12分页模式

总结

  • 1、通过0xC0300000找到的物理页就是页目录表
  • 2、这个物理页即是页目录表本身也是页表
  • 3、0xC030页目录表是一张特殊的页表,每一项PTE指向的不是普通的物理页,而是指向其他的页表.
  • 4、如果我们要访问第N个PDE,那么有如下公式:0xC0300000 + N*4

页表基址

上一节我们讲解了页目录表基址,也就是说通过0xC0300000这个线性地址
可以访问一张表,这张表就是页目录表,有了这个地址,我们可以任意修改页目录表。

但如果我们要设置某个线性地址PDE和PTE那么还要能够访问PTT,如何访问呢?

1
2
3
4
5
6
7
8
0xc0300000得到一个物理页
这个物理页跟CR3得到的PDT是一样的

0xc0000000的PTE = c03的PTE
PDT: 0xC00
PTT: 0x00

mov dword ptr ds:[0xc0300000], 0x12345678 // 改了0地址的PDE

拆分C0000000 / C0001000

总结:

  • 1、页表被映射到了从0xC0000000到0xC03FFFFF的4M地址空间
  • 2、在这1024个表中有一张特殊的表:页目录表
  • 3、页目录被映射到了0xC0300000开始处的4K地址空间

有了0xC0300000和0xC0000000能做什么

掌握了这两个地址,就掌握了一个进程所有的物理内存读写权限。

  • 1、什么是PDI与PTI
    10-10-12
    第一个10为PDI
    第二个10为PTI

  • 2、访问页目录表的公式:
    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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
#include <ntddk.h>
#include <ntddk.h>

VOID DriverUnload(PDRIVER_OBJECT pDriver)
{
UNREFERENCED_PARAMETER(pDriver);
KdPrint(("Goodbye~\n"));
}

NTSTATUS DriverEntry(PDRIVER_OBJECT pDriver, PUNICODE_STRING pRegPath)
{
UNREFERENCED_PARAMETER(pRegPath);

pDriver->DriverUnload = DriverUnload;

KdPrint(("Hello Driver!\n"));

return STATUS_SUCCESS;
}

编译环境

  • 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)
  • <2> 在应用层编程的时候,我们通过MSDN来了解函数的详细信息,在内核编程的时候,要使用WDK自己的帮助文档。

未导出函数的使用

WDK说明文档中只包含了内核模块导出的函数,对于未导出的函数,则不能直接使用。

如果要使用未导出的函数,只要自己定义一个函数指针,并且为函数指针提供正确的函数地址就可以使用了。有两种办法都可以获取为导出的函数地址:

  • <1> 特征码搜索
  • <2> 解析内核PDB文件

基本数据类型

<1> 在内核编程的时候,强烈建议大家遵守WDK的编码习惯,不要这样写:

1
unsigned long length;

<2> 习惯使用WDK自己的类型:

1
2
3
4
5
6
7
ULONG(unsigned long)		PULONG(unsigned long *)

UCHAR(unsigned char) PUCHAR(unsigned char *)

UINT(unsigned int) PUNIT(unsigned int *)

VOID(void) PVOID(void *)

返回值

大部分内核函数的返回值都是NTSTATUS类型,如:

1
2
3
NTSTATUS PsCreateSystemThread();    
NTSTATUS ZwOpenProcess();
NTSTATUS ZwOpenEvent();

这个值能说明函数执行的结果,比如:

1
2
3
STATUS_SUCCESS		0x00000000	成功		
STATUS_INVALID_PARAMETER 0xC000000D 参数无效
STATUS_BUFFER_OVERFLOW 0x80000005 缓冲区长度不够

当你调用的内核函数,如果返回的结果不是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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
CHAR(char)/WCHAR(wchar_t)/ANSI_STRING/UNICODE_STRING

ANSI_STRING字符串:
typedef struct _STRING
{
USHORT Length;
USHORT MaximumLength;
PCHAR Buffer;
}STRING;

UNICODE_STRING字符串:
typedef struct _UNICODE_STRING
{
USHORT Length;
USHORT MaxmumLength;
PWSTR Buffer;
} UNICODE_STRING

内核字符串常用函数

字符串常用的功能无非就是:

创建、复制、比较以及转换等等


内核空间与内核模块

内核空间

内核模块定义全局变量:在不同进程中查看

内核模块

  • <1> 硬件种类繁多,不可能做一个兼容所有硬件的内核,所以,微软提供规定的接口格式,让硬件驱动人员安装规定的格式编写“驱动程序” 。

  • <2> 这些驱动程序每一个都是一个模块,称为“内核模块”,都可以加载到内核中,都遵守PE结构。但本质上讲,任意一个.sys文件与内核文件没有区别。

DRIVER_OBJECT

每个内核模块都有一个对应的结构体,来描述这个模块在内核中的:
位置、大小、名称等等。

1
2
3
4
5
6
7
8
9
10
11
12
kd> dt _DRIVER_OBJECT
nt!_DRIVER_OBJECT
+0x000 Type : Int2B
+0x002 Size : Int2B
+0x004 DeviceObject : Ptr32 _DEVICE_OBJECT
+0x008 Flags : Uint4B
+0x00c DriverStart : Ptr32 Void
+0x010 DriverSize : Uint4B
+0x014 DriverSection : Ptr32 Void
+0x018 DriverExtension : Ptr32 _DRIVER_EXTENSION
+0x01c DriverName : _UNICODE_STRING
.....

打印DRIVER_OBJECT地址

1
DbgPrint("DRIVER_OBJECT对象地址:%x \r\n",driver);

通过DRIVER_OBJECT找到当前模块信息:

1
2
3
DbgPrint("驱动名称:%ws \r\n",driver->DriverName.Buffer);
DbgPrint("模块基址:%x \r\n",driver->DriverStart );
DbgPrint("模块大小:%x \r\n",driver->DriverSize );

遍历内核模块

  • <1> dt _DRIVER_OBJECT (地址)
  • <2> dt _LDR_DATA_TABLE_ENTRY (DriverSection)
  • <3> dt _LDR_DATA_TABLE_ENTRY (InLoadOrderLinks.Flink)
1
2
3
4
5
6
7
8
9
10
11
12
13
kd> dt _LDR_DATA_TABLE_ENTRY
nt!_LDR_DATA_TABLE_ENTRY
+0x000 InLoadOrderLinks : _LIST_ENTRY
+0x008 InMemoryOrderLinks : _LIST_ENTRY
+0x010 InInitializationOrderLinks : _LIST_ENTRY
+0x018 DllBase : Ptr32 Void
+0x01c EntryPoint : Ptr32 Void
+0x020 SizeOfImage : Uint4B
+0x024 FullDllName : _UNICODE_STRING
+0x02c BaseDllName : _UNICODE_STRING
+0x034 Flags : Uint4B
+0x038 LoadCount : Uint2B
....


0环与3环通信(常规方式)

设备对象

我们在开发窗口程序的时候,消息被封装成一个结构体:MSG,在内核开发时,消息被封装成另外一个结构体:IRP(I/O Request Package)。

在窗口程序中,能够接收消息的只能是窗口对象。在内核中,能够接收IRP消息的只能是设备对象。

创建设备对象

1
2
3
4
5
6
7
8
9
10
11
12
13
14
//创建设备名称
UNICODE_STRING Devicename;
RtlInitUnicodeString(&Devicename,L"\\Device\\MyDevice");

//创建设备
IoCreateDevice(
pDriver, //当前设备所属的驱动对象
0,
&Devicename, //设备对象的名称
FILE_DEVICE_UNKNOWN,
FILE_DEVICE_SECURE_OPEN,
FALSE,
&pDeviceObj //设备对象指针
);

设置交互数据的方式

1
pDeviceObj->Flags |= DO_BUFFERED_IO;
  • 缓冲区方式读写(DO_BUFFERED_IO) :操作系统将应用程序提供缓冲区的数据复制到内核模式下的地址中。
  • 直接方式读写(DO_DIRECT_IO) :操作系统会将用户模式下的缓冲区锁住。然后操作系统将这段缓冲区在内核模式地址再次映射一遍。这样,用户模式的缓冲区和内核模式的缓冲区指向的是同一区域的物理内存。缺点就是要单独占用物理页面。
  • 其他方式读写(在调用IoCreateDevice创建设备后对pDevObj->Flags即不设置DO_BUFFERED_IO也不设置DO_DIRECT_IO此时就是其他方式) :

在使用其他方式读写设备时,派遣函数直接读写应用程序提供的缓冲区地址。在驱动程序中,直接操作应用程序的缓冲区地址是很危险的。只有驱动程序与应用程序运行在相同线程上下文的情况下,才能使用这种方式。

创建符号链接

1
2
3
4
5
//创建符号链接名称
RtlInitUnicodeString(&SymbolicLinkName,L"\\??\\MyTestDriver");

//创建符号链接
IoCreateSymbolicLink(&SymbolicLinkName,&Devicename);

特别说明:

  • 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。

  • <2> 其他类型的IRP

派遣函数在哪里注册呢?

1
2
3
4
5
6
7
8
9
10
11
12
kd> dt _DRIVER_OBJECT
nt!_DRIVER_OBJECT
+0x000 Type : Int2B
+0x002 Size : Int2B
+0x004 DeviceObject : Ptr32 _DEVICE_OBJECT
+0x008 Flags : Uint4B
+0x00c DriverStart : Ptr32 Void
+0x010 DriverSize : Uint4B
....
+0x030 DriverStartIo : Ptr32 void
+0x034 DriverUnload : Ptr32 void //卸载函数
+0x038 MajorFunction : [28] Ptr32 long //派遣函数

注册派遣函数

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
NTSTATUS DriverEntry( 。。。。)  
{
//设置卸载函数
pDriverObject->DriverUnload = 卸载函数;

//设置派遣函数
pDriverObject->MajorFunction[IRP_MJ_CREATE] = 派遣函数1;
pDriverObject->MajorFunction[IRP_MJ_CLOSE] = 派遣函数2;
pDriverObject->MajorFunction[IRP_MJ_WRITE] = 派遣函数3;
pDriverObject->MajorFunction[IRP_MJ_READ] = 派遣函数4;
pDriverObject->MajorFunction[IRP_MJ_CLEANUP] = 派遣函数5;
pDriverObject->MajorFunction[IRP_MJ_SET_INFORMATION] = 派遣函数6;
pDriverObject->MajorFunction[IRP_MJ_DEVICE_CONTROL] = 派遣函数7;
pDriverObject->MajorFunction[IRP_MJ_SHUTDOWN] = 派遣函数8;
pDriverObject->MajorFunction[IRP_MJ_SYSTEM_CONTROL] = 派遣函数9;
}

IRP_MJ_MAXIMUM_FUNCTION 派遣函数的最大值

派遣函数的格式

1
2
3
4
5
6
7
8
9
10
11
12
//派遣函数的格式:

NTSTATUS MyDispatchFunction(PDEVICE_OBJECT pDevObj, PIRP pIrp)
{
//处理自己的业务...

//设置返回状态
pIrp->IoStatus.Status = STATUS_SUCCESS; // getlasterror()得到的就是这个值
pIrp->IoStatus.Information = 0; // 返回给3环多少数据 没有填0
IoCompleteRequest(pIrp, IO_NO_INCREMENT);
return STATUS_SUCCESS;
}

通过IRP_MJ_DEVICE_CONTROL交互数据

应用层调用DeviceControl函数会产生此IRP.




系统调用

kernal32!ReadProcessMemory -> ntddl!NtReadVirtualMemory ->

1
2
3
4
mov eax,0BA
mov edx, 7FFE0300
call dword ptr [edx]
retn 14h

7FFE0300地址下的函数位ntddl!KiIntSystemCall函数

1
2
3
lea edx [esp + arg_4]
int 2e
retn

至此进入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
2
3
kd> rdmsr 174   //查看CS
kd> rdmsr 175 //查看ESP
kd> rdmsr 176 //查看EIP

参考: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
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
kd> dt _KTrap_Frame
nt!_KTRAP_FRAME
+0x000 DbgEbp : Uint4B
+0x004 DbgEip : Uint4B
+0x008 DbgArgMark : Uint4B
+0x00c DbgArgPointer : Uint4B
+0x010 TempSegCs : Uint4B
+0x014 TempEsp : Uint4B
+0x018 Dr0 : Uint4B
+0x01c Dr1 : Uint4B
+0x020 Dr2 : Uint4B
+0x024 Dr3 : Uint4B
+0x028 Dr6 : Uint4B
+0x02c Dr7 : Uint4B
+0x030 SegGs : Uint4B
+0x034 SegEs : Uint4B
+0x038 SegDs : Uint4B
+0x03c Edx : Uint4B
+0x040 Ecx : Uint4B
+0x044 Eax : Uint4B
+0x048 PreviousPreviousMode : Uint4B
+0x04c ExceptionList : Ptr32 _EXCEPTION_REGISTRATION_RECORD
+0x050 SegFs : Uint4B
+0x054 Edi : Uint4B
+0x058 Esi : Uint4B
+0x05c Ebx : Uint4B
+0x060 Ebp : Uint4B
+0x064 ErrCode : Uint4B
+0x068 Eip : Uint4B
+0x06c SegCs : Uint4B
+0x070 EFlags : Uint4B
+0x074 HardwareEsp : Uint4B
+0x078 HardwareSegSs : Uint4B
+0x07c V86Es : Uint4B
+0x080 V86Ds : Uint4B
+0x084 V86Fs : Uint4B
+0x088 V86Gs : Uint4B

esp0指向了kTrapFrame结构体的+0x078 HardwareSegSs : Uint4B

所以操作系统在3环进0环的时候往这个结构体放入四个值就是

1
2
3
4
+0x068 Eip              : Uint4B
+0x06c SegCs : Uint4B
+0x070 EFlags : Uint4B
+0x074 HardwareEsp : Uint4B

每个线程都有自己的一个TrapFrame, esp0是当前线程0环堆栈的起始位置

KPCR

每个线程FS段寄存器上3环存储的是TEB, 进入0环就会变成KPCR

KPCR 叫CPU控制区(Processor Control Region)

CPU也有自己的控制块,每一个CPU有一个,叫KPCR

查看当前有几个核

1
2
3
4
5
6
7
8
9
kd> dd KeNumberProcessors
805548e0 00000001 00000006 00009e0a a0013fff
805548f0 806db4c0 00000000 00000000 0000005d
80554900 8003f118 00000000 00000000 00000000
80554910 00000001 00000000 00000001 00000000
80554920 00000000 00000000 00000000 00000000
80554930 00000000 00000000 00000000 00000000
80554940 00000000 00000000 00000000 00000000
80554950 00000000 00000000 00000000 00000000

查看每个核对应的KPCR是什么

1
2
kd> dd KiProcessorBlock  L2			
8055b240 ffdff120 00000000

ffdff120 - 120就是KPCR的地址

查看KPCR是什么

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
kd> dt _KPCR
nt!_KPCR
+0x000 NtTib : _NT_TIB
+0x01c SelfPcr : Ptr32 _KPCR
+0x020 Prcb : Ptr32 _KPRCB
+0x024 Irql : UChar
+0x028 IRR : Uint4B
+0x02c IrrActive : Uint4B
+0x030 IDR : Uint4B
+0x034 KdVersionBlock : Ptr32 Void
+0x038 IDT : Ptr32 _KIDTENTRY
+0x03c GDT : Ptr32 _KGDTENTRY
+0x040 TSS : Ptr32 _KTSS
+0x044 MajorVersion : Uint2B
+0x046 MinorVersion : Uint2B
+0x048 SetMember : Uint4B
+0x04c StallScaleFactor : Uint4B
+0x050 DebugActive : UChar
+0x051 Number : UChar
+0x052 Spare0 : UChar
+0x053 SecondLevelCacheAssociativity : UChar
+0x054 VdmAlert : Uint4B
+0x058 KernelReserved : [14] Uint4B
+0x090 SecondLevelCacheSize : Uint4B
+0x094 HalReserved : [16] Uint4B
+0x0d4 InterruptMode : Uint4B
+0x0d8 Spare1 : UChar
+0x0dc KernelReserved2 : [17] Uint4B
+0x120 PrcbData : _KPRCB

对应的地址KPCR是什么?

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
kd> dt _KPCR ffdff000
nt!_KPCR
+0x000 NtTib : _NT_TIB
+0x01c SelfPcr : 0xffdff000 _KPCR
+0x020 Prcb : 0xffdff120 _KPRCB
+0x024 Irql : 0 ''
+0x028 IRR : 0
+0x02c IrrActive : 0
+0x030 IDR : 0xffffffff
+0x034 KdVersionBlock : 0x8054df38 Void
+0x038 IDT : 0x8003f400 _KIDTENTRY
+0x03c GDT : 0x8003f000 _KGDTENTRY
+0x040 TSS : 0x80042000 _KTSS
+0x044 MajorVersion : 1
+0x046 MinorVersion : 1
+0x048 SetMember : 1
+0x04c StallScaleFactor : 0x8a0
+0x050 DebugActive : 0 ''
+0x051 Number : 0 ''
+0x052 Spare0 : 0 ''
+0x053 SecondLevelCacheAssociativity : 0 ''
+0x054 VdmAlert : 0
+0x058 KernelReserved : [14] 0
+0x090 SecondLevelCacheSize : 0
+0x094 HalReserved : [16] 0
+0x0d4 InterruptMode : 0
+0x0d8 Spare1 : 0 ''
+0x0dc KernelReserved2 : [17] 0
+0x120 PrcbData : _KPRCB

ffdff000 其实这个地址想拿到, 更容易的方式就是
当进入0环之后, 有mov fx,bx这样的段选择子, 解析出来最后的地址就是0xffdff000

接下来的汇编代码, 是保存老的KPCR里面的ExceptionList

KPCR的第一个成员NT_TIB

1
2
3
4
5
6
7
8
9
10
kd> dt _NT_TIB					
ntdll!_NT_TIB
+0x000 ExceptionList : Ptr32 _EXCEPTION_REGISTRATION_RECORD
+0x004 StackBase : Ptr32 Void
+0x008 StackLimit : Ptr32 Void
+0x00c SubSystemTib : Ptr32 Void
+0x010 FiberData : Ptr32 Void
+0x010 Version : Uint4B
+0x014 ArbitraryUserPointer : Ptr32 Void
+0x018 Self : Ptr32 _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
93
kd> 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
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
kd> dt _EThread
nt!_ETHREAD
+0x000 Tcb : _KTHREAD
+0x1c0 CreateTime : _LARGE_INTEGER
+0x1c0 NestedFaultCount : Pos 0, 2 Bits
+0x1c0 ApcNeeded : Pos 2, 1 Bit
+0x1c8 ExitTime : _LARGE_INTEGER
+0x1c8 LpcReplyChain : _LIST_ENTRY
+0x1c8 KeyedWaitChain : _LIST_ENTRY
+0x1d0 ExitStatus : Int4B
+0x1d0 OfsChain : Ptr32 Void
+0x1d4 PostBlockList : _LIST_ENTRY
+0x1dc TerminationPort : Ptr32 _TERMINATION_PORT
+0x1dc ReaperLink : Ptr32 _ETHREAD
+0x1dc KeyedWaitValue : Ptr32 Void
+0x1e0 ActiveTimerListLock : Uint4B
+0x1e4 ActiveTimerListHead : _LIST_ENTRY
+0x1ec Cid : _CLIENT_ID
+0x1f4 LpcReplySemaphore : _KSEMAPHORE
+0x1f4 KeyedWaitSemaphore : _KSEMAPHORE
+0x208 LpcReplyMessage : Ptr32 Void
+0x208 LpcWaitingOnPort : Ptr32 Void
+0x20c ImpersonationInfo : Ptr32 _PS_IMPERSONATION_INFORMATION
+0x210 IrpList : _LIST_ENTRY
+0x218 TopLevelIrp : Uint4B
+0x21c DeviceToVerify : Ptr32 _DEVICE_OBJECT
+0x220 ThreadsProcess : Ptr32 _EPROCESS
+0x224 StartAddress : Ptr32 Void
+0x228 Win32StartAddress : Ptr32 Void
+0x228 LpcReceivedMessageId : Uint4B
+0x22c ThreadListEntry : _LIST_ENTRY
+0x234 RundownProtect : _EX_RUNDOWN_REF
+0x238 ThreadLock : _EX_PUSH_LOCK
+0x23c LpcReplyMessageId : Uint4B
+0x240 ReadClusterSize : Uint4B
+0x244 GrantedAccess : Uint4B
+0x248 CrossThreadFlags : Uint4B
+0x248 Terminated : Pos 0, 1 Bit
+0x248 DeadThread : Pos 1, 1 Bit
+0x248 HideFromDebugger : Pos 2, 1 Bit
+0x248 ActiveImpersonationInfo : Pos 3, 1 Bit
+0x248 SystemThread : Pos 4, 1 Bit
+0x248 HardErrorsAreDisabled : Pos 5, 1 Bit
+0x248 BreakOnTermination : Pos 6, 1 Bit
+0x248 SkipCreationMsg : Pos 7, 1 Bit
+0x248 SkipTerminationMsg : Pos 8, 1 Bit
+0x24c SameThreadPassiveFlags : Uint4B
+0x24c ActiveExWorker : Pos 0, 1 Bit
+0x24c ExWorkerCanWaitUser : Pos 1, 1 Bit
+0x24c MemoryMaker : Pos 2, 1 Bit
+0x250 SameThreadApcFlags : Uint4B
+0x250 LpcReceivedMsgIdValid : Pos 0, 1 Bit
+0x250 LpcExitThreadCalled : Pos 1, 1 Bit
+0x250 AddressSpaceOwner : Pos 2, 1 Bit
+0x254 ForwardClusterOnly : UChar
+0x255 DisablePageFaultClustering : UChar
+0x258 KernelStackReference : Uint4B

从第一个结构溯源到_KTHREAD

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
kd> dt _KTHREAD
nt!_KTHREAD
+0x000 Header : _DISPATCHER_HEADER
+0x010 MutantListHead : _LIST_ENTRY
+0x018 InitialStack : Ptr32 Void
+0x01c StackLimit : Ptr32 Void
+0x020 Teb : Ptr32 Void
+0x024 TlsArray : Ptr32 Void
+0x028 KernelStack : Ptr32 Void
+0x02c DebugActive : UChar
+0x02d State : UChar
+0x02e Alerted : [2] UChar
+0x030 Iopl : UChar
+0x031 NpxState : UChar
+0x032 Saturation : Char
+0x033 Priority : Char
+0x034 ApcState : _KAPC_STATE
+0x04c ContextSwitches : Uint4B
+0x050 IdleSwapBlock : UChar
+0x051 VdmSafe : UChar
+0x052 Spare0 : [2] UChar
+0x054 WaitStatus : Int4B
+0x058 WaitIrql : UChar
+0x059 WaitMode : Char
+0x05a WaitNext : UChar
+0x05b WaitReason : UChar
+0x05c WaitBlockList : Ptr32 _KWAIT_BLOCK
+0x060 WaitListEntry : _LIST_ENTRY
+0x060 SwapListEntry : _SINGLE_LIST_ENTRY
+0x068 WaitTime : Uint4B
+0x06c BasePriority : Char
+0x06d DecrementCount : UChar
+0x06e PriorityDecrement : Char
+0x06f Quantum : Char
+0x070 WaitBlock : [4] _KWAIT_BLOCK
+0x0d0 LegoData : Ptr32 Void
+0x0d4 KernelApcDisable : Uint4B
+0x0d8 UserAffinity : Uint4B
+0x0dc SystemAffinityActive : UChar
+0x0dd PowerState : UChar
+0x0de NpxIrql : UChar
+0x0df InitialNode : UChar
+0x0e0 ServiceTable : Ptr32 Void
+0x0e4 Queue : Ptr32 _KQUEUE
+0x0e8 ApcQueueLock : Uint4B
+0x0f0 Timer : _KTIMER
+0x118 QueueListEntry : _LIST_ENTRY
+0x120 SoftAffinity : Uint4B
+0x124 Affinity : Uint4B
+0x128 Preempted : UChar
+0x129 ProcessReadyQueue : UChar
+0x12a KernelStackResident : UChar
+0x12b NextProcessor : UChar
+0x12c CallbackStack : Ptr32 Void
+0x130 Win32Thread : Ptr32 Void
+0x134 TrapFrame : Ptr32 _KTRAP_FRAME
+0x138 ApcStatePointer : [2] Ptr32 _KAPC_STATE
+0x140 PreviousMode : Char
+0x141 EnableStackSwap : UChar
+0x142 LargeStack : UChar
+0x143 ResourceIndex : UChar
+0x144 KernelTime : Uint4B
+0x148 UserTime : Uint4B
+0x14c SavedApcState : _KAPC_STATE
+0x164 Alertable : UChar
+0x165 ApcStateIndex : UChar
+0x166 ApcQueueable : UChar
+0x167 AutoAlignment : UChar
+0x168 StackBase : Ptr32 Void
+0x16c SuspendApc : _KAPC
+0x19c SuspendSemaphore : _KSEMAPHORE
+0x1b0 ThreadListEntry : _LIST_ENTRY
+0x1b8 FreezeCount : Char
+0x1b9 SuspendCount : Char
+0x1ba IdealProcessor : UChar
+0x1bb DisableBoost : UChar

最后溯源到了PreviouseModel, 这个有什么功能和意义呢?

这个叫先前模式, 0环的程序可能是0环或者3环直接调用的, 但是0环和3环调用的实现功能是不一样的, 所以记录一下先前是什么模式的

接下来继续分析汇编代码, sub esp, 48, -48等于堆栈上清到KTrap_Frame最上面

继续回到KTrap_Frame

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
kd> dt _KTrap_Frame
nt!_KTRAP_FRAME
+0x000 DbgEbp : Uint4B
+0x004 DbgEip : Uint4B
+0x008 DbgArgMark : Uint4B
+0x00c DbgArgPointer : Uint4B
+0x010 TempSegCs : Uint4B
+0x014 TempEsp : Uint4B
+0x018 Dr0 : Uint4B
+0x01c Dr1 : Uint4B
+0x020 Dr2 : Uint4B
+0x024 Dr3 : Uint4B
+0x028 Dr6 : Uint4B
+0x02c Dr7 : Uint4B
+0x030 SegGs : Uint4B
+0x034 SegEs : Uint4B
+0x038 SegDs : Uint4B
+0x03c Edx : Uint4B
+0x040 Ecx : Uint4B
+0x044 Eax : Uint4B
+0x048 PreviousPreviousMode : Uint4B
+0x04c ExceptionList : Ptr32 _EXCEPTION_REGISTRATION_RECORD
+0x050 SegFs : Uint4B
+0x054 Edi : Uint4B
+0x058 Esi : Uint4B
+0x05c Ebx : Uint4B
+0x060 Ebp : Uint4B
+0x064 ErrCode : Uint4B
+0x068 Eip : Uint4B
+0x06c SegCs : Uint4B
+0x070 EFlags : Uint4B
+0x074 HardwareEsp : Uint4B
+0x078 HardwareSegSs : Uint4B
+0x07c V86Es : Uint4B
+0x080 V86Ds : Uint4B
+0x084 V86Fs : Uint4B
+0x088 V86Gs : Uint4B

这样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
       +0x0e8 ApcQueueLock     : Uint4B
    +0x138 ApcStatePointer  : [2] Ptr32 `_KAPC_STATE`
    +0x14c SavedApcState    : `_KAPC_STATE`
    
    APC相关
  • 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
2
3
+0x004 CurrentThread    : Ptr32 _KTHREAD
+0x008 NextThread : Ptr32 _KTHREAD
+0x00c IdleThread : 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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
//线程结构体(仿EHREAD)
typedef struct
{
char *name; //线程名 相当于线程TID
int Flags; //线程状态
int SleepMillisecondDot; //休眠时间

void *InitialStack; //线程堆栈起始位置
void *StackLimit; //线程堆栈界限
void *KernelStack; //线程堆栈当前位置,也就是ESP

void *lpParameter; //线程函数的参数
void (*func)(void *lpParameter); //线程函数

} GMThread_t;

调度链表

1
2
//线程结构体数组
extern GMThread_t GMThreadList[MAXGMTHREAD];

初始化线程堆栈

线程切换

模拟线程切换总结:

  • 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
2
3
4
5
6
7
8
9
10
11
(1)、当前线程主动调用API:

API函数 KiSwapThread KiSwapContext SwapContext

(2)、当前线程时间片到期:

KiDispatchInterrupt KiQuantumEnd SwapContext

(3)、有备用线程(KPCR.PrcbData.NextThread)

KiDispatchInterrupt SwapContext


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
2
3
4
5
6
7
8
(1)、当前线程主动调用API:
API函数 KiSwapThread KiSwapContext SwapContext

(2)、当前线程时间片到期:
KiDispatchInterrupt KiQuantumEnd SwapContext

(3)、有备用线程(KPCR.PrcbData.NextThread)
KiDispatchInterrupt SwapContext

在KiSwapThread与KiQuantumEnd函数中都是通过KiFindReadyThread来找下一个要切换的线程,KiFindReadyThread是根据什么条件来选择下一个要执行的线程呢?

调度链表(32个)

1
2
3
4
5
6
7
8
9
kd> dd KiDispatcherReadyListHead
8055bc20 8055bc20 8055bc20 8055bc28 8055bc28
8055bc30 8055bc30 8055bc30 8055bc38 8055bc38
8055bc40 8055bc40 8055bc40 8055bc48 8055bc48
8055bc50 8055bc50 8055bc50 8055bc58 8055bc58
8055bc60 8055bc60 8055bc60 8055bc68 8055bc68
8055bc70 8055bc70 8055bc70 8055bc78 8055bc78
8055bc80 8055bc80 8055bc80 8055bc88 8055bc88
8055bc90 8055bc90 8055bc90 8055bc98 8055bc98

KiFindReadyThread查找方式:
按照优先级别进行查找:31..30..29..28.....

也就是说,在本次查找中,如果级别31的链表里面有线程,那么就不会查找级别为30的链表!

如何高效查找

调度链表有32个,每次都从头开始查找效率太低,所以Windows都过一个
DWORD类型变量的变量来记录:

当向调度链表(32个)中挂入或者摘除某个线程时,会判断当前级别的链表是否为空,为空将DWORD变量对应位置0,否则置1。

如下图:

这个变量:_kiReadySummary

多cpu会随机寻找KiDispatcherReadyListHead指向的数组中的线程。线程可以绑定某个cpu(使用api:setThreadAffinityMask)

如果没有就绪线程怎么办

1
2
3
4
5
PrcbData:

+0x004 CurrentThread : Ptr32 _KTHREAD
+0x008 NextThread : Ptr32 _KTHREAD
+0x00c IdleThread : Ptr32 _KTHREAD

那么就会转向到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
3
0x220 亲生父母:这个线程谁创建的
0x044 养父母:谁在为这个线程提供资源(也就是提供Cr3)
一般情况下,0x220与0x44指向的是同一个进程

Cr3的值可以随便改吗?

正常情况下,Cr3的值是由养父母提供的,但Cr3的值也可以改成和当前线程毫不相干的其他进程的DirectoryTableBase。

线程代码:

1
2
3
4
5
6
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内存

将当前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
2
3
4
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的值
  • 此时0x00401234中的数据还有吗?
  • 如何将数据传递给A进程的变量呢?

NtReadVirtualMemory流程解析

  • 1、切换Cr3
  • 2、将数据读复制到高2G
  • 3、切换Cr3
  • 4、从高2G复制到目标位置

NtWriteVirtualMemory流程解析

  • 1、将数据从目标位置复制到高2G地址
  • 2、切换Cr3
  • 3、从高2G复制到目标位置
  • 4、切换Cr3




句柄

句柄表

什么是句柄(内核对象)

当一个进程创建或者打开一个内核对象时,将获得一个句柄,通过这个句柄可以访问内核对象。

如:

1
2
3
4
5
6
7
HANDLE g_hMutex = ::CreateMutex(NULL,FALSE, "XYZ");

HANDLE g_hMutex = ::OpenMutex(MUTEX_ALL_ACCESS,FALSE, "XYZ");

HANDLE g_hEvent = ::CreateEvent(NULL, TRUE, FALSE, NULL);

HANDLE g_hThread = ::CreateThread(NULL, 0, Proc, NULL, 0, NULL);

为什么要有句柄?

句柄存在的目的是为了避免在应用层直接修改内核对象。

HANDLE g_hEvent = CreateEvent(NULL, TRUE, FALSE, NULL);

如果g_hEvent存储的就是EVENT内核对象的地址,那么就意味着我们可以在应用层修改这个地址,一旦指向了无效的内核内存地址就会蓝屏。

句柄表在哪?

1
2
3
4
5
6
7
8
9
10
11
12
kd> dt _EPROCESS							
+0x0c4 ObjectTable : _HANDLE_TABLE
...

kd> dt _HANDLE_TABLE
nt!_HANDLE_TABLE
+0x000 TableCode
+0x004 QuotaProcess
+0x008 UniqueProcessId
+0x00c HandleTableLock
+0x01c HandleTableList
...

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
2
3
4
0x0004
0x0008
0x000C
0x0010

所以,句柄值低2位总是0,所以低2位可以被用作标志位。
句柄值的结构类型是EXHANDLE,在EXHANDLE中低2位为标志位。
0x0000这一个句柄值被用来做位NULL无效句柄。提供给用户使用:

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
if(Handle == NULL/ *0x0000*/ )

return;

typedef struct _EXHANDLE

{

// 注意啦,这里是个联合。

// 实际上该结构体就占4个字节

union
{
struct
{
ULONG TagBits:2;
ULONG Index:30;

}

HANDLE GenericHandleOverlay; //呵呵,这是用来提供给用户使用的句柄。
#define HANLE_VALUE_INC 4
ULONG_PTR Value; //这个表示什么意思呢?
}

} EXHANDLE,*PEXHANDLE;

如何知道HANDLE_TABLE和HANDLE_TABLE_ENTRY的关系,可以参考ExpAllocateHandleTable。

ExpAllocateHandleTable用来为每个进程分配句柄表,并初始化句柄表。第一次只分配0级的句柄(保存真正句柄项)。


我不知道该怎么写才顺,我就按照我的分析流程写吧。

1
2
3
4
5
6
7
!Process 查看当前进程

PROCESS 87ca7cf8 SessionId: 0 Cid: 0cb0 Peb: 7ffd8000 ParentCid: 03c4

DirBase: 3f13d000 ObjectTable: e2d11b78 HandleCount: 76.

Image: windbg.exe

其中EPROCESS的地址在87ca7cf8,ObjectTable的地址在e2d11b78 。

ObjectTable是HANDLE_TABLE结构变量,它保存在EPROCESS中。

1
2
3
4
5
dt _EPROCESS 87ca7cf8   来查看EPROCESS结构体的值。
.....
+0x0c4 ObjectTable : 0xe2d11b78_HANDLE_TABLE

......

ObjectTable在EPROCESS偏移0x0c4处。
ObjectTable保存着关于句柄表的信息。

我们使用下面的命令来查看HANDLE_TABLE内容:

1
2
3
4
5
dt _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
2
3
注意,这个32位的对象头的指针并不是全部有效。因为对象头为0x18个字节,所以windows能保证对象头的分配地址总是8的倍数。
所以这32位的对象头的指针低3位肯定都为0,windows将这低3位用作其他用途。
因此当我们使用对象头指针时,一定要记得,低3位值不是我们需要的,应该置0 。即:value & 0xFFFFFFF8。

我们使用dt _HANDLE_TABLE_ENTRY 0xe4702000 来查看其值。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
lkd> 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
7
lkd> 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
6
dt _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
9
lkd> !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
27
lkd> !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
2
3
PsLookupProcessThreadByCid()			
PsLookupProcessByProcessId()
PsLookupThreadByThreadId()

全局句柄表结构

观察句柄表

通过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
2
3
4
5
6
7
8
9
10
11
DWORD  dwVal = 0;	//全局变量

线程中的代码:

dwVal ++; //只有一行 安全吗?

对应的汇编代码:

mov eax,[0x12345678]
add eax,1
mov [0x12345678],eax

LOCK指令

1
2
3
4
5
6
7
8
9
10
11
12
13
14
INC DWORD PTR DS:[0x12345678]	//一行汇编代码,安全吗?

改成

LOCK INC DWORD PTR DS:[0x12345678]

参考:kernel32.InterlockedIncrement

原子操作相关的API:
InterlockedIncrement InterlockedExchangeAdd
InterlockedDecrement InterlockedFlushSList
InterlockedExchange InterlockedPopEntrySList
InterlockedCompareExchange InterlockedPushEntrySList
.....

多行代码原子操作

1
2
3
4
关键代码A	//N行代码要求原子操作
关键代码B //单独加LOCK可以吗?
关键代码C
.......

临界区

一次只允许一个线程进入直到离开

1
2
3
4
5
6
7
8
9
10
11
12
DWORD dwFlag = 0;	//实现临界区的方式就是加锁
//锁:全局变量 进去加一 出去减一

if(dwFlag == 0) //进入临界区
{
dwFlag = 1
.......
.......
.......

dwFlag = 0 //离开临界区
}

自己实现临界区

全局变量:Flag = 0

进入临界区

1
2
3
4
5
6
7
8
9
Lab:
mov eax,1
lock xadd [Flag],eax
cmp eax,0
jz endLab
dec [Flag]
//线程等待Sleep..
endLab:
ret

离开临界区

1
lock dec [Flag]


自旋锁

上一节课我们讲解了什么是临界区,并且自己实现了一个临界区

全局变量:Flag = 0

进入临界区

1
2
3
4
5
6
7
8
9
Lab:
mov eax,1
lock xadd [Flag],eax
cmp eax,0
jz endLab
dec [Flag]
//线程等待Sleep..
endLab:
ret

离开临界区

1
lock dec [Flag]

不同版本的内核文件

单核:

1
2
3
ntkrnlpa.exe	2-9-9-12分页

ntoskrnl.exe 10-10-12分页

多核:

1
2
3
ntkrnlpa.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
2
3
4
5
6
7
8
9
10
11
12
13
dt _KPROCESS			进程

dt _KTHREAD 线程

dt _KTIMER 定时器

dt _KSEMAPHORE 信号量

dt _KEVENT 事件

dt _KMUTANT 互斥体

dt _FILE_OBJECT 文件

可等待对象的差异

一个线程等待一个对象

一个线程等待多个对象

等待网

总结

  • 1、等待中的线程,一定在等待链表中(KiWaitListHead),同时也一定在这张网上(KTHREAD +5C的位置不为空)。
  • 2、线程通过调用WaitForSingleObject/WaitForMultipleObjects函数将自己挂到这张网上。
  • 3、线程什么时候会再次执行取决于其他线程何时调用相关函数,等待对象不同调用的函数也不同。


WaitForSingleObject函数分析

要点回顾

无论可等待对象是何种类型,线程都是通过:

  • WaitForSingleObject
  • WaitForMultipleObjects
    进入等待状态的,这两个函数是理解线程等待与唤醒进制的核心

WaitForSingleObject参数说明

1
2
3
4
5
6
7
_DISPATCHER_HEADER
+0x000 Type //类型
+0x001 Absolute
+0x002 Size
+0x003 Inserted
+0x004 SignalState //是否有信号(>0)
+0x008 WaitListHead //双向链表头 圈着所有等待块

WaitForSingleObject参数说明

WaitForSingleObject对应的内核函数:

1
2
3
4
NTSTATUS __stdcall NtWaitForSingleObject(
HANDLE Handle,
BOOLEAN Alertable,
PLARGE_INTEGER Timeout)
  • 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
2
3
4
5
6
7
8
9
10
CreateEvent(NULL, TRUE, FALSE, NULL);


_DISPATCHER_HEADER
+0x000 Type
+0x001 Absolute
+0x002 Size
+0x003 Inserted
+0x004 SignalState
+0x008 WaitListHead

创建事件对象:类型

1
2
3
4
5
6
7
8
9
10
11
12
CreateEvent(NULL, TRUE, FALSE, NULL);

TRUE 通知类型对象
FALSE 事件同步对象

_DISPATCHER_HEADER
+0x000 Type //TRUE 0 FALSE 1
+0x001 Absolute
+0x002 Size
+0x003 Inserted
+0x004 SignalState
+0x008 WaitListHead

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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
HANDLE CreateSemaphore(		
LPSECURITY_ATTRIBUTES lpSemaphoreAttributes,
LONG lInitialCount,
LONG lMaximumCount,
LPCTSTR lpName
);
_KSEMAPHORE
+0x000 Header : _DISPATCHER_HEADER
+0x010 Limit : Int4B //lMaximumCount
_DISPATCHER_HEADER
+0x000 Type //信号量类型为5
+0x001 Absolute
+0x002 Size
+0x003 Inserted
+0x004 SignalState //lInitialCount
+0x008 WaitListHead

ReleaseSemaphore函数分析

  • 1) 设置SignalState = SignalState + N(参数)
  • 2) 通过WaitListHead找到所有线程,并从等待链表中摘掉。


互斥体

为什么要有互斥体:等待对象被遗弃

互斥体(MUTANT)与事件(EVENT)和信号量(SEMAPHORE)一样,都可以用来进行线程的同步控制。

但需要指出的是,这几个对象都是内核对象,这就意味着,通过这些对象可以进行跨进程的线程同步控制,比如:

1
2
3
A进程中的X线程
等待对象Z
B进程中的Y线程

极端情况

如果B进程的Y线程还没有来得及调用修改SignalState的函数(如SetEvent)
那么等待对象Z将被遗弃,这也就以为者X线程将永远等下去!

为什么要有互斥体:重入

1
2
3
4
5
6
WaitForSingleObject(A)
.....
WaitForMultipleObjects(A,B,C)
.....

SetEvent/ReleaseSemaphore

死锁

MUTANT结构体介绍

1
2
3
4
5
6
_KMUTANT						
+0x000 Header : _DISPATCHER_HEADER
+0x010 MutantListEntry : _LIST_ENTRY
+0x018 OwnerThread : Ptr32 _KTHREAD
+0x01c Abandoned : UChar
+0x01d ApcDisable : UChar

MutantListEntry
拥有互斥体线程(KTHREAD+0x010 MutantListHead)是个链表头 圈着所有互斥体
OwnerThread
正在拥有互斥体的线程
Abandoned
是否已经被放弃不用
ApcDisable
是否禁用内核APC

CreateMutex函数

1
2
3
4
5
6
HANDLE CreateMutex(
LPSECURITY_ATTRIBUTE SlpMutexAttributes, // 指向安全属性的指针
BOOL bInitialOwner, // 初始化互斥对象的所有者
LPCTSTR lpName // 指向互斥对象名的指针
);
CreateMutex NtCreateMutant(内核函数) KeInitializeMutant(内核函数)

初始化MUTANT结构体:

1
2
3
4
5
MUTANT.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
2
3
BOOL WINAPI ReleaseMutex(HANDLE hMutex);

ReleaseMutex ----> NtReleaseMutant ----> KeReleaseMutant

正常调用时:

1
MUTANT.Header.SignalState++;

如果SignalState=1 说明其他进程可用了 将该互斥体从线程链表中移除。

如何解决重入问题

1
2
3
4
5
6
_KMUTANT						
+0x000 Header : _DISPATCHER_HEADER
+0x010 MutantListEntry : _LIST_ENTRY
+0x018 OwnerThread : Ptr32 _KTHREAD
+0x01c Abandoned : UChar
+0x01d ApcDisable : UChar

OwnerThread:
正在拥有互斥体的线程

(参见KeWaitForSingleObject函数)

如何解决等待对象被遗弃问题

1
2
3
4
5
6
_KMUTANT						
+0x000 Header : _DISPATCHER_HEADER
+0x010 MutantListEntry : _LIST_ENTRY
+0x018 OwnerThread : Ptr32 _KTHREAD
+0x01c Abandoned : UChar
+0x01d ApcDisable : UChar

MutantListEntry:
拥有互斥体线程(KTHREAD+0x010 MutantListHead)是个链表头 圈着所有互斥体
Abandoned:
是否已经被放弃不用

1
2
3
4
5
6
7
8
9
10
11
12
13
14
MmUnloadSystemImage  ---->  KeReleaseMutant(X,Y,Abandon,Z) //是否被丢弃

if(Abandon == false) //正常调用
{
MUTANT.Header.SignalState++;
}
else
{
MUTANT.Header.SignalState == 1;
MUTANT.OwnerThread == NULL;
}
if(MUTANT.Header.SignalState==1)
MUTANT.OwnerThread == NULL;
从当前线程互斥体链表中将当前互斥体移除

(参见KeReleaseMutant函数)

禁用内核APC

1
2
3
4
5
6
7
8
9
10
11
12
_KMUTANT						
+0x000 Header : _DISPATCHER_HEADER
+0x010 MutantListEntry : _LIST_ENTRY
+0x018 OwnerThread : Ptr32 _KTHREAD
+0x01c Abandoned : UChar
+0x01d ApcDisable : UChar

ApcDisable:
是否禁用内核APC

Mutant 对应内核函数 NtCreateMutant ApcDisable=0
Mutex 对应内核函数 NtCreateMutex ApcDisable=1

(参见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
2
3
4
CPU指令检测到异常(如除0)
查IDT表,执行中断处理函数
CommonDispatchException(把异常相关的一些信息存储到一个结构体中)
KiDispatchException(分发异常,目的是找到异常处理函数)

CommonDispatchException函数分析
该函数构造了一个_EXCEPTION_RECORD结构体

1
2
3
4
5
6
7
8
kd> dt _EXCEPTION_RECORD
ntdll!_EXCEPTION_RECORD
+0x000 ExceptionCode : Int4B //异常代码
+0x004 ExceptionFlags : Uint4B //异常状态
+0x008 ExceptionRecord : Ptr32 _EXCEPTION_RECORD //下一个异常
+0x00c ExceptionAddress : Ptr32 Void //异常发生地址
+0x010 NumberParameters : Uint4B //附加参数个数
+0x014 ExceptionInformation : [15] Uint4B //附加参数指针

软件模拟异常的产生

代码内部直接

1
throw 1;

抛出异常

CxxThrowException->kernel32.RaiseException->Ntdll.RtlRaiseException->Ntdll.ZwRaiseException->Nt.NtRaiseException->Nt.KiRaiseException->KiDispatchException

异常处理流程

这里其实看张银奎的那本软件调试更加方便

VEH

RtlAddVectoredExceptionHandler

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
// VEHXXXX.cpp : Defines the entry point for the console application.
//

#include "stdafx.h"
#include <windows.h>
#include <stdlib.h>

typedef struct _EXCEPTION_INFO
{
EXCEPTION_RECORD * pRecord;
CONTEXT * pContext;

}EXCEPTION_INFO,* PEXCEPTION_INFO;

typedef PVOID (__stdcall *RtlAddVectoredExceptionHandler)(int,PVOID);

int __stdcall VectorExpceptionHandler(PEXCEPTION_INFO info )
{
printf("================\n");
if(info->pRecord->ExceptionCode == 0x80000003)
{
printf("=========VectorExpceptionHandler,%X=======\n",info->pContext->Eip);
info->pContext->Eip +=1;
return EXCEPTION_CONTINUE_EXECUTION;
}

return 1;
}

int main(int argc, char* argv[])
{
HMODULE hInstance = LoadLibrary("ntdll.dll");
RtlAddVectoredExceptionHandler addFunc = (RtlAddVectoredExceptionHandler)GetProcAddress(hInstance,"RtlAddVectoredExceptionHandler");
addFunc(0,(PVOID)VectorExpceptionHandler);
printf("fisrt \n");
__asm
{
int 3;
}
printf("last \n");
system("pause");
return 0;
}


SEH

VEH是全局的, SEH是挂在KPCR,FS:[0]是跟CPU的任务有关,每个任务都有一个SEH, 所以间接得出SEH是跟线程有关

当前产出SEH只处理当前线程的异常, 而不是像VEH一样全局性的去处理异常

汇编塞入

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
#include <stdio.h>
#include <stdlib.h>
#include <Windows.h>

typedef struct _MyExceptionStruct
{
struct _MyExceptionStruct * next;
DWORD handler;
}MyExceptionStruct;



int MyExceptionHandler(LPEXCEPTION_RECORD record, PVOID param1,
PCONTEXT context, EXCEPTION_DISPOSITION * pdispos)
{
printf("333\n");
if (record->ExceptionCode == 0xC0000095 || record->ExceptionCode == 0xC0000094)
{
printf("222\n");
context->Eip += 2;
return ExceptionContinueExecution;
}
return ExceptionContinueSearch;
}

void WINAPI testException(PVOID p)
{
DWORD temp = 0;
MyExceptionStruct exceptionStruct = {0};

__asm
{
mov eax, fs:[0];
mov temp, eax;
lea ecx, dword ptr ds:[exceptionStruct];
mov dword ptr ds : [ecx], eax;
lea ebx, dword ptr ds : [MyExceptionHandler];
mov dword ptr ds : [ecx + 4], ebx;
mov fs : [0], ecx;
}
/*
__asm
{
xor eax, eax;
xor ecx, ecx;
mov ecx, 1;
idiv ecx;
}
*/
Sleep(5000);
__asm
{
mov eax, temp;
mov fs : [0], eax;
}

printf("111\n");
}

int main(int argc, char *argv[])
{
/*
MyExceptionStruct * p = NULL;
DWORD offset = (DWORD)&p->handler;
printf("%d\n",offset);
*/
CreateThread(NULL, NULL, testException, testException, 0, NULL);


//testException();
getchar();
return 0;
}

try__except 逆向

分析try异常处理函数

所有的C++代码里面的try except编译器都很好的兼容了SEH的异常链处理函数
他的做法就是讲FS:[0]里面的EXCEPTION_REGISTRATION拿出来, 然后将当前函数栈ebp中的造一个_except_handler函数,并且把ebp-4的位置留做一个prev指针
等到时候三环的Ntdll.RtlDispatchException中的ExecuteHandler2再去处理这个函数栈中的_except_handler

编译器扩展的

1
2
3
4
5
6
7
struct _EXCEPTION_REGISTRATION{
STRUCT _EXCEPTION_REGISTRATION *prev; # ebp - 10
void(*handler)(PEXCEPTION_RECORD, PEXCEPTION_REGISTRATION, PEXCEPTION_RECORD); # ebp-C
struct scopetable_entry *scopetable; # ebp-8
int trylevel; # ebp - 4
int _ebp; # 重点
}

系统原生的

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
ntdll!_NT_TIB
+0x000 ExceptionList : Ptr32 _EXCEPTION_REGISTRATION_RECORD
+0x004 StackBase : Ptr32 Void
+0x008 StackLimit : Ptr32 Void
+0x00c SubSystemTib : Ptr32 Void
+0x010 FiberData : Ptr32 Void
+0x010 Version : Uint4B
+0x014 ArbitraryUserPointer : Ptr32 Void
+0x018 Self : Ptr32 _NT_TIB
-------------seperrator-------------
typedef struct _EXCEPTION_REGISTRATION_RECORD
{
struct _EXCEPTION_REGISTRATION_RECORD *Next; //指向下一个ERR
PEXCEPTION_ROUTINE Handler; //异常处理函数
}EXCEPTION_REGISTRATION_RECORD
-------------seperrator-------------
typedef enum _EXCEPTION_DISPOSITION
{
ExceptionContinueExecution = 0,
ExceptionContinueSearch = 1,
ExceptionNestedException = 2,
ExceptionCollidedUnwind = 3
} EXCEPTION_DISPOSITION;

scopetable

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
struct scopetable_entry{
DWORD prevouseTryLevel;
PDWRD lpfnFilter;
PDWRD lpfnHandler;
}

scopetable[0].previousTryLevel =-1
scopetable[0].lpfnFilter = 过滤函数1
scopetable[0].lpfnHandler = 异常处理函数1
scopetable[0].previousTryLevel =-1
scopetable[0].lpfnFilter = 过滤函数2
scopetable[0].lpfnHandler = 异常处理函数2
scopetable[0].previousTryLevel =1
scopetable[0].lpfnFilter = 过滤函数3
scopetable[0].lpfnHandler = 异常处理函数3

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
2
3
4
5
6
7
__try{
// 可能出错的代码
continue; // finally还是会执行
}
__finally{
// 一定要执行的代码
}

编译器如何实现的呢?

它如果出异常还是走对应scopetable里面,并且scopeentry里面的第二个为空就是过滤表达式为空, 那么处理函数从except转变为finally的代码块

它如果不出异常,那么会有一个局部展开当出现Continue,Break,Return的函数强制走一下finally

局部展开

局部展开, 当__try__finally中的__try代码提前退出流程代码块时会产生
比如Continue,Break,Return等操作的提前退出流程语句

所以再不出异常的情况下, 就是直接call 一个局部展开, 所以我们finally能够执行是因为我们使用了这个局部展开的函数

全局展开

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
__try
{
__try
{
__try
{
*(int*) 0 = 1
}
__finally
{
printf("一定会执行的代码A\n");
}
}
__finally
{
printf("一定会执行的代码B\n");
}
}
__except(1)
{
printf("异常处理函数\n");
}

当tryLevel发现这里的过滤函数为空就会继续往外找, 当找到except(1), 当他找到这里的时候, 之前语义约定finally必须执行, 这个时候编译器会怎么做?

那么当except(1)返回为1,在exceptHandler的函数内执行异常代码之前, 会逐个寻找到到之前内部finally代码块全部执行




调试

调试对象

1
2
3
4
5
6
7
8
9
10
11
12
13
typedef struct _DEBUG_OBJECT
{
KEVENT EventsPresent;
FAST_MUTEX Mutex;
LIST_ENTRY EventList;
union {
ULONG Flags;
struct {
UCHAR DebuggerInactive:1;
UCHAR KillProcessOnExit:1;
}
}
} DEBUG_OBJECT, *PDEBUG_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
2
3
4
5
6
7
8
9
10
11
typedef enum _DBGKM_APINUMBER{
DbgkmException = 0, //异常
DbgKmCreateThreadApi = 1,//创建线程
DbgKmCreateProcessApi = 2, //创建进程
DbgKmExitThreadApi = 3,//线程退出
DbgKmExitProcessApi = 4,//进程退出
DbgKmLoadDllApi = 5,//加载Dll
DbgKmUnloadApi = 6,//卸载Dll
DbgKmErrorReportApi = 7,//已废弃
DbgKmMaxApiNumber = 8,//最大值
} 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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
typedef struct _DEBUG_EVENT {
DWORD dwDebugEventCode;
DWORD dwProcessId;
DWORD dwThreadId;
union {
EXCEPTION_DEBUG_INFO Exception;
CREATE_THREAD_DEBUG_INFO CreateThread;
CREATE_PROCESS_DEBUG_INFO CreateProcessInfo;
EXIT_THREAD_DEBUG_INFO ExitThread;
EXIT_PROCESS_DEBUG_INFO ExitProcess;
LOAD_DLL_DEBUG_INFO LoadDll;
UNLOAD_DLL_DEBUG_INFO UnloadDll;
OUTPUT_DEBUG_STRING_INFO DebugString;
RIP_INFO RipInfo;
}
} DEBUG_EVENT *LPDEBUG_EVENT;




调试事件的处理

创建进程的方式

  • 第一步: 关联,通过调试对象进行关联调试器被调试进程
  • 第二部: 调试循环
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
BOOL bRet = TRUE;
BOOL nIsContinue = TRUE;
DEBUG_EVENT debugEvent = {0};


// 1.创建调试进程
STARTUPINFO StartupInfo = {0};
PROCESS_INFORMATION pInfo = {0};
GetStartupInfo(&StartupInfo);
bRet = CreateProcess(DEBUGGEE, NULL, NULL, NULL, TRUE, DEBUG_PROCESS || DEBUG_ONLY_THIS_PROCESS, NULL, NULL, &StartupInfo, &pInfo);

// 2.调试循环
while(nIsContinue){
// 判断当debugEvent链表有没有事件,如果有的话则取出来
bRet = WaitForDebugEvent(&debugEvent, INFINITE);
if(!bRet){
printf("WaitForDebugEvent error:%d\n!", GetLastError());
return 0;
}
switch(debugEvent.dwDebugEventCode){
// 1.异常
case EXCEPTION_DEBUG_EVENT:

// 2.
case EXIT_THREAD_DEBUG_EVENT:
printf("....");
break;
......
}
// DBG_CONTINUE 即表示调试器已处理了该异常
// DBG_EXCEPTION_NOT_HANDLED 即表示调试器没有处理该异常,转回到用户态种执行, 寻找可以处理该异常的异常处理器
hRet = ContinueDebugEvent(debugEvent, dwProcessId, debugEvent, dwThreadId, DBG_CONTINUE);
}

取到的DebugEvent的信息如下

1
2
3
4
typedef struct _EXCEPTION_DEBUG_INFO{
EXCEPTION_RECORD ExceptionRecord;
DWORD dwFirstChance; //记录了第几次分发
} EXCEPTION_DEBUG_INFO *LPEXCEPTION_DEBUG_INFO;

这个当一旦运行就会触发异常断点, 这个异常断点来源于系统断点int3

系统断点的由来如下

1
2
3
LdrInitializeThunk 
LdrpInitializeProcess
DbgBreakPoint()找到LdrpInitializeProcess引用地址

附加进程的方式

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
BOOL bRet = TRUE;
BOOL nIsContinue = TRUE;
DEBUG_EVENT debugEvent = {0};


// 1. 附加进程
if(!DebugActiveProcess(GetPorcessId("进程名字"))){
return 0;
}


// 2.调试循环
while(nIsContinue){
// 判断当debugEvent链表有没有事件,如果有的话则取出来
bRet = WaitForDebugEvent(&debugEvent, INFINITE);
if(!bRet){
printf("WaitForDebugEvent error:%d\n!", GetLastError());
return 0;
}
switch(debugEvent.dwDebugEventCode){
// 1.异常
case EXCEPTION_DEBUG_EVENT:

// 2.
case EXIT_THREAD_DEBUG_EVENT:
printf("....");
break;
......
}
// DBG_CONTINUE 即表示调试器已处理了该异常
// DBG_EXCEPTION_NOT_HANDLED 即表示调试器没有处理该异常,转回到用户态种执行, 寻找可以处理该异常的异常处理器
hRet = ContinueDebugEvent(debugEvent, dwProcessId, debugEvent, dwThreadId, DBG_CONTINUE);
}

当附加一个进程的时候, 会发送杜撰的假消息
你会看到创建进程、创建线程、以及dll的加载各种调试信息都会出现
这个消息都是来源于DbgkpPostFakeProcessCreateMessage(发送假的进程创建消息、假的模块加载的消息[遍历了PEB的LDR模块列表]、假的线程创建消息)
这样保证调试器无论是新创建进程的方式, 还是附加进程的方式, 调试器都想得到这些信息
这样调试器都能知道进程加载了多少模块, 调试事件就表明了多少模块, 这样写的目的就是给调试器提供必要的信息, 形成完整的调试形态


异常的处理流程

异常处理的大概

如果有调试器的话,一旦有异常, 结果对象不是异常流程而是调试流程

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15

int main(){
int x = 100;
int y = 0;
int z;

__try{
z = x/y;
printf("无法执行的代码");
}
__except(1){
printf("SEH异常处理代码");
}
return 0;
}

上面这个程序以调试的状态在运行, 会先抛给调试器, 而不是异常处理代码

如果调试器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
2
3
4
5
6
7
BOOL VritualProtectEx(
HANDLE hProcess,
LPVOID lpAddress,
SIZE_T dwSize,
DWORD flNewProtect,
PDWORD lpflOldProtect
)

将被调试进程的某内存属性修改为

  • PAGE_NOACCESS(PTE)
  • PAGE_EXECUTE_READ