天天看点

绕过缓冲溢出防护系统

-介绍

近来一段时间,一些商业化的安全机构开始提出一些方案来解决缓冲区溢出问题。本文分析这些保护方案,并且介绍一些技术来绕过这些缓冲区溢出保护系统.

现在不少商业化组织创造了许多技术来防止缓冲区溢出。最近,最流行的一种技术是栈回溯技术,它是一种最容易实现的防溢出技术,但是同时它也是最容易被攻击者绕过的一项技术.

值得一提的是,许多著名的商业化产品,比如Entercept和Okena就使用了这项技术。

2-栈回溯

现存的大多数商业安全体系实际上并不是防止缓冲区溢出,而是试图检测Shellcode的执行.

最普遍的检测Shellcode的技术是检查代码页的权限,通过检测代码是否在一个可写的内存页上执行来判断是否执行了Shellcode.这种办法是可行的,因为代码段的内存属性通常是不可写的,并且X86体系不支持non-executable这种内存属性位。

有些溢出防护体系也执行一些额外的检查,用来判断代码的内存页是否属于一个PE文件节区的内存映射,并且不属于一个匿名的内存节区

[-----------------------------------------------------------]

page = get_page_from_addr( code_addr )

if (page->permissions & WRITABLE)

return BUFFER_OVERFLOW

ret = page_originates_from_file( page )

if (ret != TRUE)

return BUFFER_OVERFLOW

[-----------------------------------------------------------]

Pseudo code for code page permission checking

依赖栈回溯的缓冲区溢出保护技术(BOPT)并不真正创建一个不可执行的堆栈段,而是通过Hook操作系统调用来监测Shellcode的执行。

大多数的操作系统可以在用户态或者内核态被Hook.

下面的章节我们讲述如何逃避内核hook,再下一节讲述如何绕过用户态hook。

3-逃避内核态hook

当hook内核时,主机入侵保护系统(HIPS)必须能够监测用户态的API是被哪里调用的。

由于kernel32.dll和ntdll.dll中的函数被大量的调用,一个API调用通常与真正的系统陷阱调用( syscall trap call)

相隔很多栈帧。因此,一些入侵防护系统依赖栈回溯来定位系统调用的原始调用者。

3.1-内核栈回溯

虽然栈回溯可以发生用户态和内核态,但是相比用户态组件而言,栈回溯技术对于缓冲区溢出保护技术的内核组件而言要重要得多.现有的商业BOPT的内核组件完全依赖栈回溯来检测Shellcode的执行,因此逃避内核Hook可以简化为使栈回溯机制失效。

栈回溯牵涉到历遍栈帧,和把返回地址传递给上层的缓冲溢出检测程序来进行检测。

通常情况下,有一个附加的"return into libc"检查,包括检查一个返回地址是否指向Call或者Jmp的下一条指令。最基本的栈回溯操作的代码(通常被用于BOPT),就像下面这个一样

[-----------------------------------------------------------]

while (is_valid_frame_pointer( ebp )) {

ret_addr = get_ret_addr( ebp )

if (check_code_page(ret_addr) == BUFFER_OVERFLOW)

return BUFFER_OVERFLOW

if (does_not_follow_call_or_jmp_opcode(ret_addr))

return BUFFER_OVERFLOW

ebp = get_next_frame( ebp )

}

[-----------------------------------------------------------]

Pseudo code for BOPT stack backtracing

当讨论如何逃避栈回溯,最要弄明白的一点就是栈回溯如何在X86体系上工作的,当调用一个函数时,一个典型的栈帧看起来就像下面这个一样:

: :

|-------------------------|

| function B parameter #2 |

|-------------------------|

| function B parameter #1 |

|-------------------------|

| return EIP address |

|-------------------------|

| saved EBP |

|=========================|

| function A parameter #2 |

|-------------------------|

| function A parameter #1 |

|-------------------------|

| return EIP address |

|-------------------------|

| saved EBP |

|-------------------------|

EBP寄存器指向下一个栈帧。没有EBP寄存器,就会非常困难,或者根本不可能正确地标识和追踪所有的栈帧

现代编译器通常不使用EBP作为栈帧指针,而是作为一个通用寄存器,经过EBP优化,一个栈帧看起来如下

|-----------------------|

| function parameter #2 |

|-----------------------|

| function parameter #1 |

|-----------------------|

| return EIP address |

|-----------------------|

注意EBP寄存器并没有出现在栈中,没有EBP寄存器,缓冲区溢出检测技术就没法准确地实施栈回溯.这样一来,他们就很难进行检测,一个简单的"return into libc "型的攻击就可以绕过保护。

简单的调用比BOPT hook住的API更底层的API可以使这种检测技术失效。

3.2-伪造栈帧

由于栈是在Shellcode的完全控制之下的,所以可以在API调用之前彻底更改它的内容,专门改造过的栈帧可以被用来绕过缓冲溢出的监测.

如同前面解释的那样,缓冲溢出检测工具寻找格法代码的3个关键标志:只读的页属性,内存映射文件节区和指向call,jmp后面的指令的返回地址.Since function pointers change

calling semantics, BOPT do not (and cannot) check that a call or jmp

actually points to the API being called. Most importantly, the BOPT cannot

check return addresses beyond the last valid EBP frame pointer

