1)Intel的X86处理器通过Ring级进行访问控制:R0~R3,权限依次递减,原先设计运用程序工作在R3,操作系统工作在R0,其它驱动程序工作在R1、R2。但目前系统只使用了2层,即R0和R3,而驱动程序被运行在了R0级。
2)只能在R0层使用的指令有(或在R3层受限):
指令 |
描述 |
lgdt |
加载GDT寄存器 |
lldt |
加载LDT寄存器 |
ltr |
加载任务寄存器 |
lidt |
加载IDT寄存器 |
mov |
加载和存储控制寄存器、调试寄存器时受限 |
lmsw |
加载机器状态字 |
clts |
清除cr0中的任务切换标记 |
invd |
缓冲无效,并不写回 |
wbinvd |
缓冲无效,并写回 |
invlpg |
无效TLB入口 |
hlt |
停止处理器 |
rdmsr |
读模式指定寄存器 |
wrmsr |
写模式指定寄存器 |
rdpmc |
读取性能监控计数器 |
rdtsc |
读取时间戳计数器 |
cli |
关闭中断 |
sti |
开启中断 |
in |
从硬件端口读 |
out |
从硬件端口写 |
其中rdpmc与rdtsc在cr4的位4(PCE)和位2(TSD)被设置时可同时被R0层和R3层调用。任何违反上面规定的操作,windows下均会产生通用保护故障异常。
最后的4条指令是IO敏感指令,在R3层使用时要检测IO许可位。
3)X86CPU运行模式:
a)实模式:CPU启动后的模式,DOS也运行于该模式。访问内存1MB,单任务;运用程序与系统程序均运行在R0。
b)保护模式:windows运行于该模式,支持分段,分页等。
c)系统管理模式(SMM):给操作系统或执行环境一个透明机制处理电源管理和OEM独有特性。
d)虚拟8086模式:保护模式下的虚拟实模式。Windows中的DOS运行在此模式,一些特殊指令无法执行。
e)64位扩展模式:64位模式和兼容模式。64位模式支持64位线性寻址,支持超过64GB物理地址,所有64位程序运行在该模式;而所有32位程序运行在兼容模式下。
4)X86处理器地址分段模式:
a)平坦模式:连续,地址不分段,所有段被映射到同一个地址空间(所有段有相同的段基址0,段寄存器的段选择子都是指向相同的段描述符(内核代码段为8号段描述符,内核堆栈段为10H = 16号段描述符,用户代码段为1BH = 28号段描述符,用户堆栈段为23H = 35号段描述符---关于用户态的段描述符值可以在(12)中验证),而这个段描述符是0基址的。这样的话那么不同进程的相同逻辑地址(虚拟地址)必然转换成相同的线性地址,而通过各自独有的一套页目录表与页表来转换到不同的物理地址),界限位4GB(32位下)。Windows和linux都运行在该模式下。
b)多段模式:8086使用这种模式,内存被分为不同的段,每个段大小64KB,各个数据段可以不连续,可以连续,也可以重叠。每个段有一个段基址,在段内的偏移叫段内偏移地址。实际的物理地址 = 段基址 * 2^4 + 段内偏移地址。
5)WinDbg中看到的地址都是虚拟地址(平坦模式下虚拟地址也是线性地址,原因见(4),即虚拟地址是具有两级的逻辑地址:段基址+段内偏移地址)。关于段管理机制,虚拟内存,映射机制等内容这里不做过多说明,只补充一下地址空间的说明:虽然每个段可寻址虚拟地址空间位4GB(32位下),但是并不是可使用的地址空间有4个GB,从《琢石成器》一书来看,用户程序进程代码和数据(包括堆栈)分别在线性空间的00400000H~0FFFFFFFH的空间内,所以大部分程序的代码都是以00400000H的线性地址打头的;用户DLL范围为10000000H~7FFFFFFFH的空间内;而系统DLL,内存映像文件在80000000H~BFFFFFFFH的范围内,所以大部分驱动程序线性地址都是以80000000H打头的。运用程序运行在R3级别,虽然它可以寻址4GB,但是由于安全描述符中权限等级的限制,它能访问的线性地址空间为00400000H~7FFFFFFFH,大约是2个GB,这个范围的空间是该运用程序独享的,而其它范围内的空间是所有的运用程序共享的。
6)分页机制:《琢石成器》对于分页机制中根据线性地址查找页表的部分讲述的比较含糊,这里补充一下(《天书夜读》讲得也很含糊),内存管理机制大概分为段管理机制(由逻辑地址得到线性地址),分页管理机制(由线性地址到物理地址),虚拟内存机制(虚拟页到物理内存页)。其中的分页机制映射具体内容是:
a)保护模式下,控制寄存器CR0最高位(PG位)控制分页管理机制是否有效
b)80386中,页大小固定位4K字节,页边界必须是4KB的整数倍。4GB可分1MB个页,每个页的起始地址为“XXXXX000H”,前20位(线性地址高20位)为页码,页码左移12位即为对应页的起始地址。
c)32位线性地址转换为32位物理地址时,低12位保持不变,因此分页机制要转换的是高20位的线性地址转换为高20位的物理地址的问题,这个问题采用映射表来实现。
d)如果只定义一张映射表,则有1M个数据项,每项占4个字节,每个进程的映射表要4M大,为了减小映射表,采用了两级映射机制。
e)页映射的第一级称为页目录表,用控制器CR3来指定页目录表的基地址域(这是一个物理地址---唯一一个存放物理地址的寄存器),10位,可存储1K个数据项;第二级为页表,也是10位,存储1K个数据项。这样,每个数据项占4个字节。使用一级页表,每个程序必须均分配独立的整张4MB的页表;而使用2级目录,只需为每个进程的页目录分配一个页框并初始化第一个页目录项,随后再为页表分配一个页框并初始化一个页表项,总共消耗8KB内存。
f)目录表中的数据项是页表,这1K个页表可以零散存放在物理内存;每张页表中的数据项是物理地址空间的页面地址,这样,每张页表可以指定1KB的物理页面,这些物理页也可以任意分布在物理地址空间。
g)转换步骤:先把线性地址高10位(31位~22位)作为页目录表中的索引,查找到页表的页码,再把线性地址的中间10位(21位~10位)作为页表中的索引,在刚刚查到的指定页码中的页表中找到实际的物理地址空间页面地址,将该地址作为高20位加上原来线性地址的低12位就构成了实际的物理地址。
h)系统为每个进程维护一个页目录(有且仅有一个,这个页目录的生存周期是程序的整个运行周期)和一套页表,每个进程在自己的内存描述符中都存有一个专门的字段用来存放页目录的起始地址(物理地址,切换到该进程时,将它存储到CR3控制器)。所以不同进程的相同线性地址指向的页目录并不一样,物理地址也不一定一样。
7)不可执行保护:NX保护。页表中的数据项格式如下:
31~12位物理页地址 |
11~6位保留 |
D |
A |
PCD |
PWT |
U位 |
W位 |
P位 |
P位:该页是否存在物理页映射。
W位:该页是否可写。
U位:用户自定义属性,windows中,U=1为用户页,U=0为系统页(只能被R0级代码访问)
32位页表并没有限定“可执行”属性,所以就出现了缓冲区溢出漏洞,即执行本不具有可执行属性的页,为了改善这个局面,在64位系统,页表进行了扩张,最高位NX位用来表示该页是否具有“可执行”属性,程序在分配堆或者栈空间时将其置1,但由于NX的保护,加壳技术变得困难。Windos XP SP2开始采用NX保护,但是留下了关闭NX保护的开关:在boot.ini中,windows内核启动参数增加/EXECUTE即可关闭NX保护,增加/NOEXECUTE则启用NX保护
8)启用NX保护的windows系统仍然存在漏洞,程序可以利用NTDLL中现成代码(可执行,不受NX保护)来关闭本进程的NX保护。手段是:往栈区增加一个地址,而ret指令就是从栈区弹出地址并跳转到弹出的地址处执行。NX保护开启后,如果这个地址是栈空间或者堆空间,则导致异常,但是如果是一个NTDLL,则可以成功跳转到已加载的可执行DLL空间。原书中关于缓冲区溢出漏洞的代码,由于手头暂没有64位CPU,用windbg调试虚拟机(32位XP SP3)也找不到两个关键函数的地址,所以先跳过。
9)X86系统4个控制寄存器:
a)CR0:之前曾经提过开启保护模式要使用到CR0的PE位(位0),开启分页机制要用到PG位(位31),在代码从R3切换到R0时需要使用到CR0的另一个控制位:WP位(位16),这是一个写保护标志位,为WP = 0表示R0级代码无法写用户只读页,而WP = 1则反之。
b)CR1:保留不用
c)CR2:用来报告错误信息,在异常发生时,用来存放发生页中断(引发异常)时的线性地址,只在页中断处理程序中使用。
d)CR3:在分页机制中也提到过,它用于存放当前进程页目录表的基地址(物理地址)。
10) 调用门:代码从R3切换到R0时,不能让用户程序随意切换,因此出现了调用门,系统在代码切换前先填写一组调用门(R0级),并保证切换后的地址都是操作系统的“系统调用”,来防止普通用户随意切换到自己编写的R0级代码。调用门在GDT中,有2个32位数据,其描述结构如下:
31~16位,段中偏移的31~16位 |
P |
DPL |
0 |
11~8位 类型,必定为1100B |
7~5位 为000 |
4~0位 参数计数 |
31~16位 段选择子 |
15~0位 段中偏移的15~0位 |
11~8位为该描述符的类型,如是段描述符还是任务门,或者调用门(1100B)。
P位指定该描述符是否可用。
DPL表示调用门的特权级。
段选择子指定要访问的代码段。
段中偏移指定在代码段中的入口点偏移。
参数计数是要传递的参数个数(调用门中的函数代码带参数时,须将外层堆栈参数复制进来,参数个数指定了要复制的数量)。
原书作者对调用门讲的很含糊,下面是来自互联网的资料:
a)调用门也是一种描述符,存储段选择子和段偏移,当低权限CPL(当前特权级)代码调用高权限CPL的时候,调用门作为中间层进行间接调用,一旦通过调用门进入后,CPL发生改变,程序完全进入被调用代码的特权级。
b)调用门有一个DPL(特权级),调用代码的CPL的特权级必须不低于DPL才能使用该门。比如说一个DPL = 2的门,那么CPL = 2的调用代码能使用该门调用一个特权级为1或者0的函数代码,而CPL = 3的调用代码无法使用该门。
c)一旦通过调用门进入后,CPL发生改变,程序完全进入被调用代码的特权级。
d)只有CALL 指令可以使用调用门将进程控制转移到一个特权级更高(DPL<CPL )的非一致代码段,而对JMP 指令使用调用门与否是一样的。如果调用特权级更高的非一致目标代码段,CPL 就降为目标代码段的DPL 特权级,并且会发生栈切换。如果调用或者跳转到一个特权级更高的一致目标代码段,CPL 不会发生变化,也不会发生栈切换。
指令 |
访问门的权限 |
访问代码权限 |
CALL |
CPL <= callgate.DPL && RPL <= callgate.DPL |
目标为一致代码段 : destination.DPL <= CPL |
目标为非一致代码段: destination.DPL <= CPL |
||
JMP |
CPL <= callgate.DPL && RPL <= callgate.DPL |
目标为一致代码段 : destination.DPL <= CPL |
目标为非一致代码段: destination.DPL = CPL |
11)早期系统从R3切换到R0采用中断门,XP以后采用快速系统调用sysenter和sysexit来进行系统调用和返回:
a)2者为机器指令,PII以后增加,并非函数,或者系统调用
b)sysenter在R3下运行,跳转到R0(不能*指定跳转到的地址);sysexit在R0下运行,跳转到R3
c)sysenter机器码位0F34H,而sysexit机器码位0F35
跳转地址由windows预先设置,保存在3个寄存器中,如下:
寄存器 |
功能 |
代号 |
SYSENTER_CS_MSR |
保存跳转之后的cs内容 |
174H |
SYSENTER_CS_ESP |
保存调转之后的esp内容(堆栈指针) |
175H |
SYSENTER_CS_EIP |
保存目标地址(跳转后第一条指令地址) |
176H |
在windbg中可使用 “rdmsr 代号”查看3个寄存器中的内容。
cs为段寄存器,保存的是一个段选择子(用于选择GDT中的段描述符),R3不能修改cs。设置目标地址方式为使用wrmsr指令,将cs放入ecx,将目标地址放入eds:eax,如下:
1 mov ecx,0x8 ;windows下段选择子总是为8
2 mov dword pt reds:[eax],08XXXXXX ;固定跳转到某个内核地址
3 wrmsr ;写入
以上代码在R0执行。sysenter与sysexit执行时都要确定段选择子,其值如下:
段选择子 |
功能 |
SYSENTER_CS_MSR(174H) |
用于sysenter执行后的代码段 |
SYSENTER_CS_MSR+8(17CH) |
用于sysenter执行后的堆栈段 |
SYSENTER_CS_MSR+16(185H) |
用于sysexit执行后的代码段 |
SYSENTER_CS_MSR+24(18DH) |
用于sysexit执行后的堆栈段 |
sysenter执行过程:
a)装载SYSENTER_CS_MSR到cs寄存器
b)装载SYSENTER_EIP_MSR到eip寄存器
c)装载SYSENTER_CS_MSR+8到ss寄存器
d)装载SYSENTER_ESP_MSR到esp寄存器
e)切换到R0层
f)清除eflags寄存器中的VM标志
g)执行eip开始的R0例程
sysexit执行过程:
a)装载SYSENTER_CS_MSR+16到cs寄存器
b)将edx值送入eip
c)装载SYSENTER_CS_MSR+24到ss寄存器
d)将ecx送入ESP
e)切换到R3层
f)执行eip处的R3指令
12)打开windbg和虚拟机进入调试模式,输入“bp ntdll!NtReadFile”设置断点(如果提示找不到,则可以先“bp NtReadFile”,在“g”后断下来时,去掉所有断点,重新下断“bp ntdll!NtReadFile”即可;或者使用“bu ntdll!NtReadFile”),输入“g”继续运行,单断下来的时候,可以看到如下代码:
1 ntdll!NtReadFile:
2 001b:7c92d9ce b8b7000000 mov eax,0B7h
3 001b:7c92d9d3 ba0003fe7f mov edx,offset SharedUserData!SystemCallStub (7ffe0300)
4 001b:7c92d9d8 ff12 call dword ptr [edx]
5 001b:7c92d9da c22400 ret 24h
6 001b:7c92d9dd 90 nop
SharedUserData!SystemCallStub为SystemCall存根地址,直接输入“dd SharedUserData!SystemCallStub”或者“dd 7ffe0300”可看到:
1 1: kd> dd SharedUserData!SystemCallStub
2 7ffe0300 7c92e510 7c92e514 00000000 00000000
也就是说,call dword ptr[edx]实际上调用了地址为“7c92e510”的函数,输入“u 7c92e510”或者在Disassembly窗口输入“7c92e510”查看反汇编代码:
1 ntdll!KiFastSystemCall:
2 7c92e510 8bd4 mov edx,esp
3 7c92e512 0f34 sysenter
4 ntdll!KiFastSystemCallRet:
5 7c92e514 c3 ret
终于看到熟悉的影子了:sysenter。在R3层所有试图进入R0的API函数都通过KiFastSystemCall调用,windows实际上做了一张跳转表,在调用任何一个API之前,要先在eax中填入编号(如上,ntdll!NtReadFile编号为0B7H),之后内核代码会自动根据这个编号查找对应的内核API地址进行转换。
KiFastSystemCall只有两行代码,对于sysenter在(11)中已经说明,而第一个MOV指令保存的是用户层的堆栈指针(当前栈顶指针),这里是为了传递参数给内核代码使用,从(11)的sysexit执行过程可以看到,这个值在返回前是要恢复到eip,可见在内核态它sysexit执行前它的值可定被修改。可单步追踪一下(试试步入)sysenter执行前后各个寄存器的改变(注意:以下调试原书并没有,其中各种结果都是我自己猜测和判断):
1 ;执行前(R3,用户态):
2 cs: 1b ;R3代码段段基址(见ntdll!NtReadFile反汇编代码)
3 eip: 7c92e512 ;R3下一条指令地址(sysenter指令所在的地址)
4 ss: 23 ;R3堆栈段段基址
5 esp: 15f814 ;R3栈顶指针
6 eax: b7 ;ntdll!NtReadFile编号
7 edx: 15f814 ;R3堆栈栈顶指针
8 SYSENTER_CS_MSR: msr[174] = 00000000`00000008 ; sysenter执行后的代码段
9 SYSENTER_CS_ESP: msr[175] = 00000000`f88df000 ; sysenter执行后的栈顶指针
10 SYSENTER_CS_EIP: msr[176] = 00000000`80542580 ; sysenter执行的第一条指令
11 SYSENTER_CS_MSR + 8: msr[17c] = 00000000`00000000 ; sysenter执行后的堆栈段
12 SYSENTER_CS_MSR + 16: msr[185] = 00000000`00000000 ; sysexit执行后的代码段
13 SYSENTER_CS_MSR + 24: msr[18d] = 00000000`00000000 ; sysexit执行后的堆栈段
从这里可以看到,将要跳转的地址是“80542580”,在Disassembly窗口输入“80542580”反汇编可以看到如下代码:
1 nt!KiFastCallEntry:
2 001b:80542580 b923000000 mov ecx,23h
3 001b:80542585 6a30 push 30h
4 001b:80542587 0fa1 pop fs
......
这段代码很长,先不做具体分析了吧,总之,现在要明确的是它就是用户态执行sysenter以后转入内核态执行的第一个例程,为了验证是否确实会进入到这,下一个断点“bp nt!KiFastCallEntry”,再单步,将中断下来:
1 nt!KiFastCallEntry:
2 80542580 b923000000 mov ecx,23h
3 80542585 6a30 push 30h
4 ……
可以看到,这的确是刚刚找到的nt!KiFastCallEntry入口。说明现在已经切换到了内核态,再继续查看下各个寄存器(可以验证sysenter做的事情):
1 ;执行起点(R0,内核态):
2 cs: 8 ;R0代码段段基址(这正好验证了(3)中的各个值)
3 eip: 80542580 ;R0下一条指令地址(中断位置:上面标粉的代码地址)
4 ss: 10 ;R0堆栈段段基址(这也正好验证了(3)中的各个值)
5 esp: f88df000 ;R0栈顶指针
6 eax: b7 ;ntdll!NtReadFile编号(由调用门切换内核态该值还是没变)
7 edx: 15f814 ;R3堆栈栈顶指针(由调用门切换内核态该值还是没变)
8 SYSENTER_CS_MSR: msr[174] = 00000000`00000008 ; sysenter执行后的代码段
9 SYSENTER_CS_ESP: msr[175] = 00000000`f88df000 ; sysenter执行后的栈顶指针
10 SYSENTER_CS_EIP: msr[176] = 00000000`80542580 ; sysenter执行的第一条指令
11 SYSENTER_CS_MSR + 8: msr[17c] = 00000000`00000000 ; sysenter执行后的堆栈段
12 SYSENTER_CS_MSR + 16: msr[185] = 00000000`00000000 ; sysexit执行后的代码段
13 SYSENTER_CS_MSR + 24: msr[18d] = 00000000`00000000 ; sysexit执行后的堆栈段
可以看到,这里的各个寄存器如SYSENTER_CS_MSR等的值均未改变。我追踪一直到这个函数返回,还是没看到有改变,所以暂时不知道是在什么时候被改变的。返回以后将到ntdll!NtReadFile的ret 24h指令;此时又切换回了用户态,可见sysexit机器指令已经执行完毕。下面再看下各个寄存器的内容:
1 ;执行后(R3,用户态):
2 cs: 1b ;R3代码段段基址(同执行前)
3 eip: 7c92d9da ;R3下一条指令地址(ntdll!NtReadFile中的ret指令)
4 ss: 23 ;R3堆栈段段基址(同执行前)
5 esp: 15f818 ;R3栈顶指针(同执行前)
6 eax: 0 ;返回值
7 edx: 7c92e514 ;在sysexit中说edx要送入eip,这里不同,说明后面别改写
8 SYSENTER_CS_MSR: msr[174] = 00000000`00000008 ; sysenter执行后的代码段
9 SYSENTER_CS_ESP: msr[175] = 00000000`f88df000 ; sysenter执行后的栈顶指针
10 SYSENTER_CS_EIP: msr[176] = 00000000`80542580 ; sysenter执行的第一条指令
11 SYSENTER_CS_MSR + 8: msr[17c] = 00000000`00000000 ; sysenter执行后的堆栈段
12 SYSENTER_CS_MSR + 16: msr[185] = 00000000`00000000 ; sysexit执行后的代码段
13 SYSENTER_CS_MSR + 24: msr[18d] = 00000000`00000000 ; sysexit执行后的堆栈段
这里其实可以看到,不管是步入函数步过,都看不到sysenter执行过程,它是硬件指令。所以我也没看出来它做了什么,我想它应该是由硬件完成的,而且,返回以后现场恢复得和执行前一样。这里有点奇怪的是SYSENTER_CS_MSR + 8、SYSENTER_CS_MSR + 16、SYSENTER_CS_MSR + 24这3个寄存器的值并没有被改变,然道是我理解错了,它们的代号不是简单的进行相加?还是说sysexit执行前对它们赋值,而返回后又被清零,而我没有追踪到?这里先存一个问号。
从上面基本可以清晰的看到R3到R0再到R3的切换过程,sysexit暂时没有追踪到,这个以后有时间再来摸索一次。