在研究HOOK API/PE INJECT等技术的时候经常会碰到需要将某个特性关闭的, 比如/RTCs, /INCREMENTAL等等. 究其原因很简单, 编译器在编译/链接的时候添加了一些我们不希望的代码, 而这些代码也正是我们在反汇编中经常碰到的. 为了方便今后的工作, 我想我们有必要找出这些隐藏在C/C++代码后面的东西, 看看vc在背后到底为我们做了哪些工作. 在本文中涉及到的vc特性有/RTC(c,u,s)、/GS、/INCREMENTAL.
RTC 表示运行时检查。RTC 有若干子选项。/RTC 设计用于调试版本,而不用于优化代码。当运行时检查失败的时候会又系统对话框提示,如果您不喜欢默认对话框,可以编写自己的处理程序。
char ch = 0; short s = 0x101; ch = s;
当运行到ch = s的时候就会有错误,因为加了RTCc之后系统认为ch = s这个转化会发生截断,我们看汇编:
00E31000 55 PUSH EBP // Main 00E31001 8BEC MOV EBP,ESP 00E31003 83EC 08 SUB ESP,8 00E31006 C645 FB 00 MOV BYTE PTR SS:[EBP-5],0 // ch = 0 00E3100A B8 01010000 MOV EAX,101 00E3100F 66:8945 FC MOV WORD PTR SS:[EBP-4],AX // s = 101 00E31013 8B4D FC MOV ECX,DWORD PTR SS:[EBP-4] // ECX = s 00E31016 E8 09000000 CALL RTCc.00E31024 // 在这里判断s是否会发生截断 00E3101B 8845 FB MOV BYTE PTR SS:[EBP-5],AL // ch = AL, 到这里说明没有截断发生 00E3101E 33C0 XOR EAX,EAX 00E31020 8BE5 MOV ESP,EBP 00E31022 5D POP EBP 00E31023 C3 RETN RTCc.00E31024: 00E31024 8BFF MOV EDI,EDI 00E31026 55 PUSH EBP 00E31027 8BEC MOV EBP,ESP 00E31029 53 PUSH EBX 00E3102A 8BD9 MOV EBX,ECX // ECX保存了s 00E3102C 8BC3 MOV EAX,EBX 00E3102E B9 00FF0000 MOV ECX,0FF00 00E31033 23C1 AND EAX,ECX // s = s & 0FF00, 00E31035 74 10 JE SHORT RTCc.00E31047 // 如果s==0 --> 0<=s<128, 无截断 00E31037 3BC1 CMP EAX,ECX // CMP s,0FF00 注:s=s & 0FF00 00E31039 74 0C JE SHORT RTCc.00E31047 // 如果s==0 --> -128<=s<0, 无截断 00E3103B 6A 01 PUSH 1 00E3103D FF75 04 PUSH DWORD PTR SS:[EBP+4] 00E31040 E8 53040000 CALL RTCc.00E31498 // 如果之前的两种情况都不符合说明发生了截断, 跳转到相应的处理函数 00E31045 59 POP ECX 00E31046 59 POP ECX 00E31047 8AC3 MOV AL,BL 00E31049 5B POP EBX 00E3104A 5D POP EBP 00E3104B C3 RETN
注释已经写的比较清楚了,不多解释。如果想避免这样的情况发生:ch = (char)(s & 0FF00)
我们还是通过一个例子:
int t; char ch; cin >> ch; if(ch == 'o') t = 10; cout << t;
当ch != 'o'的时候就会发生一个错误,因为我们使用了未初始化的变量。我们来看一下编译器是如何做到的:
012A1000 55 PUSH EBP 012A1001 8BEC MOV EBP,ESP 012A1003 83EC 0C SUB ESP,0C 012A1006 C645 F7 00 MOV BYTE PTR SS:[EBP-9],0 // 为什么是9而不是8...多分配了一个字节用于记录t的使用情况... 012A100A 8D45 FF LEA EAX,DWORD PTR SS:[EBP-1] ///////////////////// 012A100D 50 PUSH EAX // 012A100E 68 F8182C01 PUSH RTCu.012C18F8 // 这5行调用cin >> ch 012A1013 E8 780A0000 CALL RTCu.012A1A90 // 012A1018 83C4 08 ADD ESP,8 ////////////// ////// 012A101B 0FBE4D FF MOVSX ECX,BYTE PTR SS:[EBP-1] 012A101F 83F9 6F CMP ECX,6F // if(ch == 'y') 012A1022 75 0B JNZ SHORT RTCu.012A102F 012A1024 C645 F7 01 MOV BYTE PTR SS:[EBP-9],1 // 注意这里:在t=10之前插入了这么一句, 将多分配的那个字节赋1 012A1028 C745 F8 0A00000 MOV DWORD PTR SS:[EBP-8],0A // t = 10 012A102F 807D F7 00 CMP BYTE PTR SS:[EBP-9],0 // 注意这里:在使用t之前做判断, 是否用于记录的字节是0 012A1033 75 0D JNZ SHORT RTCu.012A1042 // 如果不是0说明t已经经过赋值可以使用 012A1035 68 56102A01 PUSH RTCu.012A1056 ///////////////// 012A103A E8 CC640000 CALL RTCu.012A750B // 调用错误处理函数 012A103F 83C4 04 ADD ESP,4 ///////////////// 012A1042 8B55 F8 MOV EDX,DWORD PTR SS:[EBP-8] 012A1045 52 PUSH EDX 012A1046 B9 40182C01 MOV ECX,RTCu.012C1840 012A104B E8 10000000 CALL RTCu.012A1060 // cout< 在Debug模式下我们经常会看到这样的代码: 010D1007 8D7D C4 LEA EDI,DWORD PTR SS:[EBP-3C] 010D100A B9 0F000000 MOV ECX,0F 010D100F B8 CCCCCCCC MOV EAX,CCCCCCCC 010D1014 F3:AB REP STOS DWORD PTR ES:[EDI] 这个代码其实就是在将栈上所有的局部变量全部初始化为CCCCCCCC, 这个汇编代码对应的源代码很简单: int t[10]; 那么为什么要用CCCCCCCC呢? CC其实就是int 3对应的指令,万一代码出错执行到这里就会引发一个异常。 我们通过下面的例子分析一下编译器是如何实现的: void func(int i) { _asm{} } void main() { func(1); } 汇编代码如下: 013C1000 55 PUSH EBP // func() 013C1001 8BEC MOV EBP,ESP 013C1003 3BEC CMP EBP,ESP // 检查EBP,ESP是否相等 013C1005 E8 1E000000 CALL RTCs.013C1028 // 如果不等, __RTC_CheckEsp错误处理 013C100A 5D POP EBP 013C100B C3 RETN 013C100C CC INT3 013C100D CC INT3 013C100E CC INT3 013C100F CC INT3 013C1010 55 PUSH EBP // main() 013C1011 8BEC MOV EBP,ESP 013C1013 6A 01 PUSH 1 013C1015 E8 E6FFFFFF CALL RTCs.013C1000 013C101A 83C4 04 ADD ESP,4 013C101D 33C0 XOR EAX,EAX 013C101F 3BEC CMP EBP,ESP // 检查EBP,ESP是否相等 013C1021 E8 02000000 CALL RTCs.013C1028 // 如果不等, __RTC_CheckEsp错误处理 013C1026 5D POP EBP 013C1027 C3 RETN 我们看到, 在这个例子中有两个地方对堆栈进行了检查, 分别是func和main函数返回之前. 为什么在func中有一条_asm{}却没有任何代码呢?原因是并不是任何函数在返回前都会进行检查, 据我观察如果一个函数没有内嵌汇编或者没有其他函数的调用那么就不进行堆栈检查. 而一条空的_asm{}足以让他进行堆栈检查. 如果我们使用了RTCs以后, 在Debug模式下这段代码就会弹出一个对话框告诉我们: Run-Time Check Failure #2 - Stack around the variable 'arr' was corrupted. 我们还是来看汇编吧: void test() { 012B1390 push ebp 012B1391 mov ebp,esp 012B1393 sub esp,0ECh 012B1399 push ebx 012B139A push esi 012B139B push edi 012B139C lea edi,[ebp-0ECh] ///////////////////////// 012B13A2 mov ecx,3Bh // 初始化为CCCCCCCC 012B13A7 mov eax,0CCCCCCCCh // 012B13AC rep stos dword ptr es:[edi] ///////////////////////// 012B13AE mov eax,dword ptr [___security_cookie (12B7000h)] ///////////////////////////////////////// 012B13B3 xor eax,ebp // 暂时不关心, 跟下一个主题/GS相关 012B13B5 mov dword ptr [ebp-4],eax ///////////////////////////////////////// int t1 = 0x11111111; 012B13B8 mov dword ptr [ebp-0Ch],11111111h ////////////////////////////// char arr[6] = { 0 }; // 012B13BF mov byte ptr [ebp-1Ch],0 // ************************** 012B13C3 xor eax,eax // t1,arr,t2初始化 012B13C5 mov dword ptr [ebp-1Bh],eax // 注意t1,arr,t2的内存分布情况 012B13C8 mov byte ptr [ebp-17h],al // ************************** int t2 = 0x22222222; // 012B13CB mov dword ptr [ebp-28h],22222222h ////////////////////////////// strcpy(arr, "OverFlow" ); 012B13D2 push offset string "OverFlow" (12B573Ch) ///////////////////////// 012B13D7 lea eax,[ebp-1Ch] // 012B13DA push eax // 调用strcpy 012B13DB call @ILT+155(_strcpy) (12B10A0h) // 012B13E0 add esp,8 ///////////////////////// } 012B13E3 push edx /////////////////////////// 012B13E4 mov ecx,ebp // ********************* // 012B13E6 push eax // 这里是我们需要研究的 // 012B13E7 lea edx,[ (12B1414h)] // ********************* // 012B13ED call @ILT+125(@_RTC_CheckStackVars@8) (12B1082h) /////////////////////////// 012B13F2 pop eax 012B13F3 pop edx 012B13F4 pop edi 012B13F5 pop esi 012B13F6 pop ebx 012B13F7 mov ecx,dword ptr [ebp-4] ///////////////////////////////////////// 012B13FA xor ecx,ebp // 暂时不关心, 跟下一个主题/GS相关 012B13FC call @ILT+15(@__security_check_cookie@4) (12B1014h) ///////////////////////////////////////// 012B1401 add esp,0ECh ///////////////////// 012B1407 cmp ebp,esp // 堆栈检查 012B1409 call @ILT+305(__RTC_CheckEsp) (12B1136h) ///////////////////// 012B140E mov esp,ebp 012B1410 pop ebp 012B1411 ret 再研究_RTC_CheckStackVars之前我们有必要研究一下t1,arr,t2的变量分布情况: cc cc cc cc 22 22 22 22 cc cc cc cc cc cc cc cc 00 00 00 00 00 00 cc cc cc cc cc cc cc cc cc cc 11 11 11 11 cc cc cc cc 黄颜色标注的地方用于4字节对齐. 红颜色标注的表示跟没有使用RTCs不同的地方:局部变量并不是连续存放的, 在每一个局部变量前后都会有4个字节的填充, 这些额外填充的变量将在之后的_RTC_CheckStackVars中起到关键作用. 其实我们也大概可以猜到, 在_RTC_CheckStackVars中只要判断这个数组的前后四个字节是否依旧是CCCCCCCC就可以知道是不是越界访问了. _RTC_CheckStackVars的实现稍有复杂, 我把它转化成伪代码后供大家参考: typedef struct _RTC_vardesc { int addr; // 数组首地址相对EBP的偏移 int size; // 数组大小 char* name; // 数组名字 } _RTC_vardesc; typedef struct _RTC_framedesc { int varCount; // 数组个数 _RTC_vardesc* variables; } _RTC_framedesc; void __fastcall _RTC_CheckStackVars(void *_Esp, _RTC_framedesc *_Fd) // 注意: __fastcall - 参数由寄存器传送 { if ( _Fd->varCount == 0 ) // 没有局部数组变量 return; int i = 0; while ( i < _Fd->varCount ) // 遍历每个数组信息 { char* pAddr = ( char* )_Esp + _Fd->variables[i].addr - 4; // pAddr指向数组后四个字节, 位于堆栈偏低地址 if ( *( int* )pAddr != 0xcccccccc ) _RTC_StackFailure( _RetAddr, _Fd->variables[i].name ); int ofs = _Fd->variables[i].addr + _Fd->variables[i].size; pAddr = ( char* )_Esp + ofs; // pAddr指向数组前四个字节, 位于堆栈偏高地址 if ( *( int* )pAddr != 0xcccccccc ) _RTC_StackFailure( _RetAddr, _Fd->variables[i].name ); ++i; } } 基本上这个函数的实现我们已经了解, 但是对第二个参数似乎还不是很明白, 我们再回头看一下_RTC_CheckStackVars是怎么被调用的: 012B13E4 mov ecx,ebp // 第一个参数, 保存了ebp 012B13E6 push eax 012B13E7 lea edx,[ (12B1414h)] // 第二个参数, 12B1414紧跟test函数之后, 是什么呢? 012B13ED call @ILT+125(@_RTC_CheckStackVars@8) (12B1082h) 看完下面数据, 我们应该很清楚_RTC_CheckStackVars的来龙去脉了: 0x00EB1414: 01 00 00 00 1c 14 eb 00 varCount = 1 variables = 0x00EB141C(见下面) 0x00EB141C: e4 ff ff ff 06 00 00 00 28 14 eb 00 addr = -28 size = 6 name = 00EB1428(见下面) 0x00EB1428: 61 72 72 00 "arr" 所有的这一切都是编译器在编译的时候悄无声息的安插在我们的代码中! 如果使用 /GS 进行编译,将在程序中插入代码,以检测可能覆盖函数返回地址的缓冲区溢出。如果发生了缓冲区溢出,系统将向用户显示一个警告对话框,然后终止程序。这样,攻击者将无法控制应用程序。用户也可以编写自定义的错误处理例程,以代替默认对话框来处理错误。 在返回地址之前将插入一个专门的 cookie(系列字节),以使得任何缓冲区溢出都将更改该 cookie。在函数返回之前,将测试 cookie 的值。如果 cookie 值已被更改,将会调用处理程序。 此 cookie 由 C 运行库在程序启动时生成,攻击者将无法知晓 cookie 值,并且在每次运行程序时,该值都不相同。 此编译器选项适用于已发布的代码。 其实我们在之前的例子中已经接触到了, 我们删掉不相关的代码: 012B1390 push ebp 012B1391 mov ebp,esp 012B1393 sub esp,0ECh ...... 012B13AE mov eax,dword ptr [___security_cookie (12B7000h)] 012B13B3 xor eax,ebp 012B13B5 mov dword ptr [ebp-4],eax ...... 012B13F7 mov ecx,dword ptr [ebp-4] 012B13FA xor ecx,ebp 012B13FC call @ILT+15(@__security_check_cookie@4) (12B1014h) ....... 012B140E mov esp,ebp 012B1410 pop ebp 012B1411 ret 其实大概的思想我们通过这段短短的代码已经知晓, 将一个特殊的数值保存ebp - 4的位置也就是所有局部变量之前. 然后在函数退出之前判断该数值有没有被修改, 如果被修改了就意味着返回地址遭到破坏, 报错. 那么有几个问题值得我们思考: 1. 这个特殊的数值是哪里来的? 2. 为什么要拿这个数值跟ebp异或一下? 我们就顺着这两个问题研究一下. 首先我们找到的是如下定义: #ifdef _WIN64 #define DEFAULT_SECURITY_COOKIE 0x00002B992DDFA232 #else #define DEFAULT_SECURITY_COOKIE 0xBB40E64E #endif DECLSPEC_SELECTANY UINT_PTR __security_cookie = DEFAULT_SECURITY_COOKIE; DECLSPEC_SELECTANY UINT_PTR __security_cookie_complement = ~(DEFAULT_SECURITY_COOKIE); 从名字上分析似乎跟我们讨论的东西相近, 都是security_cookie相关的, 难道就是这个数值保存在ebp-4中么?当然要不是, 要不然hack起来毫无难度, 由此可见真正写入ebp-4的数值应该是随机的, 通过某种特殊的算法动态生成. 接下来我们有找到了这个函数: int mainCRTStartup( void ) { /* * The /GS security cookie must be initialized before any exception * handling targetting the current image is registered. No function * using exception handling can be called in the current image until * after __security_init_cookie has been called. */ __security_init_cookie(); return __tmainCRTStartup(); } void __cdecl __security_init_cookie() { UINT_PTR cookie; FT systime={0}; LARGE_INTEGER perfctr; GetSystemTimeAsFileTime(&systime.ft_struct); cookie = systime.ft_struct.dwLowDateTime; cookie ^= systime.ft_struct.dwHighDateTime; cookie ^= GetCurrentProcessId(); cookie ^= GetCurrentThreadId(); cookie ^= GetTickCount(); QueryPerformanceCounter(&perfctr); cookie ^= perfctr.LowPart; cookie ^= perfctr.HighPart; __security_cookie = cookie; __security_cookie_complement = ~cookie; } 在这里我们看到在mainCRTStartup最开始调用了一个叫__security_init_cookie的函数, 这个正是之前我们讨论的算法, 也就是产生随机数的地方. 这里可以看出来, 为了取得好的随机性, 先是取出时间, 异或之, 然后是分别跟其他一些列具有随机性的数据(进程ID, 线程ID, TickCount和性能计数器)进行异或运算. 这个变量是全局的, 不会再改变。如果想要查看这个cookie变量的值. 可以再调试的时候拉出"即时窗口(Immediate)"在里面输入__security_cookie回车就能看到了. 但是在汇编代码中我们看到并不是直接将这个数值写到了ebp-4中, 而是于ebp异或以后再保存到ebp-4中, 这样做有什么好处? 1. 于ebp异或已经可以保证每个函数的cookie变量都是随机的, 更好的保证了随机性. 2. 因为ebp的值也是不允许变的, 通过与ebp异或我们还能检查ebp是否被破坏, 一举两得! 到此为止我们已经把vc运行时检查相关的内容介绍了一遍, 在结束本文之前, 我还想对另外一个vc特性提一下: 增量链接. 尽管这个特性跟运行时检查没有关系, 但是因为最近研究的一些东西经常跟这个特性有关. 那么在研究微软的实现细节之前, 我们先了解一下关于增量链接的基础知识: 1. 默认情况下增量链接是打开的, 如果您确定您不需要这个特性, 请设置为/INCREMENTAL:NO 2. 使用增量链接与为什么增量链接对程序的功能性没有影响. 3. 使用增量链接的目的是为了加快链接的速度, 也就是为开发人员提供的. 4. 使用了增量链接之后, 程序的大小会增加, 因为会有代码和数据的填充. 5. 为了确保最终发布版本不包含填充或 thunk,请非增量链接您的程序. 接下来我们还是通过一个简单的例子观察一下使用增量链接到底给我们带来了什么. void test1(){} void test2(){} int main() { test1(); test2(); } 这个例子再简单不过, 我们先来看看不使用增量链接时的情况: ////////////////////////////////////////////// 01331000 push ebp // test1 01331001 mov ebp,esp ...... 01331021 mov esp,ebp 01331023 pop ebp 01331024 ret ////////////////////////////////////////////// 01331030 push ebp // test2 01331031 mov ebp,esp ...... 01331051 mov esp,ebp 01331053 pop ebp 01331054 ret ///////////////////////////////////////////// 01331060 push ebp // main 01331061 mov ebp,esp ...... 0133107E call test1 (1331000h) 01331083 call test2 (1331030h) ...... 0133109A mov esp,ebp 0133109C pop ebp 0133109D ret 这个不需要多解释, 简单的说就是该干嘛干嘛. 现在让我们看一下使用增量链接的情况: ////////////////////////////////////////////////////////////////////// ...... 008F1041 jmp test1 (8F1390h) // jmp table 008F1046 jmp GetModuleFileNameW (8F349Ch) 008F104B jmp __security_init_cookie (8F2890h) ...... 008F1109 jmp mainCRTStartup (8F17C0h) 008F110E jmp test2 (8F13C0h) 008F1113 jmp __CxxUnhandledExceptionFilter (8F2570h) ...... /////////////////////////////////////////////////////////////////////// 008F1390 push ebp // test1 008F1391 mov ebp,esp ...... 008F13B1 mov esp,ebp 008F13B3 pop ebp 008F13B4 ret /////////////////////////////////////////////////////////////////////// 008F13C0 push ebp // test2 008F13C1 mov ebp,esp ...... 008F13E1 mov esp,ebp 008F13E3 pop ebp 008F13E4 ret ////////////////////////////////////////////////////////////////////// 008F13F0 push ebp // main 008F13F1 mov ebp,esp ...... 008F140E call test1 (8F1041h) 008F1413 call test2 (8F110Eh) ...... 008F142A mov esp,ebp 008F142C pop ebp 008F142D ret 使用增量链接以后, 当我们调用一个函数的时候并没有跳转的这个函数真正的地址去执行, 而是在内部维护了一个jmp table. 通过这个jmp table再jmp到真正的函数地址. 那么为什么说链接的速度加快了?我们先来考虑一下正常情况下链接的过程: 比如有两个函数a(),b(), 在release版本下面为了使程序尽量紧凑, 这两个函数会连续的分布在代码段, 所谓连续就是说这两个函数之间没有空隙. 现在假设a的入口0x0400, 长度100. 这样b的入口就是400+100=500. 由于在开发过程中会不断地修改, 假设经过某一次修改以后a的大小变为150, 那么b的入口点也要往后挪50个字节, 也就是550. 这样程序中所有调用b的地方我们都需要做相应的修改, 由于这样的地方可能会很多, link的时间也会增加. 在Debug模式下, 情况略有不同, 在每一个函数后面都会相应地加上一些padding, 也就是最常见的CCCCCCCC(int 3). 加上这些padding以后, 之前说的情况有所缓解, 但是如果函数增加的内容超过了padding, 这时增量链接就发挥了作用. 增量链接把所有的函数调用集中到一个表里(ILT, 在debug的时候我们经常看到的@ILT+305(...) 就是源自这个表), 这样我们就不需要把每个调用的地方一一修改, 唯一需要修改的就是ILT中对应的函数地址, 因为所有对该函数的调用都要通过到这个表. 启用增量链接以后有一个问题需要注意: 函数的地址不是真正的地址了!这个问题尤其是在研究Hook API/CreateRemoteThread等东东的时候尤其值得注意. 看下面的例子: static DWORD WINAPI ThreadFunc (INJDATA *pData) { pData->fnSendMessage( pData->hwnd, WM_GETTEXT, // Get password sizeof(pData->psText), (LPARAM)pData->psText ); return 0; } // 该函数在ThreadFunc之后标记内存地址, 并且我们假设这两个函数紧挨着. // int cbCodeSize = (PBYTE) AfterThreadFunc - (PBYTE) ThreadFunc. static void AfterThreadFunc (void) { } ThreadFunc即将被我们写入另外的进程中. 如果启用了增量链接以后我们写入的不是真正ThreadFunc的内容, 而是ILT中从jmp ThreadFunc开始的大小为cbCodeSize个字节的内容. 虽然基本上增量链接的知识都了解了, 但是我不得不承认还有一个问题我没搞明白. 假设有10个CPP, 已经编译出10个obj和最终的exe文件. 现在我们修改了其中一个CPP中的一个函数, 编译以后一个新的obj就生成了(原exe文件还存在). 这个时候链接跟exe不存在的时候链接有什么区别?为了使增量链接有效果, 肯定不是从零生成一个exe, 而是对原有的exe进行一些修改. 但是这个过程我不了解, 我不知道链接器是如何对现有exe进行更新的. 如果有朋友明白这个过程, 请务必指点. 感激不尽! 参考: http://msdn.microsoft.com/en-us/aa289171(zh-cn,VS.71).aspx/RTCs - 此选项在保护堆栈不被破坏方面采取了若干措施
在每次调用函数时,将所有局部变量初始化为非零值。这样可以防止以前的调用对堆栈中的值的无意使用。
验证堆栈指针能够检查到堆栈破坏,例如,在一个位置将函数定义为 __stdcall,而在另一个位置将函数定义为 __cdecl 可导致堆栈破坏。
检测局部变量的溢出和不足。这与 /GS 不同,因为它仅适用于调试版本,并且检测缓冲区的两端以及所有缓冲区是否遭到破坏。
#include
/GS -- 缓冲区安全性检查
/INCREMENTAL(增量链接)