【矛与盾】调戏调试器:反断点技术

首先声明,理解本文需要对用户态的调试器原理有所了解,否则可能有些内容会不理解。

先来个测试程序,本文以分析和理解此程序为主。

百度下载

无论是逆向分析、还是脱壳破解,都离不开调试器。而windows下面用户态调试器最常用的,那就是OllyDbg了。

现在就用OD载入并调试该程序,如下图所示。


OD载入程序后,就停在了OEP(原始入口点)。

可能有经验的朋友观察入口代码,就会发现,该程序的入口是典型的VC++。但是不要着急得意,慢慢来。

现在F9运行程序,程序是控制台界面的。如下图所示:


提示下个断点,随便找个地方按F2下断点就行(不要下在INT 3处),我就下在OEP了。


刚下完断点,就被中断了,一般程序是不可能,因为入口点一般只调用一次。

现在F7单步一下试试,如下图:


单步一下,程序居然直接崩了,很奇怪吧。好了,就调试到这里。大笑现在看TRE.exe的源码来讲解。

#include 
   
   
    
    
#include 
    
    
     
     
#include 
     
     
      
      
#include 
      
      
       
       
#include 
       
       
         #pragma comment(linker, "/ENTRY:MyEntry") #define ONLY_ASM __declspec(naked) #ifdef __cplusplus extern "C"{ #endif #ifdef UNICODE int wmainCRTStartup( #else int mainCRTStartup( #endif void); #ifdef __cplusplus } #endif #ifdef UNICODE #define _tmainCRTStartup wmainCRTStartup #else #define _tmainCRTStartup mainCRTStartup #endif // UNICODE typedef VOID (NTAPI * Func_RtlRaiseException)(__in PEXCEPTION_RECORD ExceptionRecord); typedef NTSTATUS (NTAPI * Func_NtRaiseException)(__in PEXCEPTION_RECORD ExceptionRecord, __in PCONTEXT ContextRecord, __in BOOLEAN FirstChance); int _tmain(int argc, _TCHAR* argv[]); DWORD WINAPI ProtectFunc(void * lParam); DWORD tid = 0; DWORD dwtmp0; DWORD dwtmp1; Func_RtlRaiseException f_rre; Func_NtRaiseException f_nre; DWORD dwtarr[12]; char fName[17] = { 0x82, 0xb8, 0x9e, 0xad, 0xa5, 0xbf, 0xa9, 0x89, 0xb4, 0xaf, 0xa9, 0xbc, 0xb8, 0xa5, 0xa3, 0xa2, 0X00 }; PVOID GetSectionAddrByName(HMODULE hMod, PCHAR pSecName, DWORD * pSecSize) { if (!hMod || !pSecName) return NULL; PIMAGE_DOS_HEADER pDos; PIMAGE_NT_HEADERS32 pNtHeader; PIMAGE_SECTION_HEADER pSection; WORD sNum; WORD i; pDos = (PIMAGE_DOS_HEADER)hMod; if (pDos->e_magic != IMAGE_DOS_SIGNATURE) return NULL; pNtHeader = (PIMAGE_NT_HEADERS32)((UINT)hMod + pDos->e_lfanew); if (pNtHeader->Signature != IMAGE_NT_SIGNATURE) return NULL; pSection = (PIMAGE_SECTION_HEADER)((UINT)pNtHeader + 0x18 + pNtHeader->FileHeader.SizeOfOptionalHeader); sNum = pNtHeader->FileHeader.NumberOfSections; for (i = 0; i < sNum; i++) { if (strcmp((PCHAR)pSection[i].Name, pSecName) == 0) { *pSecSize = pSection[i].Misc.VirtualSize; return (PVOID)((UINT)hMod + pSection[i].VirtualAddress); } } return NULL; } ONLY_ASM INT_PTR WINAPI GetFuncAddr(INT_PTR hMod, const PCHAR pName) { _asm { push ebp mov ebp, esp sub esp, 0x10 //为局部变量开辟空间 push ebx push esi push edi mov ebx, [ebp + 0x08] mov eax, [ebx + 0x3c] //dosheader->e_lfanew mov eax, [ebx + eax + 0x78] //导出表地址 test eax, eax //判断导出表地址是否为空 je ReturnNull add eax, ebx //加模块基址 //取出输出表中一些有用的值 mov ebx, [eax + 0x18] mov[ebp - 0x04], ebx mov ebx, [eax + 0x1C] add ebx, [ebp + 0x08] mov[ebp - 0x08], ebx mov ebx, [eax + 0x20] add ebx, [ebp + 0x08] mov[ebp - 0x0C], ebx mov ebx, [eax + 0x24] add ebx, [ebp + 0x08] mov[ebp - 0x10], ebx mov esi, [ebp + 0x0C] test esi, 0xFFFF0000 jne Get_API_AddressByName mov eax, esi dec eax jmp Get_API_AddressByIndex //函数名取地址 Get_API_AddressByName : xor eax, eax mov edi, [ebp - 0x0C] mov ecx, [ebp - 0x04] LoopNumberOfName : mov esi, [ebp + 0x0C] push eax mov ebx, [edi] add ebx, [ebp + 0x08] Match_API : mov al, byte ptr[ebx] cmp al, [esi] jnz Not_Match or al, 0x00 jz Get_API_Index_Found inc ebx inc esi jmp Match_API Not_Match : pop eax inc eax add edi, 0x04 loop LoopNumberOfName jmp ReturnNull Get_API_Index_Found : pop eax Get_API_AddressByIndex : mov ebx, [ebp - 0x10] movzx eax, word ptr[ebx + eax * 0x02] imul eax, 0x04 add eax, [ebp - 0x08] mov eax, [eax] add eax, [ebp + 0x08] jmp ReturnVal ReturnNull : xor eax, eax ReturnVal : pop edi pop esi pop ebx add esp, 0x10 pop ebp retn 0x08 } } void MyInit1() { INT_PTR NtBase = 0; PCHAR pc = fName; __asm mov eax, fs:[0x30] __asm mov eax, [eax + 0x0C] __asm mov eax, [eax + 0x1C] __asm mov eax, [eax + 0x08] __asm mov NtBase, eax f_nre = (Func_NtRaiseException)GetFuncAddr(NtBase, fName); while (*pc) { *pc ^= 0xCC; pc++; } CreateThread(NULL, 4194304, ProtectFunc, MyInit1, 0, &tid); if (!tid || !f_nre) { printf("初始化失败!\n"); ExitProcess(0); } } void MyInit() { HMODULE hMod = GetModuleHandle(NULL); PCHAR pc = fName; PIMAGE_DOS_HEADER pDos; PIMAGE_NT_HEADERS32 pNtHeader; pDos = (PIMAGE_DOS_HEADER)hMod; while (*pc) { *pc ^= 0xCC; pc++; } MyInit1(); if (pDos->e_magic != IMAGE_DOS_SIGNATURE) return; pNtHeader = (PIMAGE_NT_HEADERS32)((UINT)hMod + pDos->e_lfanew); if (pNtHeader->Signature != IMAGE_NT_SIGNATURE) return; return; } ONLY_ASM int CallCrt() { __asm call _tmainCRTStartup /* 下面的指令就是混淆视线的 */ __asm retn __asm nop __asm push ebp __asm mov ebp, esp __asm int 3 __asm int 1 __asm mov esp, ebp __asm pop ebp __asm retn } ONLY_ASM int MyEntry() { __asm { call MyInit jmp CallCrt /* 下面的指令就是混淆视线的 */ push ebp mov ebp, esp call IsDebuggerPresent push 1 mov dwtmp0, eax call _tmain push dword ptr [ebp + 0x08] call ProtectFunc cmp dwtmp1, 0 pop ecx pop ecx jnz JMP0 push 0x1 call _tmain pop ecx JMP0 : push 0xC0000409 call ProtectFunc pop ecx pop ebp retn push ebp mov ebp, esp push 0x17 call IsDebuggerPresent test eax, eax je JMP0 push 0x2 pop ecx mov dwtarr[0], eax mov dwtarr[1], ecx mov dwtarr[2], edx mov dwtarr[3], ebx mov dwtarr[4], esi mov dwtarr[5], edi mov word ptr dwtarr[6], ss mov word ptr dwtarr[7], cs mov word ptr dwtarr[8], ds mov word ptr dwtarr[9], es mov word ptr dwtarr[10], fs mov word ptr dwtarr[11], gs pushfd push 0x4 call _tmain pop eax imul eax, eax, 0x0 mov dword ptr ss : [ebp + eax - 0x8], ecx push 0x4 pop eax mov esp, ebp pop ebp retn } } PBYTE WINAPI CompBytes(PBYTE pm0, PBYTE pm1, UINT mMax, PBYTE pOByte) { UINT i; for (i = 0; i < mMax;i++) { if (pm0[i] != pm1[i]) { if (pm0[i] == 0xCC) { *pOByte = pm1[i]; return &pm0[i]; } } } return NULL; } DWORD WINAPI ProtectFunc(void * lParam) { PBYTE poMem; PBYTE oAddr; DWORD mSize; PBYTE nAddr; NTSTATUS ns; EXCEPTION_RECORD ER; CONTEXT ct; BYTE OByte; oAddr = (PBYTE)GetSectionAddrByName(GetModuleHandle(NULL), ".text", &mSize); poMem = (PBYTE)HeapAlloc(GetProcessHeap(), 0, mSize); if (!poMem) { printf("HeapAlloc失败!退出...\n"); ExitProcess(0); } memcpy_s(poMem, mSize, oAddr, mSize); while (true) { nAddr = CompBytes(oAddr, poMem, mSize, &OByte); if (nAddr) { while (*nAddr != OByte) { /* 抛出假断点异常 */ ER.ExceptionCode = EXCEPTION_BREAKPOINT; ER.ExceptionFlags = 0; ER.ExceptionRecord = NULL; ER.ExceptionAddress = nAddr; ER.NumberParameters = 1; ER.ExceptionInformation[0] = 0; ct.ContextFlags = CONTEXT_FULL; ct.Eip = DWORD(nAddr); ct.Dr0 = 0; ct.Dr1 = 0; ct.Dr2 = 0; ct.Dr3 = 0; ct.Dr6 = 0; ct.Dr7 = 0; ct.SegGs = 0x2B; ct.SegFs = 0x53; ct.SegEs = 0x2B; ct.SegDs = 0x2B; ct.SegCs = 0x23; ct.EFlags = 0x00000246; ct.SegSs = 0x2B; ns = f_nre(&ER, &ct, FALSE); Sleep(60); } } Sleep(80); } HeapFree(GetProcessHeap(), 0, poMem); return 0; } int _tmain(int argc, _TCHAR* argv[]) { printf("下个断点试试!:)\n"); getchar(); printf("按任意键退出!\n"); getchar(); return 0; } 
       
      
      
     
     
    
    
   
   

