翻译 by 银雁冰
原文链接
这篇博客将会审视一个微软在十多年前处理软件断点时所用的假设,利用这一点可以检测到大部分(市面上所有的?)用户态和内核态调试器。
x86
架构可以用多种方式编码一条特定的汇编指令。举个例子,将两个寄存器eax
和ebx
累加,并且将结果存储到eax中可采用如下助记符:add eax, ebx
。这可以被编码成字节序列0x03 0xC3
或0x01 0xD8
.这两组机器码代表同一个汇编操作。
如果你只关心反调试技巧(并不想结合上下文理解它的原理),请将进度条滚动到这篇博客的最下面。对于那些想要勇敢地读完整篇文章的人,请系好安全带(老司机要开车了)。
一个int 3
指令可以被编码成一个单字节指令0xCC
,也可以通过不常见的方式被编码成多指令序列0xCD 0x03
:
来自《Intel指令手册
》(第2卷
,第3章
,第3.2节
)
所以,如果Windows
遇到一个多字节的int 3
指令会发生什么呢?我们写一个简单的C++
程序来看一下:
在你运行这个程序之后,你应该看到与之类似的输出:
一个单字节的int 3(0xCC)
和预期的一致。代码块开始处位于0x000001BE94B90000
。当这段代码被执行后,异常处理例程启动,我们看到_EXCEPTION_RECORD.ExceptionAddress
和_CONTEXT.Rip
都位于0x000001BE94B90000
,这就是int 3
指令的开始处。完美!
多字节的int 3(0xCD 0x03)
的代码块开始处位于0x000001BE94B90002
。当这段代码被执行后,异常处理例程认为_EXCEPTION_RECORD.ExceptionAddress
和_CONTEXT.Rip
都位于0x000001BE94B90003
。这是int 3
指令的中间位置。为什么?哪里出问题了?
注意:从现在开始,所有的汇编代码和伪代码都从Windows x64 10.0.15063
(创意者更新版本)提供的系统文件中重新构建。如果你想要跟着做下去,请确保你使用和我相同的操作系统版本!
微软假设所有的int 3
都源自单字节的情况。
这个假设发生在中断处理过程很前面的时候。顾名思义,当一个中断发生的时候,例如当一个int 3
被处理器执行时,控制流被CPU
重定向到一个注册在IDT
(中断控制描述符表)的例程。在Windows
操作系统中,软件中断对应的例程可以在nt!KiBreakpointTrap
的符号中找到:
nt!KiBreakpointTrap
做的第一件事是在栈上生成一个用来传递给后续例程的陷阱帧(_KTRAP_FRAME
)。这个结构体的其中一个定义如下:
这个结构体的部分成员在中断发生时自动被CPU
填写,具体地说,是 +0x160 (_KTRAP_FRAME.ErrorCode)
到 +0x188 (_KTRAP_FRAME.SegSs)
这一区间内的成员。
来自《Intel指令手册
》(第3卷
,第6章
,第6.12节
)
_KTRAP_FRAME
事实上是被CPU
存储在栈上的成员的一个扩展。它的目的是提供一个地方,去存储那些易失性寄存器,这些寄存器在调用C语言
编译生成的函数时会被破坏。
需要指出的一件非常重要的事是,被CPU
保存在栈上的指令寄存器(eip
) (_KTRAP_FRAME.Rip
)会被设为引发异常指令的下一句指令。在我们的场景中,这意味着_KTRAP_FRAME.Rip
成员会被设为紧跟int 3
的下一条指令,在上面的例子中,这个指令是ret(0xC3)
。
在易失性寄存器的值被保存后,nt!KiBreakpointTrap
执行一次快速的检查,以判断中断是由用户态(ring3
)还是内核态(ring0
)发生。如果执行流来自ring3
,一个swapgs
语句需要被执行,同时填写一些另外的调试寄存器。
最终,控制流会恢复,然后易失性浮点寄存器也会被保存到_KTRAP_FRAME
。在进入更多的异常处理逻辑前,指令指针会被从_KTRAP_FRAME.Rip
(在前面进入nt!KiBreakpointTrap
时被CPU
所保存)取出,并且减1
,然后作为一个参数被传入nt!KiExceptionDispatch
。另外,异常代码,EXCEPTION_BREAKPOINT(0x80000003)
,也会被传入。nt!KiExceptionDispatch
的声明如下:
需要指出的是nt!KiExceptionDispatch
(和nt!KiBreakpointTrap
一样)是用手工汇编写成。它假设ecx
包含异常代码,edx
是异常参数的数量(最多3个
),r8
包含异常发生的地址,r9
是第一个异常参数(如果有存在),r10
是第二个异常参数(如果存在),r11
是第三个异常参数(如果存在),rbp
指向_KTRAP_FRAME
结构体中的一个段(位于偏移+0x80
处)
在nt!KiExceptionDispatch
的入口处,第一件发生的事情是生成一个_KEXCEPTION_FRAME
。_KTRAP_FRAME
用来存储易失性寄存器,_KEXCEPTION_FRAME
则提供一个地方用来存储所有非易失性寄存器:
nt!KiExceptionDispatch
还会在栈上创建一个_EXCEPTION_RECORD
.aspx)结构体。如果你写过任何Windows
上的错误处理例程(可以是用户模式或内核模式),你会很熟悉这个数据结构,因为它包含一个子结构体_EXCEPTION_POINTERS
.aspx)。我们在上面的例子里面同时使用了这两个结构体。
更进一步,这解释了我们谜团中的第一部分,顾名思义,为什么_EXCEPTION_RECORD.ExceptionAddress
是不正确的。回想一下_EXCEPTION_RECORD.ExceptionAddress
是从给nt!KiExceptionDispatch
的r8寄存器参数中传递过来的,而这个值来自nt!KiBreakpointTrap
。这个值正是被减1
后的_KTRAP_FRAME.Rip
成员的一份拷贝。
为了找出_CONTEXT.Rip
成员是如何被填充的,我们需要将兔子洞再挖得深一点。
nt!KiExceptionDispatch
会调用nt!KiDispatchException
(是的,这两个单词的顺序被有意翻转),同时传入刚创建的_EXCEPTION_RECORD
和_KEXCEPTION_FRAME
:
这个函数会在_KTRAP_FRAME和_KEXCEPTION_FRAME
之外构造一个_CONTEXT
:通过调用辅助例程KeContextFromKFrame
。在_CONTEXT
被创建后,会对_EXCEPTION_RECORD.ExceptionCode
做一次检查(作为nt!KiExceptionDispatch
的一个参数被接收),以判断它是否是STATUS_BREAKPOINT (0x80000003)
。如果是,_CONTEXT.Rip
成员会被减1:
这解释了最后一个谜团,从而导致_CONTEXT.Rip
的值也被破坏。
知道了Windows
如何处理不同的int 3
类型后,就可以利用这一差异来检测反调试吗?答案是肯定的。
调试器会在异常发生的时候显示程序的状态。既然Windows
会不正确地假设我们的int 3
异常来自单字节的情况,完全可以迷惑调试器,让它读取“额外”内存。我们利用这种不一致来进行一趟到“守护页”的旅行。
正如我们在我们的第一个例子里看到的那样(见本文的开始),当多字节的int 3
出现时,_EXCEPTION_RECORD.ExceptionAddress
和_CONTEXT.Rip
会位于我们多字节指令的中间而不是开始。这意味着调试器将会不正确地认为抛出软件断点的那条指令开始于操作码0xC3
。当我们引用靠谱的Intel指令手册
时,我们可以看到这个操作码代表一个2字节
的add
指令:
来自《Intel指令手册
》(第2卷
,第3章
,第3.2节
)
如果我们将我们的多字节int 3
指令放在一个内存页的最后会发生什么?
当操作系统提示(我们附加的)调试器一个断点异常发生时,指令指针会被指向(被操作系统)错误解释为一个add(0x03)
指令开始处的内存地址。这会导致调试器去反汇编相邻页的数据(既然这是一个2字节
长度的指令),然后有效阅读一个我们“合法”内存外的字节。
我们的技巧依赖于Windows
上的一个事实:作为一种优化手段,Windows
并不会将虚拟内存提交到物理内存,除非它必须需要它。也就是说大多数内存,尤其在用户态,是被分页的。当不在物理内存中的内存需要被使用时,一个缺页异常会发生。想要了解更多关于内存管理的知识,可以阅读我们网站上的下列文章:Introduction to IA-32e hardware paging
和Exploring Windows virtual memory management
.
所以,我们可以通过调用QueryWorkingSetEx
.aspx) 检测到读取相邻页的内存,因为这个过程会插入对应的PTE
(页表入口)。如果相邻页位于我们进程的工作集中(例如,被调试器映射到我们的进程中),_PSAPI_WORKING_SET_EX_BLOCK
.aspx) 中的有效位就会被设定。
[注意]传递专业知识、拓宽行业人脉——看雪讲师团队等你加入!