软件调试详解

在windows里面调试跟异常息息相关,如果想要对调试得心应手,异常处理的知识是必不可少的,本文主要介绍的是软件调试方面的有关知识,讲解调试程序和被调试程序之间如何建立联系。

0x00 前言

在windows里面调试跟异常息息相关,如果想要对调试得心应手,异常处理的知识是必不可少的,本文主要介绍的是软件调试方面的有关知识,讲解调试程序和被调试程序之间如何建立联系。

0x01 调试对象

我们知道在windows里面,每个程序的低2G是独立使用的,高2G(内核)区域是共用的。那么我们假设一个场景,我们的调试器要想和被调试程序之间建立通信肯定就需要涉及到进程间的通信以及数据的交换,如果这个过程放在3环完成,不停的进程通信会很繁琐,所以windows选择将这个过程放在0环进行

调试器与被调试程序之间建立起联系的两种方式

  • CreateProcess
  • DebugActiveProcess

与调试器建立连接

首先看一下DebugActiveProcess

image-20220331111629799.png

调用ntdll.dllDbgUiConnectToDbg

image-20220331111914219.png

image-20220331112008360.png

再调用ZwCreateDebugObject

image-20220331112048790.png

通过调用号进入0环

image-20220331112119321.png

进入0环创建DEBUG_OBJECT结构体

typedef struct _DEBUG_OBJECT {
     KEVENT EventsPresent;
     FAST_MUTEX Mutex;
     LIST_ENTRY EventList;
     ULONG Flags;
} DEBUG_OBJECT, *PDEBUG_OBJECT;

然后到ntoskrnl里面看一下NtCreateDebugObject

image-20220331113057515.png

然后调用了ObInsertObject创建DebugObject结构返回句柄

image-20220331113136955.png

再回到ntdll.dll,当前线程回0环创建了一个DebugObject结构,返回句柄到3环存放在了TEB的0xF24偏移处

也就是说,遍历TEB的0xF24偏移的地方,如果有值则一定是调试器

image-20220331113252356.png

与被调试程序建立连接

还是回到kernel32.dllDebugActiveProcess,获取句柄之后调用了DbgUiDebugActiveProcess

image-20220331114614705.png

调用ntdll.dllDbgUiDebugActiveProcess

image-20220331114716677.png

跟到ntdll.dll里面的DbgUiDebugActiveProcess,传入两个参数,分别为调试器的句柄和被调试进程的句柄

image-20220331114846279.png

通过调用号进0环

image-20220331115013208.png

来到0环的NtDebugActiveProcess, 第一个参数为被调试对象的句柄,第二个参数为调试器的句柄

image-20220331115116879.png

执行ObReferenceObjectByHandle,把被调试进程的句柄放到第五个参数里面,这里eax本来存储的是调试器的EPROCESS,执行完之后eax存储的就是被调试进程的EPROCESS

image-20220331120028495.png

这里判断调试器打开的进程是否是自己,如果是自己则直接退出

image-20220331143227822.png

也不能调试系统初始化的进程

image-20220331143429325.png

然后获取调试对象的地址,之前是句柄,但是句柄在0环里面是无效的,这里就要找真正的地址

image-20220331143605658.png

获取到调试对象的地址之后还是存到ebp+Process的地方,这里之前是被调试对象的地址,现在存储的是调试对象的地址

image-20220331143902163.png

将调试进程和被调试的PEPROCESS传入_DbgkpSetProcessDebugObject,将调试对象和被调试进程关联起来

image-20220331145648149.png

跟进函数,发现有判断DebugPort是否为0的操作,ebx为0,edi为被调试进程的EPROCESS,那么edi+0bc就是调试端口

image-20220331145849944.png

然后再把调试对象的句柄放到被调试对象的DebugPort里面

image-20220331150347451.png

0x02 调试事件的采集

调试事件的种类