代码有些技术性的东西,可能会有点难理解。
首先,本程序入口不是默认的CRT入口。
程序入口是ONLY_ASM int MyEntry();函数。
由纯汇编写成。本函数整容成了CRT入口的样子:
call XXXXXXXX
jmp XXXXXXXX
后面的代码完全就是混淆视线用的。一般调试VC的程序,不会跟进CRT里面的。
第一个call就直接步过了,如果你在这里步过了,那可就错过精彩内容了。
第二个jmp才是跳转到真正的CRT入口。
看来下call MyInit都做了些什么吧。
void MyInit()
{
  HMODULE hMod = GetModuleHandle(NULL);
  PCHAR pc = fName;
  PIMAGE_DOS_HEADER pDos;
  PIMAGE_NT_HEADERS32 pNtHeader;

  pDos = (PIMAGE_DOS_HEADER)hMod;

  while (*pc)
  {
    *pc ^= 0xCC;
    pc++;
  }

  MyInit1();
  if (pDos->e_magic != IMAGE_DOS_SIGNATURE) return;
  pNtHeader = (PIMAGE_NT_HEADERS32)((UINT)hMod + pDos->e_lfanew);
  if (pNtHeader->Signature != IMAGE_NT_SIGNATURE) return;
  return;
}

这段代码里,只有2部分是有用的。
while和MyInit1。
while是解码字符串用的,因为需要动态获取API的地址,又不想被枚举出来字符串。所以我把每个字符都xor 0xCC了。
用的时候,在xor 0xCC解密出来。
解码完的字符串是"NtRaiseException"。
然后是调用MyInit1();,看下MyInit1()的代码:
void MyInit1()
{
  INT_PTR NtBase = 0;
  PCHAR pc = fName;

  __asm mov eax, fs:[0x30]
  __asm mov eax, [eax + 0x0C]
  __asm mov eax, [eax + 0x1C]
  __asm mov eax, [eax + 0x08]
  __asm mov NtBase, eax

  f_nre = (Func_NtRaiseException)GetFuncAddr(NtBase, fName);

  while (*pc)
  {
    *pc ^= 0xCC;
    pc++;
  }

  CreateThread(NULL, 4194304, ProtectFunc, MyInit1, 0, &tid);

  if (!tid || !f_nre)
  {
    printf("初始化失败!\n");
    ExitProcess(0);
  }
}
MyInit1函数里的代码都是有用的。
先看第一段,取TEB放到eax。然后取第一个模块的基址。
这里要补充一点,不论是xp还是win 8.1,不论32位还是64位系统,一个进程的第一个模块,一定是ntdll.dll。
32位和64位的差距是第二个模块,32位下第二个模块是kernel32.dll,64位是kernelbase.dll模块。
然后调用GetFuncAddr(GetFuncAddr是汇编写的,功能等于GetProcAddress),取ntdll模块中NtRaiseException的地址。
把取到的地址存到f_nre中,接着while再把字符串加密了,这个可以不要。
创建一个新线程,线程入口是ProctectFunc,线程参数是MyInit1的地址。这个可以没有,就是用来迷惑人的。
如果创建失败,就退出进程。
现在再看看在ProctectFunc里都做了什么:
DWORD WINAPI ProtectFunc(void * lParam)
{
  PBYTE poMem;
  PBYTE oAddr;
  DWORD mSize;
  PBYTE nAddr;
  NTSTATUS ns;
  EXCEPTION_RECORD ER;
  CONTEXT ct;
  BYTE OByte;

  oAddr = (PBYTE)GetSectionAddrByName(GetModuleHandle(NULL), ".text", &mSize);
  poMem = (PBYTE)HeapAlloc(GetProcessHeap(), 0, mSize);
  
  if (!poMem)
  {
    printf("HeapAlloc失败!退出...\n");
    ExitProcess(0);
  }

  memcpy_s(poMem, mSize, oAddr, mSize);

  while (true)
  {
    nAddr = CompBytes(oAddr, poMem, mSize, &OByte);
    if (nAddr)
    {
      while (*nAddr != OByte)
      {
        /* 抛出假断点异常 */
        ER.ExceptionCode = EXCEPTION_BREAKPOINT;
        ER.ExceptionFlags = 0;
        ER.ExceptionRecord = NULL;
        ER.ExceptionAddress = nAddr;
        ER.NumberParameters = 1;
        ER.ExceptionInformation[0] = 0;

        ct.ContextFlags = CONTEXT_FULL;
        ct.Eip = DWORD(nAddr);
        ct.Dr0 = 0;
        ct.Dr1 = 0;
        ct.Dr2 = 0;
        ct.Dr3 = 0;
        ct.Dr6 = 0;
        ct.Dr7 = 0;
        ct.SegGs = 0x2B;
        ct.SegFs = 0x53;
        ct.SegEs = 0x2B;
        ct.SegDs = 0x2B;
        ct.SegCs = 0x23;
        ct.EFlags = 0x00000246;
        ct.SegSs = 0x2B;

        ns = f_nre(&ER, &ct, FALSE);
        Sleep(60);
      }
    }
    Sleep(80);
  }

  HeapFree(GetProcessHeap(), 0, poMem);
  return 0;
}

