羽夏逆向指引——补丁

写在前面

  此系列是本人一个字一个字码出来的,包括示例和实验截图。可能有错误或者不全面的地方,如有错误,欢迎批评指正,本教程将会长期更新。 如有好的建议,欢迎反馈。码字不易,如果本篇文章有帮助你的,如有闲钱,可以打赏支持我的创作。如想转载,请把我的转载信息附在文章后面,并声明我的个人信息和本人博客地址即可,但必须事先通知我

你如果是从中间插过来看的,请仔细阅读 羽夏逆向指引——序 ,方便学习本教程。

补丁是什么

  对于我们老一辈的长辈,补丁是在日常生活中最常见的东西,裤子或者衣服破了一个洞,如果比较大,就会找一块布,然后缝在上面,这就是所谓的打补丁。对于计算机程序来说,补丁的作用好比衣物上的补丁,用来修补程序在代码逻辑上的漏洞。不过对于破解人员来说,补丁就是所谓的通过修改二进制数据以实现自己绕过正版检测的文件。在之前的教程,我们就见识到了所谓的补丁了,它是一个普通的补丁,直接写到可执行文件当中。补丁有好几种常见的类型,比如内存补丁、劫持补丁、硬件断点补丁等等,它们虽然形式各异,本质特征就是更改程序的执行流程,原理都是一样的,下面将对常见的补丁进行讲解。

文件补丁

  这种补丁应该比较常见,比如网上常见的替换exe/dll文件以及添加伪造正版信息文件实现破解就是所谓的文件补丁。在之前的教程 羽夏逆向指引——破解第一个程序 当中,我们在X32Dbg中修改汇编指令,然后另存为可执行文件,重新生成的exe就是文件补丁。对于这类补丁,我就不再赘述了。

劫持补丁

  什么是劫持补丁呢?它用到了所谓的DLL劫持技术。当一个可执行文件运行时,Windows加载器将可执行模块映射到进程的地址空间中,加载器分析可执行模块的输入表,并设法找出任何需要的DLL,并将它们映射到进程的地址空间中。由于输入表中只包含DLL名而没有它的路径名,因此加载程序必须在磁盘上搜索DLL文件。首先会尝试从当前程序所在的目录加载DLL,如果没找到,则在Windows系统目录中查找,最后是在环境变量中列出的各个目录下查找。利用这个特点,先伪造一个系统同名的DLL,提供同样的输出表,每个输出函数转向真正的系统DLL。程序调用系统DLL时会先调用当前目录下伪造的DLL,完成相关功能后,再跳到系统DLL同名函数里执行。这个过程用个形象的词来描述就是系统DLL被劫持了。
  如果看不懂这些东西的,证明你缺少PE结构的知识,请自行学习,学会后再继续。
  但是仅仅通过劫持,我们还需要配合其他技术来实现补丁的功能,之后讲解完本篇文章后,我们将使用劫持,配合其他手段,实现一个劫持补丁。

内存补丁

  什么是内存补丁呢?如果使用某阁的破解工具你或许能有所耳闻。它有一个程序会引导启动真正的程序,不过会处于挂起状态,打好补丁后,然后放开程序运行。下面我们拿之前使用的程序开刀,实现一个内存补丁。
  那么首先考虑,在Windows平台如何创建程序?创建进程的API还是挺多的,我们用CreateProcess来实现:

BOOL CreateProcess(
    LPCTSTR lpApplicationName,
    LPTSTR lpCommandLine,
    LPSECURITY_ATTRIBUTES lpProcessAttributes,
    LPSECURITY_ATTRIBUTES lpThreadAttributes,
    BOOL bInheritHandles,
    DWORD dwCreationFlags,
    LPVOID lpEnvironment,
    LPCTSTR lpCurrentDirectory,
    LPSTARTUPINFO lpStartupInfo,
    LPPROCESS_INFORMATION lpProcessInformation
);

  看到这个函数,是不是感觉非常头大,不过没关系,里面很多参数都是填NULL的,意为使用系统默认值,代码如下:

WCHAR filename[] = L"E:\\ConsoleApplication1.exe";
STARTUPINFO si = {sizeof(si)};
PROCESS_INFORMATION pi;
BOOL ret =  CreateProcess(NULL, filename, NULL, NULL,  FALSE, CREATE_SUSPENDED, NULL, NULL, &si, &pi);

  这个函数的具体细节可以自行搜索学习,不要指望一个指引就讲明白所有的细节。有些细节甚至你研究明白Windows的系统内核你才能明白的,正应正了我的座右铭:IT深似海,一入出不来。
  好,闲话不多说了。代码中的filename不能这么初始化:

