65.9K
CodeProject 正在变化。 阅读更多。
Home

反向工程指南

starIconstarIconstarIconstarIcon
emptyStarIcon
starIcon

4.96/5 (176投票s)

2008年11月9日

CPOL

23分钟阅读

viewsIcon

867556

downloadIcon

8733

本文深入探讨了反向工程防护领域,并为开发人员提供了一些功能和思路,以防止程序被反向工程。

retool.JPG

目录

  1. 断点
  2. 时序攻击
  3. Windows 内部机制
  4. 进程利用
  5. 反转储
  6. IA-32 指令利用
  7. OllyDBG 特定
  8. WinDBG 特定
  9. 其他技术

引言

在我之前的文章中,我简要介绍了主要涉及使用 Win32 API 函数的一些反调试/调试器检测技术。在本文中,我计划更深入地探讨反向工程这个有趣的世界,并探索一些更中级的技术来骚扰反向工程师。我之前文章中的一些评论指出,我提出的技术可以,而且大多数时候,很容易被中级反向工程师绕过;我想说的是,开发程序以防止破解和反向工程的编码人员与工程师之间存在着一场持续的战争。每当保护者发布一项新技术时,工程师就会找到一种绕过该特定方法的方法。这是破解“场景”和反向工程防护领域背后的驱动力。这里的大多数技术都可以很容易地被绕过,而其他一些技术则不容易被消除;但是,所有这些技术都可以通过某种方式、形式或形式被绕过。我在这里展示这些方法是为了分享知识,并可能启发其他人找到将这些方法应用于新颖创意的方式,以挑战当代方法论。

背景

任何对逆向工程领域感兴趣的人都需要对汇编语言有扎实的理解,所以如果你的汇编知识有点生疏或者你刚开始学习,这里有一些可以帮助你的网站

内联函数

我觉得这个旁注不需要单独一节;但是,在阅读本文或附件源代码时,您会注意到函数被标记为内联。虽然这会导致可执行文件膨胀,但在反向工程中却很重要。如果函数入口和节非常详细,那么反向工程师的工作就容易得多。现在,他或她确切地知道该函数被调用时发生了什么。当内联时,这种情况不会发生,工程师只能猜测实际发生了什么。

断点

反汇编工程师可以使用三种类型的断点:硬件、内存和 INT 3h 断点。断点对反汇编工程师至关重要,没有它们,对模块的实时分析对他或她没什么好处。断点允许程序在放置断点的任何位置停止执行。通过利用这一点,反汇编工程师可以在 Windows API 等区域放置断点,并且可以非常容易地找到“坏消息”(例如,一个提示您输入了错误序列号的消息框)的来源。事实上,这可能是破解中最常用的技术,唯一的竞争对手是引用文本字符串搜索。这就是为什么在 MessageBoxVirtualAllocCreateDialog 等对保护用户信息过程起重要作用的重要 API 上进行断点检查的原因。第一个示例将涵盖最常见的断点类型,它利用 INT 3h 指令。

INT 3

INT 3h 断点在 IA-32 指令集中用操作码 CC (0xCC) 表示。这是这种类型断点最常见的表达方式;然而,它也可以表示为字节序列 0xCD 0x03,这可能会引起一些麻烦。检测这种类型的断点相对简单,一些源代码可能看起来像下面的示例。但是,我们应该小心,因为使用这种扫描方法可能会导致误报。

bool CheckForCCBreakpoint(void* pMemory,  size_t SizeToCheck)
{
     unsigned char *pTmp = (unsigned char*)pMemory; 
    for (size_t i = 0; i < SizeToCheck; i++)
     {
         if(pTmp[i] == 0xCC)
             return true;
     } 

    return false;
}

这是另一种用于检查 INT 3 断点的混淆方法。重要的是要记住,即使是新手逆向工程师,上面显示的代码也会显得格格不入。通过增加另一层间接性,您,作为保护者,正在提高成功保护应用程序的机会。

bool CheckForCCBreakpointXor55(void* pMemory,  size_t SizeToCheck)
 {
     unsigned char *pTmp = (unsigned char*)pMemory;
    unsigned char tmpchar = 0;
        
    for (size_t i = 0; i < SizeToCheck; i++)
     {
        tmpchar = pTmp[i];
        if( 0x99 == (tmpchar ^ 0x55) ) // 0xCC xor 0x55 = 0x99
            return true;
     } 

    return false;
 }

内存断点

内存断点由调试器使用保护页实现,它们的作用类似于“内存页访问的一次性警报”(创建保护页)。简而言之,当一块内存被标记为 PAGE_GUARD 并被访问时,会引发 STATUS_GUARD_PAGE_VIOLATION 异常,然后该异常可以由当前程序处理。目前,没有准确的方法来检查内存断点。但是,我们可以使用调试器实现内存断点所使用的技术来发现我们的程序当前是否在调试器下运行。本质上,所发生的是我们分配一个动态缓冲区并将一个 RET 写入缓冲区。然后我们将该页标记为保护页,并将一个潜在的返回地址压入堆栈。接下来,我们跳到我们的页,如果我们处于调试器下,特别是 OllyDBG,那么我们将命中 RET 指令并返回到我们在跳到我们的页之前压入堆栈的地址。否则,将发生 STATUS_GUARD_PAGE_VIOLATION 异常,我们就知道我们没有被 OllyDBG 调试。这是一个源代码示例

