要理解该漏洞的成因,最重要的是要理解函数执行细节。简单来说,由于程序使用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的栈空间,导致覆盖掉返回地址产生漏洞。
接下来通过调试器来观察数据的保存,首先通过如下代码查看正常情况下,也就是输入数据的长度小于0x8的时候,数据是如何保存的。
将程序运行到strcpy函数调用前,此时ecx保存的就是要赋值的目标字符串的地址,可以看到在赋值前,该数组的元素都是0。紧邻这个数组后面的栈地址,所保存的就是原ebp以及返回地址。
执行完strcpy函数以后,数组中的元素都变成了0x41,也就是字符'A'对应的asscill码值。
紧跟该数组保存的就是原ebp和返回地址的值。因为输入数据的长度没有超过数据szStr的大小(0x8),因此,原ebp和返回地址并没有被覆盖掉,函数可以正常返回到调用函数的指令的下一条指令开始正常运行。
但是,如果此时输入数据的长度超过局部变量数组szStr的长度(0x8)的话,输入数据就会将原ebp以及返回地址覆盖掉。接下来,将输入数据的长度修改为0x10,这样就可以刚好覆盖掉原ebp和返回地址。
此时,调用完strcpy以后,可以看到输入数据将原ebp以及返回地址全部覆盖掉了,修改成了字符'A'对应的asscill值。
函数继续运行,在运行retn指令返回函数前,可以看到esp所指向的栈地址中保存的返回地址被修改成了0x41414141。
接着执行retn指令,程序就会将eip修改为0x41414141。
由于0x41414141这个地址没有保存合法的指令,因此程序会抛出异常。
由上内容可以知道,可以通过控制输入数据的长度和数值实现对返回地址的修改。这样,当函数执行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需要是测试的机器上的地址,这个可以使用调试器获取。
最终完成漏洞利用的代码如下:
编译好程序以后,首先查看当strcpy运行完时的栈中数据可以看到,此时的原ebp和返回地址已经被覆盖掉,返回地址修改为了ntdll.dll中的地址。
当执行retn指令的时候,此时的栈顶保存的就是ntdll.dll中的该地址。
因此,继续执行retn指令,就会跳转到ntdll.dll中的地址执行,而该地址保存的指令就是jmp esp。且此时的esp进行了+4的操作,所以此时的esp,就是紧跟在输入数据中返回地址后的ShellCode。
因此,继续执行jmp esp指令,就会让程序跳转到在栈中保存的ShellCode执行。
继续执行ShellCode就会弹窗后退出函数。
为了缓解栈溢出漏洞带来的问题,微软提供了如下的内存保护措施:
增加了对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即异常处理结构体,它是Windows异常处理机制所采用的重要数据结构。每个SEH包含两个DWORD指针:SEH链表指针和异常处理函数句柄,共八字节,如下图所示:
SEH的结构体是保存在系统栈中的,栈中一般会同时存在多个SEH。这些SEH会通过链表指针由栈顶向栈底串成单项链表,位于链表最顶端的SEH通过TEB偏移为0字节所保存的指针标识,如下图所示。
当异常发生时,操作系统会中断程序,并首先从TEB的0字节偏移处取出距离栈顶最近的SEH,使用异常处理函数句柄所指向的代码来处理异常。当离“事故现场”最近的异常处理函数运行失败时,将顺着SEH链表以此尝试其他的异常处理函数。如果程序安装的所有异常处理函数都不能处理,系统将采用默认的异常处理函数。通常,这个函数会弹出一个错误对话框,然后强制关闭程序。
由于SEH是存放在栈中的,因此如果数据溢出缓冲区,那么就很有可能会淹没掉SEH。以下就是利用SEH来产生攻击的步骤:
精心制造的溢出数据可以把SEH中异常处理函数的入口地址更改为shellcode的起始地址
溢出后错误的栈往往会触发异常
当Windows开始处理溢出后的异常时,会错误地把shellcode当作异常处理函数而执行
接下来依然使用上面有栈溢出漏洞的test函数作为测试,但是此时需要在栈中注册一个结构化异常处理器。注册的方式也很简单,只要在栈中保存一份SEH结构体即可,且异常处理函数指针指向的函数满足如下的格式:
因此对于函数的调用,要改成如下的代码:
由于要覆盖的是异常处理函数地址,所以要计算test函数中的局部变量具体SEH结构的偏移,这样才可以构造足够长度的输入数据来覆盖第一个异常处理函数之前的栈空间,然后才可以覆盖掉异常处理函数的地址。
因此首先要在调试器中中断到test函数的strcpy函数的调用处。
可以看到此时局部变量的保存地址是0x12FE00,异常处理函数的保存地址是0x12FE18。因此,局部变量地址距离SEH结构的地址相差0x18,首先就需要对这0x18大小的栈空间进行覆盖,随后在的4字节覆盖的就是异常处理函数的地址,可以将其覆盖为shellcode的地址,这样程序出现异常的时候就会跳转到shellcode的地址继续执行。据此,可以写出如下的漏洞利用代码:
在调试器中可以看到,当test函数执行完strcpy以后,SEH结构被覆盖掉,此时异常处理函数指向了shellcode的地址
程序继续向下运行,由于返回地址被修改会0x41414141,所以执行retn指令会出现异常。在处理异常的过程中,就会执行shellcode。
在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机制。
由于当异常处理函数指向堆中的内存地址的时候,不会触发SafeSEH机制。因此,可以通过将ShellCode复制到堆中,同时将异常处理函数覆盖为保存了ShellCode的堆地址的方式来绕过SafeSEH机制,触发漏洞。
此时的漏洞利用代码如下:
此时运行程序,则ShellCode就会顺利执行。
当异常处理函数指向的地址在未开启SafeSEH模块的时候,也可以突破SafeSEH机制。如下图所示,此时的SEH_NoSafeSEH_JUMP.dll没有开启SafeSEH。那就可以尝试从该模块中查找可以修改eip执行的指令,将异常处理函数的地址修改为该指令的地址,就可以实现对程序的劫持。
在该模块中的0x11121012和0x11121015都有pop + retn组合的指令,这样的组合可以控制程序的运行。接下来用以下代码查看运行的细节:
运行程序以后,使用调试器对其进行附加,在程序执行完strcpy的时候可以看到异常处理函数地址已经被修改为未开启SafeSEH的模块的地址
在该地址下断点以后,继续运行程序,可以看到程序成功跳转到该处执行。此时已经证明,通过将异常处理函数地址修改为未开启SafeSEH模块的地址是可以绕过SafeSEH。但是此时的esp的值变得过小(和局部变量szStr相差-0x3C0),导致漏洞难以利用,就没有再进一步尝试执行ShellCode
一个进程会以共享的方式打开多个其他文件,此时保存这些文件内容的内存的类型是Map类型,如下图所示。SafeSEH是无视它们的,当异常处理函数指针指向的是这些地址范围内,是不对其进行有效性验证的。因此,可以通过在这些模块中查找跳转指令,将指令地址覆盖给异常处理函数,就可以绕过SafeSEH。
基本上做法和上面的差不多,只不过这次换成了用共享内存的方式加载的其他模块中,然后问题也是同样的(esp太小),不好利用,就不继续了。
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安全机制,在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开始,就引入了一个新的安全标识符
如下所示,可以通过该标识让不符合GS保护条件的函数添加GS保护
除了在返回地址前面添加Security Cookie外,在Visual Studio 2005及以后的版本中,还是用了变量重排技术,在编译时根据局部变量的类型对变量在栈帧中的位置进行调整,将字符串变量移动到栈帧的高地址。这样可以防止该字符串溢出时破坏其他的局部变量。同时,还会将指针参数和字符串参数赋值到内存中低地址,防止函数参数被破坏
如下图所示,在不启用GS的时候,如果变量Buff发生溢出变量i,返回地址,函数参数arg等都会被覆盖,而启用GS后,变量Buff被重新调整到栈帧的高地址,因此当Buff溢出时不会影响变量i的值,虽然函数参数arg还是会被覆盖,但由于程序会在栈帧低地址处保存参数的副本,所以Buff的溢出也不会影响到传递进来的函数参数。
对于上面存在漏洞的test函数,当它在开启了GS保护的编译器中编译出来的程序会如下所示,其中与未开启GS保护时候产生的代码的不同之处已用注释标识出来。
由上内容可知,Security Cookie产生的细节如下:
系统以.data节的第一个双子作为Cookie的种子,或称原始Cookie(所有函数的Cookie都是用这个DWORD生成)
在程序每次运行时Cookie的种子都不同,因此种子有很强的随机性
在栈帧初始化以后系统用EBP异或种子,作为当前函数的Cookie,以此作为不同函数之间的区别,并增加Cookie的随机性
在函数返回前,用EBP还原出(异或)Cookie的种子
由此可以知道,想要突破GS保护,需要同时对保存在.data中的Cookie和保存在栈中的Cookie进行修改。
考虑如下代码,此时的buf指针会指向一个堆空间,参数i因为是个有符号整型,因此当它为负数的时候依然会进入到if语句中,此时就可以通过计算堆变量的地址与.data节中保存的Security Cookie的地址来得出i值应该如何输入可以改变.data中的Security Cookie。
经过调试器验证发现,申请的堆变量地址为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的地址。最终完整的漏洞利用代码如下:
编译后程序后,在调试器中strcpy函数后面下断点,可以看到此时.data中的Security Cookie已经被成功修改为0x90909090,栈中的Security Cookie和返回地址也都被成功覆盖。
继续运行程序,可以看到在Security Check函数运行前,ecx的值已经变成0x90909090,因此此时不会触发GS保护。
[招生]科锐逆向工程师培训(2024年11月15日实地,远程教学同时开班, 第51期)
最后于 2022-12-28 16:13
被1900编辑
,原因: