xfocus logo xfocus title
首页 焦点原创 安全文摘 安全工具 安全漏洞 焦点项目 焦点论坛 关于我们
添加文章 Xcon English Version

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


创建时间:2007-11-10
文章属性:原创
文章提交:KiSSinGGer (kyller_clemens_at_hotmail.com)

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

Author:[CISRG]KiSSinGGer
E-mail:kissingger@gmail.com
MSN:kyller_clemens@hotmail.com

题目有点搞......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)