在Blackhat USA 2011上,微软发起了一项名为Bluehat的技术挑战赛,旨在于全球范围内征集漏洞利用缓解机制(说实话我不太喜欢这个蹩脚的翻译,还是Exploit Mitigation更通俗易懂吧)。大赛为期一年,近日方落下帷幕。说来有趣,本届大赛最终杀入决赛的mitigation方案,竟都是用于Anti-ROP的,皆被微软集成在最新版本的EMET中。
本人好奇,是何等犀利的技术能配得上微软500,000刀的奖励,于是写点代码进行了一番研究,现贴点“研究成果“,以备忘。
EMET中对于ROP,共有以下五种ANTI措施:
1.StackPviot:check if stack is pviotted
2.Caller:check if critical functions was called and not returned into
3.SimExecFlow:Simulate the execution flow after the return address to detect subsequent
ROP Gadgets
4.MemProt:specail check on memory protections API
5.LoadLib:check and prevent LoadLibrary calls againts UNC paths
首先需要说明的是,以上所有“check”或者“detect”,只有当ROP Chain将控制流转向受EMET保护的函数时才会进行。以下是受保护的函数:
kernel32.MapViewOfFileEx
kernel32.MapViewOfFile
kernel32.CreateFileMappingW
kernel32.CreateFileMappingA
kernel32.CreateFileW
kernel32.CreateFileA
kernel32.CreateFile
kernel32.WinExec
kernel32.WriteProcessMemory
kernel32.CreateRemoteThread
kernel32.CreateProcessInternalW
kernel32.CreateProcessInternalA
kernel32.CreateProcessW
kernel32.CreateProcessA
kernel32.HeapCreate
kernel32.VirtualAllocEx
kernel32.VirtualAlloc
kernel32.LoadLibraryExW
kernel32.LoadLibraryExA
kernel32.LoadLibraryA
kernel32.VirtualProtectEx
kernel32.VirtualProtect
EMET会对这些函数进行inline-hook,一旦其被调用,就会先跳转到EMET的钩子函数上进行check/detect,为行文方便,下文中统称这些函数为关键函数。
1.StackPviot
EMET采用的这种ANTI技术的实现细节我并没有深入分析,因为我知道去年放出来的Windows 8 DP中就尝试性的引入了这种ANTI-ROP的技术,其原理非常简单,即在关键函数的入口处,调用PsValidateUserStack对当前ESP中保存的栈顶指针进行检验。如果发现ESP指向的地址,不在当前线程的栈地址范围内,就认为检测到ROP!正因为简单,所以很快就有安全研究人员找到了旁路办法,并放出了POC(详见本人拙文《Debug ROP Chain For W8》)。不知道是不是因为此种方法很快就被旁路,在随后的Windows8 CP和Windows8 DP中,已经看不到PsValidateUserStack的身影了。
2.Caller
Caller对关键函数的返回地址进行验证,如果返回地址的前一条指令是call指令,而且目标跳转地址是关键函数的地址,那么则通过验证。反之,如果返回地址的前一条指令不是call,或者目标跳转地址不是关键函数的地址,都认为检测到ROP。在看伪代码之前,先定义一个枚举类型,用于表示指令的类型。这个枚举类型在下文分析SimExecFlow的检测原理时是还要提到。
typedef enum INSTRUCTION_TYPE
{
ERROR_INS,
...
MOV_ESP_EBP_INS //MOV ESP,EBP指令
CALL_INS, //CALL指令
PUSH_INS, //PUSH指令
POP_INS, //一般的POP指令
POP_ESP_INS, //POP ESP指令
POP_EBP_INS, //POP EBP指令
ADD_ESP_INS, //ADD ESP,XXX指令
ADD_EBP_INS, //ADD EBP,XXX指令
SUB_ESP_INS, //SUB ESP,XXX指令
SUB_EBP_INS, //SUB EBP,XXX指令
LEAVE_INS, //LEAVE指令
RETURN_INS, //RETURN指令
}INSTURCTION_TYPE
extern DWORD g_CallInsLen[5]={2,3,5,6,7}; //全局变量,保存call指令所有可能的长度
BOOL ROP_CallerCheck(LPVOID ReturnAddress, //返回地址
LPVOID CriticalFuncAddress) //关键函数地址
{
...省略无关代码...
for(int i=0;i<5;i++)
{
//计算返回地址前一条指令的地址
LPVOID RreviousInsAddress = (LPVOID)((DWORD)ReturnAddress + g_CallInsLen[i]);
int InsLen;
//对返回地址前一条指令进行解码,判断指令的类型,指令的长度保存在ins_len中
if(CALL_INS == DecodeOpcode(PreviousInsAddress,&InsLen))
{
//如果是call指令
if(NULL == CriticalFuncAddress)
{
return TRUE;
}
else
{
//如果前一条指令是call指令,则进一步判断其目标地址是否是CriticalFuncAddress
if(CriticalFuncAddress == GetTargetBranchAddress(PreviousInsAddress))
{
return TRUE;
}
}
}
}
return FALSE;
}
3.SimExecFlow
SimExecFlow技术通过模拟执行控制流,来检测ROP Gadgets。实际上其仅仅模拟的只是特定的几条指令对ESP/EBP进行的修改,重头戏还是在对返回地址的检测上。
看伪代码之前,先定义一个数据结构,用于表示模拟的上下文环境。
typedef _struct _SIMULATE_CONTEXT
{
DWORD UNKONWN;
DWORD EBP;
DWORD ESP;
DWORD ESP;
}SIMULATE_CONTEXT;
再定义一个枚举类型,用于表示指令的控制流类型
typedef enum _EXE_FLOW_TYPE
{
EXE_FLOW_ERROR = 0;
EXE_FLOW_BRANCH = 1; //call或者jmp跳转指令
EXE_FLOW_SEQ = 2; //顺序执行指令
EXE_FLOW_RETURN = 3; //返回指令
}
以下是伪代码:
SIMULATE_CONTECT sim_cxt;
sim_cxt.EBP = 关键函数的返回地址
sim_cxt.EIP = 关键函数返回地址
sim_cxt.ESP = 从关键函数返回后的栈顶地址
BOOL bIsNormal = ROP_SimExecFlow(&sim_cxt);
if(FALASE == bIsNormal)
{
//ROP detected
}
else
{
//no ROP
}
....
BOOL ROP_SimExecFlow(SIMULATE_CONTEXT* sim_cxt)
{
...省略无关代码...
for(int i=0; i<15; i++)
{
EXE_FLOW_TYPE flow_type = SimulateExeFlow(sim_cxt);
if(EXE_FLOW_ERROR == flow_type || EXE_FLOW_BRANCH == flow_type)
{
//如果模拟执行的是跳转指令,或者函数SimulateExeFlow函数执行错误,则返回TRUE
return TRUE;
}
else if(EXE_FLOW_RETURN == flow_type)
{
//如果模拟执行的是ret/ret n指令,则需要对返回地址的有效性进行检验,这里主要是判断
//返回地址的前一条指令是否为call指令
if(FALSE == ROP_CallerCheck(sim_cxt->EIP,NULL))
{
return FALSE;
}
}
}
return TRUE;
}
以下是SimulateExeFlow函数的伪代码
EXE_FLOW_TYPE SimulateExeFlow(SIMULATE_CONTEXT* sim_cxt)
{
EXE_FLOW_TYPE flow_type = EXE_FLOW_TYPE_SEQ;
INT ins_len = 0;
LPVOID return_addr = NULL;
//对当前sim_cxt中EIP所指向的指令进行解码,判断指令的类型,指令的长度保存在ins_len中
INSTRUCTION_TYPE ins_type = DecodeOpcode(sim_cxt->EIP,&ins_len);
switch(ins_type)
{
CASE ERROR_INS:
//如果解码失败
flow_type = EXE_FLOW_ERROR;
break;
CASE CALL_INS:
CASE JMP_INS:
flow_type = EXE_FLOW_BRANCH;
break;
CASE MOV_ESP_EBP_INS:
sim_cxt->ESP = sim_cxt->EBP;
break;
CASE PUSH_INS:
sim_cxt->ESP = sim_cxt->ESP - 4;
break;
CASE POP_INS:
sim_cxt->ESP = sim_cxt->ESP + 4;
break;
CASE POP_EBP_INS:
sim_cxt->EBP = DWORD PTR[sim_cxt->ESP]
sim_cxt->ESP = sim_cxt->ESP + 4;
break;
CASE POP_ESP_INS:
sim_cxt->ESP = DWORD PTR[sim_cxt->ESP]
sim_cxt->ESP = sim_cxt->ESP + 4; //此处值得商榷,按说POP ESP之后,无须再对新的
//ESP进行调整但EMET的代码里确实是真么做的-_-!!
break;
CASE LEAVE_INS:
sim_cxt->EBP = DWORD PTR[sim_cxt->ESP]
sim_cxt->ESP = sim_cxt->ESP + 4;
sim_cxt->ESP = sim_cxt->EBP;
break;
CASE RETURN_INS:
flow_type = EXE_FLOW_RETURN;
return_addr = DWORD PTR[sim_cxt->ESP];
sim_cxt->ESP = sim_cxt->ESP + 4;
break;
...还有对sub esp,xxx/add esp,xxx/sub ebp,xxx/sub esp,xxx指令的模拟,以下省略...
}
//根据指令类型,修改EIP
if(EXE_FLOW_TYPE_SEQ == flow_type)
{
//如果顺序执行,那么下一条指令地址,为当前EIP加上指令长度
sim_cxt->EIP = sim_cxt->EIP + ins_len;
}
else if(EXE_FLOW_RETURN == flow_type)
{
//如果是返回指令,那么EIP更新为返回地址
sim_cxt->EIP = return_addr;
}
return flow_type;
}
分析清楚SimExeFlow的检测方式,不难想到这样的一种旁路办法,即构造一条由15个形如CALL XXX/n POP XXX/ret n的ROP Gadget组成的ROP Chain。
试举一例,假如原本的ROP Chain,在执行关键函数VirtualProtect之前形式下:
rop_gadget_1--------------->pop ebp,ret
shellcode_address
0x100
0x40
writeable_address
JUNK_DATA
rop_gadget_2--------------->pop esp,ret
shellcode
如果rop_gadget_2地址上的上一条指令不是call指令,那么就无法通过SimExeFlow的验证。现对这个ROP Chain做以下修改
rop_gadget_1--------------->pop ebp,ret
shellcode_address
0x100
0x40
writeable_address
JUNK_DATA
rop_gadget_3--------------->pop ebp,ret------|
JUNK_DATA |
...... |
rop_gadget_3--------------->pop ebp,ret 15个
JUNK_DATA |
rop_gadget_3--------------->pop ebp,ret------|
JUNK_DATA
rop_gadget_2
shellcode
rop_gadget_3在pop ebp指令之前,恰好有一条call指令。这样在ROP_SimExeFlow函数15次循环执行完之后,就会返回TRUE,从而通过SimExeFlow的验证。当然了,这种形如call xxxx/n POP/ret n的rop gadget的存在,需要一定的机缘,但是确实是存在的。比如,Bypass DEP+ASLR 必备的msvcr71.dll中就有两个call xxxx/pop ebp/retn指令序列。
4.MemProt
MemProt通过对传入VirtualProtect/VirtualProtectEx的参数进行检验,以判断是否存在ROP的。其这两个API用于改写内存页访问保护属性,有以下三个关键参数:
a.lpAddress
b.dwSize
c.flwNewProtect
MemProt先对flwNewProtect进行检验,如果发现flwNewProtect中设置了可执行标记位,那么则进一步对lpAddress进行检验。如果发现[lpAddress,lpaddress+dwSize]这段内存恰好在当前线程的栈地址范围内,即以下不等式成立:
TEB.StackTop < lpAddress && (lpAddress+dwSize) < TEB.StackBottom
则认为检测到ROP。
个人觉得MemProt旨在防护exploit栈溢出漏洞的ROP。虽说“GS+SafeSEH+SEHOP”的重重防护使得一般的栈溢出利用技术已毫无用武之地,但在某些特殊的情形下,比如攻击者能够指哪儿写哪儿,或者在函数返回前利用被改写的函数指针等来说,攻击者还是有机会的。
5.LoadLib
LoadLib用于防止攻击者通过执行ROP Chain来从远程服务器上加载恶意的DLL,所以LoadLib对LoadLibraryA/LoadLibraryW/LoadLibraryEx/LoadLibraryExW的参数lpFileName做如下检验:
if(lpFileName[0] == '\\' && lpFileName[1] != '?')
{
//检测到ROP
return FALSE;
}
else
{
//正常,没有检测到ROP
return TRUE;
}
在SyScan'10上Brett Moore的演讲《Depth In DEP》中,提到了这种通过WebDAV/SMB加载DLL来旁路DEP的技术,不过我本人并没有见过使用了这种技术的POC。
以上是对EMET中5种ROP Mitigation的简单分析,个人觉得最狠的还是Caller和SimExeFlow这两种检测技术!当然了,在高人(比如......你懂的)眼里,一切Mitigation都是纸老虎,统统bypass!我等初学者只有顶礼膜拜的份儿了