bool MemoryBreakpointDebuggerCheck()
{
     unsigned char *pMem = NULL;
     SYSTEM_INFO sysinfo = {0}; 
     DWORD OldProtect = 0;
     void *pAllocation = NULL; // Get the page size for the system 
 
    GetSystemInfo(&sysinfo); // Allocate memory 
 
    pAllocation = VirtualAlloc(NULL, sysinfo.dwPageSize, 
                        MEM_COMMIT | MEM_RESERVE, 
                         PAGE_EXECUTE_READWRITE); 
        
    if (pAllocation == NULL)
        return false; 
    
    // Write a ret to the buffer (opcode 0xc3)
    pMem = (unsigned char*)pAllocation;
    *pMem = 0xc3; 
    
    // Make the page a guard page         
    if (VirtualProtect(pAllocation, sysinfo.dwPageSize, 
                    PAGE_EXECUTE_READWRITE | PAGE_GUARD, 
                    &OldProtect) == 0)
    {
        return false;
    } 
    
    __try
    {
        __asm
        {
            mov eax, pAllocation
            // This is the address we'll return to if we're under a debugger
            push MemBpBeingDebugged
            jmp eax // Exception or execution, which shall it be :D?
        }
    }
    __except(EXCEPTION_EXECUTE_HANDLER)
    {
        // The exception occured and no debugger was detected
        VirtualFree(pAllocation, NULL, MEM_RELEASE);
        return false;
    }     
    
    __asm{MemBpBeingDebugged:}
    VirtualFree(pAllocation, NULL, MEM_RELEASE);
    return true;
}

硬件断点

硬件断点是 Intel 在其处理器架构中实现的一项技术,并通过使用称为 Dr0-Dr7 的特殊寄存器进行控制。Dr0 到 Dr3 是用于保存断点地址的 32 位寄存器。Dr4 和 Dr5 由 Intel 保留用于调试其他寄存器,而 Dr6 和 Dr7 用于控制断点的行为 (Intel1)。关于 Dr6 和 Dr7 寄存器如何影响断点行为的信息太多,我无法全部涵盖。但是,任何感兴趣的人都应该阅读《Intel® 64 和 IA-32 架构软件开发人员手册第 3B 卷:系统编程指南》,以深入了解寄存器的工作原理。

现在,为了检测和/或移除硬件断点,我们可以利用两种方法:Win32 的 GetThreadContextSetThreadContext,或者使用结构化异常处理。在第一个例子中,我将展示如何使用 Win32 API 函数

// CheckHardwareBreakpoints returns the number of hardware 
// breakpoints detected and on failure it returns -1.
int CheckHardwareBreakpoints()
{
    unsigned int NumBps = 0;

    // This structure is key to the function and is the 
    // medium for detection and removal
    CONTEXT ctx;
    ZeroMemory(&ctx, sizeof(CONTEXT)); 
    
    // The CONTEXT structure is an in/out parameter therefore we have
    // to set the flags so Get/SetThreadContext knows what to set or get.
    ctx.ContextFlags = CONTEXT_DEBUG_REGISTERS; 
    
    // Get a handle to our thread
    HANDLE hThread = GetCurrentThread();

    // Get the registers
    if(GetThreadContext(hThread, &ctx) == 0)
        return -1;

    // Now we can check for hardware breakpoints, its not 
    // necessary to check Dr6 and Dr7, however feel free to
    if(ctx.Dr0 != 0)
        ++NumBps; 
    if(ctx.Dr1 != 0)
           ++NumBps; 
    if(ctx.Dr2 != 0)
           ++NumBps; 
    if(ctx.Dr3 != 0)
        ++NumBps;
        
    return NumBps;
}

SEH 方法操作调试寄存器在反向工程程序中更为常见,并且在 ASM 中更容易实现,如下例所示

; One quick note about this little prelude; in Visual Studio 2008 
; release builds are compiled with the /SAFESEH flag which helps
; prevent exploitation of SEH by shellcode and the likes.
; What this little snippet does is add our SEH Handler to a
; special table containing a list of "safe" exceptions handlers , which
; if we didn't in release builds our handler would never be called,
; this problem plauged me for a long time, and im considering writing
; a short article on it

ClrHwBpHandler proto
 .safeseh ClrHwBpHandler

ClearHardwareBreakpoints proc
     assume fs:nothing
     push offset ClrHwBpHandler
    push fs:[0]
    mov dword ptr fs:[0], esp ; Setup SEH
     xor eax, eax
     div eax ; Cause an exception
     pop dword ptr fs:[0] ; Execution continues here
     add esp, 4
     ret
ClearHardwareBreakpoints endp

ClrHwBpHandler proc 
     xor eax, eax
    mov ecx, [esp + 0ch] ; This is a CONTEXT structure on the stack
     mov dword ptr [ecx + 04h], eax ; Dr0
     mov dword ptr [ecx + 08h], eax ; Dr1
     mov dword ptr [ecx + 0ch], eax ; Dr2
     mov dword ptr [ecx + 10h], eax ; Dr3
     mov dword ptr [ecx + 14h], eax ; Dr6
     mov dword ptr [ecx + 18h], eax ; Dr7
     add dword ptr [ecx + 0b8h], 2 ; We add 2 to EIP to skip the div eax
     ret
ClrHwBpHandler endp

时序攻击

时序攻击的理论是,执行一段代码,特别是小段代码,应该只需要极少的时间。因此,如果一段定时代码花费的时间超过了某个设定的限制,那么很可能有一个调试器附加,并且有人正在逐步执行代码。这种攻击类型有许多小的变体,最常见的例子是使用 IA-32 的 RDTSC 指令。其他方法则利用不同的时序方法,例如 timeGetTimeGetTickCountQueryPerformanceCounter

RDTSC

RDTSC 是一条 IA-32 指令,代表“读取时间戳计数器”,其本身已非常清楚。自奔腾处理器以来,处理器都带有一个计数器,该计数器在每个时钟周期递增,并在处理器复位时重置为 0。如您所见,这是一种非常强大的时序技术;然而,Intel 并未序列化该指令;因此,不能保证其 100% 准确。这就是为什么 Microsoft 鼓励使用其 Win32 时序 API,因为它们应该尽可能地准确。不过,时序攻击的优点在于,实现该技术相当简单;开发人员只需决定他或她想使用时序攻击保护哪些函数,然后他或她只需将代码块封装在一个时序块中,并将其与程序员设定的限制进行比较,如果定时代码执行时间过长,则可以退出程序。这是一个示例

#define SERIAL_THRESHOLD 0x10000 // 10,000h ticks

DWORD GenerateSerial(TCHAR* pName)
{
    DWORD LocalSerial = 0;

    DWORD RdtscLow = 0; // TSC Low
    __asm
    {
        rdtsc
        mov RdtscLow, eax
    } 
    
    size_t strlen = _tcslen(pName); 
    
    // Generate serial 

    for(unsigned int i = 0; i < strlen; i++)
    { 
        LocalSerial += (DWORD) pName[i];
        LocalSerial ^= 0xDEADBEEF;
    }

    __asm
    {
        rdtsc
        sub eax, RdtscLow
        cmp eax, SERIAL_THRESHOLD
        jbe NotDebugged
        push 0
        call ExitProcess
        NotDebugged:
    } 
    return LocalSerial;
}

Win32 计时函数

这种变体中的概念完全相同,只是我们有不同的计时函数方法。在下面的示例中,使用了 GetTickCount,但正如注释所说,可以用 timeGetTimeQueryPerformanceCounter 替换。

#define SERIAL_THRESHOLD 0x10000 // 10,000h ticks


GenerateSerialWin32Attack(TCHAR* pName)
{
    DWORD LocalSerial = 0;
    size_t strlen = _tcslen(pName);

    DWORD Counter = GetTickCount(); // Could be replaced with timeGetTime()

     // Generate serial 
    for(unsigned int i = 0; i < strlen; i++)
    { 
        LocalSerial += (DWORD) pName[i];
        LocalSerial ^= 0xDEADBEEF;
    } 
    
    Counter = GetTickCount() - Counter; // Could be replaced with timeGetTime() 
    if(Counter >= SERIAL_THRESHOLD)
        ExitProcess(0); 
    
    return LocalSerial;
}

Windows 内部原理

以下反向工程技术利用 Windows 操作系统的特殊性来实现某种保护,范围从隐藏线程不被调试器发现,到揭示调试器的存在。以下示例中使用的许多函数是从 _ntdll.dll_ 导出的,Microsoft 不保证它们在不同版本的操作系统中行为一致。因此,在您自己的程序中使用这些示例时应谨慎。话虽如此,我还没有看到其中任何一个 API 的行为发生剧烈变化,所以不要将上述声明视为避免这些实现的指令。

ProcessDebugFlags

ProcessDebugFlags (0x1f) 是一个未公开的类,可以传递给 NtQueryProcessInformation 函数。当 NtQueryProcessInformation 调用时带有 ProcessDebugFlags 类,该函数将返回 EPROCESS->NoDebugInherit 的反值,这意味着如果存在调试器,则如果进程正在被调试,此函数将返回 FALSE。这是 CheckProcessDebugFlags

// CheckProcessDebugFlags will return true if 
// the EPROCESS->NoDebugInherit is == FALSE, 
// the reason we check for false is because 
// the NtQueryProcessInformation function returns the
// inverse of EPROCESS->NoDebugInherit so (!TRUE == FALSE)
inline bool CheckProcessDebugFlags()
{
    // Much easier in ASM but C/C++ looks so much better
    typedef NTSTATUS (WINAPI *pNtQueryInformationProcess)
        (HANDLE ,UINT ,PVOID ,ULONG , PULONG); 

    DWORD NoDebugInherit = 0; 
    NTSTATUS Status; 

    // Get NtQueryInformationProcess
    pNtQueryInformationProcess NtQIP = (pNtQueryInformationProcess)
        GetProcAddress( GetModuleHandle( TEXT("ntdll.dll") ), 
        "NtQueryInformationProcess" ); 

    Status = NtQIP(GetCurrentProcess(), 
            0x1f, // ProcessDebugFlags
            &NoDebugInherit, 4, NULL); 

    if (Status != 0x00000000)
        return false; 

    if(NoDebugInherit == FALSE)
        return true;
    else
        return false;
}

调试对象句柄

从 Windows XP 开始,当进程被调试时,会为该调试会话创建一个调试对象。还会创建一个指向该对象的句柄,可以使用 NtQueryInformationProcess 进行查询。该句柄的存在表明进程正在被 actively 调试,并且由于它来自内核,因此隐藏此信息可能相当麻烦。这是 DebugObjectCheck 函数

