伪造返回地址绕过CallStack检测以及检测伪造返回地址的实践笔记

来源:安全焦点

Author:[CISRG]KiSSinGGer
E-mail:[email protected]
MSN:[email protected]

题目有点搞……Anti-CallStack Check and Anti-Anti-CallStack Check…(;- -)

发现最近MJ0011的“基于CallStack的Anti-Rootkit HOOK检测思路”和gyzy的“基于栈指纹检测缓冲区溢出的一点思路”两篇文章有异曲同工之妙。
两者都通过检测CallStack中的返回地址来做文章。
最近在初步学习一些AntiRootkit技术,这两个不得不吸引我的眼球。

按照MJ0011大侠的逻辑,从Rootkit Detector的Hook点向上检测CallStack.
但是CallStack里面都是些DWORDs,怎么判断哪儿是参数,哪儿是返回地址呢?
我Goo了两把…普遍是用EBP回溯的方式.
考虑大部分的__stdcall的形式:
mov edi edi
push ebp
mov ebp esp


我们从dword ptr [EBP]里面可以获得上个call的EBP,dword ptr [EBP+4]里面获得需要检测的返回地址,然后EBP = dword ptr [EBP],继续找下去.找到栈基址为止.
每次得到的返回地址,判断一下它是否在一个合法的模块中.

但是,根据gyzy大侠的<编写绕过卡巴主动防御的Shellcode>一文启示,我们可以知道如下一种方式,可使这样的检测方式失效.

1.在合法的系统模块里(e.g. ntoskrnl.exe),找到一个'C3'(ret Opcode)字节,它的指针是K.
2.使用如下方式的Hook函数

HookedZwXxx(…)
{
//
// 一些参数处理操作
//

jmp __pushrealretaddr
__trickstage:

push Arg[N]
push Arg[N-1]

push Arg[0]

push K
jmp ZwXxx; //调用原始函数

__pushrealretaddr:
call __trickstage

realretaddr:

//
// 另一些结果处理操作
//
}

这样,在ZwXxx深处检查调用栈,dword ptr [EBP+4]是一个处于合法模块中的地址K.

我写了一个如下的ring3示例程序.

定义如下一些函数:
int __stdcall Call_C(int a, int b)
{
check_callstack();
return a+b;
}

int __stdcall Call_B(int a, int b)
{
return Call_C(a,b);
}

int __stdcall Call_A(int a, int b)
{
return Call_B(a,b);
}

调用次序是A->B->C,其中C里面执行check_callstack()来检测是否有非法的返回地址.

void
__stdcall
check_callstack( void )
{
int saved_ebp;
int retaddr;

printf("Check Call Stack Methord 1:\n");
__asm
{
mov eax, dword ptr [ebp+4]
mov retaddr, eax
mov eax, dword ptr [ebp]
mov saved_ebp, eax
}
printf("retaddr = 0x%08X\n",retaddr);

while(saved_ebp < StackBase && saved_ebp > 0)
{
if(saved_ebp != 0)
{
retaddr = *(int*)(saved_ebp+4);
printf("retaddr = 0x%08X\n",retaddr);
saved_ebp = *(int*)saved_ebp;
}
}
}

在没有Hook的情况下,我们执行Call_A(1,2),得到正常返回为3.

check_callstack输出:
retaddr = 0x00401008
retaddr = 0x00401030
retaddr = 0x00401050
retaddr = 0x00401126
retaddr = 0x0040149D
retaddr = 0x7C816FD7

我们现在使用一个函数Hooked_Call_B来在Call_A中把Call_B给Hook掉.
Hook掉的Call_B做的只是把的返回值改成4.

__declspec( naked )
int Hooked_Call_B(int a, int b)
{
__asm
{
push ebp
mov ebp, esp
jmp __a

__trickstage:

mov eax, b
push eax
mov eax, a
push eax
//为了方便这里使用一个OD得到的硬编码:P
push 0x004011AD //这个地址指向一个'C3'
jmp Call_B

__a:
call __trickstage
mov eax, 4 //这里,改返回值,使得1+2的结果为4.
pop ebp
ret 8
}
}

用来改写Call_A的函数,这个函数在2003编译出来的EXE中会导致异常
因为.text段没有写权限.实际测试中我用StudPE改了段属性.在内核态
的话…这个修改代码段段属性问题…应该很简单把…

int __stdcall SetHook( int Hook_Call )
{
int Original_Call = 0;
int hook_pos = (int)Call_A;

//
// 以下丑陋代码是在Call_A中找到"call Call_B"指令的位置
//
__asm
{
__again:
mov eax,hook_pos
xor ecx,ecx
mov cl,byte ptr ds:[eax]
cmp cl,0xE8
je __finish
mov edx,hook_pos
add edx,1
mov hook_pos,edx
jmp __again
}
__finish:

//
// 用Hook_Call patch掉call后面的地址
//

Hook_Call = Hook_Call – hook_pos – 5;
__asm
{
mov eax, Hook_Call
mov edi, hook_pos
mov dword ptr [edi+1], eax
}
return hook_pos;
}

我们之后将调用SetHook( Hooked_Call_B )将Call_A中的"call Call_B"改掉.

我们的Hooked_Call_B,在调试器中看到是[0x004010B0,0x004010D2]这段地址.
那么,如果我们根据EBP回溯CallStack的方法有效,在Hooked_Call_B生效以后应该成功的找到一个retaddr属于[0x004010B0,0x004010D2]区间.

遗憾的是,没有…

