这个星期在研究CVE-2018-8897 理解这个漏洞的原理之前 必不可少的一步是理解调试寄存器的相关知识 google之后发现了这篇好文 文章讲的的内容很清晰 难度也适合我这种初入内核的小白 所以按捺不住想翻译一下 第一次翻译 如果有什么不当之处麻烦指出 见笑
对于常规用户来说 调试器是一个黑盒 调试器与操作系统之间的交互行为被隐藏了 让我们揭开它并且探究它是如何工作的
本文基于2007年8月25号 在FASM技术交流会上 所播放的幻灯片"Debugging in Long Mode(AMD 64)", 改进而成
当我们处于调试状态的时候 我们与一个执行中程序打交道 我们可以针对这个程序做一下几件事
CPU 以非常快的速度执行代码 处于调试状态的时候 我们可以以我们感官所能观察到的速度去执行代码
为了实现这个功能 我们需要调试器的帮助
为什么需要调试呢:
多亏CPU有一种叫做异常的特性调试才变为可能
前32个中断(0x00 - 0x1F(十进制: 31 -- 译者bb)) 对于异常来说是保留值 异常与中断的行为十分的相似 -- 异常强制性的中断一个程序 然后程序的控制权转移到一个能处理这个异常的程序 这个程序属于操作系统内核的一部分 叫做"异常处理程序" 在控制权转交到"异常处理程序"的时候 CPU停止原程序的执行 并且保存指向返回处地址的寄存器RIP 栈指针RSP 标志寄存器RFLAGS “异常处理程序”有责任保留原程序需要保留的信息(GPR, XMM ...) 被保存的寄存器将会帮助CPU在执行完"异常处理程序"之后重新启动被中断的程序
多数时候 异常的出现意味着程序当中出现了 "退化" 的指令或者代码 在这种情况下 异常边界会在指令造成这个异常之前被提交 并且这条指令不会被完整的执行 这种异常被称作 "错误“
少数的情况会更加复杂 异常边界的指令的指针放在异常指令之后 所以异常边界会在指令执行造成异常之后被提交 并且允许产生异常的指令执行完整 这种异常叫做 "陷阱" “陷阱“型异常给我们带来了巨大的好处 它是"调试"的核心
触发 ' int 0x0 ' 向量
[+] 示例1: 除数是0 注意: 所有的例子遵守我最喜欢的汇编器(fasm) 的语法
[+] 示例2: 运算的结果(商)太大超出了指定的寄存器的存储范围
触发 'int 0x1' 向量
[+] 示例1:
[+] 示例2: 设置单步异常的基本操作(实际上, 操作系统设置程序的上下文(Context)相关的bit位, 然后在在处理完后恢复初值)
[+] 示例3:设置硬件断点的基本操作(接下来的代码看不懂都没有关系 后面有相关的讲解 -- 译者bb)
[+] 示例4:
触发 ‘int 0x3' 向量
[+] 示例:
触发 'int 0x6' 中断向量
[+] 示例1: 文档中未定义的指令
[+] 示例2: 源操作码数是一个寄存器 正确的写法应该是 ' lea rax, [rdx] '
注意:
触发 'int 0x8' 向量
触发 ’int 0x0c' 向量
触发 'int 0x0D' 向量
触发 'int 0x0E' 向量
触发 'int 0x11' 向量
只有当CR0寄存器AM bit位被置位的时候 这个异常才可能发生 内核的实现代码和如下代码相似:
[+] 示例: 用户模式下的代码(CPL = 3) 这里假设是一个 'QWORD' 或者 'DQWORD' 栈对齐
注意:
程序和调试器是怎么和操作系统交互的呢?
如果程序没有引起任何异常 程序会运行到它的末尾处并终止 在这种情况下 调试器也不会接收到任何的异常 调试器只有在程序终止的时候被通知 这对于汇编开发者是梦寐以求的 然后 在某些情况下 一些程序会返回非预期的值 执行错误的流程
硬件断点总是触发 'int 0x1'向量 通过设置某些调试寄存器的值来创建这个断点 有6个有用的调试寄存器 分别是 Dr0, Dr1, Dr2, Dr3, Dr6, Dr7 其他的调试寄存器没有被使用(如果访问他们会造成一个操作码异常) 是不是听起来很简单? 但是从某些方面来说 只有6个调试寄存器可能更加的复杂
调试寄存器只有在CPL0层(内核模式)可以被读写:
当程序在产生一个异常之后从而停止 用户模式下的调试器可以访问程序的调试寄存器
调试寄存器Dr0, Dr1, Dr2, Dr3 存储一个虚拟(线性)地址的值
如果我们需要设置调试寄存器(Dr0-Dr3)的值 我们得设置Dr7中的相关信息 -- 包括相关的"断点启用标志bit位" "断点类型" "断点长度"
--
--
如果你想使用DR0-Dr3寄存器来设置断点 使用下面的公式即可:
[+] 示例1: 内存读写断点
我们想要监视对地址100005120h处一个qword长度数据的读写操作(地址范围100005120h-100005127h)
设置完毕! 我们现在只需要等待代码落入到这个"陷阱"当中 当代码访问处于100005120h-100005127h当中的任何字节时 将会引发int 1 DR6.B0也会被设置为1
[+] 示例2: 内存读写断点(未对齐地址)
我们想要监视地址40AF31h-40AF38h之间的8个字节的写操作 直接以8字节的形式进行设置是不会成功的 因为这个地址与dqword边界处于未对齐状态 我们必须设置多个断点去覆盖整个内存地址
[+] 示例3: 指令执行断点:
我们想要在401234h处的指令下一个断点
注意指令的起始点必须在这个地址 如果设置的地点在指令的里面(考虑指令有2个byte或者更多byte的情况) 将不会引发int 1
现在我们想要在端口号为 20-27h的端口设置端口断点 当CR4.DE位(第三bit位)被设置为1的时候 这个愿望是可以实现的 DR4.DE位的设置与下面的内核代码类似
在内核模式下 这类断点是非常有用的(in, out, insb, outsb指令)
这种状况下int 1 异常的部分信息会被调试状态寄存器Dr6的一些bit位记录
若此bit位被设置 当尝试去执行mov drn指令时 将会引发int 1, 当进入到int 1处理程序时, 此bit位会被设置为0, 从而保证int 1处理程序能够读写Dr寄存器, int 1异常会在指令执行之前发生, 处理器会设置DR6.BD为1,调试器可以使用这个bit位免受被调试程序的干扰
当处于"异常处理程序"的入口的时候 CPU会清除DR6.BD(bit 13)位 所以mov rax dr6指令不会再次触发int 1
如果以上的bit位都没有被探测到 异常会执行 icebp 指令(操作码: 0XF1)
注意:
"指令执行断点" 和 "通用检测条件" 都会在指令执行之前引发int 1异常
而其他的断点 比如(数据的只读操作, 数据的读写操作, I/O的读写操作) 都是在当前指令执行完之后触发int 1异常
而针对一些重复的指令(伴随着prefix的前缀 比如ret movsb), 可以通过异常或者中断将其置于挂起状态 所以int 1可以在重复迭代的过程中被触发
可能会出现“数据访问断点”后面紧接着一台哦"指令执行断点"(这种情况可以由前面的规律推测相应的行为 -- 译者bb)的情况 然后 不幸的情况也有可能会出现 数据访问断点和指令访问断点可能在单条指令中同时出现 他们遵守以下规律 指令访问断点先执行 之后执行当前指令 再之后执行数据访问断点
可以通过设置rFlags.TF 位为1来启用单步断点(触发器 int 1向量) 当单步断点可用的时候 在恢复rFlags.TF值为0之前 int 1异常会在每一条指令执行之后被触发 设置TF的指令不是单步的 后续的指令在完成之后才进入int 1的中断例程(因为单步属于 "陷阱" 类型的异常) 指令清除TF bit位(因为TF在指令执行之前被置位 并且单步异常属于陷阱型异常 -- 在指令执行之后被触发)
当由于单步引发int 1的时候 在进入异常处理程序之前 TF会被设置为0 这样将会保证异常处理程序的执行过程不是单步的 处理器会设置DR6.BS位为1 用来标记int 1异常是由于单步执行而产生
rFlags(TF被置位)的镜像被压入到调试处理程序的栈中 当子程序执行完iretq的时候 rFlags的镜像恢复 单步的状态也恢复
单步异常可以变得更加的复杂 我们会在下面的工作中继续讨论它
软件断点总是触发int 3向量 软件断点基于int3指令(操作码为0xcc) 这条指令非常有用因为其适合用于填充除此指令之外的任意指令的第一个字节
实际上我们可以用其它的操作码对这条指令进行编码 比如 0CDH, 03H 但是这些太有用 因为他们不适合填充某些指令的第一个字节(比如 cld; push/pop; gbr64; xchg gpr32, eax; stosb; ...)
调试器将这个字节放在代码段中期待发生中断的的地址 当程序的执行命中这条指令的时候 调试器停止程序的执行
程序源在开发过程中可以在源码当中放入这条指令(int 3) 这是一个方便调试器轻松的进入到需要调试的目标位置的小技巧
我们可以监视导致控制转移和异常的指令 这些指令是: JMP, CALL, RET, Jcc, JrCXZ, LOOPcc, JMPF, CALLF, RETF, INT n, INT 3, ICEBP, IRETQ, SYSCALL, SYSRET, RSM 我们也可以监视NMIS 和SMIS
我们只要在某个寄存器中设置其中一个bit位 然而我认为windows和linux没有启用这个bit位了 这个寄存器被称作 Debug-Control MSR
设置这个bit位为1的相似的内核代码如下:
设置LBR位是为了让处理器能够记录下最近一次转移指令目标地址和源地址(分支指令, 中断, 异常)
当int 1发生的时候 处理器会自动将DebugCtlMSR.LBR位清0 从而禁用控制转移记录值 控制转移记录值在int 1过程中不会被修改 在退出调试异常处理程序之前 软件可以设置DebugCtlMSR.LBR为1 然后重新使用记录值机制
启用bit位之后 目的地址和源地址会被处理器所保存 -- 分支(call/jmp) 异常 中断 我们有四个寄存器 LastBranchFromIP(010Bh) LastBranchToIP(01DCh) LastExceptionFromIP(01DDh) LastExceptionToIP(01DEH) 这些64位的寄存器被设置为只读的 这样可以保护他们的值不会在上下文切换的时候被破坏
译者bb -- 上面这一句话我怎么翻译都是有矛盾的 所以我直接把原文放在这里
[+] 示例: 演示了如何读取LastBranchFromIP的值
DebugCtlMSR.BTF 影响着rFlags.TF的行为 当DebugCtlMSR.BTF位为0的时候(大多数情况都是如此) rFlags.TF bit位控制着指令单步执行(正常情况下) 如果DebugCtlMSR.BTF位被设置为1 rFlags.TF 控制着指令进行分支转移(分支指令 中断 异常) -- 即不会在每一条指令都进行单步 但是会进行一个大范围的单步 通过这种方式 单步机制允许程序在控制转移之间进行单步 而不是在这些指令之间的每一条指令都进行单步
[注意]传递专业知识、拓宽行业人脉——看雪讲师团队等你加入!
最后于 2018-7-30 13:09
被wjzid编辑
,原因: