avatar

Catalog
内存断点

前言

前一篇了解了软件断点,本篇来看内存断点。参考软件断点,本篇依旧从3个角度来看,首先是内存断点的本质;其次站在被调试进程的角度来看,也就是内存断点的执行流程;最后是站在调试器进程的角度来看,就是调试器的代码逻辑。下面,进入正文。

内存断点的本质

首先来说说内存断点的本质,前一篇学习的软件断点的本质实际上就是将某个地址的机器码修改为0xCC。内存断点修改的不是机器码,而是修改物理页的属性。调试器进程通过调用VirtualProtectEx函数(Ex:跨进程)来修改被调试进程的物理页属性来达到实现内存断点的目的。以下为VirtualProtectEx函数原型:

c
1
2
3
4
5
6
7
8
//改变内存地址内存页的属性
BOOL VirtualProtectEx(
IN HANDLE hProcess, // 要修改内存的进程句柄
IN LPVOID lpAddress, // 要修改内存的起始地址
IN SIZE_T dwSize, // 页区域大小
IN DWORD flNewProtect, // 新内存页属性
OUT PDWORD lpflOldProtect //原内存页属性 用于保存改变前的属性
)

这里主要关注的是第四个参数flNewProtect,修改它的值,达到修改所指内存所在物理页的PTE属性

  • PAGE_NOACCESS:不可访问(PTE.P位 = 1)
  • PAGE_EXECUTE_READ:可读可执行,不可写(PTE.P位 = 1, PTE.R/W = 0)

内存断点执行流程(被调试进程角度)

当被调试进程下一次试图访问或者写入这个被修改过属性的物理页时,会触发相应的页异常,进入异常处理流程,并最终将该调试事件发送到调试对象,接下来交由调试器接管,所以本质上,还是异常处理流程。

内存断点的执行流程可以完全参考软件断点的执行流程,仅有开始的异常处理函数不一样,这里只做简单概括:

  1. CPU访问/写入错误内存地址,触发页异常
  2. 查IDT表找到对应的中断处理函数(nt!_KiTrap0E)
  3. CommonDispatchException
  4. KiDispatchException
  5. DbgkForwardException收集并发送调试事件
    • DbgkpSendApiMessage(x, x)
      • 参数1:消息结构
      • 参数2:是否把本进程内除自己外的其它进程挂起。

内存断点的处理(调试器角度)

下断点

首先是下断点,手动触发一个内存断点,用来测试自己代码是否跑的通。下断采用的就是VirtualProtectEx,跨进程修改物理页属性。

c
1
2
3
4
5
6
7
VOID SetMemBreakPoint(PCHAR pAddress)
{
//1.访问断点
VirtualProtectEx(hDebugeeProcess, pAddress, 1, PAGE_NOACCESS, &dwOriginalProtect);
//2.写入断点
VirtualProtectEx(hDebugeeProcess, pAddress, 1, PAGE_EXECUTE_READ, &dwOriginalProtect)
}

下断点的位置与软件断点一样,位于OEP,即发生创建进程事件时下断点,也因此需要与软件断点分开测试。

异常的判断

由于软件断点,内存断点,都是通过异常分发流程执行而来的,所以调试器在收到异常调试事件后,需要判断出是哪种类型的异常(断点)

调试事件DebugEvent的成员dwDebugEventCode可以判断当前调试事件的类型,通过DebugEvent.u.Exception可以获取到异常事件对应的结构体:

c
1
2
3
4
typedef struct _EXCEPTION_DEBUG_INFO {
EXCEPTION_RECORD ExceptionRecord;
DWORD dwFirstChance;
} EXCEPTION_DEBUG_INFO, *LPEXCEPTION_DEBUG_INFO;

该结构体内的ExceptionRecord成员指向描述异常信息的结构体,这个在学习异常记录时曾提到过。

c
1
2
3
4
5
6
7
8
typedef struct _EXCEPTION_RECORD {
DWORD ExceptionCode;
DWORD ExceptionFlags;
struct _EXCEPTION_RECORD *ExceptionRecord;
PVOID ExceptionAddress;
DWORD NumberParameters;
UINT_PTR ExceptionInformation[EXCEPTION_MAXIMUM_PARAMETERS];
} EXCEPTION_RECORD;

其中ExceptionCode指向异常的类型。其取值如下:

这里面可以看到我们比较熟悉的0xC0000005,访问违例,这也正是内存断点引发的异常情况。从而我们可以通过如下的switch…case语句完成对不同类型异常的分别处理。

c
1
2
3
4
5
6
7
8
9
10
11
12
swtich(pExceptionInfo->ExceptionRecord.ExceptionCode)
{
//软件异常
case EXCEPTION_BREAKPOINT:
bRet = Int3ExceptionProc(pExceptionInfo);
break;

//内存断点(访问异常)
case EXCEPTION_ACCESS_VIOLATION:
bRet = AccessExceptionProc(pExceptionInfo);
break;
}

这里之所以用了EXCEPTION_BREAKPOINT替代STATUS_BREAKPOINT是为了看着更清晰,本质上是一样的,只是Windows又定义了一遍它的宏。

内存断点处理函数

下面简要看一下内存断点处理函数的逻辑:

c
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
BOOL AccessExceptionProc(EXCEPTION_DEBUG_INFO* pExceptionInfo)
{
BOOL bRet = FALSE;
CONTEXT Context;
DWORD dwAccessFlag;
DWORD dwAccessAddr;
DWORD UselessTemp;

//1.获取异常信息,修改内存属性
dwAccessFlag = pExceptionInfo->ExceptionRecord.ExceptionInformation[0];
dwAccessAddr = pExceptionInfo->ExceptionRecord.ExceptionInformation[1];
printf("内存断点 %x 0x%p\n", dwAccessFlag, dwAccessAddr);
VirtualProtectEx(hDebugeeProcess, (PVOID)dwAccessAddr, 1, dwOriginalProtect, &UselessTemp);

//2.获取线程上下文
Context.ContextFlags = CONTEXT_FULL | CONTEXT_DEBUG_REGISTERS;
GetThreadContext(hDebugeeThread,&Context);

//3.修复EIP,内存断点不需要修复EIP,软件断点需要

//4.显示反汇编

//5.等待用户命令
while(bRet == FALSE)
{
bRet = WaitForUserCommand();
}
return bRet;
}

后面的处理逻辑,与软件断点的差别不大。这里主要看第一个部分,在ExceptionRecord结构体中,有一个数组

根据MSDN,其第一个成员指明异常是因为什么原因导致的(0:有线程试图去读,1:有线程试图去写)。第二个成员标识了虚拟地址在哪,即产生异常的地址是哪里。由于内存断点是对整个物理页下断,因此断下的地方可能并不是我们下断的地方,所以这里取地址判断是否为下断处,若不是则直接放过执行,若是则进行处理。

参考资料

参考笔记:

参考教程:

  • 海哥逆向中级预习班
Author: cataLoc
Link: http://cataloc.gitee.io/blog/2020/09/18/%E5%86%85%E5%AD%98%E6%96%AD%E7%82%B9/
Copyright Notice: All articles in this blog are licensed under CC BY-NC-SA 4.0 unless stating additionally.
Donate
  • 微信
    微信
  • 支付寶
    支付寶