// This function uses NtQuerySystemInformation
// to try to retrieve a handle to the current
// process's debug object handle. If the function
// is successful it'll return true which means we're
// being debugged or it'll return false if it fails
// or the process isn't being debugged
inline bool DebugObjectCheck()
{
    // Much easier in ASM but C/C++ looks so much better
    typedef NTSTATUS (WINAPI *pNtQueryInformationProcess)
            (HANDLE ,UINT ,PVOID ,ULONG , PULONG); 

    HANDLE hDebugObject = NULL;
    NTSTATUS Status; 

    // Get NtQueryInformationProcess
    pNtQueryInformationProcess NtQIP = (pNtQueryInformationProcess)
                GetProcAddress(  GetModuleHandle( TEXT("ntdll.dll") ), 
                "NtQueryInformationProcess" ); 

    Status = NtQIP(GetCurrentProcess(), 
            0x1e, // ProcessDebugObjectHandle
            &hDebugObject, 4, NULL); 
    
    if (Status != 0x00000000)
        return false; 

    if(hDebugObject)
        return true;
    else
        return false;
}

线程隐藏

在 Windows 2000 中,Windows 团队引入了一个新类,可传递给 NtSetInformationThread,名为 HideThreadFromDebugger。它是 Windows 实现的第一个反调试 API,功能非常强大。该类可防止调试器接收来自任何已调用 NtSetInformationThread 并带有 HideThreadFromDebugger 类的线程的事件。这些事件包括断点,如果该类在应用程序的主线程上调用,还包括程序的退出。这是 HideThread 函数

// HideThread will attempt to use
// NtSetInformationThread to hide a thread
// from the debugger, Passing NULL for
// hThread will cause the function to hide the thread
// the function is running in. Also, the function returns
// false on failure and true on success
inline bool HideThread(HANDLE hThread)
{
    typedef NTSTATUS (NTAPI *pNtSetInformationThread)
                (HANDLE, UINT, PVOID, ULONG); 
    NTSTATUS Status; 

    // Get NtSetInformationThread
    pNtSetInformationThread NtSIT = (pNtSetInformationThread)
        GetProcAddress(GetModuleHandle( TEXT("ntdll.dll") ),
        "NtSetInformationThread");

    // Shouldn't fail
    if (NtSIT == NULL)
        return false; 

    // Set the thread info
    if (hThread == NULL)
        Status = NtSIT(GetCurrentThread(), 
                0x11, // HideThreadFromDebugger
                0, 0);
    else
        Status = NtSIT(hThread, 0x11, 0, 0); 

    if (Status != 0x00000000)
        return false;
    else
        return true;
}

BlockInput

这再简单不过了。BlockInput 顾名思义,阻止鼠标和键盘消息到达目标应用程序;此技术有效,因为只有调用 BlockInput 的线程才能调用它来解除阻止(“BlockInput 函数”)。这并不是一种真正的反向工程技术,更像是一种干扰调试应用程序的方式。一个简单的源代码如下

BlockInput(TRUE); // Nice and simple :D

OutputDebugString

OutputDebugString 技术的工作原理是判断 OutputDebugString 是否会导致错误。只有当进程没有活动调试器来接收字符串时才会发生错误;因此,我们可以得出结论,如果在调用 OutputDebugString 后没有错误(通过调用 GetLastError),则存在调试器。

// CheckOutputDebugString checks whether or 
// OutputDebugString causes an error to occur
// and if the error does occur then we know 
// there's no debugger, otherwise if there IS
// a debugger no error will occur
inline bool CheckOutputDebugString(LPCTSTR String)
{
    OutputDebugString(String);
    if (GetLastError() == 0)
        return true;
    else
        return false;
}

进程利用

这些技术利用 Windows 进程环境和管理系统来实现保护。其中一些技术,特别是自调试,被许多打包器和保护器广泛使用。

Open Process

此调试器检测技术利用进程权限来判断进程当前是否在调试器下运行。此技术之所以有效,是因为当进程附加到或在调试器下运行时,如果附加调试器未能正确重置进程权限,则进程会获得 SeDebugPrivilege 集,从而允许进程打开任何进程的句柄(“如何使用 SeDebugPrivilege 获取任何进程句柄”)。这包括像 _csrss.exe_ 这样的关键系统进程,我们通常无法访问它。以下是一些源代码来演示该技术

// The function will attempt to open csrss.exe with 
// PROCESS_ALL_ACCESS rights if it fails we're 
// not being debugged however, if its successful we probably are
inline bool CanOpenCsrss()
{
    HANDLE Csrss = 0; 

    // If we're being debugged and the process has
    // SeDebugPrivileges privileges then this call
    // will be successful, note that this only works
    // with PROCESS_ALL_ACCESS.
    Csrss = OpenProcess(PROCESS_ALL_ACCESS, 
                FALSE, GetCsrssProcessId());
    
    if (Csrss != NULL)
    {
        CloseHandle(Csrss);
        return true;
    }        
    else
        return false;
}

父进程

通常,Windows 用户从 Windows Shell 创建或提供的窗口启动进程。在这种情况下,子进程的父进程是 _Explorer.exe_。因此,我们可以检索 _Explorer.exe_ 和我们父进程的进程 ID 并进行比较。当然,这是一个有点冒险的过程,因为您的进程的父进程不保证是 _Explorer.exe_;尽管如此,它仍然是一种有趣的技术,下面是一个示例

// This function returns true if the parent process of
// the current running process is Explorer.exe
inline bool IsParentExplorerExe()
{
    // Both GetParentProcessId and GetExplorerPIDbyShellWindow
    // can be found in the attached source
    DWORD PPID = GetParentProcessId();
    if(PPID == GetExplorerPIDbyShellWindow())
        return true;
    else
        return false;
        //return GetParentProcessId() == GetExplorerPIDbyShellWindow()'
}