check_callstack输出:
retaddr = 0x00401008
retaddr = 0x00401030
retaddr = 0x004011AD <–注意这里
retaddr = 0x00401050
retaddr = 0x0040114D
retaddr = 0x0040149D
retaddr = 0x7C816FD7

我们可以看到,我们正常的返回地址被一个貌似合法的0x004011AD给偷梁换柱了.

于是,我们在这里断定…根据EBP的回溯,被这种方式(叫做Detour Ret? :P)给愚弄了.

另想辙.

我们来OD里面看看实际的堆栈,这是停在Call_C里面的时候.

0012FEA0 0012FEB4 <–当前EBP
0012FEA4 004011AD <–伪造的返回地址,指向C3
0012FEA8 00000001 <-
0012FEAC 00000002 <-两个参数
0012FEB0 004010CC <–真正的返回地址!
0012FEB4 /0012FEC4
0012FEB8 |00401050
0012FEBC |00000001
0012FEC0 |00000002

当Call_C退出时,执行:
pop ebp
ret 8
此后寄存器状态:
ebp = 0012FEB4
esp = 0012FEB0
eip = 004011AD

这时就执行到004011AD了,004011AD处的ret将使得eip = dword ptr [esp],这样就顺利的返回到004010CC了.

呃?这么看来,004010CC这个恶意的返回地址确确实实是存在于CallStack中的.关键就是怎么确定它的.
EBP回溯不行,也许ESP回溯…这个具体方式我这个愚人就不知了.MJ0011就是说使用ESP回溯的.这样得考虑经过的每个call的参数个数问题.

这样我就有了一个思路:
对每一个返回地址判断一下,是否指向一个'C3'.
若是,则retaddr = 第一个参数位置 + 参数个数*4
若否,则retaddr = dword ptr [EBP + 4]

改一下check_callstack:

void
__stdcall
check_callstack( void )
{
int saved_ebp;
int retaddr;

//[参数个数]x4,对于内核例程,参数一般是固定的.
int stack_fix = 0x8;

printf("Check Call Stack Methord 2:\n");

__asm
{
mov eax, dword ptr [ebp+4]
mov retaddr, eax
mov eax, dword ptr [ebp]
mov saved_ebp, eax
}
printf("retaddr = 0x%08X\n",retaddr);

while(saved_ebp < StackBase && saved_ebp > 0)
{
if(saved_ebp != 0)
{
retaddr = *(int*)(saved_ebp+4);
printf("retaddr = 0x%08X\n",retaddr);

if(retaddr != 0)
{
if(*(unsigned char*)retaddr == 0xC3)
{
//
// 若返回指令指向一个'C3',我们得检查在参数push之后的返回地址
// Sorry for my 丑陋的表达式 :(

retaddr = *(int*)(saved_ebp+8+stack_fix);
printf("Suspicious retaddr found : 0x%08x\n",retaddr);
}
}
saved_ebp = *(int*)saved_ebp;
}
}
}

我们来运行程序来验证一下:

没Hook的情况:

retaddr = 0x0040100D
retaddr = 0x00401030
retaddr = 0x00401050
retaddr = 0x00401126
retaddr = 0x0040149D
retaddr = 0x7C816FD7

有Hook的情况:

retaddr = 0x0040100D
retaddr = 0x00401030
retaddr = 0x004011AD
Suspecious retaddr found : 0x004010cc
retaddr = 0x00401050
retaddr = 0x0040114D
retaddr = 0x0040149D
retaddr = 0x7C816FD7

比较顺利的找到属于[0x004010B0,0x004010D2]的0x004010cc

那么我们是否可用就此断定,这种堆栈回溯检测有效了?
还不可妄下结论…

如果,伪造的返回地址指向一个"C2 XXXX"?
比如,我们在Hooked_Call_B里面这么写:
push xxx //这里随便push两个,与ret 8配合平衡堆栈
push xxx
mov eax, b
push eax
mov eax, a
push eax

push K //这个地址指向一个'C2 08 00'(ret 8)
jmp Call_B

那么,我们还得检测返回地址为C2的情况,并取得C2后面的一个WORD,通过这个WORD判断真正的返回地址在Arg[N]栈位置后面的第3个DWORD处.

更进一步,如果,伪造的返回地址K指向一个如下的指令序列:
pop eax
pop ebx
pop ebp
ret 8

我们还得对这个返回地址做一些语义(pop+ret)上的分析,才能确定真正的返回地址…它在Arg[N]栈位置后面的第6个DWORD的处…

还有
如果返回地址里还有对esp的add,sub..这些东西,呵呵,需要做检测工作的就多了去了.

虽然我在实践中实现了一个比较简单的'C3'检测,但我还是觉得这个Callstack回溯,并不是想象中好搞.

我不想和自己下棋了,没完没了……这篇陋文权当抛砖引玉了.
搞来搞去…我发现各Rootkit Coders以及ARK Coders都进入了一种Code Tricks的较量.
想象各种伎俩的RK/ARK代码在内核中堆积…他进我退他退我追他疲我生…
祸邪?福邪?

最后
感谢有人看完冗长的文章以及丑陋的代码
向以下达人及其共享的文档及其共享的精神致敬:

gyzy <编写绕过卡巴主动防御的Shellcode>
gyzy <基于栈指纹检测缓冲区溢出的一点思路>
MJ0011 <基于CallStack的Anti-Rootkit HOOK检测思路>
l0pht <点评"基于栈指纹检测缓冲区溢出的一点思路>
Matt Conover (Show me his trick "without put anything extra on the callstack" :0)

相关日志

发表评论