(it cannot stack backtrace any further).

因此,逃避BOPT可以通过创建一个带有有效返回地址的最终(final)栈帧,这个返回地址必须指向一条驻留在只读内存页上的来自于内存映射文件节的,并且紧跟在一条call或者jmp指令之后的指令.假设伪造的返回地址合理的接近于第2个返回地址,Shellcodeo可以轻松地再次获得控制权

为了指向伪造的返回地址,理想的指令顺序是

[-----------------------------------------------------------]

jmp [eax] or call [eax], or another register

dummy_return: ... some number of nops or easily

reversed instructions, e.g. inc eax

ret any return will do, e.g. ret 8

[-----------------------------------------------------------]

绕过内核BOPT组件很容易,因为它们必须依赖用户控制的数据(栈)来检验API调用的有效性,通过正确地操作栈,可以有效地结束站返回地址的分析

这种栈回溯逃避技术也影响用户态的hooks

4.逃避用户态hooks

假使在一个有效的内存区域内出现了一系列正确的指令,那么绕过内核缓冲溢出保护是可能的.相似的技术可以被用来绕过用户态的BOPT组件,更加的是,由于Shellcode运行的时候拥有和用户态Hook相同的权限,我们还能采取其他不少技术来逃避BOPT的监测.

4.1-实际问题-不完全的API Hooking

用户态缓冲溢出防护技术存在很多问题.例如攻击者会选择很多不同的办法来实现他们的目的,而溢出保护系统可能只能检测其中的一部分.

尝试预先判断一个攻击者会如何构造它的Shellcode是非常困难的,甚至是不可能的,要选择一种好的办法并不容易,有很多障碍横在你面前,如

a.没有同时考虑到API调用的UNICODE和ANSI版本

b.没有考虑到API的链状调用关系,比如很多kernel32.dll中的函数仅仅是把ntdll.dll中的函数微微包装

c.Microsoft Windows API的经常性的更改

4.1-没有Hook API的所有版本

实行用户态Hooking时,一种最常遇到的失误就是没有完全覆盖代码路径.为了阻止恶意代码,所有攻击者可以利用的API都必须被Hook.这就要求溢出保护系统Hook住攻击者必须用到的代码,然而,就像我们下面要揭示的那样,一旦一个攻击者已经开始执行他的代码,第3方保护系统就很难再掌握他所有的行动,事实上,我们已经测试过的商业保护系统中,没有一个能有效覆盖攻击者的代码路径

很多Windows的API有2个不同的版本, ANSI和UNICODE。ANSI函数通常以A结尾,而UNICODE以W结尾.ANSI的函数一般仅仅是UNICODE函数的简单包装,例如, CreateFileA是一个ANSI函数,传递给它的参数被它转换成UNICODE字符串,然后它再调用CreateFileW.除非我们Hook住 API的ANSI和UNICODE2个版本,否则攻击者可以通过调用API的另一个版本来绕过我们的保护机制

例如, Entercept4.1 Hook了LoadLibraryA,但是它忘记了Hook LoadlibraryW.如果一个保护系统仅仅Hook了API的一个版本,那它应该Hook UNICODE版本的API,在这一方面,Okenal/CSA做得比较好,它Hook了 LoadLibraryA,LoadlibraryW,LoadlibraryExA,LoadlibraryExW.不幸的是,对于第3方溢出保护系统来说,简单的多Hook几个kernel32.dll的函数并不够.

4.1.2-没有Hook得足够深

在WindowsNT 中,kernel32.dll只是Ntdll.dll得简单包装,但是大多数的溢出保护系统并没有Hook ntdll.dll里面的函数,这个错误和没有Hook API的2个版本很相似,攻击者可以直接调用ntdll.dll里的函数来绕过保护系统在Kernel32.dll里面设置的检查.

例如,NAI Entercept尝试检测Shellcode调用kernel32.dll中的GetProcAddress(),然而,Shellcode可以被改写成调用ntdll.dll的LdrGetProcedureAddress(),这可以达到相同的目的,并且绕过了检测.

一般而言,shellcode可以完全直接避开用户态的hook,通过调用系统调用来达到目的.(见4.5小节)

4.1.3-没有充分地Hook-

各种不同的Win32 API之间的相互作用是非常复杂的,并且很难弄清楚,一不小心就会给入侵者留下一个进入的窗口.

例如,Okena/CSA和NAI entercept都hook了WinExec试图防止攻击者执行一个进程,WinExec调用顺序如下

WinExec()-->CreateProcessA()-->CreateProcessInternalA()

Okena/CSA 和NAI entercept Hook了WinExec()和CreateProcessA(),(参见附录A,但是,这2种系统都没Hook CreateProcessInternalA()(被kernel32.dll导出),当编写Shellcode时,攻击者可以使用 CreateProcessInternalA()来替代WinExec().

在调用CreateProcessInternalA() 之前,CreateProcessA()压入2个NULL进栈,因此Shellcode只需要压入2个Null进栈,然后直接调用 CreateProcessInteralA(),便可逃避这2种产品的用户态Hook的监测.

当新的DLLs和APIs发布时,Win32 API之间的相互作用更加复杂化了,这使问题更加复杂。第三方保护系统在实施他们的溢出保护技术的时候会很不利,一个小小的失误就可能被攻击者利用。

继续阅读