自调试

自调试是一种技术,其中主进程生成一个子进程,该子进程调试创建该子进程的进程,如图所示。该技术非常有用,因为它可以用于实现 Nanomites 等技术。这还阻止了其他调试器附加到同一进程;但是,可以通过将 EPROCESS->DebugPortEPROCESS 结构是内核模式函数 PsGetProcessId 返回的结构)字段设置为 0 来绕过此限制。这允许另一个调试器附加到已经附加了调试器的进程。这里有一些示例代码

selfdebug.png

// Debug self is a function that uses CreateProcess
// to create an identical copy of the current process
// and debugs it
void DebugSelf()
{
    HANDLE hProcess = NULL;
    DEBUG_EVENT de;
    PROCESS_INFORMATION pi;
    STARTUPINFO si;
    ZeroMemory(&pi, sizeof(PROCESS_INFORMATION));
    ZeroMemory(&si, sizeof(STARTUPINFO));
    ZeroMemory(&de, sizeof(DEBUG_EVENT)); 

    GetStartupInfo(&si);

    // Create the copy of ourself
    CreateProcess(NULL, GetCommandLine(), NULL, NULL, FALSE,
            DEBUG_PROCESS, NULL, NULL, &si, &pi); 

    // Continue execution
    ContinueDebugEvent(pi.dwProcessId, pi.dwThreadId, DBG_CONTINUE); 

    // Wait for an event
    WaitForDebugEvent(&de, INFINITE);
}

UnhandledExceptionFilter

UnhandledExceptionFilter 是异常处理程序的完整名称,当没有其他处理程序来处理异常时调用。下图显示了 Windows 如何传播异常。使用 UnhandledExceptionFilter 技术时,需要注意,如果附加了调试器,该进程将退出而不是恢复执行,在我看来,这在反向工程的上下文中是完全可以接受的。

ExceptionPropagation.png

LONG WINAPI UnhandledExcepFilter(PEXCEPTION_POINTERS pExcepPointers)
{
    // Restore old UnhandledExceptionFilter
    SetUnhandledExceptionFilter((LPTOP_LEVEL_EXCEPTION_FILTER)
          pExcepPointers->ContextRecord->Eax);

    // Skip the exception code
    pExcepPointers->ContextRecord->Eip += 2;

    return EXCEPTION_CONTINUE_EXECUTION;
}

int main()
{
    SetUnhandledExceptionFilter(UnhandledExcepFilter);
    __asm{xor eax, eax}
    __asm{div eax}

    // Execution resumes here if there is no debugger
    // or if there is a debugger it will never
    // reach this point of execution
}

NtQueryObject

当使用 ObjectAllTypesInformation 类调用 NtQueryObject 函数时,它将返回有关宿主系统和当前进程的信息。从该函数可以挖掘出大量信息,但我们最关心的是环境中 DebugObject 的相关信息。在 Windows XP 和 Vista 中,此对象列表中维护着一个 DebugObject 条目,最重要的是,每种类型对象的数量。对象及其相关信息可以表示为 OBJECT_INFORMATION_TYPE 结构。然而,使用 ObjectAllTypesInformation 类调用 NtQueryObject 函数实际上返回一个以 OBJECT_TYPE_INFORMATION 结构开头的缓冲区。但是,存在多个 OBJECT_INFORMATION_TYPE 条目,并且遍历包含这些条目的缓冲区并不像数组索引那么简单。源代码显示下一个 OBJECT_INFORMATION_TYPE 结构位于前一个 UNICODE_STRING.Buffer 条目之后。这些结构也经过填充并进行 DWORD 对齐;请参阅源代码以了解如何导航缓冲区。

typedef struct _OBJECT_TYPE_INFORMATION {
    UNICODE_STRING TypeName;
    ULONG TotalNumberOfHandles;
    ULONG TotalNumberOfObjects;
}OBJECT_TYPE_INFORMATION, *POBJECT_TYPE_INFORMATION;

typedef struct _OBJECT_TYPE_INFORMATION {
    UNICODE_STRING TypeName;
    ULONG TotalNumberOfHandles;
    ULONG TotalNumberOfObjects;
}OBJECT_TYPE_INFORMATION, *POBJECT_TYPE_INFORMATION;