typedef enum _DBGKM_APINUMBER
{
    DbgKmExceptionApi = 0,  //异常
    DbgKmCreateThreadApi = 1,   //创建线程
    DbgKmCreateProcessApi = 2,  //创建进程
    DbgKmExitThreadApi = 3, //线程退出
    DbgKmExitProcessApi = 4,    //进程退出
    DbgKmLoadDllApi = 5,        //加载DLL
    DbgKmUnloadDllApi = 6,  //卸载DLL
    DbgKmErrorReportApi = 7,    //已废弃
    DbgKmMaxApiNumber = 8,  //最大值
} DBGKM_APINUMBER;  

调试事件的采集函数

image-20220331160251734.png

当创建进程或者线程的时候,一定会调用PspUserThreadStartup

image-20220331160332662.png

判断当前线程是否为当前进程的第一个线程,如果是的话就生成一个编号为1的调试事件

image-20220331160457595.png

再看一下退出线程必经的函数PspExitThread

image-20220331172528276.png

判断Debugport是否为0,如果为0则不搜集信息

image-20220331172641988.png

image-20220331173503130.png

进入跳转,判断这个线程是不是当前最后一个线程,如果是则调用DbgkExitProcess

image-20220331173645459.png

如果不是则调用DbgkExitThread

image-20220331174052501.png

DbgkpSendApiMessage

DbgkpSendApiMessage这个api主要就是将各种调试信息封装成一个结构体写到_DEBUG_OBJECT结构里面,无论是哪种事件,最后都会调用DbgkpSendApiMessage,如果想隐藏进程/线程的创建,就可以给DbgkCreateThread挂钩子,如果想隐藏所有的调试事件那么就可以给DbgkpSendApiMessage挂钩子

image-20220331174541264.png

这里跟一下DbgkExitThreadDbgkpSendApiMessage的过程,跟进函数直接就可以看到DbgkpSendApiMessage

image-20220331174839809.png

所有搜集调试事件的api都会调用DbgkpSendApiMessage

image-20220331175318674.png

image-20220331175326227.png

DbgkpSendApiMessage(x, x)参数说明:

1) 第一个参数:消息结构 每种消息都有自己的消息结构 共有7种类型

2) 第二个参数:要不要把本进程内除了自己之外的其他线程挂起。

有些消息需要把其他线程挂起,比如CC 有些消息不需要把线程挂起,比如模块加载。DbgkSendApiMessage是调试事件收集的总入口,如果在这里挂钩子,调试器将无法调试。

LoadLibrary

首先在kernel32.dll里面调用RtlAllocateHeap

image-20220331175840245.png

然后跟到ntdll.dll调用了NtQueryPerformanceCounter

image-20220331175913433.png

通过调用号进0环

image-20220331175926381.png

image-20220331175949840.png

总结来说,LoadLibrary首先调用CreateMapping创建一块共享内存,再通过NtMapViewOfSection映射到线性地址,调用DbgkMapViewOfSection将结构体发送给DbgkpSendApiMessage

_DEBUG_OBJECT

typedef struct _DEBUG_OBJECT {
     KEVENT EventsPresent;      //+00 用于指示有调试事件发生
     FAST_MUTEX Mutex;          //+10 用于同步互斥对象
     LIST_ENTRY EventList;      //+30 保存调试消息的链表
     ULONG Flags;               //+38 标志 调试消息是否已读取
} DEBUG_OBJECT, *PDEBUG_OBJECT;

调试事件的处理

因为每种事件的调试信息不一样,所以会有很多种类(7种)的api去采集

image-20220331220729871.png

编号的值也是对应的

image-20220331221540163.png

image-20220331221558581.png

// Debug1.cpp : Defines the entry point for the console application.
//

#include "stdafx.h"
#include <iostream>
#include <Windows.h>
#include <stdlib.h>

void TestDebugger()
{
    BOOL nIsContinue = NULL;
    STARTUPINFOA sw = { 0 };
    PROCESS_INFORMATION pInfo = { 0 };
    auto retCP = CreateProcessA("C:\\Dbgview.exe",NULL, NULL, NULL, TRUE,DEBUG_PROCESS|| DEBUG_ONLY_THIS_PROCESS, NULL, NULL, &sw, &pInfo);

    if (retCP == 0)
    {
        printf("CreateProcess error : %d\n", GetLastError());
        return;
    }

    while (TRUE)
    {
        DEBUG_EVENT debugEvent = { 0 };
        auto rDebugEvent = WaitForDebugEvent(&debugEvent, -1);

        if (rDebugEvent)
        {
            switch (debugEvent.dwDebugEventCode)
            {
                 case EXCEPTION_DEBUG_EVENT:
                     printf("EXCEPTION_DEBUG_EVENT\n");
                     break;

                 case CREATE_THREAD_DEBUG_EVENT:
                     printf("CREATE_THREAD_DEBUG_EVENT\n");
                     break;

                 case CREATE_PROCESS_DEBUG_EVENT:
                     printf("CREATE_PROCESS_DEBUG_EVENT\n");
                     break;

                 case EXIT_THREAD_DEBUG_EVENT:
                     printf("EXIT_THREAD_DEBUG_EVENT\n");
                     break;

                 case EXIT_PROCESS_DEBUG_EVENT:
                     printf("EXIT_PROCESS_DEBUG_EVENT\n");
                     break;

                 case LOAD_DLL_DEBUG_EVENT:
                     printf("LOAD_DLL_DEBUG_EVENT\n");
                     break;

                 case UNLOAD_DLL_DEBUG_EVENT:
                     printf("UNLOAD_DLL_DEBUG_EVENT\n");
                     break;

                 case OUTPUT_DEBUG_STRING_EVENT:
                     printf("OUTPUT_DEBUG_STRING_EVENT\n");
                     break;
            } 
        }
        //在发送事件event给调试器debugger时,被调试进程会被挂起,直到调试器调用了continueDebugEvent函数
        ContinueDebugEvent(debugEvent.dwProcessId, debugEvent.dwThreadId,DBG_CONTINUE);
    }
}

int main()
{
    TestDebugger();
    system("pause");
    return 0;
}

这里用调试模式启动windbg

image-20220401105959845.png

可以发现这里有一个异常,这里先打印一下异常处理返回的代码

printf("EXCEPTION_DEBUG_EVENT : %x %x %x\n",debugEvent.u.Exception.ExceptionRecord.ExceptionAddress,debugEvent.u.Exception.ExceptionRecord.ExceptionCode,debugEvent.u.Exception.ExceptionRecord.ExceptionFlags);

image-20220401110739388.png

将程序拖入OD看到系统有一个int3断点

image-20220401111013210.png

那么为什么会有一个异常处理的事件呢?这里首先看一下进程的创建过程

1.映射exe文件
2.创建内核对象EPROCESS
3.映射系统dll(ntdll.dll)
4.创建线程内核对象ETHREAD
5.系统启动线程
    映射dll(ntdll.LdrInitializeThunk)
    线程开始执行

在映射dll的过程中调用了LdrInitializeThunk这个api,LdrInitializeThunk会调用LdrpInitializeProcess初始化进程

首先找到TEB,然后找TEB的0x30偏移的PEB放入ebx

image-20220401111818596.png

DbgBreakPoint其实就是int3的封装

image-20220401111950688.png

看一下交叉引用,可以看到LdrpRunInitializeRoutines引用了DbgBreakPoint

image-20220401112037459.png

这里只有当程序处于调试模式的时候才会启动

image-20220401112129503.png

在内核文件里面看一下NtDebugActiveProcess

image-20220401113546366.png

会发送线程和模块的加载信息

image-20220401113811656.png

但是这个信息是不靠谱的,因为这个api是通过遍历PEB链表的方式来寻找模块