LPWSTR filename = (LPWSTR)L"E:\\ConsoleApplicatio1.exe";

  如果这样声明,这个就会放到常量区,而CreateProcess使用这个数组时会进行一些操作,如果只读,就会触发异常。
  CreateProcess这个函数若返回非零,那么创建函数成功。下面我们将写补丁,那么用到哪个API呢?
  写补丁的话,我们需要用到WriteProcessMemory这个函数:

BOOL WriteProcessMemory(
    HANDLE hProcess,
    LPVOID lpBaseAddress,
    LPVOID lpBuffer,
    DWORD nSize,
    LPDWORD lpNumberOfBytesWritten
);

  第一个参数就是你要写进程的句柄,那么什么是句柄,如果真的要研究明白需要学习系统内核的知识。这里仅简单描述一下:句柄类似一个编号,比如你要找一个人,这个人很神秘,隐姓埋名,只留下这个编号,但你知道在哪个组织去找,你拿这个编号让内部人员给传个话,内部人员就帮你做了这个事情。
  第二个参数是要写的地址。如果这个东西不懂的话,请重新学习汇编的相关知识。
  第三个参数就是存储要写内容的地址,第四个参数是要写入的大小,最后一个是成功写入的字节数,填NULL表示不想知道。好,开始写代码:

unsigned short buffer = 0x9090;
if (ret)
{
    if (WriteProcessMemory(pi.hProcess, (LPVOID)0x401721,   buffer, sizeof(buffer), NULL))
    {
        cout << "写入补丁成功!" << endl;
    }
}

  为什么是写这个补丁,长度为什么是这个长度,不会请返回学习硬编码的知识。
  补丁写好了,但程序创建的时候是挂起的状态,我们需要恢复执行的状态程序才能跑起来,我们需要用到下面的API

DWORD WINAPI ResumeThread(_In_ HANDLE hThread);

  填入的参数就是线程的句柄,所以我们应该这么写:

ResumeThread(pi.hThread);

  不过需要注意的是,前面我们创建进程的时候,会产生两个句柄:一个是进程句柄,另一个是线程句柄。我们需要调用CloseHandle函数销毁,防止占用系统资源:

CloseHandle(pi.hProcess);
CloseHandle(pi.hThread);

  到现在为止,代码就长成下面的样子:

#include <iostream>
#include <Windows.h>
using namespace std;

int main()
{
    WCHAR filename[] = L"E:\\ConsoleApplication1.exe";    
    STARTUPINFO si = {sizeof(si)};
    PROCESS_INFORMATION pi; 
    BOOL ret = CreateProcess(NULL, filename, NULL, NULL, FALSE, CREATE_NEW_CONSOLE | CREATE_SUSPENDED, NULL, NULL, &si, &pi);
    unsigned short buffer = 0x9090;
    if (ret)
    {
        if (WriteProcessMemory(pi.hProcess, (LPVOID)0x401721, &buffer, sizeof(buffer), NULL))
        {
            cout << "写入补丁成功!" << endl;
        }
    }
    ResumeThread(pi.hThread);   
    CloseHandle(pi.hProcess);
    CloseHandle(pi.hThread);    
    system("pause");
    return 0;
}

  好,我们看一看实际效果:

硬件断点补丁

  在知道什么是硬件断点补丁,我们首先知道什么是硬件断点。Intel 80306以上的CPU给我们提供了调试寄存器用于软件调试,硬件断点是通过设置调试寄存器实现的,如下图所示:

  DR0-DR3为设置断点的地址,DR4DR5保留,DR6为调试异常产生后显示的一些信息,DR7保存了断点是否启用、断点类型和长度等信息。我们在使用硬件断点的时候,就是要设置调试寄存器,将断点的位置设置到DR0-DR3中,断点的长度设置到DR7LEN0-LEN3中,将断点的类型设置到DR7RW0-RW3中,将是否启用断点设置到DR7L0-L3中。这就是我们3环调试器,使用GUI下硬件断点为我们做的事情。
  可以看出,我们能够下的硬件断点是十分有限的,总共就4个。硬件断点是和线程相关的,这些寄存器的信息存储于线程上下文当中。我们通过设置线程上下文,实现硬件断点的设置。代码实现如下:

void SetHwBreakPoint(HANDLE HThread)
{
    CONTEXT ctx;
    ctx.ContextFlags = CONTEXT_ALL;
    GetThreadContext(HThread,&ctx);
    ctx.Dr0 = 0x040172C; //你要下断点的位置
    ctx.Dr7 = 0x01;
    SetThreadContext(HThread,&ctx);
}

  如果我们要设置硬件断点,就必须获取当前线程句柄,获得进程句柄就可以修改线程上下文,是不是很简单?不过在设置线程上下文的时候,必须挂起线程:为什么必须要挂起线程,这个是微软告诉我们的:

Do not try to set the context for a running thread; the results are unpredictable. Use the SuspendThread function to suspend the thread before calling SetThreadContext.

  也就是说,如果为了保证SetThreadContext是正确的,就必须保证线程是挂起的,否则结果不可预测。我们只需要挂起,设置好线程上下文,然后恢复线程运行就行了。
  设置好了硬件断点,我们如何相应这个断点触发并进行处理呢?答案就是使用VEH,结构化异常处理。

PVOID WINAPI AddVectoredExceptionHandler(
    _In_ ULONG First,
    _In_ PVECTORED_EXCEPTION_HANDLER Handler
    );

  上面的函数是添加一个结构化处理函数,第一个参数如果非零,那么异常发生时,第一个调用的就是它。什么?硬件断点触发就是一个异常?对的,当它触发时,会产生STATUS_SINGLE_STEP异常,如果在调试状态,会由调试器接管。如果没有调试器,但有异常处理程序,那么就会执行它。我们使用异常处理实现破解的程序代码的如下:

DWORD NTAPI ExceptionHandler ( EXCEPTION_POINTERS*
    ExceptionInfo )
{
    if ( ( DWORD )
        ExceptionInfo->ExceptionRecord->ExceptionAddress
        == 0x040172C ) //判断是不是我们下断点的地址
    {
        ExceptionInfo->ContextRecord->Eip += 2;  //两个字节的指令
        return EXCEPTION_CONTINUE_EXECUTION;
    }   
    return EXCEPTION_CONTINUE_SEARCH;
}

  假如我们不想要这个函数了,恢复还是通过设置线程上下文,如何移除向量异常处理程序?我们需要用到下面的API

ULONG RemoveVectoredExceptionHandler(
  PVOID Handle
);

  这个句柄就是你的添加向量异常处理程序的句柄,具体咋用我就不赘述了,下面我们将会用硬件断点 + 劫持技术实现破解我们的“小白鼠”。

硬件断点劫持补丁

实现

  假设我们劫持的Dll是系统的,我们既然劫持它,导出的函数它有的我得也有,我劫持执行的函数必须还得调用原系统Dll的函数,一个一个写未免不太费劲。我们需要借助一个工具,实现帮我们生成劫持代码模板,通过略微的修改实现我们的核心代码,从而实现劫持。常见可以劫持的Dll有:lpk.dllwinmm.dllversion.dllws2_32.dll等等。我们先看看这个工具长啥样子:

  接下来我们实战一波,但是为了看到效果,我们需要在小白鼠上面加个代码:

#include <iostream>
#include <Windows.h>

using namespace std;

int main(int argc, char* argv[])
{
    int x = 0;
    LoadLibrary(L"winmm.dll"); //假设我需要这个dll,我加载进去
    cout << "请输入密钥:" << endl;
    cin >> x;
    if (x == 1234)
    {
        cout << "成功,By.寂静的羽夏,CNBLOG Only!!!" << endl;
    }
    else
    {
        cout << "失败,By.寂静的羽夏,CNBLOG Only!!!" << endl;
    }

    system("pause");
    return 0;
}

  为什么要添加一句LoadLibrary呢?因为劫持是有条件的,就是它必须加载Dll才能行,别看导入表有一些,它们其实根本没有加载:

  可以看出,里面的导入表的Dll根本没有加载,就算你打算劫持里面的,没加载,根本无效。然而这个程序实在太小了,所以用常规的方式加载个系统Dll以供测试。
  打开我们的AheadLib工具,然后打开我们想要劫持的Dll作为输入,然后找到指定目录作为输出,就点击生成,就在我们想要的位置生成代码了:

  下一步该创建一个动态链接库项目,如下图所示:

  为了生成的名字和系统的Dll一致,就用一样的名字:

  然后把生成的代码覆盖到dllmain.cpp文件中,如下图所示:

  然而,这是不能直接生成代码的,我们需要做一些配置。首先设置使用多字节字符集:

  设置不使用预编译头:

  注意还要在上面项目配置之前要注意设置平台为x86

  如果不设置x86,就会报错如下:

  然后需要做一些小修改并实现代码,如下如所示修改和编码实现:

////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
// AheadLib 命名空间
namespace AheadLib
{
    HMODULE m_hModule = NULL;   // 原始模块句柄
    DWORD m_dwReturn[193] = { 0 };  // 原始函数返回地址
    const DWORD addr = 0x040172C;   
    DWORD NTAPI ExceptionHandler(EXCEPTION_POINTERS*
        ExceptionInfo)
    {
        if ((DWORD)
            ExceptionInfo->ExceptionRecord->ExceptionAddress
            == addr)
        {
            ExceptionInfo->ContextRecord->Eip += 2;
            return EXCEPTION_CONTINUE_EXECUTION;
        }   
        return EXCEPTION_CONTINUE_SEARCH;
    }   
    void SetHwBreakPoint(HANDLE HThread)
    {
        CONTEXT ctx={0};
        ctx.ContextFlags = CONTEXT_ALL;
        GetThreadContext(HThread, &ctx);
        ctx.Dr0 = addr;
        ctx.Dr7 = 0x01;
        SetThreadContext(HThread, &ctx);
    }   
    DWORD WINAPI ThreadProc(_In_ LPVOID
        lpParameter)
    {
        HANDLE htread = OpenThread(THREAD_ALL_ACCESS,
            TRUE, (DWORD)lpParameter);
        if (htread)
        {
            SuspendThread(htread);
            SetHwBreakPoint(htread);
            ResumeThread(htread);
            CloseHandle(htread);
            return TRUE;
        }
        return FALSE;
    }   
    // 加载原始模块
    inline BOOL WINAPI Load()
    {
        TCHAR tzPath[MAX_PATH];
        TCHAR tzTemp[MAX_PATH * 2]; 
        /*变动的代码区:开始*/
        BOOL isWowSystem = FALSE;
        if (IsWow64Process((HANDLE)-1,
            &isWowSystem))
        {
            if (isWowSystem)
            {
                UINT r =  GetSystemWow64Directory(tzPath,   MAX_PATH);
                if (!r)
                {
                    wsprintf(tzTemp, TEXT("无法加载 %s,程序无法正常运行。"), tzPath);
                    MessageBox(NULL, tzTemp, TEXT("AheadLib"), MB_ICONSTOP);
                }
            }   
            else
            {
                GetSystemDirectory(tzPath, MAX_PATH);
            }
        }   
        lstrcat(tzPath, TEXT("\\winmm.dll"));
        AddVectoredExceptionHandler(1 , (PVECTORED_EXCEPTION_HANDLER)ExceptionHandler);
        HANDLE Hhandle = CreateThread(NULL, NULL,   ThreadProc, (LPVOID)GetCurrentThreadId(), NULL,NULL    ;   
        /*变动的代码区:结束*/  
        m_hModule = LoadLibrary(tzPath);
        if (m_hModule == NULL)
        {
            wsprintf(tzTemp, TEXT("无法加载 %s,程序无法正  运行。"), tzPath);
            MessageBox(NULL, tzTemp, TEXT("AheadLib"), MB_ICONSTOP);
        }   
        return (m_hModule != NULL);
    }

    //其他区域的代码保持不变,编译即可

  然后我们看看效果:

  比如我不确定写的劫持是否正确,或者写劫持发现没效果,如何进行调试呢?很简单,把被劫持的程序拷一份到调试目录,然后在项目配置中修改下面的路径为被劫持的程序,正常下断点调试即可:

反制措施

  既然是劫持,我们如果想要反制,可以在程序代码中加上自己所在目录有没有这个Dll,虽然有一定的作用,但是对于能力有点的逆向者来说,这个东西是没用的,尤其不是按照本实例进行动态加载的。如果不是动态加载,你根本掌握不了主导权。我在你检查之前加载完毕,我直接patch掉正确的路径为错误的路径,你根本查不到。
  还有一个方式可以阻止Dll劫持,在注册表键值:KEY_LOCAL_MACHINE\SYSTEM\CurrentControlSet\Control\Session Manager\KnownDLLs里面存储了一些项目,如果需要里面的Dll,直接从系统目录加载,而不通过先查一查当前目录有没有。这不失一个很好的反制措施,但是不幸的是,如果你没有System权限,你是根本修改不了该项目下的值的,修改后,需要重启项目生效。

下一篇

  羽夏逆向指引——反制

posted @ 2021-12-07 22:03  寂静的羽夏  阅读(1356)  评论(0编辑  收藏  举报