// ObjectListCheck uses NtQueryObject to check the environments
// list of objects and more specifically for the number of
// debug objects. This function can cause an exception (although rarely)
// so either surround it in a try catch or __try __except block
// but that shouldn't happen unless one tinkers with the function
inline bool ObjectListCheck()
{
    typedef NTSTATUS(NTAPI *pNtQueryObject)
            (HANDLE, UINT, PVOID, ULONG, PULONG);

    POBJECT_ALL_INFORMATION pObjectAllInfo = NULL;
    void *pMemory = NULL;
    NTSTATUS Status;
    unsigned long Size = 0;

    // Get NtQueryObject
    pNtQueryObject NtQO = (pNtQueryObject)GetProcAddress( 
                GetModuleHandle( TEXT( "ntdll.dll" ) ),
                "NtQueryObject" );

    // Get the size of the list
    Status = NtQO(NULL, 3, //ObjectAllTypesInformation
                        &Size, 4, &Size);

    // Allocate room for the list
    pMemory = VirtualAlloc(NULL, Size, MEM_RESERVE | MEM_COMMIT, 
                    PAGE_READWRITE);

    if(pMemory == NULL)
        return false;

    // Now we can actually retrieve the list
    Status = NtQO((HANDLE)-1, 3, pMemory, Size, NULL);

    // Status != STATUS_SUCCESS
    if (Status != 0x00000000)
    {
        VirtualFree(pMemory, 0, MEM_RELEASE);
        return false;
    }

    // We have the information we need
    pObjectAllInfo = (POBJECT_ALL_INFORMATION)pMemory;

    unsigned char *pObjInfoLocation = 
        (unsigned char*)pObjectAllInfo->ObjectTypeInformation;

    ULONG NumObjects = pObjectAllInfo->NumberOfObjects;

    for(UINT i = 0; i < NumObjects; i++)
    {

        POBJECT_TYPE_INFORMATION pObjectTypeInfo =
            (POBJECT_TYPE_INFORMATION)pObjInfoLocation;

        // The debug object will always be present
        if (wcscmp(L"DebugObject", pObjectTypeInfo->TypeName.Buffer) == 0)
        {
            // Are there any objects?
            if (pObjectTypeInfo->TotalNumberOfObjects > 0)
            {
                VirtualFree(pMemory, 0, MEM_RELEASE);
                return true;
            }
            else
            {
                VirtualFree(pMemory, 0, MEM_RELEASE);
                return false;
            }
        }

        // Get the address of the current entries
        // string so we can find the end
        pObjInfoLocation = 
            (unsigned char*)pObjectTypeInfo->TypeName.Buffer;

        // Add the size
        pObjInfoLocation += 
                pObjectTypeInfo->TypeName.Length;

        // Skip the trailing null and alignment bytes
        ULONG tmp = ((ULONG)pObjInfoLocation) & -4;

        // Not pretty but it works
        pObjInfoLocation = ((unsigned char*)tmp) + 
                        sizeof(unsigned long);
    }

    VirtualFree(pMemory, 0, MEM_RELEASE);
    return true; 
}

反转储

“转储”是逆向工程领域的一个特殊术语,它描述了以下过程:获取一个受保护的可执行文件,在可执行文件被解密到内存中之后,截取程序的本质快照并将其保存到磁盘上,如下图所示。有许多技术可以防止被加密或压缩的可执行文件被转储,以下技术是一些更流行或文档更完善的方法。

AntiDumping.png

纳米码

我发现这项技术非常有趣,它的工作原理是将程序中的某些分支指令(J_cc_ 指令)替换为 INT 3 断点;然后,有关已移除跳转的信息存储在一个高度加密的表中。这些信息包括跳转的目的地、所需的 CPU 标志和跳转的大小(通常为两个或五个字节)。然后,通过自调试,当这些断点之一被触发时,调试过程将处理调试异常,并查找有关调试中断的特定信息。此信息包括断点是否是纳米码或真正的调试断点,以及是否应执行跳转(这包括根据跳转类型适当比较 EFLAGS 寄存器,例如 JNZ 需要 ZF = 0)。如果是,则检索跳转地址,并且调试器中的执行将从该处恢复;如果不是,则检索被替换跳转的长度,并在调试器中跳过该特定跳转的大小,然后执行恢复。现在,为了让事情变得更糟,随机的 INT 3 指令将被放置在代码的不可访问部分,并且在纳米码表中创建条目。甚至还有一些条目没有相应的 INT 3 但却放置在那里以骚扰反向工程师。当在可执行文件内部的正确位置使用时,这项技术非常强大,并且对性能几乎没有影响。不幸的是,这项技术的源代码对于本文来说过于复杂。

被盗字节(被盗代码)

这是一种由 _ASprotect_ 引入的技术,对于从未遇到过这种方法的人来说可能很有趣。在被盗字节例程中,受打包程序保护的原始进程的代码或字节被移除,通常是从 OEP(原始入口点)移除,并被加密在打包代码的某个位置。字节所在区域随后被替换为将跳转到动态分配缓冲区的代码,该缓冲区包含从原始代码“窃取”的解密字节;该缓冲区还包含一个跳回适当执行地址的指令。通常,字节被移除的区域和原始字节所在的动态分配缓冲区都充满了垃圾代码,甚至更多反向工程技术。如果底层概念对反向工程师隐藏,这可能是一种强大的技术;否则,修复起来并不难。

SizeOfImage

SizeOfImage 是 PE 文件 IMAGE_OPTION_HEADER 中的一个字段,通过在运行时增加 PEB 中此字段的大小,我们可以为那些未开发处理此问题的工具带来麻烦。此方法很容易应用于应用程序,并且很容易通过枚举所有带有 MEM_IMAGE 标志的页面(从应用程序的 ImageBase 页面开始)来反转应用程序。这之所以有效,是因为内存中的页面不能有间隙。以下是一些示例代码

// Any unreasonably large value will work say for example 0x100000 or 100,000h
void ChangeSizeOfImage(DWORD NewSize)
{
    __asm
    {
        mov eax, fs:[0x30] // PEB
        mov eax, [eax + 0x0c] // PEB_LDR_DATA
        mov eax, [eax + 0x0c] // InOrderModuleList
        mov dword ptr [eax + 0x20], NewSize // SizeOfImage
    }
}

虚拟机

虚拟化被认为是反向工程的未来,并且已经很大程度上进入了现在。Themida 和 VMProtect 等保护器已经在其保护方案中使用了虚拟机。然而,简单地使用虚拟机并不是这项技术的全部。例如,Themida 使用一项技术,为**每个**利用虚拟机保护的受保护可执行文件创建一个独特的虚拟机。通过这种方式实现虚拟机,Themida 防止了针对其虚拟化保护的通用攻击。此外,许多利用虚拟机的保护方案通常在其虚拟机字节码中实现垃圾代码指令,就像垃圾代码插入到本地 IA-32 代码中一样(Ferrie)。

