处理器调试功能与用户模式调试器的结合

在工程领域,常常依赖处理器提供的扩展特性,但很少深入理解它们。本文将详细解释AMDIntel处理器的系统调试MSR(Model Specific Registers)以及如何将这些特性应用于用户模式级别的调试器,而不仅仅是在CPL(Current Privilege Level)为0的代码中运行。

值得注意的是,不同处理器的调试MSR特性可能会有所不同。例如,某些IntelCPU提供了多达15个最后的分支记录,而AMD则没有。然而,这些特性不能从CPL为3的代码中利用,这也不在本文讨论的范围内。

假设已经具备了对Windows调试API、Windows内部结构和汇编语言的深入了解。最后分支记录和分支跟踪应该是恶意软件分析师或软件逆向工程师分析环3代码的有力工具。Windows操作系统本身提供了多种后门,可以从用户模式利用这些技术。本文的目标是提供如何使用这些特性并将它们整合到自己的调试器和分析工具中的详细解释。

分支跟踪

分支是一个可以有条件或无条件地转移控制流的指令。例如,任何条件跳转、无条件跳转、调用、返回、远调用、远跳转、iret、retf、int n、syscall、sysexit、icebp等。

“分支采取”这个术语意味着由于分支导致实际的控制流变化。在无条件分支指令中,分支总是会被采取。然而,在条件跳转(例如,跟随位比较)中,分支并不总是被采取,这取决于之前的比较结果。

正如可能已经知道的,处理器的单步执行特性(EFLAGS.TF=1)会在每个指令边界到达后引发一个#DB异常。这种类型的异常被称为陷阱,意味着被推送到中断处理程序堆栈上的指令指针将指向即将执行的下一条指令。

简单的x86示例:

pushfd
or dword ptr [esp], 0x100
popfd
inc eax
// <--after this instruction has finished execution,
// the address pushed onto the handler stack is the next instruction
push ebx

DebugCtl MSR提供了一个位,当设置这个位以及EFLAGS.TF=1时,只有在分支指令边界被到达后才会触发一个#DB陷阱(单步执行),而不是每条指令。这仅在分支被采取时发生。然后被推送到处理程序堆栈上的指令就是分支的目标地址,这当然就是在Windows调试器CONTEXT结构中的指令指针EIP/RIP。

简单的x86示例:

(EFLAGS.TF=1 and DebugCtl.BTF=1)
push ebx
// <--even though trap flag is set, nothing.
push eax
// <--even though trap flag is set, nothing.
call ecx
// <--will raise a #DB exception, IP on handler stack will be the destination of the call. In this case ECX.
xor eax, eax
// <--even though trap flag is set, nothing.
inc ebx
// <--even though trap flag is set, nothing.
pop eax
// <--even though trap flag is set, nothing.
pop ebx
// <--even though trap flag is set, nothing.
ret
// <--will raise a #DB exception, IP on handler stack will be the destination of the return. In this case, the contents pointed to by ESP.

现在,如何从用户模式访问DebugCtl呢?很简单,Windows提供了对DebugCtl的BTF和LBR位的访问,通过DR7的第8位和第9位。如果感兴趣,请参阅KiRestoreDebugRegisterState。

DR7的第8位代表DebugCtl的第0位。这是LBR位(最后分支记录,稍后解释)。DR7的第9位代表DebugCtl的第1位。这是BTF位(单步执行分支)。

正如所能想象的,这可以大大加快运行跟踪的速度。因为从理论上讲,当寻找代码控制流的差异或错误时,答案很可能依赖于哪些分支被采取以及何时采取,而只跟踪分支,可以每秒跟踪成千上万条指令,而不是在每个指令边界后生成一个中断。

现在,也许已经注意到了,或者没有,这给留下了一个问题。被推送到处理程序堆栈上的指令指针是分支指令的目标。因此,用户模式CONTEXT结构中的RIP/EIP将是目标。如果想知道分支指令本身的位置呢?这就是最后分支记录栈的用武之地,也称为LBR。

让想象一下,已经在使用用户模式调试器或分析工具进行分支跟踪程序。已经设置了DR7的第9位以启用分支跟踪,并且也设置了陷阱标志。这里是需要做的。另外通过DR7设置LBR位(如上所示的第8位)。当由于采取的分支而发生#DB异常时,分析CONTEXT中的EIP/RIP。正如前面所述,那是目标指令。现在文章的美味部分:分支指令本身的地址被Windows隐藏在EXCEPTION_RECORD->ExceptionInformation[0]中,当然前提是正确地启用了LBR。然后这就是分支到指令指针的分支指令本身的虚拟地址。

在这方面也有点创意,在网上找不到任何深入的文章,所以决定自己写一篇。在为朋友分析一个小软件时,注意到在调用ws32.send()之前,它会清除堆栈,并设置一个假的返回地址,然后JMP到send(),以免将原始返回地址推送到堆栈上,这使得找到它的来源变得非常痛苦。

LBR来救援

以下是如何轻松克服这个问题的方法,中的一些人可能已经知道这一点,但请继续阅读重要细节。首先,必须在正在分析的线程上初始化LBR。在这种情况下,不需要BTF特性。所以为线程设置DR7的第8位。

接下来,必须在ws32.send()或正在分析的任何内容上建立一个断点。这里是重要的部分:引发的异常类型必须是#DB异常。这是因为唯一将LastBranchFromIp插入EXCEPTION_RECORD->ExceptionInformation[0]的Windows中断处理程序是Windows int 01处理程序。Windows int 3处理程序不会为这样做,如果使用int 3,ExceptionInformation[0]成员将是空的。

可以使用调试寄存器断点或ICEBP(int 01,无需DPL检查)。个人推荐ICEBP,原因如下:代码可能会IRET到send(),并且设置了恢复标志。如果调试器用户在send()的第一指令上初始化了一个断点,它将被忽略!让面对现实吧,中的许多人喜欢在API的第一指令上放置断点。

除了分析一个用假堆栈设置的函数分支之外,LBR特性也是检测程序是否在虚拟机中运行的一个相当好的方法。这是因为大多数虚拟化软件,包括VMware和Vbox,都不使用LBR虚拟化(尽管这是可能的并且得到支持)。

RunningInHyperVisor PROC
// eax = CONTEXT pointer
mov [eax], 0x10
// CONTEXT_DEBUG_REGISTERS
lea ebx, [eax+0x18]
mov [ebx], 0x100
// LBR bit @ DR7
push eax
push ecx
call SetThreadContext
_emit 0xeb
// previous branch
_emit 0x00
_emit 0xf1
// icebp
RunningInHyperVisor ENDP
沪ICP备2024098111号-1
上海秋旦网络科技中心:上海市奉贤区金大公路8218号1幢 联系电话:17898875485