亡羊补牢:SafeSEH
[TOC]
SafeSEH 对异常处理的保护原理
SafeSEH的原理
在程序调用异常处理函数前,对要调用的异常处理函数进行一系列的有效性校验,当发现异常处理函数不可靠时终止异常处理函数的调用。SafeSEH 需要操作系统和编译器的双重支持,二者缺一都会降低 SafeSEH 的保护能力。
编译器在SafeSEH 机制中所做的工作
启用/SafeSEH 链接选项后,编译器在编译程序的时候将程序所有的异常处理函数地址提取出来,编入一张安全 S.E.H 表,并将这张表放到程序的映像中。当程序调用异常处理函数的时候,会将函数地址与安全 S.E.H 表进行匹配,检查调用的异常处理函数是否位于安全 S.E.H 表中。
比较同一段代码在 VC++ 6. 0 (没有SafeSEH机制)和 VS 2008(有SafeSEH机制)分别编译后安全 S.E.H 表的区别。VS 2008 在编译程序时将程序中的异常处理函数的地址提取出来放到安全 S.E.H 表中。
操作系统在SafeSEH机制中的作用
通过前面(文章《形形色色的内存攻击技术》中)对S.E.H的的介绍,我们知道异常处理函数的调用是通过 RtlDispatchException()函数处理实现的,SafeSEH机制也是从这里开始的。
保护措施:
(1)检查异常处理链是否位于当前程序的栈中,如下图所示。如果不在当前栈中,程序将终止异常处理函数的调用。
(2)检查异常处理函数的指针是否指向当前程序的栈中,如上图所示。如果指向当前栈中,程序将终止异常处理函数的调用。
(3)在前面两项检查都通过后,程序调用 RtlIsValidHandler(),来对异常处理函数的有效性进行验证。
RtlIsValidHandler() 都做了哪些工作呢?
首先,该函数判断异常处理函数地址是不是在加载模块的内存空间,如果属于加载模块的内存空间,校验函数将依次进行如下校验。
( 1)判断程序是否设置了 IMAGE_DLLCHARACTERISTICS_NO_SEH 标识。如果设置了这个标识,这个程序内的异常会被忽略,函数直接返回校验失败。 ( 2)检测程序是否包含安全 S.E.H 表。如果程序包含安全 S.E.H 表,则将当前的异常处理函数地址与该表进行匹配,匹配成功则返回校验成功,匹配失败则返回校验失败。 ( 3)判断程序是否设置 ILonly 标识。如果设置了这个标识,说明该程序只包含.NET 编译人中间语言,函数直接返回校验失败。 ( 4)判断异常处理函数地址是否位于不可执行页( non-executable page)上。当异常处理函数地址位于不可执行页上时,校验函数将检测 DEP 是否开启,如果系统未开启 DEP 则返回校验成功,否则程序抛出访问违例的异常。
如果异常处理函数的地址没有包含在加载模块的内存空间,校验函数将直接进行 DEP 相关检测,函数依次进行如下校验。 ( 1)判断异常处理函数地址是否位于不可执行页( non-executable page)上。当异常处理函数地址位于不可执行页上时,校验函数将检测 DEP 是否开启,如果系统未开启 DEP 则返回校验成功,否则程序抛出访问违例的异常。 ( 2)判断系统是否允许跳转到加载模块的内存空间外执行,如果允许则返回校验成功,否则返回校验失败。
RtlIsValidHandler()函数的伪代码如下所示:
|
|
RtlIsValidHandler() 函数在哪些情况下允许异常处理函数执行? ( 1)异常处理函数位于加载模块内存范围之外, DEP 关闭。 ( 2)异常处理函数位于加载模块内存范围之内,相应模块未启用 SafeSEH(安全 S.E.H 表为空),同时相应模块不是纯 IL。 ( 3)异常处理函数位于加载模块内存范围之内,相应模块启用 SafeSEH(安全 S.E.H 表不为空),异常处理函数地址包含在安全 S.E.H 表中。
我们来分析一下这三种情况攻击成功的可行性。 ( 1)现在我们只考虑 SafeSEH,不考虑 DEP,针对 DEP 的讨论我们放到下一节中。排除DEP 干扰后,我们只需在加载模块内存范围之外找到一个跳板指令就可以转入 shellcode 执行,这点还是比较容易实现的。 ( 2)在第二种情况中,我们可以利用未启用 SafeSEH 模块中的指令作为跳板, 转入 shellcode 执行,这也是为什么我们说 SafeSEH 需要操作系统与编译器的双重支持。在加载模块中找到一个未启用的 SafeSEH 模块也不是一件很困难的事情。 ( 3)这种情况下我们有两种思路可以考虑,一是清空安全 S.E.H 表,造成该模块未启用SafeSEH 的假象;二是将我们的指令注册到安全 S.E.H 表中。由于安全 S.E.H 表的信息在内存中是加密存放的,所以突破它的可能性也不大,这条路我们就先放弃吧。
通过以上分析可以得出结论:突破 SafeSEH 还是可以做到的。您可能会问这些方法貌似有点复杂,有没有更为简便的方法突破呢?很负责地告诉您,有两种更为简便直接方法可以突破 SafeSEH。 ( 1)不攻击 S.E.H(太邪恶了),可以考虑覆盖返回地址或者虚函数表等信息。 ( 2)利用 S.E.H 的终极特权!这种安全校验存在一个严重的缺陷——如果 S.E.H 中的异常函数指针指向堆区,即使安全校验发现了 S.E.H 已经不可信,仍然会调用其已被修改过的异常处理函数,因此只要将 shellcode 布置到堆区就可以直接跳转执行!
请注意本节所有关于绕过 SafeSEH 机制的讨论均不考虑 DEP 的影响
攻击返回地址绕过 SafeSEH
如果碰到一个程序,他启用了 SafeSEH 但是未启用 GS,或者启用了 GS 但是刚好被攻击的函数没有 GS 保护(我们不考虑这种事情发生的概率,而且这种漏洞的的确确存在),攻击者肯定会直接攻击函数返回地址。实验请看《栈中的守护天使:GS》,这里不再重复介绍了。
利用虚函数绕过 SafeSEH
利用思路和我们在《栈中的守护天使:GS》中介绍的类似,通过攻击虚函数表来劫持程序流程,这个过程不涉及任何异常处理, SafeSEH 也就只是个摆设。在这我们就不做过多介绍了。
从堆中绕过 SafeSEH
演示如何利用堆绕过 SafeSEH。
|
|
实验思路: ( 1)首先在堆中申请 500 字节的空间,用来存放 shellcode。 ( 2)函数 test 存在一个典型的溢出,通过向 str 复制超长字符串造成 str 溢出,进而覆盖程序的 S.E.H 信息。 ( 3)用 shellcode 在堆中的起始地址覆盖异常处理函数地址,然后通过制造除 0 异常,将程序转入异常处理,进而跳转到堆中的 shellcode 执行。
推荐使用的环境 | 备 注 | |
---|---|---|
操作系统 | Window XP SP3 | DEP 关闭 |
编译器 | Visual Studio 2008 | |
编译选项 | 禁用优化选项 | |
build 版本 | release 版本 |
说明: shellcode 中尾部的 0x00393DF8 为 shellcode 在堆中的起始地址,该地址可能在实验过程中需要重新设置。
首先将 shellcode 填充为多个 0x90,然后将程序用 VS2008 编译好后运行,由于我们再程序中加入了 int 3 指令,程序会自动中断,我们选择调试后系统会调用默认调试器进行调试,程序会自动停在 __asm int 3 处 。
如下图,程序中断前刚刚完成堆中空间申请,此时寄存器 EAX 中存放着申请空间的首地址 0x00393DF8,这个地址在不同机器上会有所不同。有了 shellcode的首地址,我们还需要确定shellcode需要填充多少字节才能淹没异常函数的地址。继续运行程序,中断在 test 函数中字符串复制结束时。
如下图所示,被溢出的字符串起始地址为 0x0012FE8C,S.E.H 异常处理函数指针位于 0x0012FFB0+4 的位置。所以我们使用 300 个字节就能覆盖掉异常处理函数指针。
布置 shellcode:
验证我们的分析是否正确。程序依然会被 INT 3 中断,等OllyDbg运行后 按Ctrl+G 跳转到 0x00393DF8 设置断点,然后按F9继续运行,可以看到程序在 0x00393DF8 处中断,说明我们已经成功绕过SafeSEH 转入 shellcode 执行。继续执行就会看到 failwest 对话框了。
利用未启用 SafeSEH 模块绕过 SafeSEH
SafeSEH 对于未启用 SafeSEH 模块中的异常处理的校验过程:如果模块未启用 SafeSEH,并且该模块包含除中间语言(IL)之外的其它语言,这个异常处理就可以被执行。所以我们可以利用未启用 SafeSEH 的模块中的指令作为跳板来绕过 SafeSEH。
实验思路:构造一个不启用 SafeSEH 的 dll,然后将其加载,并通过它里面的指令作为跳板实现 SafeSEH 的绕过。
|
|
实验思路:
( 1)用 VC++ 6 .0 编译一个不使用 SafeSEH 的动态链接库 SEH_NOSafeSEH_JUMP.DLL,然后由启用 SafeSEH 的应用程序 SEH_NOSafeSEH.EXE 去加载它。 ( 2)SEH_NOSafeSEH 中的 test 函数通过向 str 复制超长字符串造成 str 溢出,进而覆盖程序的 S.E.H 信息。 ( 3)使用 SEH_NOSafeSEH_JUMP.DLL 中的“pop pop retn”指令地址覆盖异常处理函数地址,然后通过制造除 0 异常,让程序转入异常处理。通过劫持异常处理流程,程序转入SEH_NOSaeSEH_JUMP.DLL 中执行“pop pop retn” 指令,在执行 retn 后程序转入 shellcode 执行。
推荐使用的环境 | 备 注 | |
---|---|---|
操作系统 | Window XP SP3 | DEP 关闭 |
EXE 编译器 | Visual Studio 2008 | |
DLL 编译器 | VC++ 6.0 | 将 dll 基址设置为 0x11120000 |
编译选项 | 禁用优化选项 | |
build 版本 | release 版本 |
说明:将 dll 基址设置为 0x11120000 是为了防止“ pop pop retn”指令地址中存在 0x00。 如果以 VC++ 6.0 的默认加载基址 0x10000000 为 DLL 的加载基址,DLL 中的 “pop pop retn” 指令地址可能会包含 0x00,这会导致我们在进行 strcpy 操作时会将字符串截断影响 shellcode 的复制。
注意:记得禁用优化选项,Project –> Properties –> Configuration Properties –> C/C++ –> Optimization –> Optimization 选择 Disabled
实验步骤:
( 1)编译一个不启用 SafeSEH 的 DLL
- 在VC++ 6.0 中建立一个 Win32 的动态链接库,如下图所示
- 重新设置基址,在顶部菜单中选择 “工程 —> 设置”,然后切换到 ”连接“ 选项卡,在 “工程选项“ 的输入框中添加 ” /base:“0x11120000” “即可,如下图所示
- 编译好后,将 SEH_NOSafeSEH_JUMP.DLL 复制到与 SEH_NOSafeSEH.EXE 相同目录下。
( 2)分析要溢出的主程序
- 添加 INT 3中断,然后通过 OllySSEH 插件查看加载模块的 SafeSEH 情况。
插件下载地址:( https://bbs.pediy.com/thread-45544.htm ) OllySSEH 对于 SafeSEH 的描述: (1) /SafeSEH OFF,未启用 SafeSEH,这种模块可以作为跳板。
(2) /SafeSEH ON,启用 SafeSEH,可以使用右键点击查看 S.E.H 注册情况。
(3) No SEH,不支持 SafeSEH,即 IMAGE_DLLCHARACTERISTICS_ NO_SEH 标志位被设置,模块内的异常会被忽略,所以不能作为跳板。
(4) Error,读取错误
查看结果,如下图所示。主程序 SEH_NOSafeSEH.EXE 中启用了 SafeSEH,但是它里面的模块SEH_NOSafeSEH_JUMP.DLL 未启用 SafeSEH,我们可以利用这个 DLL 中的 ”pop pop retn“ 指令作为跳板来绕过 SafeSEH。
( 3)确定跳板地址
转到 0x11120000 中右击,查找 –> 命令序列
在命令序列框中输入下图命令,查找它
然后我们就找到了在DLL中的 “pop eax pop eax retn”,位于 0x11121068 处,如下图
( 4)构造 shellcode
- 计算被溢出字符串到最近的异常处理函数指针的距离。 先将 shellcode 赋值为 0x90 串,长度小于 200 个字节,然后再 strcpy 操作结束后中断程序。 如下图,被溢出字符串起始位置为 0x0012FDB8
距离它最近的异常处理函数指针位于 0x0012FE90+4 位置。
由于这次使用的是 “pop pop retn” 指令序列,所以我们要将弹出 “failwest” 对话框的机器码放到 shellcode 的后半部分。(避免未命中)
注意:经过 VS 2008 编译的程序,在进入含有__try{}的函数时会在 Security Cookie+4 的位置压入-2( VC++ 6.0 下为-1),在程序进入__try{}区域时程序会根据该__try{}块在函数中的位置而修改成不同的值。
例如,函数中有两个__try{}块,在进入第一个__try{}块时这个值会被修改成 0,进入第二个的时候被修改为 1。如果在__try{}块中出现了异常,程序会根据这个值调用相应的__except()处理,处理结束后这个位置的值会重新修改为-2;如里没有发生异常,程序在离开__try{}块时这个值也会被修改回-2。当然这个值在异常处理时还有其他用途,在这我们不过多介绍,有兴趣的话可以自己跟踪调试一下。我们只需要知道由于它的存在,我们的 shellcode 可能会被破坏。
为了避免shellcode 关键部分被破坏,我们采用一下布局:shellcode 最开始部分为 220 个字节的 0x90 填充;在 221~224 位置用跳板地址 0x11121068 覆盖;然后再跟上 8 个字节的 0x90 填充;最后附上弹出 “ failwest” 对话框的机器码。这样就可以保证弹出对话框的机器码不被破坏了。
题外话: 在实际的溢出过程中由于条件限制和未知因素, shellcode 有时会被破坏,出现这种情况时可以尝试不同的 shellcode 布局,使用不同的跳转指令,以避开这些破坏。
( 5)调试运行
- 将上面的 shellcode 布置好后,编译运行程序,用 OllyDbg 调试程序,在 0x11121068 处下断点,让程序继续运行。
- 我们将会看到,程序停在 0x11121068 处,说明我们已经进入DLL 绕过 SafeSEH,成功劫持程序流程了,如下图。
- 继续单步执行,我们将会看到从 0x0012FE90 到 0x0012FEA0 中有一些未知命令,分别是我们用来覆盖异常函数指针的跳板地址和进入__try{}块时被赋值为 0 的部分。本实验中它们对实验结果没有影响,如果有影响,就要用向后跳转指令,跳过影响指令,直接进入关键部分。
可以尝试将 shellcode 中的 217~220 字节用 0xEB0E9090 填充,执行后,他会跳过 shellcode 中间部分,直接运行到弹出对话框部分。
- 按F9继续执行,就能看到对话框了。
利用加载模块之外的地址绕过 SafeSEH
在前面我们讲过,SafeSEH 只检查异常处理函数指针是否指向栈中地址,对于指向其它地址它是不对其进行有效性验证的。所以我们可以让异常处理函数指针指向非栈地址,进而就能绕过 SafeSEH 检验。
然而当程序加载到内存中后,在它所占的整个内存空间中,除了我们平时常见的 PE 文件模块( EXE 和 DLL)外,还有其他一些映射文件(我们可以通过 OllyDbg 的“view→memory”查看程序的内存映射状态)。例如下图中的 map类型,如果我们能在这些文件中找到跳转指令的话就可以绕过 SafeSEH,而这样的指令也确实存在。
|
|
只要找到一条指令就能绕过 SafeSEH 了。我们通过下面的程序来演示和分析如何在所有加载模块都开启 SafeSEH 机制的情况下绕过 SafeSEH。
|
|
实验思路: ( 1) Test 函数中通过向 str 复制超长字符串造成 str 溢出,进而覆盖程序的 S.E.H 信息。 ( 2)该程序中所有加载模块都启用了 SafeSEH 机制,故我们不能通过未启用 SafeSEH 的模块还绕过 SafeSEH 了。 ( 3)将异常处理函数指针覆盖为加载模块外的地址来实现对 SafeSEH 的绕过,然后通过除 0 触发异常将程序转入异常处理,进而劫持程序流程。
推荐使用的环境 | 备 注 | |
---|---|---|
操作系统 | Window XP SP3 | DEP 关闭 |
编译器 | Visual Studio 2008 | |
编译选项 | 禁用优化选项 | |
build 版本 | release 版本 |
说明: shellcode 中尾部的 0x00290B0B 为 Windows XP SP3 下的跳板地址,如果您在其他操作系统下测试,该地址可能需要重新设置。
实验步骤:
( 1)分析程序,先将 shellcode 填充为多个 0x90(长度不超过200个),然后编译运行程序。 在 Ollydbg 中用 OllySEH 插件分析加载模块的 SafeSEH 情况,如下图,可以看到,所有加载模块都没有 /SafeSEH OFF 状态。
( 2)在加载模块内存之外寻找合适的跳板绕过 SafeSEH。
- 接下来需要使用 OllyFindAddr 插件,它能在整个程序的内存空间搜索指令。OllyFindAddr下载地址:https://bbs.pediy.com/thread-198080.htm
- 使用 call/jmp dword ptr[ebp+n]指令作为跳板。插件 —> OllyFindAddr —> Overflow return address —> Find CALL/JMP [EBP+N]
- 在日志中查看搜索结果。Module:Unknown,就是加载模块之外的指令,将 0x00280B0B 作为跳板。
如果不确定,也可以将它与加载模块逐一比对。
( 3)消除异常。如果直接用 0x00280B0B 构造 shellcode,那shellcode 可能会被它中的0x00截断,所以我们要将跳板放在shellcode的最后,防止其造成异常。
通过前面利用未启用 SafeSEH 模块绕过 SafeSEH,我们知道通过跳板指令转入 shellcode 后首先是4个字节的 0x90 的填充,所以我们可以利用这4个字节来跳转到 shellcode,而前面提到的 0xEB0E9090,其实 0xEB0E 是向前跳转 0x0E 的机器码,可以把它放在这4字节中,但由于1个字节的操作数向前回跳的范围有限,不足以跳转到shellcode 的起始地址,所以我们用两次跳转来完成跳跃。
部署两个跳板,在刚刚的 4个字节中部署短跳转指令 0xEBF6 向前回跳 8个字节。(JMP 指令用相对地址跳转时,是以 JMP 下一条指令的地址为基准,所以实际上是向后跳转10个字节)在这 8个字节中再布置一条 5字节的长跳转指令,跳转到 shellcode 的起始部分。
( 4)构造 shellcode
- 确定 shellcode 起始地址到长跳转指令之间的距离。
本实验中,被溢出字符串起始位置为 0x0012FE88,距离最近 SEH地址为 0x0012FF60,部署长跳转指令位于 0x0012FF58,所以我们需要回跳 213 个字节(包含长跳转指令的 5 个字节),使用 E92BFFFFFF(跳转 0xFFFFFF2B 个字节)填充长跳位置。
除 0 异常后,最近SEH 地址为0x0012FF60
- 部署 shellcode
( 5)运行验证,对话框弹出