保护页面

打包器和保护器可以利用保护页面来实现按需解密/解压缩系统。当可执行文件加载到内存中时,保护器不会在运行时解压缩/解密文件的全部内容,而是将所有不需要立即使用的页面标记为保护页面。之后,当需要另一段代码或数据时,将引发 EXCEPTION_GUARD_PAGE (0x80000001) 异常,并且数据可以从文件或内存中的加密/压缩内容中解密或解压缩。

这项技术已通过两种方式实现,一种是挂钩 KiUserUserDispatcher (Shrinker),另一种是使用自调试 (Armadillo 的 CopyMemoryII) (Ferrie)。以 Shrinker 为例,当引发异常时,从放置在 KiUserUserDispatcher 上的挂钩进行检查,以查找异常发生的位置以及异常是否发生在受保护可执行文件的进程空间中;如果是,则内容将从磁盘解压缩到程序期望数据或可执行代码的页面。利用这项技术可以显著减少可执行文件的加载时间和内存使用量,因为只有所需的页面才会被物理内存 (RAM) 支持,并且只有需要使用的页面才会被解压缩和解密。

Armadillo 也以 CopyMemII 的名称实现了这项技术,其行为方式类似,只是它需要使用自调试;此外,CopyMemII 不是拥有空页面并从磁盘加载页面,而是简单地将页面解压缩到内存中。请注意,这并不会解密页面;因此,代码和数据仍然是安全的。然后,当访问一个尚未解密的页面时,将引发 EXCEPTION_GUARD_PAGE (0x80000001) 异常,并且作为调试器的进程将捕获该异常并根据需要解密页面。然而,Armadillo 的技术实现存在一个弱点,即一旦页面被解密,它将保持在内存中解密状态。通过利用这个弱点,逆向工程师可以强制进程触及程序所需的所有页面,并使整个程序在内存中解密,并处于完美的转储状态。在这两种技术实现中,进程一次只解密或解压缩一个页面;因此,如果访问跨越多个页面,保护器将简单地允许下一个异常发生并解密该页面。最后,如果保护器能够记住上次访问的页面,并在解密下一个页面之前丢弃或擦除上次使用的页面,那么这项技术将极其强大。

移除可执行文件头

这是一种简单的反转储技术,它在运行时从内存中移除可执行文件的可移植可执行文件头;通过这样做,被转储的镜像将缺少重要信息,例如重要表(重定位、导入、导出等)的 RVA(相对虚拟地址)、入口点,以及 Windows 加载程序在加载镜像时需要利用的其他信息。在使用此技术时需要小心,因为 Windows API 或许合法的外部程序可能需要访问已移除的此信息。

// This function will erase the current images
// PE header from memory preventing a successful image
// if dumped
inline void ErasePEHeaderFromMemory()
{
    DWORD OldProtect = 0;
    
    // Get base address of module
    char *pBaseAddr = (char*)GetModuleHandle(NULL);

    // Change memory protection
    VirtualProtect(pBaseAddr, 4096, // Assume x86 page size
            PAGE_READWRITE, &OldProtect);

    // Erase the header
    ZeroMemory(pBaseAddr, 4096);
}

IA-32 指令利用

以下技术利用了调试器处理 IA-32 指令时遇到的问题。其中大多数方法都是不常使用的底层技术。

中断 2D

INT 2D 指令可以用作通用调试器检测方法,因为当执行该指令时,如果没有调试器存在,就会发生异常。但是,如果存在调试器,就不会发生异常,并且根据您使用的调试器,情况会变得有趣。OllyDBG,如图所示,实际上会在其反汇编中跳过一个字节,导致分析出错。Visual Studio 2008 的调试器毫无问题地处理该指令,至于其他调试器,我们需要自己测试。

int2d.png

// The Int2DCheck function will check to see if a debugger
// is attached to the current process. It does this by setting up
// SEH and using the Int 2D instruction which will only cause an
// exception if there is no debugger. Also when used in OllyDBG
// it will skip a byte in the disassembly and will create
// some havoc.
inline bool Int2DCheck()
{
    __try
    {
        __asm
        {
            int 0x2d
            xor eax, eax
            add eax, 2
        }
    }
    __except(EXCEPTION_EXECUTE_HANDLER)
    {
        return false;
    }
    
    return true;
}

堆栈段

通过使用 push sspop ss 操作栈段,我们可以让调试器不情愿地执行指令。在下面的函数中,当使用任何调试器单步执行代码时,mov eax, 9 行将执行,但不会被调试器单步执行。

inline void PushPopSS()
{

    __asm
    {
        push ss
        pop ss
        mov eax, 9 // This line executes but is stepped over
        xor edx, edx // This is where the debugger will step to
    }
}

指令前缀

以下技术利用了调试器处理指令前缀的方式。当在 OllyDBG 或 Visual Studio 2008 中单步执行此代码时,我们将到达第一个发出指令处,并立即被带到 __try 块的末尾。发生的情况是调试器实质上跳过了前缀并处理了 INT 1。当在没有调试器的情况下运行此代码时,将发生异常,SEH 将捕获该异常,并且程序将继续执行。

