前言
Yarden Shafir 分享了两篇非常通俗易懂的,关于 windbg
新引入的调试数据模型的文章。原文链接如下:
part1
:https://medium.com/@yardenshafir2/windbg-the-fun-way-part-1-2e4978791f9b
part2
:https://medium.com/@yardenshafir2/windbg-the-fun-way-part-2-7a904cba5435
本文是第二部分的译文。同样在有道词典、必应词典、谷歌翻译的大力帮助下完成,感谢以上翻译工具,我只是一个搬运工。强烈建议英文好的朋友阅读原文,因为在翻译的过程中不可避免的按我的理解做了调整。
第一部分译文在这里。
以下是译文!
欢迎来到我试图让您享受在 Windows
上调试的第 2
部分(哇,我真是个书呆子)!
在第一部分中,我们已经了解了新调试数据模型的基本知识——使用新对象、使用自定义寄存器、搜索和过滤输出、声明匿名类型以及解析列表和数组。在这一部分中,我们将学习如何在 dx
中使用传统命令(译注: 原文是 legacy commands
,我理解是在引入 dx
之前就存在的一些命令,比如 dt
、u
等),了解令人惊叹的新反汇编器,创建合成方法和类型,查看断点的奇妙变化,并在调试器中使用文件系统。
听起来有很多内容。因为确实有很多内容。我们开始吧!
传统命令(Legacy Commands)
新数据模型完全改变了调试体验。但有时确实需要使用我们已经习惯的旧命令或扩展(这些功能在 dx
中没有对应的实现)。
我们仍然可以通过 Debugger.Utility.Control.ExecuteCommand
在 dx
中使用这些旧命令,它让我们可以将传统命令作为 dx
查询的一部分运行。例如,我们可以使用传统的 u
命令查看第二个栈帧中 RIP
所指向的地址对应的反汇编。
由于 dx
输出默认是十进制的,而传统命令只接受十六进制输入,我们首先需要使用 ToDisplayString("x")
将其转换为十六进制:
1 | dx Debugger.Utility.Control.ExecuteCommand("u " + @$curstack.Frames[1].Attributes.InstructionOffset.ToDisplayString("x")) |
另一个有用的传统命令是 !irp
。这个命令为我们提供了大量关于 IRP
的信息,所以没必要大费周章的使用 dx
重新实现它。
我们将尝试对 lsass.exe
进程中所有的 IRP
执行 !irp
命令。让我们看看整个过程:
首先,我们需要找到 lsass.exe
进程的容器。我们已经知道如何使用 Where()
来做到这一点。我们选择返回的第一个进程。通常应该只有一个 lsass
,除非机器上有服务隔离。
译注: 服务隔离对应的原文是
server silos
,在网上找到一个解释 :A silo in IT is an isolated point in a system where data is kept and segregated from other parts of the architecture.
1 | dx @$lsass = @$cursession.Processes.Where(p => p.Name == "lsass.exe").First() |
然后,我们需要遍历进程中每个线程的 IrpList
并获得 IRP
本身。我们可以很容易地通过前面提到的 FromListEntry()
做到这一点。我们只选取包含 IRP
的线程:
1 | dx -r4 @$irpThreads = @$lsass.Threads.Select(t => new {irp = Debugger.Utility.Collections.FromListEntry(t.KernelObject.IrpList, "nt!_IRP", "ThreadListEntry")}).Where(t => t.irp.Count() != 0) |
我们可以在这里停一下,点击其中一个 IRP
的 IoStack
(或者运行 -r5
来查看全部内容)可以得到调用栈信息:
1 | dx @$irpThreads.First().irp[0].IoStack |
最后,我们将遍历每个线程,并且遍历线程中的每个 IRP
,并对其执行 Executeccommand !IRP <irp address>
。这里我们也需要类型转换和 ToDisplayString("x")
来匹配传统命令所期望的格式。( !irp
的输出很长,所以我们将其截断,只关注感兴趣的数据):
1 | dx -r3 @$irpThreads.Select(t => t.irp.Select(i => Debugger.Utility.Control.ExecuteCommand("!irp " + ((__int64)&i).ToDisplayString("x")))) |
!irp
提供给我们的大部分信息都可以通过使用 dx
解析 IRP
并转储其 IoStack
获得。但是有一些信息如果不使用传统命令则很难获得。比如, IrpExtension
是否存在及其地址,以及可能与 IRP
关联的 Mdl
的信息。
反汇编器(Disassembler)
我们以 u
命令为例,下面的例子展示的是:在 dx
中通过 Debugger.Utility.Code.CreateDisassember
和 DisassembleBlock
创建可以遍历和搜索的反汇编代码:
1 | dx -r3 Debugger.Utility.Code.CreateDisassembler().DisassembleBlocks(@$curstack.Frames[1].Attributes.InstructionOffset) |
只选择 Instructions
并将其扁平化显示,整理后的版本如下:
1 | dx -r2 Debugger.Utility.Code.CreateDisassembler().DisassembleBlocks(@$curstack.Frames[1].Attributes.InstructionOffset).Select(b => b.Instructions).Flatten() |
合成方法(Synthetic Methods)
新调试数据模型提供的另一个功能是允许我们创建并使用自定义函数。语法如下:
1 | 0: kd> dx @$multiplyByThree = (x => x * 3) |
我们也可以创建包含多个参数的函数:
1 | 0: kd> dx @$add = ((x, y) => x + y) |
或者,如果我们真的想要玩的更高端一些,我们可以将这些函数应用到前面的反汇编输出中,以便找出 ZwSetInformationProcess
函数中所有的内存写入指令。为此,我们需要对每条指令进行一些检查,以便知道它是否是内存写入指令:
指令至少有两个操作数吗?
例如,
ret
指令的操作数是0
,而jmp <address>
指令的操作数是1
。我们只关心将一个值写入某个位置的情况,这总是需要两个操作数。为了验证这一点,我们需要检查每条指令的Operands.Count() > 1
。
- 是否是内存引用操作?
我们只对内存写入操作感兴趣,并希望忽略像mon r10, rcx
这样的指令。为此,我们将检查每条指令的Operands[0].Attributes.IsMemoryReference == true
。我们检查Operands[0]
,因为它是目的操作数。如果我们想查找内存读取指令,我们应该检查源操作数,它在Operands[1]
中。
目的操作数是输出吗?
我们想过滤掉引用内存但没有写入内存的指令。我们将使用
Operands[0].IsOutput == true
进行检查。
作为最后一个过滤器,我们希望忽略栈内存写入操作,它看起来像
mov [rsp+0x18],1
或mov [rbp-0x10],rdx
。我们将检查第一个操作数的寄存器,并确保其索引不是
rsp
索引(0x14
)或rbp
索引(0x15
)。
我们将编写一个函数 @$isMemWrite
,它接收一个代码块并且只返回内存写入指令(基于上面的检查)。然后我们可以创建一个反汇编器,反汇编我们的目标函数并且只输出其中的内存写入指令:
1 | dx -r0 @$rspId = 0x14 |
作为另外一个项目,它几乎结合了上面提到的所有内容 —— 实现 dx
版本的 !apc
。为了简化,我们只寻找内核 APC
。要做到这一点,需要如下几个步骤:
- 使用
@$cursession.Processes
遍历所有进程,以找到这些进程:包含KTHREAD.ApcState.KernelApcPending
值为1
的线程。
- 在对应进程中创建一个容器,只包含那些包含挂起内核
APC
的线程,忽略其它线程。
- 对于每个线程,遍历
KTHREAD.ApcState.ApcListHead[0]
(包含内核APC
)并收集感兴趣的信息。可以通过前面介绍的FromListHead()
方法实现。为了使我们的容器尽可能地与!apc
相似,我们将只获取KernelRoutine
和RundownRoutine
,尽管在你的实现中,你可能会发现其他感兴趣的字段。
- 为了让容器更容易遍历,我们将收集进程名、进程
ID
、EPROCESS
地址以及线程ID
和ETHREAD
地址。
在我们的实现中,我们创建了几个辅助函数:
@$printLn —— 运行传统命令
ln
,以获取指定地址对应的符号信息。@$extractBetween —— 提取两个字符串之间的字符串,用于从
@$printLn
的输出中获取子字符串。@$printSymbol —— 传递一个地址给
@$printLn
,并且使用@$extractSymbol
提取符号名。@$apcsForThread —— 找到一个线程中的所有内核
APC
,并且创建一个包含KernelRoutine
和RundownRoutine
的容器。
在得到所有进程(包含挂起的内核 APC
的线程)后,将其保存到 @$procWithKernelApcs
寄存器中,然后在一个单独的命令中使用 @$apcsForThread
获取 APC
信息。我们将 EPPROCESS
和 ETHREAD
指针强制转换为 void*
,这样当我们显示最终结果时 dx
就不会显示整个结构。
这是我解决问题的方法,但也有其他方法,你的方法不一定和我的完全相同!
我想出的脚本是:
1 | dx -r0 @$printLn = (a => Debugger.Utility.Control.ExecuteCommand(“ln “+((__int64)a).ToDisplayString(“x”))) |
它会产生以下输出:
1 | dx -r6 @$procWithKernelApc.Select(p => new { Name = p.Name, PID = p.PID, Object = p.Object, ApcThreads = p.ApcThreads.Select(t => @$apcsForThread(t))}) |
虽然输出结果没有 !apc
美观,但已经很美观了。
我们还可以将其输出成表格,显示相关进程的信息并且可以分别浏览每个进程中的 APC
:
1 | dx -g @$procWithKernelApc.Select(p => new { Name = p.Name, PID = p.PID, Object = p.Object, ApcThreads = p.ApcThreads.Select(t => @$apcsForThread(t))}) |
但是请等一下,这些包含 nt!EmpCheckErrataList
的 APC
是怎么回事?为什么 SearchUI.exe
里到处都是?这个进程和 erratas
有什么关系?(译注: 函数 nt!EmpCheckErrataList
中包含 Errata
。)
秘密在于实际上并没有 APC
会调用 nt!EmpCheckErrataList
。不,符号也没有错。(译注: 哈哈,感觉作者在跟读者讨论问题)
我们之所以看到这样的现象,是因为编译器很智能 —— 当编译器看到不同的函数具有相同的代码的时候,编译器会让它们全部指向同一段代码,而不是多次复制这段代码。你可能会认为这不是一个会经常发生的事情,但让我们看看 nt!EmpCheckErrataList
的反汇编代码(这次使用旧办法):
1 | u EmpCheckErrataList |
这实际上只是一个存根。它可能是一个尚未实现的函数(EmpCheckErrataList
可能是这种情况),也可能是出于某些原因被故意定义为存根函数。这些 APC
的 KernelRoutine/RundownRoutine
真正对应的函数是 nt!KiSchedulerApcNop
,它被故意设计成存根函数,并且已经多年了。我们可以看到它具有相同的代码并且指向同一地址:
1 | u nt!KiSchedulerApcNop |
那么为什么我们会看到这么多这样的 APC
呢?
当一个线程被挂起时,系统会创建一个信号量,并将一个等待该信号量的 APC
发送到该线程。线程将一直等待,直到有人唤醒它,然后系统将释放信号量,线程将停止等待并恢复运行。APC
本身不需要做很多事情,但它必须有一个 KernelRoutine
和一个 RundownRoutine
,所以系统使用了存根。这个存根使用了这些函数(包含存根代码)中的一个,这次是 nt!EmpCheckErrataList
,但在下一个版本中可能是一个不同的函数名。
任何对挂起机制感兴趣的人都可以查看 ReactOS。这些函数的代码发生了一点变化,并且存根函数名从 KiSuspendNop
变成了 KiSchedulerApcNop
,但总体设计保持相似。
我跑题了,这不是本篇文章应该讨论的内容。让我们回到 WinDbg
,接着讲合成类型:
合成类型(Synthetic Types)
在介绍完合成方法后,我们还可以添加自定义的命名类型(named types),并使用它们来解析之前无法解析的数据。
例如,让我们尝试打印 PspCreateProcessNotifyRoutine
数组,该数组包含所有已注册的进程通知例程 —— 这些函数由驱动程序注册,在进程启动时会收到一个通知。但是这个数组不包含指向注册例程的指针。相反,它包含指向未文档化的结构体 EX_CALLBACK_ROUTINE_BLOCK
的指针。
因此,为了解析这个数组,我们需要确保 WinDbg
认识这个类型 —— 我们需要使用合成类型。我们首先要创建一个头文件,里面包含我们想要定义的所有类型(我使用 c:\temp\header.h
)。在本例中,我们只定义了 EX_CALLBACK_ROUTINE_BLOCK
,可以在 ReactOS 中找到它的定义:
1 | typedef struct _EX_CALLBACK_ROUTINE_BLOCK |
现在我们可以让 WinDbg
加载这个头文件并将里面的类型添加到 nt
模块中:
1 | dx Debugger.Utility.Analysis.SyntheticTypes.ReadHeader("c:\\temp\\header.h", "nt") |
这会为我们返回一个对象,通过这个对象,我们可以查看添加到此模块的所有类型。现在,类型已经定义好了,我们可以通过 CreateInstance
使用它:
1 | dx Debugger.Utility.Analysis.SyntheticTypes.CreateInstance("_EX_CALLBACK_ROUTINE_BLOCK", *(__int64*)&nt!PspCreateProcessNotifyRoutine) |
需要注意的是 CreateInstance
只接受 __int64
作为输入,所以任何其他类型都必须强制转换。提前知道这一点很好,因为返回的错误消息并不总是那么容易理解的。
现在如果查看输出结果,特别是 Context
,有些东西似乎很奇怪。实际上,如果我们试图转储 Function
,我们会看到它并不指向任何代码:
1 | dq 0xfffff8074cbdff50 |
发生了什么?
不是我们强制转换到 EX_CALLBACK_ROUTINE_BLOCK
出了问题,而是我们转换的地址有问题。如果我们转储 PspCreateProcessNotifyRoutine
中的值,我们可能会看到它的值:
1 | dx ((void**[0x40])&nt!PspCreateProcessNotifyRoutine).Where(a => a != 0) |
所有这些地址的低半字节都是 0xF
,而我们知道 x64
机器中的指针总会对齐到 8
字节,通常是 0x10
。这是因为我之前过度简化了 —— 这些不是指向 EX_CALLBACK_ROUTINE_BLOCK
的指针,它们实际上是包含 EX_RUNDOWN_REF
的 EX_CALLBACK
结构体(另一种不在公共符号中的类型)。但是为了使这个示例更简单,我将把它们当作与 ~0xF
执行与操作后的简单指针,因为这对于我们的目的来说已经足够好了。如果你选择写一个驱动程序来处理 PspCreateProcessNotifyRoutine
,请不要使用这个 hack
,查看 ReactOS
并正确地做事情。😊
因此,要修复我们的命令,我们只需要在对地址进行强制类型转换之前将其对齐到 0x10
。我们可以像下面这样做:
1 | <address> & 0xFFFFFFFFFFFFFFF0 |
或更好的版本:
1 | <address> & ~0xF |
让我们在我们的命令中使用它:
1 | dx Debugger.Utility.Analysis.SyntheticTypes.CreateInstance("_EX_CALLBACK_ROUTINE_BLOCK", (*(__int64*)&nt!PspCreateProcessNotifyRoutine) & ~0xf) |
看起来好些了。这次让我们确认 Function
确实指向了一个函数:
1 | ln 0xfffff8074ea7f310 |
看起来好多了!现在我们可以将这种类型转换定义为一个合成方法,并使用它来获取数组中所有例程的函数地址:
1 | dx -r0 @$getCallbackRoutine = (a => Debugger.Utility.Analysis.SyntheticTypes.CreateInstance("_EX_CALLBACK_ROUTINE_BLOCK", (__int64)(a & ~0xf))) |
如果我们能看到符号而不是地址,这会更有趣。我们已经知道如何通过执行传统命令 ln
来获取符号,但这一次我们将使用 .printf
。
首先,我们将编写一个辅助函数 @$getsym
,它将运行命令 printf "%y", <address>
:
1 | dx -r0 @$getsym = (x => Debugger.Utility.Control.ExecuteCommand(".printf\"%y\", " + ((__int64)x).ToDisplayString("x"))[0]) |
然后我们使用这个辅助函数打印每个函数地址对应的符号:
1 | dx ((void**[0x40])&nt!PspCreateProcessNotifyRoutine).Where(a => a != 0).Select(a => @$getsym(@$getCallbackRoutine(a).Function)) |
你看,好太多了!
断点(Breakpoints)
条件断点(Conditional Breakpoint)
调试时,条件断点是一个巨大的痛点。在旧的 MASM
语法中,它们几乎不可用。我花了几个小时试图让它们以我想要的方式工作,但结果是如此糟糕,以至于我甚至不知道我在试图做什么,更不用说为什么它不过滤任何东西或者如何修复它。
好吧,这些日子已经过去了。我们现在可以在条件断点中使用 dx
查询,语法为:bp /w "dx query" <address>
。
例如,假设我们正在调试一个 Wow64
进程打开文件的问题。NtOpenProcess
函数一直在被调用,但是我们只关心来自 Wow64
进程的调用,这并不是现代操作系统上的大多数进程(译注: Wow64
进程指的是运行在 64
位系统上的 32
位进程,64
位进程逐渐成为主流)。因此,为了避免在走狗屎运之前无助的经历 100
次调试中断或者避免与 MASM
风格的条件断点做斗争,我们可以这样做:
1 | bp /w "@$curprocess.KernelObject.WoW64Process != 0" |
然后让系统运行起来,当再次中断时,我们可以检查它是否奏效:
1 | Breakpoint 3 hit |
触发断点的进程是一个 WoW64
进程!对于那些曾经尝试过在 MASM
中使用条件断点的人来说,dx
真是一个大救星。
其它断点选项(Other Breakpoint Options)
在 Debugger.Utility.Control
下,还有其他一些有趣的断点选项:
SetBreakpointAtSourceLocation
—— 允许我们在模块对应的源文件中设置断点,语法如下:dx Debugger.Utility.Control.SetBreakpointAtSourceLocation("MyModule!myFile.cpp", "172")
SetBreakpointAtOffset
—— 在函数内部偏移设置断点 ——dx Debugger.Utility.Control.SetBreakpointAtOffset("NtOpenFile", 8, “nt")
SetBreakpointForReadWrite
—— 类似于传统的ba
命令,但其语法更具可读性。它允许我们设置断点,以便在任何读或写某个地址时中断。它的默认配置为type = Hardware Write
并且size = 1
。(译注: 原文中的函数名是SetBreakpointForReadWriteFile
,应该是笔误。)比如,让我们在读取
Ci!g_CiOptions
(一个4
字节大小的变量)时中断下来:
1 | dx Debugger.Utility.Control.SetBreakpointForReadWrite(&Ci!g_CiOptions, “Hardware Read”, 0x4) |
让系统继续运行,断点几乎立即命中:
1 | 0: kd> g |
CI!CiValidateImageHeader
在验证镜像头时会读取此全局变量。在这个特定的示例中,我们将看到读取这个变量是很频繁的,修改此变量则加有趣,因为它可以向我们展示篡改签名验证的尝试。
值得注意的是,这些命令不仅仅是设置了一个断点,实际上会返回给我们一个可以操作的对象,包含属性 IsEnabled
, Condition
(允许我们设置一个条件),PassCount
(告诉我们这个断点已经访问了多少次),还有更多其它属性。
文件系统(FileSystem)
在 Debugger.Utility
下提供了 FileSystem
模块,允许我们在调试器中查询和控制主机上的文件系统(不是正在被调试的机器):
1 | dx -r1 Debugger.Utility.FileSystem |
我们可以创建文件、打开文件、写入文件、删除文件或检查某个文件是否存在于某个特定路径中。来看一个简单的例子,让我们转储当前目录(C:\Windows\System32
)的内容:
1 | dx -r1 Debugger.Utility.FileSystem.CurrentDirectory.Files |
我们可以选择删除其中的一个文件:
1 | dx -r1 Debugger.Utility.FileSystem.CurrentDirectory.Files[1].Delete() |
或者通过 DeleteFile
删除:
1 | dx Debugger.Utility.FileSystem.DeleteFile(“C:\\WINDOWS\\system32\\71”) |
注意,在这个模块中,路径必须使用双反斜杠(“\\“),就像我们自己调用 Win32 API
时一样。
作为最后一个练习,我们将把在这里学到的东西放在一起 —— 我们将在内核变量上创建一个断点,从调用栈中获取访问它的符号,并将访问它的符号写入主机上的一个文件中。
让我们把它分成几个步骤:
打开一个文件,以便写入结果。
创建一个
text writer
,我们将使用它写入文件。创建访问变量的断点。在本例中,我们将选择
nt!PsInitialSystemProcess
并设置读断点。我们将使用旧的MASM
语法来设置一个断点,每次断点命中时,会执行一个dx
命令并继续运行:ba r4 <address> "dx <command>; g”
我们的命令将使用
@$curstack
来获取访问该变量的函数地址,然后使用前面编写的@$getsym
辅助函数来查找该地址对应的符号。然后使用text writer
将结果写入文件。最后,关闭文件。
整合在一起:
1 | dx -r0 @$getsym = (x => Debugger.Utility.Control.ExecuteCommand(".printf\"%y\", " + ((__int64)x).ToDisplayString("x"))[0]) |
我们让系统想运行多久就运行多久,当我们想停止记录日志时,我们可以禁用或清除断点并使用 dx @$tmpFile.Close()
关闭文件。
现在我们可以打开 @$tmpFile
并查看结果:
就是这样!记录关于调试器的信息是多么简单啊!
这就是我们 WinDbg
系列的全部内容!本系列的所有脚本都将被上传到 github
,还有一些新的没有包含在这里的脚本。我鼓励您进一步研究这个数据模型,因为我们甚至没有涵盖它包含的所有不同方法。编写你自己的工具,并与世界分享它们 :)
译注:
github
地址是 https://github.com/yardenshafir/WinDbg_Scripts)
尽管本指南很长,但这些甚至还不是新数据模型中的所有可能选项。我甚至没有提到新增的对 Javascript
的支持!
你可以在这篇精彩的文章中获得更多关于在 WinDbg
中使用 Javascript
的信息,以及对令人兴奋的 TTD (time travel debugging)
的新支持。