前言
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
本文是第一部分的译文。在有道词典、必应词典、谷歌翻译的大力帮助下完成,感谢以上翻译工具,我只是一个搬运工。强烈建议英文好的朋友阅读原文,因为在翻译的过程中不可避免的按我的理解做了调整。
说明:
- 翻译已征得原作者同意。
鸽了太久了,对不住原作者
标题实在想不到更好的翻译了
作者的 github
以下是译文!
不久前,WinDbg
添加了对新调试数据模型(debugger data model)的支持,这一变化彻底改变了我们使用 WinDbg
的方式。不再有可怕的 MASM
命令和晦涩的语法。无需再将地址或参数复制到记事本以便在后续命令中使用它们。不用再一遍又一遍地运行相同的命令(传递不同的地址)来遍历列表或数组。
这是该指南的第一部分,因为我认为实际上不会有人从头到尾读完长达 8000
字的 WinDbg
命令解释。所以,我准备了 2
篇 4000
字的文章!这样会好些,对吧?
在第一篇文章中,我们将学习如何使用这个新数据模型的基础知识 —— 使用自定义寄存器和新的内置寄存器,遍历对象,使用匿名类型来搜索、过滤、自定义对象。最后,我们将学习如何以一种比以前更好、更简单的方式解析数组和列表。
在这两篇文章中,我们将学习这个数据模型为我们提供的更复杂、更高级的方法和特性。现在我们都知道将会发生什么,准备好一杯咖啡,让我们开始吧!
这个数据模型,可以在 WinDbg
中通过 dx
命令使用,是一个极其强大的工具,能够定义自定义变量、结构体、函数并使用很多其它新功能。它还允许我们使用 LINQ
(一种建立在 SQL
数据库语言之上的自然查询语言)进行查询和过滤信息。
这个数据模型已经被文档化了,甚至在 GitHub
上还有使用范例。此外,所有模块都有对应的文档,可以在调试器中通过 dx -v <method>
查看。(您也可以通过运行不带 -v
的 dx <method>
命令获得相同的文档 ) :
1 | dx -v Debugger.Utility.Collections.FromListEntry |
除此之外,还有一些外部文档,但我觉得有些事情需要进一步解释,并且这个特性值得更多的关注。
译注:
debugger data model
文档链接:https://docs.microsoft.com/en-us/windows-hardware/drivers/debugger/data-model-cpp-overview)使用范例
github
链接:https://github.com/microsoft/WinDbg-Samples)
自定义寄存器(Custom Registers)
首先,NatVis
允许我们添加自定义寄存器。有点像 MASM
中的 @$t1
、@$t2
、@$t3
等。 只是,现在你可以起任何想要的名字,并且可以指定类型:
1 | dx @$myString = "My String" |
我们可以使用 dx @$vars
来查看所有变量,使用 dx @$vars.Remove("var name")
来移除某个变量,或者使用 @$vars.Clear()
清除所有变量。我们还可以使用 dx
来处理更复杂的结构,比如 EPROCESS
。您可能知道,公共调试符号(public PDBs)中的符号不包含类型信息。使用旧调试器,这并不总是一个问题,因为在 MASM
中,反正也没有类型,我们可以使用 poi
命令对指针进行解引用。
1 | 0: kd> dt nt!_EPROCESS poi(nt!PsInitialSystemProcess) |
但当变量不是指针时,事情就变得更混乱了,比如 PsIdleProcess
:
1 | 0: kd> dt nt!_KPROCESS @@masm(nt!PsIdleProcess) |
我们必须先使用显式的 MASM
操作符来获取 PsIdleProcess
的地址,然后将它当作 EPROCESS
显示出来。通过使用 dx
,我们可以更聪明地直接使用 C
风格的类型转换对符号进行强制转换。但是当我们尝试把 nt!Psinitialsystemprocess
转换为一个指向 EPROCESS
的指针:
1 | dx @$systemProc = (nt!_EPROCESS*)nt!PsInitialSystemProcess |
我们得到了一个错误。
就像我提到的那样,符号没有类型。我们不能转换没有类型的东西。所以我们需要获取符号的地址,并转换成一个指向我们想要的类型的指针(在当前示例中,PsInitialSystemProcess
已经是一个指向 EPROCESS
的指针,所以我们需要将其地址转换为一个指向 EPROCESS
指针的指针)。
1 | dx @$systemProc = *(nt!_EPROCESS**)&nt!PsInitialSystemProcess |
现在,我们有了一个有类型的变量,我们可以像在 C
中那样访问它的字段:
1 | 0: kd> dx @$systemProc->ImageFileName |
我们还可以对它进行类型转换以得到更好的输出:
1 | dx (char*)@$systemProc->ImageFileName |
我们还可以使用 ToDisplayString
将它从 char*
转换为 string
。我们有两个选项 —— ToDisplayString("s")
,将输出结果转换为字符串并将引号作为字符串的一部分,或者ToDisplayString("sb")
,将删除引号:
1 | dx ((char*)@$systemProc->ImageFileName).ToDisplayString("s") |
内置寄存器(Built-in Registers)
这很有趣,但对于进程(和其他一些东西),还有一种更简单的方法。WinDbg
中的 NatVis
为我们提供了一些有用的“免费”寄存器 —— curframe
, curprocess
, cursession
, curstack
和 curthread
。不难通过它们的名字猜出对应的内容,但还是看一看吧:
@$curframe
提供有关当前帧的信息。我自己从来没用过,但它也许有用:
1 | dx -r1 @$curframe.Attributes |
@$curprocess
一个包含当前进程信息的容器。但不是 EPROCESS
(尽管包含它)。它包含了关于当前进程的易于访问的信息,比如线程、加载的模块、句柄等。
1 | dx @$curprocess |
我们不仅可以访问 EPROCESS
(通过 KernelObject
),还可以使用其他字段。例如,我们可以通过 @$curprocess.Io.Handles
访问进程持有的所有句柄。@$curprocess.Io.Handles
会返回一个由句柄值索引的句柄数组:
1 | dx @$curprocess.Io.Handles |
System
进程有很多句柄,这只是开始的几个!让我们来看看第一个(我们也可以通过 @$curprocess.Io.Handles[0x4]
来访问它):
1 | dx @$curprocess.Io.Handles.First() |
我们可以看到句柄,句柄对应的对象类型,它被授予的访问权限,甚至有一个指向对象本身的指针(或者更准确地说,它指向的是对象头)!
这个寄存器还有很多东西可以探索,我鼓励您去调查它们,但我不会全部展示。
顺便说一下,我是否提过 dx
允许 tab
补全?
@$cursession
顾名思义,这个寄存器为我们提供关于当前调试会话的信息:
1 | dx @$cursession |
因此,我们可以获得关于调试会话的信息,这总是很有趣。但是还可以找到更多有用的东西,比如 Processes
字段,它是所有进程的数组,由 PID
索引。让我们选一个吧:
1 | dx @$cursession.Processes[0x1d8] |
现在我们可以获得每个进程的全部有用信息!我们还可以通过过滤来搜索进程(例如,通过进程名、加载到进程中的特定模块、命令行中的字符串等等)。我稍后再解释这些。
@$curstack
这个寄存器只包含一个字段 —— 帧,它以一种易于处理的方式向我们显示当前调用栈:
1 | dx @$curstack.Frames |
@$curthread
为我们提供当前线程的相关信息,就像 @$curprocess
:
1 | dx @$curthread |
KernelObject
字段包含了 ETHREAD
, Environment
字段包含了 TEB
,除此之外,还包括线程 ID
、调用栈和寄存器。
1 | dx @$curthread.Registers |
寄存器被方便地分为用户寄存器、内核寄存器、SIMD
寄存器和浮点寄存器,我们可以分别查看它们:
1 | dx -r1 @$curthread.Registers.Kernel |
搜索和过滤(Searching and Filtering)
我们在前面简要提到过,NatVis
允许我们做一些非常有用的操作 —— 通过类似 SQL
中的 Select
、Where
、Orderderby
及其它方法对信息进行查找、过滤和排序。
例如,让我们尝试找到所有没启用 high entropy ASLR
的进程。该信息存储在 EPROCESS->MitigationFlags
字段中,HighEntropyASLREnabled
的值是 0x20
(所有值都可以在这里和公共符号中找到)。
首先,我们声明一个值为 0x20
的新寄存器,这只是为了使代码更具可读性:
1 | 0: kd> dx @$highEntropyAslr = 0x20 |
然后创建查询来遍历所有进程,只选择那些没有设置 HighEntropyASLREnabled
标志位的进程:
1 | dx -r1 @$cursession.Processes.Where(p => (p.KernelObject.MitigationFlags & @$highEntropyAslr) == 0) |
或者我们可以直接检查 MitigationFlagsValues
并获得相同的结果:
1 | dx -r1 @$cursession.Processes.Where(p => (p.KernelObject.MitigationFlagsValues.HighEntropyASLREnabled == 0)) |
我们还可以使用 Select()
来显示正在遍历的对象的某些特定属性。这里我们只查看每个进程的线程数:
1 | dx @$cursession.Processes.Select(p => p.Threads.Count()) |
我们还可以通过在命令末尾加上 ,d
来以十进制格式显示所有内容。(b
表示以二进制显示,o
表示以八进制显示,s
表示以字符串显示):
1 | dx @$cursession.Processes.Select(p => p.Threads.Count()), d@$cursession.Processes.Select(p => p.Threads.Count()), d |
再看一个稍微复杂一点的示例 —— 查看某个特定进程中每个线程的理想处理器(我随机选择了一个进程,只是为了查看非 System
进程的一些信息):
1 | dx -r1 @$cursession.Processes[0x1b2c].Threads.Select(t => t.Environment.EnvironmentBlock.CurrentIdealProcessor.Number) |
我们还可以使用 OrderBy
来获得更好的输出,例如获取按字母顺序排序的进程列表:
1 | dx -r1 @$cursession.Processes.OrderBy(p => p.Name) |
如果我们希望按降序排列,可以使用 OrderByDescending
。
但是,如果我们想要查看多个属性该怎么办呢?有办法。
匿名类型(Anonymous Types)
我们可以声明一个自定义的类型,它将是未命名的并且只在查询作用域中有效,语法如下:Select(x => new {var1 = x.A, var2 = x.B,…})
。
我们把它用在前面的示例中。假设我们想要显示每个进程的进程名和线程数:
1 | dx @$cursession.Processes.Select(p => new {Name = p.Name, ThreadCount = p.Threads.Count()}) |
但是现在我们只看到进程容器,而不是实际的信息。为了查看信息本身,我们需要使用 -r2
来多显示一层。r
后面的数字表示递归层数。默认值是 -r1
, -r0
将不显示输出,-r2
将显示两层,以此类推。
1 | dx -r2 @$cursession.Processes.Select(p => new {Name = p.Name, ThreadCount = p.Threads.Count()}) |
这看起来已经好多了,但是我们可以使用新的表格视图让它看起来更好(通过 -g
选项):
1 | dx -g @$cursession.Processes.Select(p => new {Name = p.Name, ThreadCount = p.Threads.Count()}) |
这看起来太棒了。是的,这些标题是可点击的,点击标题会将表格排序!
如果我们想看到以十进制显示的 PID
和线程数,我们可以在命令的末尾加上 ,d
:
1 | dx -g @$cursession.Processes.Select(p => new {Name = p.Name, ThreadCount = p.Threads.Count()}),d |
数组和链表(Arrays and Lists)
dx
为我们提供了一种新的、更简单的方法来处理数组和列表。
让我们先来看看数组,语法是 dx *(TYPE(*)[Size])<pointer to array start>
。
作为示例,我们将转储 PsInvertedFunctionTable
中的内容,它的 TableEntry
字段包含了最多容纳 256
个缓存模块的数组。
首先,我们将获得该符号的指针,并将其转换为 _INVERTED_FUNCTION_TABLE
:
1 | dx @$inverted = (nt!_INVERTED_FUNCTION_TABLE*)&nt!PsInvertedFunctionTable |
现在我们可以创建数组了。不幸的是,数组的大小必须是静态的,不能使用变量,所以我们需要根据 CurrentSize
手动输入(或者将它设置为 256
,这是整个数组的大小)。我们可以用表格视图很好地显示它:
1 | dx -g @$tableEntry = *(nt!_INVERTED_FUNCTION_TABLE_ENTRY(*)[0xbe])@$inverted->TableEntry |
或者,我们可以使用 Take()
方法来获得相同的结果,该方法接收一个数字并显示指定数量的集合元素:
1 | dx -g @$inverted->TableEntry->Take(@$inverted->CurrentSize) |
我们也可以用同样的方法来查看 UserInvertedFunctionTable
(在我们切换到非 System
进程的用户态空间后),从 nt!KeUserInvertedFunctionTable
开始:
1 | dx @$inverted = *(nt!_INVERTED_FUNCTION_TABLE**)&nt!KeUserInvertedFunctionTable |
当然,我们可以使用 Select()
、Where()
或者其他函数来过滤、排序或者选择特定的字段进行输出,以得到完全满足我们需求的结果。
接下来要处理的是列表 —— Windows
中到处都是链表,你可以在任何地方找到它们。进程、线程、模块、DPCs
、IRPs
、还有更多。
幸运的是,新数据模型提供了一个非常有用的方法—— Debugger.Utiilty.Collections.FromListEntry
,它接收一个链表头,链表中的对象类型和类型中包含 LIST_ENTRY
的字段名称,并返回一个包含所有列表内容的容器。
让我们以转储系统中所有的句柄表为例。我们的起点将是符号 nt!HandleTableListHead
,链表中的对象类型是 nt!HANDLE_TABLE
,连接列表的字段是 HandleTableList
:
1 | dx -r2 Debugger.Utility.Collections.FromListEntry(*(nt!_LIST_ENTRY*)&nt!HandleTableListHead, "nt!_HANDLE_TABLE", "HandleTableList") |
看到 QuotaProcess
字段了吗?该字段指向当前句柄表所属的进程。由于每个进程都有一个句柄表,这允许我们以一种不为人所知的方式枚举系统中的所有进程。这种方法在过去被 rootkit
用于枚举进程,而不被 EDR
产品检测到。因此,要实现这一点,我们只需要从句柄表列表中的每个条目中选择 QuotaProcess
(通过 Select()
方法)。为了让输出结果更易读,我们还可以创建一个包含进程名、PID
和 EPROCESS
指针的匿名容器:
1 | dx -r2 (Debugger.Utility.Collections.FromListEntry(*(nt!_LIST_ENTRY*)&nt!HandleTableListHead, "nt!_HANDLE_TABLE", "HandleTableList")).Select(h => new { Object = h.QuotaProcess, Name = ((char*)h.QuotaProcess->ImageFileName).ToDisplayString("s"), PID = (__int64)h.QuotaProcess->UniqueProcessId}) |
第一条结果属于 System
进程,它没有 QuotaProcess
,这就是查询返回错误的原因。但对于数组中的其他元素,应该是完美的。如果我们想让输出结果更漂亮,可以在执行 Select()
之前过滤掉 QuotaProcess == 0
的条目:
1 | dx -r2 (Debugger.Utility.Collections.FromListEntry(*(nt!_LIST_ENTRY*)&nt!HandleTableListHead, “nt!_HANDLE_TABLE”, “HandleTableList”)).Where(h => h.QuotaProcess != 0).Select(h => new { Object = h.QuotaProcess, Name = ((char*)h.QuotaProcess->ImageFileName).ToDisplayString("s"), PID = h.QuotaProcess->UniqueProcessId}) |
如前所示,我们也可以在表格视图中显示这个列表,或者使用任何 LINQ
查询来使输出满足我们的需要。
第一部分到此结束,但不要担心,第二部分就在这里。它包含了全部新奇的 dx
方法,比如一个新的反汇编器,自定义方法,可以真正工作的条件断点,以及更多。