// The IsDbgPresentPrefixCheck works in at least two debuggers
// OllyDBG and VS 2008, by utilizing the way the debuggers handle
// prefixes we can determine their presence. Specifically if this code
// is ran under a debugger it will simply be stepped over;
// however, if there is no debugger SEH will fire :D
inline bool IsDbgPresentPrefixCheck()
{
    __try
    {
        __asm __emit 0xF3 // 0xF3 0x64 disassembles as PREFIX REP:
        __asm __emit 0x64
        __asm __emit 0xF1 // One byte INT 1
    }
    __except(EXCEPTION_EXECUTE_HANDLER)
    {
        return false;
    }

    return true;
}

OllyDBG 特定

以下技术可专门用于攻击 OllyDBG,这可能是目前 Windows 上使用最广泛的调试工具。检测 OllyDBG 的技术比我在这里展示的更多,而且可能对于本文中讨论的每种技术,都有一个 OllyDBG 插件可以解决这个问题。然而,对于缺乏经验的逆向工程师来说,这些技巧仍然有效。

FindWindow

使用 Win32 API 函数 FindWindow,我们可以检查 OllyDBG 窗口类 OLLYDBG 的存在,如果它存在,那么 OllyDBG 很可能已打开并正在等待附加到进程,或者正在主动调试当前进程或其他进程。

HANDLE hOlly = FindWindow(TEXT("OLLYDBG"), NULL);

if(hOlly)
    ExitProcess(0); 

OutputDebugString 漏洞

在漏洞利用的世界里,有许多方法可以利用程序的安全措施或缺乏安全措施,OllyDBG 确实有一个。这是一个格式字符串漏洞,已被各种定制版本的 OllyDBG 修补,但存在于正常的未修改版本中,而这正是 OllyDBG 的主要版本。如果 OllyDBG 当前附加到进程,以下代码将使其崩溃,这是一种非常强大的技术。

OutputDebugString( TEXT("%s%s%s%s%s%s%s%s%s%s%s")
                TEXT("%s%s%s%s%s%s%s%s%s%s%s%s%s")
                TEXT("%s%s%s%s%s%s%s%s%s%s%s%s%s")
                TEXT("%s%s%s%s%s%s%s%s%s%s%s%s%s") );

WinDBG 特定

以下技术可用于检测 WinDBG 是否在宿主机器上运行。由于逆向工程师倾向于偏爱其他调试器和分析工具而不是 WinDBG,因此对检测 WinDBG 的研究不多。

FindWindow

这与 OllyDBG 示例中显示的技术完全相同,只是窗口类不同,工作方式也相同。

HANDLE hWinDbg = FindWindow(TEXT("WinDbgFrameClass"), NULL);

if(hWinDbg)
    ExitProcess(0);

其他技术

以下技术并未真正融入我在上一节中涵盖的其他类别,由于它们具有共同之处,我将它们都放入自己的独特部分。

垃圾代码

垃圾代码是一种恰如其名的代码混淆技术,顾名思义,它利用无用或不需要的代码来迷惑逆向工程师,使其不清楚当前代码实际试图实现什么。当插入到例程中的垃圾代码具有说服力并成功迷惑逆向工程师时,这种技术会相当有效;但是,使用这种技术会带来性能损失,因为例程或函数包含的指令越多,函数完成所需的时间就越长。使用垃圾代码的另一个问题是,对于内存和堆栈操作(如 pushpopmov ptr []),很可能会发生堆栈或内存损坏;因此,这些指令要么小心放置和使用,要么根本不使用。以下是一个添加两个数字并减去一个数字的例程示例,但其中添加了垃圾代码。

junkcode.png

#define JUNK_CODE_ONE        \
    __asm{push eax}            \
    __asm{xor eax, eax}        \
    __asm{setpo al}            \
    __asm{push edx}            \
    __asm{xor edx, eax}        \
    __asm{sal edx, 2}        \
    __asm{xchg eax, edx}    \
    __asm{pop edx}            \
    __asm{or eax, ecx}        \
    __asm{pop eax}

inline int AddSubOne(int One, int Two)
{
    JUNK_CODE_ONE
    return ( (One + Two) - 1 );
}

正如我们所看到的,这个例程做了很多无用的操作,只有最后两条指令才真正完成了函数的目标。

本地代码置换

置换的定义是“(如性格或条件)基于现有元素的重新排列而发生的重大或根本性变化”,这在代码世界中指的是实现相同目标或任务的不同方式(“置换”)。对于不熟悉置换概念的人,我将首先用数字解释它,然后我们将用代码探索这个概念。

集合 {1,5,9} 的置换将是

159
195
519
591
915
951

当我们对一个项目或对象进行置换时,我们只是试图以不同的方式表示相同的信息或操作。现在,我们将对 mov m32, imm 指令进行置换

Original: 
mov [mem addr], 7 (mov m32, imm)

Permutation 1: 
push 7
pop [mem addr]

Permutation 2:
mov eax, mem addr
mov [eax], 7

Permutation 3:
mov edi, mem addr
mov eax, 7
stosd

Permutation 4:
push mem addr
pop edi
push 7
pop eax
stosd

And on....

我们可以想象,这是一种非常强大和灵活的混淆方法,特别是考虑到很多人在逆向时只是遵循教程,即使是很小的改动也会完全阻止他们逆向你的应用程序。然后,当这个概念在运行时应用于可执行文件时,如果在每次运行时程序都被置换和变形,我们就可以实现变异代码,这与虚拟机一起,是反向工程防护技术的巅峰。然而,这项技术很难正确实现,考虑到需要一个优秀的逆向引擎,对于熟悉创建精确逆向器地狱的人来说,这是一项相当大的任务。

参考文献

© . All rights reserved.