在PEB的Ldr结构里面有三个模块,DbgkpPostFakeProcessCreateMessages这个api就是通过查询这个结构来判断加载了哪些模块

也就是说当程序加载完成之后,这个api才会去链表里面找模块,但是这个时候可能信息已经被摘除,所以如果要想更准确的获取信息,就可以通过遍历vad树的方式来获取1

0x03 异常的处理流程

处理流程

image-20220401135907896.png

正常的异常处理流程

image-20220401140234726.png

产生异常的时候首先会将异常传递给调试器,如果调试器不处理则继续寻找异常处理函数

这里设置为异常为忽略的话就会执行自己的异常处理函数

image-20220401140635618.png

image-20220401141003958.png

如果设置为不忽略的情况下就会一直断在某一行

image-20220401141053693.png

UnhandledExceptionFilter

相当于编译器为我们生成了一段伪代码

__try
{

}
__except(UnhandledExceptionFilter(GetExceptionInformation())
{
    //终止线程
    //终止进程
}

只有程序被调试时,才会存在未处理异常

UnhandledExceptionFilter的执行流程:

1) 通过NtQueryInformationProcess查询当前进程是否正在被调试,如果是,返回EXCEPTION_CONTINUE_SEARCH,此时会进入第二轮分发 

2) 如果没有被调试: 

查询是否通过SetUnhandledExceptionFilter注册处理函数 如果有就调用 

如果没有通过SetUnhandledExceptionFilter注册处理函数 弹出窗口 让用户选择终止程序还是启动即时调试器 

如果用户没有启用即时调试器,那么该函数返回EXCEPTION_EXECUTE_HANDLER

SetUnhandledExceptionFilter

如果没有通过SetUnhandledExceptionFilter注册异常处理函数,则程序崩溃

image-20220401142104425.png

image-20220401142146258.png

测试代码如下,我自己构造一个异常处理函数callback并用SetUnhandledExceptionFilter注册,构造一个除0异常,当没有被调试的时候就会调用callback处理异常,然后继续正常运行,如果被调试则不会修复异常,因为这是最后一道防线,就会直接退出,起到反调试的效果

// SEH7.cpp : Defines the entry point for the console application.
//

#include "stdafx.h"
#include <windows.h>

long _stdcall callback(_EXCEPTION_POINTERS* excp)
{
    excp->ContextRecord->Ecx = 1;
    return EXCEPTION_CONTINUE_EXECUTION;
}

int main(int argc, char* argv[])
{
    SetUnhandledExceptionFilter(callback);

    _asm
    {
        xor edx,edx
        xor ecx,ecx
        mov eax,0x10
        idiv ecx
    }

    printf("Run again!");
    getchar();
    return 0;
}

直接启动可以正常运行

image-20220329113645787.png

使用od打开则直接退出

image-20220329113851211.png

image-20220329113905022.png

// Debug3.cpp : Defines the entry point for the console application.
//

#include "stdafx.h"
#include <windows.h>
DWORD g_Test = 0;

LONG NTAPI TopLevelExceptFilter(PEXCEPTION_POINTERS pExcepinfo)
{
    printf("The top_function fix the exception!\n");
    g_Test = 1;
    return EXCEPTION_CONTINUE_EXECUTION;

}

int main(int argc, char* argv[])
{
    int x = 0;
    int y = 100;

    SetUnhandledExceptionFilter(&TopLevelExceptFilter);

    x = y/g_Test;

    printf("正常逻辑开始执行\n");

    for (int i=0;i<10;i++)
    {
        ::Sleep(1000);
        printf("%d\n", i);
    }

    getchar();
    return 0;
}

正常情况下执行程序

image-20220401144025331.png

如果是调试程序则直接退出

image-20220401144253203.png

  • 发表于 2022-04-15 09:44:27
  • 阅读 ( 7419 )
  • 分类:漏洞分析

0 条评论

请先 登录 后评论
szbuffer
szbuffer

30 篇文章

站长统计