Windows的地址空间分用户模式与内核模式,低2GB的部分叫用户模式,高2G的部分叫内核模式,位于用户空间的代码不能访问内核空间,位于内核空间的代码却可以访问用户空间
一个线程的运行状态分内核态与用户态,当指令位于用户空间时,就表示当前处于内核态,当指令位于内核空间时,就处于内核态.
一个线程由用户态进入内核态的途径有3种典型的方式:
1、 主动通过int 2e(软中断自陷方式)或sysenter指令(快速系统调用方式)调用系统服务函数,主动进入内核
2、 发生异常,被迫进入内核
3、 发生硬件中断,被迫进入内核
现在讨论第一种进入内核的方式:(又分为两种方式)
1、 通过老式的int 2e指令方式调用系统服务(因为老式cpu没提供sysenter指令)
如ReadFile函数调用系统服务函数NtReadFile
Kernel32.ReadFile() //点号前面表示该函数的所在模块
{
//所有Win32 API通过NTDLL中的系统服务存根函数调用系统服务进入内核
NTDLL.NtReadFile();
}
NTDLL.NtReadFile()
{
Mov eax,152 //我们要调用的系统服务函数号,也即SSDT表中的索引,记录在eax中
If(cpu不支持sysenter指令)
{
Lea edx,[esp+4] //用户空间中的参数区基地址,记录在edx中
Int 2e //通过该自陷指令方式进入KiSystemService,‘调用’对应的系统服务
}
Else
{
Lea edx,[esp +4] //用户空间中的参数区基地址,记录在edx中
Sysenter //通过sysenter方式进入KiFastCallEntry,‘调用’对应的系统服务
}
Ret 36 //不管是从int 2e方式还是sysenter方式,系统调用都会返回到此条指令处
}
Int 2e的内部实现原理:
该指令是一条自陷指令,执行该条指令后,cpu会自动将当前线程的当前栈切换为本线程的内核栈(栈分用户栈、内核栈),保存中断现场,也即那5个寄存器。然后从该cpu的中断描述符表(简称IDT)中找到这个2e中断号对应的函数(也即中断服务例程,简称ISR),jmp 到对应的isr处继续执行,此时这个ISR本身就处于内核空间了,当前线程就进入内核空间了
Int 2e指令可以把它理解为intel提供的一个内部函数,它内部所做的工作如下
Int 2e
{
Cli //cpu一中断,立马自动关中断
Mov esp, TSS.内核栈地址 //切换为内核栈,TSS中记录了当前线程的内核栈地址
Push SS
Push esp
Push eflags
Push cs
Push eip //这5项工作保存了中断现场【标志、ip、esp】
Jmp IDT[中断号] //跳转到对应本中断号的isr
}
IDT的整体布局:【异常->空白->5系->硬】(推荐采用7字口诀的方式重点记忆)
异常:前20个表项存放着各个异常的描述符(IDT表不仅可以放中断描述符,还放置了所有异常的异常处理描述符,0x00-0x13)
保留:0x14-0x1F,忽略这块号段
空白:接下来存放一组空闲的保留项(0x20-0x29),供系统和程序员自己分配注册使用
5系:然后是系统自己注册的5个预定义的软中断向量(软中断指手动的INT指令)
(0x2A-0x2E 5个系统预注册的中断向量,0x2A:KiGetTickCount, 0x2B:KiCallbaclReturn
0x2C:KiRaiseAssertion, 0x2D:KiDebugService, 0x2E:KiSystemService)
硬: 最后的表项供驱动程序注册硬件中断使用和自定义注册其他软中断使用(0x30-0xFF)
下面是中断号的具体的分配情况:
0x00-0x13固定分配给异常:
0x00: Divide error(故障)
0x01: Debug (故障或陷阱)
0x02: 保留未用(为非屏蔽中断保留的,NMI)
0x03: breakpoint(陷阱)
0x04: Overflow(陷阱)
0x05: Bounds check(故障)
0x06: Invalid Opcode(故障)
0x07: Device not available(故障)
0x08: Double fault(异常中止)
0x09: Coprocessor segment overrun(异常中止)
0x0A: Invalid TSS(故障)
0x0B: Segment not present(故障)
0x0C: Stack segment(故障)
0x0D: General protection(故障)
0x0E: Page fault(故障)
0x0F: Intel保留
0x10: Floating point error(故障)
0x11: Alignment check(故障)
0x12: Machine check(异常中止)
0x13: SIMD floating point(故障)
0x14-0x1f:Intel保留给他公司将来自己使用(OS和用户都不要试图去使用这个号段,不安全)
----------------------以下的号段可用于自由分配给OS、硬件、用户使用-----------------------
linux等其他系统是怎么划分这块号段的,不管,我们只看Windows的情况
0x20-0x29:Windows没占用,因此这块号段我们也可以自由使用
0x2A-0x2E:Windows自己本身使用的5个中断号
0x30-0xFF:Windows决定把这块剩余的号段让给硬件和用户使用
参见《寒江独钓》一书P93页注册键盘中断时,搜索空闲未用表项是从0x20开始,到0x29结束的,就知道为什么寒江独钓是在这段范围内搜索空白表项了(其实我们也完全可以从0x14开始搜索)
Windows系统中,0x30-0xFF这块号段让给了硬件和用户自己使用。事实上,这块号段的开头部分默认都是让给硬件IRQ使用的,也即是分配给硬件IRQ的。IRQ N默认映射到中断号0x30+N,如IRQ0用于系统时钟,系统时钟中断号默认对应就是0x30。当然程序员也可以修改APIC(可编程中断控制器)将IRQ映射到自定义的中断号。
IRQ对外部设备分配,但IRQ0,IRQ2,IRQ13必须如下分配:
IRQ0 ---->间隔定时设备
IRQ2 ---->8259A芯片
IRQ13 ---->外部数学协处理器
其余的IRQ可以任意分配给外部设备。
虽然一个IRQ只对应一个中断号,但是由于IRQ数量有限,而设备种类成千上万,因此多个设备可以使用同一个IRQ,进而,多个设备可以分配同一个中断号。因此,一个中断号可以共享给多个设备同时使用。
明白了IDT,就可以看到0x2e号中断的isr为KiSystemService,顾名思义,这个中断号专用于提供系统服务。
在正式分析KiSystemService,前,先看下几个辅助函数
SaveTrap() //这个函数用来保存寄存器现场和其他状态信息
{
Push 0 //LastError
Push ebp
Push ebx
Push esi
Push edi
Push fs //此时的fs若是从用户空间自陷进来的就指着TEB,反之指着kpcr
Push kpcr.ExceptionList
Push kthread.PreviousMode
Sub esp,0x48 //腾给调式寄存器保存用
-----------至此,上面的这些语句连同int 2e中的语句在栈上构造了一个trap帧-----------------
Mov CurTrapFrame,esp //当前Trap帧的地址
Mov CurTrapFrame.edx, kthread.TrapFrame //将上次的trap帧地址记录到edx成员中
Mov kthread.TrapFrame, CurTrapFrame, //修改本线程当前trap帧的地址
Mov kthread.PreviousMode,GetMode(进入内核前的CS) //根据CS自动确定上次模式
Mov kpcr.ExceptionList,-1 //表示刚进入内核时,尚未安装seh
Mov fs,kpcr //一进入内核就让fs改指向当前cpu的描述符kpcr,不再指向TEB
If(当前线程处于调试状态)
保存DR0-DR7到trap帧中
}
FindTableCall() //这个函数用来查表,拷贝参数,调用系统服务
{
Mov edi,eax //系统函数号,低12位为索引,第13为表示是哪张系统服务表中的索引
Mov eax, edi.低12位 //eax=真正的服务号
If(edi.第13位=1) //if这是shadow SSDT中的系统函数号
{
If(当前线程.服务描述符表!=shadow)
当前线程.服务描述符表=shadow //换用另外一张描述符表
}
服务表描述符=当前线程.服务描述符表[edi.第13位]
Mod edi=服务表描述符.base //这个系统服务表的地址
Mov ebx,[edi+eax*4] //查表获得这个函数的地址
Mov ecx=服务表描述符.Number[eax] //查表获得的这个系统函数的参数大小
Mov esi,edx //esi=用户空间中的参数地址
Mov edi,esp //esp已经为内核栈的栈顶地址
Rep movsb //将所有参数从用户空间复制到内核空间,相当于N个连续push压参
Call ebx //调用对应的系统服务函数
}
KiSystemService()//int 2e的isr,内核服务函数总入口,注意这个函数可以嵌套、递归!!!
{
SaveTrap();
Sti //开中断
---------------上面保存完寄存器等现场后,开始查SSDT表调用系统服务------------------
FindTableCall();
---------------------------------调用完系统服务函数后------------------------------
Move esp,kthread.TrapFrame; //将栈顶回到trap帧结构体处
Cli //关中断
If(上次模式==UserMode)
{
Call KiDeliverApc //遍历执行本线程的内核APC和用户APC队列中的所有APC函数
清理Trap帧,恢复寄存器现场
Iret //返回用户空间
}
Else
{
返回到原call处后面的那条指令处
}
}
上面所说的trap帧(TrapFrame)是指一个结构体,用来保存系统调用、中断、异常发生时的寄存器现场,方便以后回到用户空间/回到中断处时,恢复那些寄存器的值,继续执行
Trap帧中除了保存了所有寄存器现场外,还附带保存了一些其他信息,如seh链表的地址等
必须说一下trap帧的结构体布局定义:
typedef struct _KTRAP_FRAME //Trap现场帧
{
------------------这些是KiSystemService保存的---------------------------
ULONG DbgEbp;
ULONG DbgEip;
ULONG DbgArgMark;
ULONG DbgArgPointer;
ULONG TempSegCs;
ULONG TempEsp;
ULONG Dr0;
ULONG Dr1;
ULONG Dr2;
ULONG Dr3;
ULONG Dr6;
ULONG Dr7;
ULONG SegGs;
ULONG SegEs;
ULONG SegDs;
ULONG Edx;//xy 这个位置不是用来保存edx的,而是用来保存上个Trap帧,因为Trap帧是可以嵌套的
ULONG Ecx; //中断和异常引起的自陷要保存eax,系统调用则不需保存ecx
ULONG Eax;//中断和异常引起的自陷要保存eax,系统调用则不需保存eax
ULONG PreviousPreviousMode;
struct _EXCEPTION_REGISTRATION_RECORD FAR *ExceptionList;//上次seh链表的开头地址
ULONG SegFs;
ULONG Edi;
ULONG Esi;
ULONG Ebx;
ULONG Ebp;
----------------------------------------------------------------------------------------
ULONG ErrCode;//发生的不是中断,而是异常时,cpu还会自动在栈中压入对应的具体异常码在这儿
-----------下面5个寄存器是由int 2e内部本身保存的或KiFastCallEntry模拟保存的现场---------
ULONG Eip;
ULONG SegCs;
ULONG EFlags;
ULONG HardwareEsp;
ULONG HardwareSegSs;
---------------以下用于用于保存V86模式的4个寄存器也是cpu自动压入的-------------------
ULONG V86Es;
ULONG V86Ds;
ULONG V86Fs;
ULONG V86Gs;
} KTRAP_FRAME, *PKTRAP_FRAME;
KPCR与KPRCB结构,都是用来描述处理器的,前者叫处理器描述符,后者叫处理器控制块
Struct KPCR
{
KPCR_TIB Tib;//类似于TEB.TIB,内部第一个字段都是ExceptionList
KPCR* self;//自身结构体的地址,方便直接寻址
KPRCB* kprcb;//处理器控制块的地址、
KIRQL irql;//当前cpu的irql
USHORT* IDT;//本cpu的IDT地址,一有中断/异常就去这个表找isr、epr
USHORT* GDT;//全局描述符表地址
KTSS* TSS;//记录了本cpu上当前运行线程的状态信息,重要字段有内核栈地址,IO权限位图
……
}
Struct KPRCB
{
KTHREAD* CurrentThread;//本cpu上当前运行的线程
KTHREAD* NextThread;//本cpu上将抢占当前线程的下个线程(抢占式调度核心)
BYTE CpuID;//不多说
ULONG KernelTime,UserTime;//本cpu的累计运行时间统计信息
……
}
系统中有两张“系统服务表”,即SSDT和shadow SSDT。同样,系统中也有两张“系统服务表描述符表”,每个表都包含两个描述符。两张表中第一个描述符都是SSDT的描述符,第二个描述符都是shadow SSDT的描述符。但是第一个表的第二个描述符是空白的,因此,第一张表实际上只能描述SSDT表,第二张表可以描述SSDT表和shadow SSDT表。所以一旦调用的是shadow SSDT表中系统服务函数,这个线程就会自动换用第二张服务表描述符表,具体为:
Mov kthread.ServiceTable, 第二张服务表描述符表,这样,这个线程就变为一个GUI线程,以后都使用第二张“系统服务表描述符表”了
“系统服务表描述符”是一个结构体,用来描述一张系统服务表的各种信息,如下定义:
Struct KSERVICE_TABLE_DESCRIPTOR
{
ULONG* base;//系统服务表的地址
ULONG* CountTable;//该系统服务表中每个函数的历史调用次数统计表
ULONG limit;//该系统服务表的大小,也即容量
BYTE* ArgSizeTable;//记录该系统服务表中每个函数参数大小的表
}
2、 通过快速调用指令(Intel的是sysenter,AMD的是syscall)调用系统服务
老式的cpu不支持、不提供sysenter指令,只能由int 2e模拟中断方式进入内核,调用系统服务,
但是,那种方式有一个明显的缺点,就是速度慢!(如int 2e内部本身要保存5个寄存器的现场,然后还要去IDT中查找isr,这个过程消耗的时间太多),因此x86系列从奔腾2代开始为系统调用专门增设了一条sysenter指令以及相应的寄存器msr。同样,sysenter指令也可看做intel提供的一个内部函数,它做的工作如下:
Sysenter()
{
Mov ss,msr_ss
Mov esp,msr_esp //关键
Mov cs,msr_cs
Mov eip,msr_eip //关键
}
系统在启动初始化过程中,会将上面四个msr寄存器设为固定的值,其中msr_esp为DPC函数专用堆栈,
Msr_eip则固定为KiFastCallEntry
KiFastCallEntry() //快速系统调用总入口
{
Mov fs,kpcr //一进入内核,就将fs改指向处理器描述符kpcr
Mov esp,TSS.ESP //一进入内核,就换用内核栈(每个线程的内核栈地址保存在TSS中)
Push ds
Push edx //edx为用户空间栈的栈顶地址,保存在这儿,方便以后回到用户空间时恢复
Push eflags
Push cs
Push sysenter指令的后面那条指令的地址 //将用户空间中的返回地址保存在这儿
--------上面的5条push指令模拟中断、异常发生时cpu自动保存的那5个寄存器的现场------------
Cli //关中断,构造Trap现场帧的过程中需要暂时关中断
Mov eflags,0x2
SaveTrap();
Sti //开中断
---------------上面保存完寄存器等现场后,查SSDT表调用对应系统服务----------------------
FindTableCall();
------------------------------------调用完系统服务函数后--------------------------------
Move esp,kthread.TrapFrame; //将栈顶回到trap帧结构体处
Cli //关中断
…
Call KiDeliverApc //遍历执行本线程的内核APC和用户APC队列中的所有APC函数
…
清理Trap帧,恢复寄存器现场
Sti //开中断
-----------------------------------下面返回用户空间-------------------------------------
Mov ecx,保存的用户空间栈顶地址
Mov edx,保存的返回地址,也即sysenter指令的后面那条指令的地址
sysexit //可以把这条指令理解为一个fastcall调用约定函数
}
Sysexit指令也可理解为一个函数,它做的工作如下:
Sysexit
{
Mov cs,msr_cs
Mov ss,msr_ss
Mov esp,ecx //换用用户空间中的栈
Mov eip,edx //这样,就返回用户空间中了,所有系统调用总是先返回到NTDLL.dll中的某个固定位置,最后一路返回到NTDLL中发起系统调用的那个存根函数体内
}
前面讲过,线程的内核结构KTHREAD中,有一个字段记录了PreviousMode,这个“上一模式”指的就是,进入本次系统调用前的模式,也即指进入SSDT表中的服务函数前的模式是在用户空间还是内核空间。Windows不仅支持由用户空间发起系统调用,也支持由内核空间发起系统调用,为此,Windows专门配备了Zw系列的内核服务封装函数,如:
Ntoskrnl.ZwCreateFile() //模拟构造一个Trap现场,然后调用系统服务
{
Mov eax,系统服务号
Lea edx,[esp+4]
Push eflags
Push cs //关键。根据cs的值设置KTHREAD.PreviousMode字段
//注意在调用本函数前,此处不再模拟中断、异常时自动保存的ss、esp、eip寄存器
Call KiSystemService
Ret //这样,调用完系统服务后,就返回到这儿了,不再返回到NTDLL中的sysenter指令后面了
}
NTSTATUS NtReadFile(…)
{
…
KPROCESSOR_MODE PreviousMode = KeGetPreviousMode();
…
}
KeGetPreviosMode()
{
Return kthread.PreviousMode;
}
这样:内核API KeGetPreviosMode的返回值就是内核模式了
上面这个NtReadFile系统服务函数需要获得‘上次模式’,而这个‘上次模式’是在构造TrapFrame中的过程中根据cs的值设置的。因此,凡是需要读取‘上次模式’的系统服务函数,都必须有一个“正确的TrapFrame”。因此ZwXXX系列的系统服务封装函数会在内部Push eflags,Push cs,Call KiSystemService,这三条指令就恰好伪造了一个“正确的TrapFrame”,使得系统服务能够正确运行。
换言之:凡是需要读取“正确TrapFrame”的系统服务函数都不能直接手工调用,必须调用他们的ZwXXX封装函数。反之,就可以直接调用。
附:cs,ds,es,fs,gs,ss这六个段寄存器的介绍:
fs在用户态间接指向TEB,在内核态间接指向kpcr
其他5个段寄存器都可以理解为一个描述符
如struct cs
{
BOOL bInGDT;//指示下面的idx是在GDT表中还是LDT表中的索引,一般为TRUE
Int idx;// GDT/LDT描述符表中,本cs段描述符的索引位置
Int rpl://本段的特权级别:0或者3
}
简单的讲,可以将他们视为GDT或LDT中的段描述符索引
更多基础信息参考:张银奎 -《软件调试》