美文网首页
萌新逆向学习笔记——API钩取

萌新逆向学习笔记——API钩取

作者: 全汪汪 | 来源:发表于2020-08-26 23:19 被阅读0次

    前言

    著名的迪奥布兰多先生曾说过“人类是有极限的。”

    确实如此,但也正因为我们不能飞,所以才造出了飞机。正因为我们跑的不快,所以才造出了汽车。人类确实存在极限,但人的学习能力却是无限的。而作为一个正在入门逆向萌新的来说,正确认识自身的极限和意识到学习的无穷是很重要的。

    为什么这么说呢。回想几个星期前,笔者还在苦苦做论坛上总结的120个CM。每当破解并分析了一个CM感到满足的同时,也为自己耗费长时间去破解分析CM的行为是否值得而感到疑惑,似乎就在原地踏步,而没有学到新的知识。

    后来我才意识到,这正是目前自身的极限。缺乏C++编程知识,缺乏系统的API知识,缺乏常规破解手段的知识等等。所以笔者才决定突破自身的极限,学习《逆向工程核心思想》一逆向书籍,才有了今天这篇文章。

    可这谈何容易呢?要想突破极限就必须付出相应的努力,去提高自己极限的程度。笔者必须学习C++,必须跟着书籍编写win32程序。当我们知识储备扩展后,极限的程度也就高了,而只有不断提高自身的极限,或许才能成为真正的“大师”吧。

    准备工作

    本篇文章从原理和实现过程两个角度总结书中API钩取一章节,笔者也是萌新。因此难免会有令人困惑和误解甚至错误的地方。倘若存有疑问请务必留言指教。

    为了读者能有更好的阅读体验,最好但不一定必要具备以下知识和工具:

    1. C++编程基础
    2. 到微软官方查看文档的能力
    3. 用于编程的集成环境工具visual studio
    4. 用于查看进程PID的监控软件Process Explorer
    5. 文章附带的附件

    再次提醒一下,就算读者没有C++编程基础,也可浏览此文到原理部分,实践部分若无编程基础可忽略不看。当熟知原理以及记住几个关键的函数过后,全文核心内容基本可算基本掌握。

    API钩取的作用

    我们为什么要做API钩取,它有什么作用。在描述原理之前,这是我们必须要弄清楚的问题。如同我们去逆向分析软件,都会问自己为什么会这么做。可能是兴趣使然,可能是好玩,也可能是为了钻研等等。为什么很重要,因为它是我们唯一的学习动力。

    API钩取它让我们能够直接修改程序中的流程或代码。说的通俗易懂一点,它可以让我们在别人的程序里为所欲为。

    例如我们可以直接修改弹窗的标题或内容;又如可以改变我们输入。只要我们知道要修改的地址,我们就可以让他断下来,然后进行各种操作,来达到我们的目的。如果用过IDA或者olldbg的读者一定会发现,这不正是这些调试器的功能吗?

    6.png

    原理

    那到底要怎么做呢?为什么别人写的程序本来好好的却能够突然停下来让你操作,这难道是什么魔法吗?

    然而并不是,众所周知Window的编程是函数编程,说的明白一点,程序员在写诸如EXE的Window程序时,都会去使用微软早已做好的函数。而API钩取,也正是使用微软提供的函数去拦截程序使用微软的函数(有点拗口)。有点以毒攻毒的味道呢。

    7.png

    当一个程序被注册了调试程序,他们关系就会发生3600°的变化。本来互不干扰的俩,被注册之后只要产生了调试事件,它就会过问调试自己的程序,把自己的控制权交给调试者。而我们正是利用这个原理,注册成为别人的程序的调试者,再促使被调试程序产生断点调试事件,来获得其生死大权。

    如何促使被调试程序产生断点调试事件呢?这里有两个关键点:

    1. 当程序被注册调试器成功后,会发送一个调试事件来告诉调试者你成功了。
    2. 当被调试的程序遇到16进制为CC的指令就会产生中断的调试事件。

    结合这两点,我们可以在注册成功的时候,改写相应的地址为CC,使其下次遇到CC时触发中断调试事件。

    8.png

    这样我们接收CC的中断调试事件后便可更改被调试程序的相关数据。

    实践

    实践步骤需要有C++的基础知识,否则可能会难以下咽,没有C++的基础可以跳过。

    整个过程如下:
    附加要修改的程序使目标程序成为被调试者——>收取附加成功消息——>找到要修改的地址并下断点——>触发断点——>收到异常,程序断下——>实现自己的操作(修改数据)——>让程序继续运行

    步骤一:使目标程序成为被调试者

    为什么要先附加程序,使其成为被调试者?从流程上讲因为他是必须的,就像有了加法才有乘法,有了减法才有除法一样。

    而从原理上讲,附加程序就是向目标程序注册成为调试器,只有拥有调试与被调试的关系后,每当目标程序产生调试事件(如断点产生的异常)后才会报告给调试者。因此我们附加程序,是为了获得目标程序产生异常而造成的空隙,这样我们才有修改数据等操作的机会。

    从代码中看,使用DebugActiveProcess函数,同时传递目标程序PID作为参数,来使特定的程序成为被调试者,而自己的程序成为调试者。以下为函数文档

    BOOL DebugActiveProcess(
      DWORD dwProcessId
    );
    

    步骤二:收取附加成功消息

    为了给特定地址下断点,我们需要合适的机会,而这个机会便是程序被附加成功的时候。因此我们只要在接收到附加成功的事件后进行断点操作即可。

    同样,贴心的微软提供了一个接收调试事件的方法给我们:

    BOOL WaitForDebugEvent(
      LPDEBUG_EVENT lpDebugEvent,  //  接收调试事件存储的地方
      DWORD         dwMilliseconds  //  等待事件的时间,可设置永久
    );
    

    WaitForDebugEvent函数会接收调试事件,例如附加成功,遇到断点产生异常等。那我们如何才知道哪个事件是附加成功呢?

    当事件接收成功后,会将一个叫DEBUG_EVENT 的结构体存储在函数的第一个参数lpDebugEvent中,这个结构体的结构如下

    typedef struct _DEBUG_EVENT {
      DWORD dwDebugEventCode;  //调试事件各类代码
      DWORD dwProcessId;  //进程ID
      DWORD dwThreadId;
      union {
        EXCEPTION_DEBUG_INFO      Exception;
        CREATE_THREAD_DEBUG_INFO  CreateThread;  
        CREATE_PROCESS_DEBUG_INFO CreateProcessInfo;  //附加成功后产生
        EXIT_THREAD_DEBUG_INFO    ExitThread;
        EXIT_PROCESS_DEBUG_INFO   ExitProcess;
        LOAD_DLL_DEBUG_INFO       LoadDll;
        UNLOAD_DLL_DEBUG_INFO     UnloadDll;
        OUTPUT_DEBUG_STRING_INFO  DebugString;
        RIP_INFO                  RipInfo;
      } u;
    } DEBUG_EVENT, *LPDEBUG_EVENT;
    

    通过dwDebugEventCode的事件代码我们能判断当前事件的类型,而附加成功的代码是CREATE_PROCESS_DEBUG_EVENT,当产生这个事件,CreateProcessInfo中的另一个结构体——CREATE_PROCESS_DEBUG_INFO ,它储存着一些关于目标程序的详细信息

    是不是看的头晕眼花,简单来说,我们只要使用WaitForDebugEvent来接收调试事件,用其结果中的dwDebugEventCode来判断是否为附加成功。最后再用其结果产生的CreateProcessInfo来初始化一些设置:

    DEBUG_EVENT event;
    while(WaitForDebugEvent(&event, INFINITE)){  // 循环获得调试事件
            DWORD code = event.dwDebugEventCode;
            switch (code)
            {
            case CREATE_PROCESS_DEBUG_EVENT:
                //附加成功后设置断点
                onCreateProEvent(&event);
                break;
            case EXCEPTION_DEBUG_EVENT:
                //断点触发后修改数据
                onExceptionEvent(&event);
                break;
            case EXIT_PROCESS_DEBUG_EVENT:
                //退出
                break;
            }
    }
    

    步骤三:找到要修改的地址下断点

    从上面的代码注释可以看到,在判断为附加成功的事件后,调用了一个onCreateProEvent的函数。这是微软提供的吗?当然不是,这是笔者自己写的:

    LPVOID address;
    void onCreateProEvent(LPDEBUG_EVENT event) {
        //设置断点,获取断点地址,因为是系统DLL的函数因此与目标进程地址一致
        address = GetProcAddress(hmod, "MessageBoxA");  
        BYTE bp = 0xCC;
        memcpy_s(&ori, sizeof(event->u.CreateProcessInfo), &event->u.CreateProcessInfo, sizeof(event->u.CreateProcessInfo));
        if (!ReadProcessMemory(event->u.CreateProcessInfo.hProcess, address, &oribyte, sizeof(BYTE), NULL)) {
            //读取原地址开头
            cout << "读取目标进程内存失败";
            return;
        }
        if (!WriteProcessMemory(event->u.CreateProcessInfo.hProcess, address, &bp, sizeof(BYTE), NULL))
        {
            //写入CC
            cout << "写入目标进程内存失败";
            return;
        }
    }
    

    首先我们要获取地址。比如笔者想修改弹窗的标题,那我们就要知道目标程序调用弹窗的代码地址,而当前已知目标程序调用弹窗的函数名叫MessageBoxA。利用系统函数地址不变的原理,使用GetProcAddress函数获取其弹窗函数地址:

    address = GetProcAddress(hmod, "MessageBoxA");

    然后我们只要在这地址下断点就行了。这样当目标程序调用弹窗函数时,便会产生调试事件并断下。

    可我们要如何在这地址下断呢?其实很简单,我们只要把这地址的首部替换成0xCC即可。这是因为0xCC在汇编中代表的是一个int3中断指令,当程序遇到它时会报告给调试者

    例如我们通过GetProcAddress获得MessageBoxA的地址为76EA13D0,那我们下断点后就断成了:CCEA13D0了。

    为了修改数据后能让程序正常运行,同时还要保存原地址开头的76,用来复原。

    步骤四:触发断点

    触发断点很简单,如果像笔者一样是断在弹框处,那只要让目标程序弹出弹窗即可。

    步骤五:接收断点调试事件

    同样的,我们接收断点调试事件也是使用步骤二的方法WaitForDebugEvent函数,只不过这次的调试事件类型为EXCEPTION_DEBUG_EVENT

    DEBUG_EVENT event;
    while(WaitForDebugEvent(&event, INFINITE)){  // 循环获得调试事件
            DWORD code = event.dwDebugEventCode;
            switch (code)
            {
            case CREATE_PROCESS_DEBUG_EVENT:
                //附加成功后设置断点
                onCreateProEvent(&event);
                break;
            case EXCEPTION_DEBUG_EVENT:
                //断点触发后修改数据
                onExceptionEvent(&event);
                break;
            case EXIT_PROCESS_DEBUG_EVENT:
                //退出
                break;
            }
    }
    

    在接收到类型为EXCEPTION_DEBUG_EVENT的调试事件后,结果里会产生一个叫EXCEPTION_DEBUG_INFO的结构体:

    typedef struct _EXCEPTION_DEBUG_INFO {
      EXCEPTION_RECORD ExceptionRecord;
      DWORD            dwFirstChance;
    } EXCEPTION_DEBUG_INFO, *LPEXCEPTION_DEBUG_INFO;
    

    然后里面又会有一个叫EXCEPTION_RECORD的结构体(套娃)

    typedef struct _EXCEPTION_RECORD {
      DWORD                    ExceptionCode;
      DWORD                    ExceptionFlags;
      struct _EXCEPTION_RECORD *ExceptionRecord;
      PVOID                    ExceptionAddress;
      DWORD                    NumberParameters;
      ULONG_PTR                ExceptionInformation[EXCEPTION_MAXIMUM_PARAMETERS];
    } EXCEPTION_RECORD;
    

    我们需要用到ExceptionCode来判断是不是我们的断点事件,然后再用ExceptionAddress来判断是不是我们断下的窗口地址:

    if(ExceptionRecord == EXCEPTION_BREAKPOINT) {
    if(ExceptionAddress == address) {
    ...修改窗口标题
    }
    }
    

    步骤六:实现自己的操作

    从上面代码可以看到,程序断点调试事件收到后会调用onExceptionEvent函数,这个也是自己书写的函数,他的内容便是修改弹窗标题。

    那要如何获得弹窗的标题呢?我们可以使用GetThreadContext来进行获取CONTEXT结构体,再从其中来获取我们的标题。

    对照MessageBoxA文档:

    int MessageBoxA(
      HWND   hWnd,
      LPCSTR lpText,
      LPCSTR lpCaption,
      UINT   uType
    );
    

    观察文档可以看到,我们要的标题在第三个参数(lpCaption)。在提取标题前我们必须知道一个汇编的知识:ESP代表调用栈的栈顶,当调用一个函数时,根据调用约束的不同,对函数的参数进行不同方式的传递。而C/C++的默认约定便是将参数压入栈。

    听不懂不要紧,我们只要知道ESP+4是第一个参数,ESP+8是第二个参数,ESP+C就是我们要的标题。

    而所谓的ESP我们可以从CONTEXT结构体获得:

    CONTEXT context;
    context.ContextFlags = CONTEXT_CONTROL;
    GetThreadContext(ori.hThread, &context);
    
    DWORD ptitle;
    ReadProcessMemory(ori.hProcess, (LPCVOID)(context.Esp + 0xC), &ptitle, sizeof(DWORD), NULL);
    

    然后通过WriteProcessMemory更改标题即可:

    CStringA newTitle = "z";
    WriteProcessMemory(ori.hProcess, (LPVOID)ptitle, newTitle.GetBuffer(), titleSize, NULL)
    

    最后我们要把更改成CC了的地址恢复为原样,并把EIP复原。因为EIP的值指示着当前代码的地址,地址改了当然EIP也要改:

    WriteProcessMemory(ori.hProcess, address, &oribyte, sizeof(byte), NULL);
    context.Eip = (DWORD)address;  // EIP复原
    SetThreadContext(ori.hThread, &context);
    

    最终步骤:让程序继续运行

    当调试事件发生后,程序会断下来。而我们为了在修改程序后还能继续运行,必须通知程序没事了你继续吧。因此我们可以调用ContinueDebugEven函数来通知:

    BOOL ContinueDebugEvent(
      DWORD dwProcessId,
      DWORD dwThreadId,
      DWORD dwContinueStatus
    );
    

    结果

    例子是将弹框标题t改成z:


    1.png 3.png 2.png

    函数列表

    以下为一些较为关键的函数(仅名称):
    DebugActiveProcess——附加程序,成为目标程序的调试者
    WaitForDebugEvent——接收调试事件
    GetProcAddress——获取要下断的目标地址
    GetThreadContext——获取上下文,以此来获取对应数据,如弹窗标题内容等。
    SetThreadContext——上下文设置,用来恢复原来的地址
    ContinueDebugEvent——让程序继续运行
    ReadProcessMemory——读取目标相关数据
    WriteProcessMemory——更改目标数据,如写入CC断点,修改弹窗标题,内容等。
    VirtualProtectEx——更改虚拟内存权限,若无法写入数据可使用

    总结

    随着学习的不断深入,知识的难度越来越难是理所当然的。在写这篇文章的时,如何去用简洁的语言去描述过程,并让那些没有编程基础的读者也能看的明白就成为了一个问题。因为知识难度越高,解释中用到的其他的知识铺垫也会越来越多,这就会陷入一个“不断解释”的问题。

    例如给别人解释2x3的意义,我们可以用加法来告诉他2x3就是2个3相加或者3个2相加。但这前提是他必须懂得加法。

    那如果说我解释的东西用了n个知识铺垫呢?那我是不是得解释这n个知识。所以说,倘若读者不太看得懂这篇文章那只有两种可能:

    1. 笔者文笔不好
    2. 这n个的知识铺垫的缺失

    所以说只有不断提高自己,才能向更高的层次进发。

    问题:

    当我们改写标题的时候,如果新标题的长度超过了4个字节,会使新标题溢出到弹窗内容区:

    4.png

    如上图所示,原标题为t,原内容为z。写入新标题I am title title title title后内容也变成了title tile tile tile。

    这是为什么呢?因为在虚拟内存当中,弹窗的标题和内容是连在一起的:

    5.png

    因此标题过长会覆盖到隔壁的内容区域。

    可如果我们想用一段任意长的内容替代它而不造成溢出,该怎么做呢?笔者想过是否可以替换掉指向标题的地址:

    9.png

    可一直没成功,如有解决方法希望读者可以留言指点!

    附件

    工程源码及可直接运行的示例https://share.weiyun.com/IHC4yI7k

    相关文章

      网友评论

          本文标题:萌新逆向学习笔记——API钩取

          本文链接:https://www.haomeiwen.com/subject/ejqtsktx.html