首先取本模块的.text段的基址和段大小。
然后再进程默认堆上申请一块内存。内存大小是.text段的大小。
然后把.text段复制过去,之后开始死循环。
不停的比较2块内存块。当某字节被修改,并且那个字节是0xCC(int3的机器码),那就返回那个字节所在的地址。
然后就是重中之重,构造一个异常的结构,并且用NtRaiseException抛出。
这里你也许会问,为什么不用RaiseException抛异常?如果真那么简单,我想就不用写本文了。
先来稍微讲下抛出异常函数的结构:
void WINAPI RaiseException(
__in DWORD dwExceptionCode,
__in DWORD dwExceptionFlags,
__in DWORD nNumberOfArguments,
__in const ULONG_PTR *lpArguments);

VOID NTAPI RtlRaiseException (
__in PEXCEPTION_RECORD ExceptionRecord);

NTSTATUS NTAPI NtRaiseException (
__in PEXCEPTION_RECORD ExceptionRecord,
__in PCONTEXT ContextRecord,
__in BOOLEAN FirstChance);

微软只给开发者公开了第一个API,即RaiseException,
后两个函数都在ntdll中被导出,但是没有公开。
这后两个函数的结构好像是在MS泄露的NT源码中提到的。
我是在一个外国网站找到定义的: 链接
其中RaiseException是对RtlRaiseException的封装,
RtlRaiseException又是对NtRaiseException的封装。
如果调用RaiseException的话,用户只能配置简单的几个参数。
调用RtlRaiseException的话,用户只能配置EXCEPTION_RECORD结构。
调用NtRaiseException的话,才算是真正用户自定义的异常。能配置异常记录结构,还能配置线程上下文。
先看下RaiseException的反汇编代码:

再看看RtlRaiseException的反汇编代码:


我们如果调用ZwRaiseException(NtRaiseException和ZwRaiseException其实是一个函数),需要自己配置参数即可。
本来我的代码是调用RtlRaiseException抛出断点异常给调试器的,可惜没有骗过OD。
个人认为,OD是需要根据CONTEXT的Eip而不是EXCEPTION_RECORD的ExceptionAddress来判断int3的地址的。

现在再来了解下,用户态调试器是怎么处理异常的。
详细内容可以参考 Windows用户态调试器原理
调试器使用WaitForDebugEvent等待调试事件,
处理了异常或者事件之后,调用ContinueDebugEvent,恢复线程。
有一点要清楚,并不是所有的int3断点异常OD都会处理的。
OD也是可以配置的:

这里的忽略异常,是指的未知来源的异常,什么是未知来源异常呢?
就是指异常不是OD引发的。是OD自己干的"好事",它肯定要自己收拾。
如果OD所有的异常都处理的话,那检测调试器就很简单了:
设置好VEH、SEH或者UEF后,触发一个int3,如果没有跳转到异常处理器,就直接退出进程。

那么OD是怎么判断什么样的异常是它该处理的呢。
这个其实写过调试器的人都知道:
比如OD在0x00401000处下了一个断点。
OD会记录下0x00401000处的一个字节,再把0x00401000处写成0xCC(int3),并记录下0x00401000这个地址。
这一切在OD的反汇编界面是看不到的。
当触发断点异常后,OD会判断异常地址是否是自己设置的(0x00401000)。如果不是,那就放弃处理异常。
如果是的话,把记录下的那一字节再写回0x00401000处,当用户按F9运行时,od还会设置单步异常。
在下一条指令单步异常被触发时候,OD又把0x00401000处改写成0xCC。这样断点就不至于只用一次。
所以说,当检测到有字节被修改,并且是0xCC,那基本就是调试器下的断点无疑的。
这时候,配置异常记录结构,和线程上下文。
把异常类型设置成EXCEPTION_BREAKPOINT,异常地址和线程的eip配置成前面检测到被修改的地址。
然后抛出异常,这样OD就中计了,其实根本没有被触发int3异常。
但是为什么OD收到假异常,再执行进程就会崩溃,这个就不是很清楚了,反正我们的目的达到了。
只要你敢下int3,我就给你抛异常玩 大笑

但是这种方法也有弊端。就是.text太大的话,效率肯定不快。

其实还可以只对IAT入口第一字节执行断点检测就行。

以上内容为本人分析的结果,如有错误,欢迎指出。


  • 0
    点赞
  • 3
    收藏
    觉得还不错? 一键收藏
  • 1
    评论
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值