难译 | windbg 乐趣之道(下)

前言

Yarden Shafir 分享了两篇非常通俗易懂的,关于 windbg 新引入的调试数据模型的文章。原文链接如下:

part1https://medium.com/@yardenshafir2/windbg-the-fun-way-part-1-2e4978791f9b

part2https://medium.com/@yardenshafir2/windbg-the-fun-way-part-2-7a904cba5435

本文是第二部分的译文。同样在有道词典、必应词典、谷歌翻译的大力帮助下完成,感谢以上翻译工具,我只是一个搬运工。强烈建议英文好的朋友阅读原文,因为在翻译的过程中不可避免的按我的理解做了调整。

第一部分译文在这里

以下是译文!


欢迎来到我试图让您享受在 Windows 上调试的第 2 部分(哇,我真是个书呆子)!

在第一部分中,我们已经了解了新调试数据模型的基本知识——使用新对象、使用自定义寄存器、搜索和过滤输出、声明匿名类型以及解析列表和数组。在这一部分中,我们将学习如何在 dx 中使用传统命令(译注: 原文是 legacy commands,我理解是在引入 dx 之前就存在的一些命令,比如 dtu 等),了解令人惊叹的新反汇编器,创建合成方法和类型,查看断点的奇妙变化,并在调试器中使用文件系统。

听起来有很多内容。因为确实有很多内容。我们开始吧!

传统命令(Legacy Commands)

新数据模型完全改变了调试体验。但有时确实需要使用我们已经习惯的旧命令或扩展(这些功能在 dx 中没有对应的实现)。

我们仍然可以通过 Debugger.Utility.Control.ExecuteCommanddx 中使用这些旧命令,它让我们可以将传统命令作为 dx 查询的一部分运行。例如,我们可以使用传统的 u 命令查看第二个栈帧中 RIP 所指向的地址对应的反汇编。

由于 dx 输出默认是十进制的,而传统命令只接受十六进制输入,我们首先需要使用 ToDisplayString("x") 将其转换为十六进制:

1
2
3
4
5
6
7
8
9
10
11
12
dx Debugger.Utility.Control.ExecuteCommand("u " + @$curstack.Frames[1].Attributes.InstructionOffset.ToDisplayString("x"))

Debugger.Utility.Control.ExecuteCommand("u " + @$curstack.Frames[1].Attributes.InstructionOffset.ToDisplayString("x"))
[0x0] : kdnic!TXTransmitQueuedSends+0x125:
[0x1] : fffff807`52ad2b61 4883c430 add rsp,30h
[0x2] : fffff807`52ad2b65 5b pop rbx
[0x3] : fffff807`52ad2b66 c3 ret
[0x4] : fffff807`52ad2b67 4c8d4370 lea r8,[rbx+70h]
[0x5] : fffff807`52ad2b6b 488bd7 mov rdx,rdi
[0x6] : fffff807`52ad2b6e 488d4b60 lea rcx,[rbx+60h]
[0x7] : fffff807`52ad2b72 4c8b15d7350000 mov r10,qword ptr [kdnic!_imp_ExInterlockedInsertTailList (fffff807`52ad6150)]
[0x8] : fffff807`52ad2b79 e8123af8fb call nt!ExInterlockedInsertTailList (fffff807`4ea56590)

另一个有用的传统命令是 !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
2
3
4
5
6
7
8
9
10
11
12
13
14
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)

@$irpThreads = @$lsass.Threads.Select(t => new {irp =
Debugger.Utility.Collections.FromListEntry(t.KernelObject.IrpList, "nt!_IRP", "ThreadListEntry")}).Where(t => t.irp.Count() != 0)
[0x384]
irp
[0x0] [Type: _IRP]
[<Raw View>] [Type: _IRP]
IoStack : Size = 12, Current IRP_MJ_DIRECTORY_CONTROL / 0x2 for Device for "\FileSystem\Ntfs"
CurrentThread : 0xffffb90a59477080 [Type: _ETHREAD *]
[0x1] [Type: _IRP]
[<Raw View>] [Type: _IRP]
IoStack : Size = 12, Current IRP_MJ_DIRECTORY_CONTROL / 0x2 for Device for "\FileSystem\Ntfs"
CurrentThread : 0xffffb90a59477080 [Type: _ETHREAD *]

我们可以在这里停一下,点击其中一个 IRPIoStack (或者运行 -r5 来查看全部内容)可以得到调用栈信息:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
dx @$irpThreads.First().irp[0].IoStack

@$irpThreads.First().irp[0].IoStack : Size = 12, Current IRP_MJ_DIRECTORY_CONTROL / 0x2 for Device for "\FileSystem\Ntfs"
[0] : IRP_MJ_CREATE / 0x0 for {...} [Type: _IO_STACK_LOCATION]
[1] : IRP_MJ_CREATE / 0x0 for {...} [Type: _IO_STACK_LOCATION]
[2] : IRP_MJ_CREATE / 0x0 for {...} [Type: _IO_STACK_LOCATION]
[3] : IRP_MJ_CREATE / 0x0 for {...} [Type: _IO_STACK_LOCATION]
[4] : IRP_MJ_CREATE / 0x0 for {...} [Type: _IO_STACK_LOCATION]
[5] : IRP_MJ_CREATE / 0x0 for {...} [Type: _IO_STACK_LOCATION]
[6] : IRP_MJ_CREATE / 0x0 for {...} [Type: _IO_STACK_LOCATION]
[7] : IRP_MJ_CREATE / 0x0 for {...} [Type: _IO_STACK_LOCATION]
[8] : IRP_MJ_CREATE / 0x0 for {...} [Type: _IO_STACK_LOCATION]
[9] : IRP_MJ_CREATE / 0x0 for {...} [Type: _IO_STACK_LOCATION]
[10] : IRP_MJ_DIRECTORY_CONTROL / 0x2 for Device for "\FileSystem\Ntfs" [Type: _IO_STACK_LOCATION]
[11] : IRP_MJ_DIRECTORY_CONTROL / 0x2 for Device for "\FileSystem\FltMgr" [Type: _IO_STACK_LOCATION]

最后,我们将遍历每个线程,并且遍历线程中的每个 IRP,并对其执行 Executeccommand !IRP <irp address>。这里我们也需要类型转换和 ToDisplayString("x") 来匹配传统命令所期望的格式。( !irp 的输出很长,所以我们将其截断,只关注感兴趣的数据):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
dx -r3 @$irpThreads.Select(t => t.irp.Select(i => Debugger.Utility.Control.ExecuteCommand("!irp " + ((__int64)&i).ToDisplayString("x"))))

@$irpThreads.Select(t => t.irp.Select(i => Debugger.Utility.Control.ExecuteCommand("!irp " + ((__int64)&i).ToDisplayString("x"))))
[0x384]
[0x0]
[0x0] : Irp is active with 12 stacks 11 is current (= 0xffffb90a5b8f4d40)
[0x1] : No Mdl: No System Buffer: Thread ffffb90a59477080: Irp stack trace.
[0x2] : cmd flg cl Device File Completion-Context
[0x3] : [N/A(0), N/A(0)]
...
[0x34] : Irp Extension present at 0xffffb90a5b8f4dd0:
[0x1]
[0x0] : Irp is active with 12 stacks 11 is current (= 0xffffb90a5bd24840)
[0x1] : No Mdl: No System Buffer: Thread ffffb90a59477080: Irp stack trace.
[0x2] : cmd flg cl Device File Completion-Context
[0x3] : [N/A(0), N/A(0)]
...
[0x34] : Irp Extension present at 0xffffb90a5bd248d0:

!irp 提供给我们的大部分信息都可以通过使用 dx 解析 IRP 并转储其 IoStack 获得。但是有一些信息如果不使用传统命令则很难获得。比如, IrpExtension 是否存在及其地址,以及可能与 IRP 关联的 Mdl 的信息。

反汇编器(Disassembler)

我们以 u 命令为例,下面的例子展示的是:在 dx 中通过 Debugger.Utility.Code.CreateDisassemberDisassembleBlock 创建可以遍历和搜索的反汇编代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
dx -r3 Debugger.Utility.Code.CreateDisassembler().DisassembleBlocks(@$curstack.Frames[1].Attributes.InstructionOffset)

Debugger.Utility.Code.CreateDisassembler().DisassembleBlocks(@$curstack.Frames[1].Attributes.InstructionOffset)
[0xfffff80752ad2b61] : Basic Block [0xfffff80752ad2b61 - 0xfffff80752ad2b67)
StartAddress : 0xfffff80752ad2b61
EndAddress : 0xfffff80752ad2b67
Instructions
[0xfffff80752ad2b61] : add rsp,30h
[0xfffff80752ad2b65] : pop rbx
[0xfffff80752ad2b66] : ret
[0xfffff80752ad2b67] : Basic Block [0xfffff80752ad2b67 - 0xfffff80752ad2b7e)
StartAddress : 0xfffff80752ad2b67
EndAddress : 0xfffff80752ad2b7e
Instructions
[0xfffff80752ad2b67] : lea r8,[rbx+70h]
[0xfffff80752ad2b6b] : mov rdx,rdi
[0xfffff80752ad2b6e] : lea rcx,[rbx+60h]
[0xfffff80752ad2b72] : mov r10,qword ptr [kdnic!__imp_ExInterlockedInsertTailList (fffff80752ad6150)]
[0xfffff80752ad2b79] : call ntkrnlmp!ExInterlockedInsertTailList (fffff8074ea56590)
[0xfffff80752ad2b7e] : Basic Block [0xfffff80752ad2b7e - 0xfffff80752ad2b80)
StartAddress : 0xfffff80752ad2b7e
EndAddress : 0xfffff80752ad2b80
Instructions
[0xfffff80752ad2b7e] : jmp kdnic!TXTransmitQueuedSends+0xd0 (fffff80752ad2b0c)
[0xfffff80752ad2b80] : Basic Block [0xfffff80752ad2b80 - 0xfffff80752ad2b81)
StartAddress : 0xfffff80752ad2b80
EndAddress : 0xfffff80752ad2b81
Instructions
...

只选择 Instructions 并将其扁平化显示,整理后的版本如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
dx -r2 Debugger.Utility.Code.CreateDisassembler().DisassembleBlocks(@$curstack.Frames[1].Attributes.InstructionOffset).Select(b => b.Instructions).Flatten()

Debugger.Utility.Code.CreateDisassembler().DisassembleBlocks(@$curstack.Frames[1].Attributes.InstructionOffset).Select(b => b.Instructions).Flatten()
[0x0]
[0xfffff80752ad2b61] : add rsp,30h
[0xfffff80752ad2b65] : pop rbx
[0xfffff80752ad2b66] : ret
[0x1]
[0xfffff80752ad2b67] : lea r8,[rbx+70h]
[0xfffff80752ad2b6b] : mov rdx,rdi
[0xfffff80752ad2b6e] : lea rcx,[rbx+60h]
[0xfffff80752ad2b72] : mov r10,qword ptr [kdnic!__imp_ExInterlockedInsertTailList (fffff80752ad6150)]
[0xfffff80752ad2b79] : call ntkrnlmp!ExInterlockedInsertTailList (fffff8074ea56590)
[0x2]
[0xfffff80752ad2b7e] : jmp kdnic!TXTransmitQueuedSends+0xd0 (fffff80752ad2b0c)
[0x3]
[0xfffff80752ad2b80] : int 3
[0x4]
[0xfffff80752ad2b81] : int 3
...

合成方法(Synthetic Methods)

新调试数据模型提供的另一个功能是允许我们创建并使用自定义函数。语法如下:

1
2
3
4
5
6
7
0: kd> dx @$multiplyByThree = (x => x * 3)

@$multiplyByThree = (x => x * 3)

0: kd> dx @$multiplyByThree(5)

@$multiplyByThree(5) : 15

我们也可以创建包含多个参数的函数:

1
2
3
4
5
6
7
0: kd> dx @$add = ((x, y) => x + y)

@$add = ((x, y) => x + y)

0: kd> dx @$add(5, 7)

@$add(5, 7) : 12

或者,如果我们真的想要玩的更高端一些,我们可以将这些函数应用到前面的反汇编输出中,以便找出 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],1mov [rbp-0x10],rdx

    我们将检查第一个操作数的寄存器,并确保其索引不是 rsp 索引(0x14)或 rbp 索引(0x15)。

我们将编写一个函数 @$isMemWrite,它接收一个代码块并且只返回内存写入指令(基于上面的检查)。然后我们可以创建一个反汇编器,反汇编我们的目标函数并且只输出其中的内存写入指令:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
dx -r0 @$rspId = 0x14

dx -r0 @$rbpId = 0x15

dx -r0 @$isMemWrite = (b => b.Instructions.Where(i => i.Operands.Count() > 1 && i.Operands[0].Attributes.IsOutput && i.Operands[0].Registers[0].Id != @$rspId && i.Operands[0].Registers[0].Id != @$rbpId && i.Operands[0].Attributes.IsMemoryReference))

dx -r0 @$findMemWrite = (a => Debugger.Utility.Code.CreateDisassembler().DisassembleBlocks(a).Select(b => @$isMemWrite(b)))

dx -r2 @$findMemWrite(&nt!ZwSetInformationProcess).Where(b => b.Count() != 0)

@$findMemWrite(&nt!ZwSetInformationProcess).Where(b => b.Count() != 0)
[0xfffff8074ebd23d4]
[0xfffff8074ebd23e9] : mov qword ptr [r10+80h],rax
[0xfffff8074ebd23f5] : mov qword ptr [r10+44h],rax
[0xfffff8074ebd2415]
[0xfffff8074ebd2421] : mov qword ptr [r10+98h],r8
[0xfffff8074ebd2428] : mov qword ptr [r10+0F8h],r9
[0xfffff8074ebd2433] : mov byte ptr gs:[5D18h],al
[0xfffff8074ebd25b2]
[0xfffff8074ebd25c3] : mov qword ptr [rcx],rax
[0xfffff8074ebd25c9] : mov qword ptr [rcx+8],rax
[0xfffff8074ebd25d0] : mov qword ptr [rcx+10h],rax
[0xfffff8074ebd25d7] : mov qword ptr [rcx+18h],rax
[0xfffff8074ebd25df] : mov qword ptr [rcx+0A0h],rax
[0xfffff8074ebd263f]
[0xfffff8074ebd264f] : and byte ptr [rax+5],0FDh
[0xfffff8074ebd26da]
[0xfffff8074ebd26e3] : mov qword ptr [rcx],rax
[0xfffff8074ebd26e9] : mov qword ptr [rcx+8],rax
[0xfffff8074ebd26f0] : mov qword ptr [rcx+10h],rax
[0xfffff8074ebd26f7] : mov qword ptr [rcx+18h],rax
[0xfffff8074ebd26ff] : mov qword ptr [rcx+0A0h],rax
[0xfffff8074ebd2708] : mov word ptr [rcx+72h],ax
...

作为另外一个项目,它几乎结合了上面提到的所有内容 —— 实现 dx 版本的 !apc 。为了简化,我们只寻找内核 APC。要做到这一点,需要如下几个步骤:

  • 使用 @$cursession.Processes 遍历所有进程,以找到这些进程:包含 KTHREAD.ApcState.KernelApcPending 值为 1 的线程。
  • 在对应进程中创建一个容器,只包含那些包含挂起内核 APC 的线程,忽略其它线程。
  • 对于每个线程,遍历 KTHREAD.ApcState.ApcListHead[0] (包含内核 APC)并收集感兴趣的信息。可以通过前面介绍的 FromListHead() 方法实现。为了使我们的容器尽可能地与 !apc 相似,我们将只获取 KernelRoutineRundownRoutine,尽管在你的实现中,你可能会发现其他感兴趣的字段。
  • 为了让容器更容易遍历,我们将收集进程名、进程 IDEPROCESS 地址以及线程 IDETHREAD 地址。
  • 在我们的实现中,我们创建了几个辅助函数:

    @$printLn —— 运行传统命令 ln,以获取指定地址对应的符号信息。

    @$extractBetween —— 提取两个字符串之间的字符串,用于从 @$printLn 的输出中获取子字符串。

    @$printSymbol —— 传递一个地址给 @$printLn,并且使用 @$extractSymbol 提取符号名。

    @$apcsForThread —— 找到一个线程中的所有内核 APC,并且创建一个包含 KernelRoutineRundownRoutine 的容器。

在得到所有进程(包含挂起的内核 APC 的线程)后,将其保存到 @$procWithKernelApcs 寄存器中,然后在一个单独的命令中使用 @$apcsForThread 获取 APC 信息。我们将 EPPROCESSETHREAD 指针强制转换为 void*,这样当我们显示最终结果时 dx 就不会显示整个结构。

这是我解决问题的方法,但也有其他方法,你的方法不一定和我的完全相同!

我想出的脚本是:

1
2
3
4
5
6
7
8
9
10
11
dx -r0 @$printLn = (a => Debugger.Utility.Control.ExecuteCommand(“ln “+((__int64)a).ToDisplayString(“x”)))

dx -r0 @$extractBetween = ((x,y,z) => x.Substring(x.IndexOf(y) + y.Length, x.IndexOf(z) — x.IndexOf(y) — y.Length))

dx -r0 @$printSymbol = (a => @$extractBetween(@$printLn(a)[3], “ “, “|”))

dx -r0 @$apcsForThread = (t => new {TID = t.Id, Object = (void*)&t.KernelObject, Apcs = Debugger.Utility.Collections.FromListEntry(*(nt!_LIST_ENTRY*)&t.KernelObject.Tcb.ApcState.ApcListHead[0], “nt!_KAPC”, “ApcListEntry”).Select(a => new { Kernel = @$printSymbol(a.KernelRoutine), Rundown = @$printSymbol(a.RundownRoutine)})})

dx -r0 @$procWithKernelApc = @$cursession.Processes.Select(p => new {Name = p.Name, PID = p.Id, Object = (void*)&p.KernelObject, ApcThreads = p.Threads.Where(t => t.KernelObject.Tcb.ApcState.KernelApcPending != 0)}).Where(p => p.ApcThreads.Count() != 0)

dx -r6 @$procWithKernelApc.Select(p => new { Name = p.Name, PID = p.PID, Object = p.Object, ApcThreads = p.ApcThreads.Select(t => @$apcsForThread(t))})

它会产生以下输出:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
dx -r6 @$procWithKernelApc.Select(p => new { Name = p.Name, PID = p.PID, Object = p.Object, ApcThreads = p.ApcThreads.Select(t => @$apcsForThread(t))})

@$procWithKernelApc.Select(p => new { Name = p.Name, PID = p.PID, Object = p.Object, ApcThreads = p.ApcThreads.Select(t => @$apcsForThread(t))})
[0x15b8]
Name : SearchUI.exe
Length : 0xc
PID : 0x15b8
Object : 0xffffb90a5b1300c0 [Type: void *]
ApcThreads
[0x159c]
TID : 0x159c
Object : 0xffffb90a5b14f080 [Type: void *]
Apcs
[0x0]
Kernel : nt!EmpCheckErrataList
Rundown : nt!EmpCheckErrataList
[0x1528]
TID : 0x1528
Object : 0xffffb90a5aa6b080 [Type: void *]
Apcs
[0x0]
Kernel : nt!EmpCheckErrataList
Rundown : nt!EmpCheckErrataList
[0x16b4]
TID : 0x16b4
Object : 0xffffb90a59f1e080 [Type: void *]
Apcs
[0x0]
Kernel : nt!EmpCheckErrataList
Rundown : nt!EmpCheckErrataList
[0x16a0]
TID : 0x16a0
Object : 0xffffb90a5b141080 [Type: void *]
Apcs
[0x0]
Kernel : nt!EmpCheckErrataList
Rundown : nt!EmpCheckErrataList
[0x16b8]
TID : 0x16b8
Object : 0xffffb90a5aab20c0 [Type: void *]
Apcs
[0x0]
Kernel : nt!EmpCheckErrataList
Rundown : nt!EmpCheckErrataList
[0x1740]
TID : 0x1740
Object : 0xffffb90a5ab362c0 [Type: void *]
Apcs
[0x0]
Kernel : nt!EmpCheckErrataList
Rundown : nt!EmpCheckErrataList
[0x1780]
TID : 0x1780
Object : 0xffffb90a5b468080 [Type: void *]
Apcs
[0x0]
Kernel : nt!EmpCheckErrataList
Rundown : nt!EmpCheckErrataList
[0x1778]
TID : 0x1778
Object : 0xffffb90a5b6f7080 [Type: void *]
Apcs
[0x0]
Kernel : nt!EmpCheckErrataList
Rundown : nt!EmpCheckErrataList
[0x17d0]
TID : 0x17d0
Object : 0xffffb90a5b1e8080 [Type: void *]
Apcs
[0x0]
Kernel : nt!EmpCheckErrataList
Rundown : nt!EmpCheckErrataList
[0x17d4]
TID : 0x17d4
Object : 0xffffb90a5b32f080 [Type: void *]
Apcs
[0x0]
Kernel : nt!EmpCheckErrataList
Rundown : nt!EmpCheckErrataList
[0x17f8]
TID : 0x17f8
Object : 0xffffb90a5b32e080 [Type: void *]
Apcs
[0x0]
Kernel : nt!EmpCheckErrataList
Rundown : nt!EmpCheckErrataList
[0xb28]
TID : 0xb28
Object : 0xffffb90a5b065600 [Type: void *]
Apcs
[0x0]
Kernel : nt!EmpCheckErrataList
Rundown : nt!EmpCheckErrataList
[0x1850]
TID : 0x1850
Object : 0xffffb90a5b6a5080 [Type: void *]
Apcs
[0x0]
Kernel : nt!EmpCheckErrataList
Rundown : nt!EmpCheckErrataList

虽然输出结果没有 !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))})

dx-g-procWithKernelApc.Select

但是请等一下,这些包含 nt!EmpCheckErrataListAPC 是怎么回事?为什么 SearchUI.exe 里到处都是?这个进程和 erratas 有什么关系?(译注: 函数 nt!EmpCheckErrataList 中包含 Errata。)

秘密在于实际上并没有 APC 会调用 nt!EmpCheckErrataList。不,符号也没有错。(译注: 哈哈,感觉作者在跟读者讨论问题)

我们之所以看到这样的现象,是因为编译器很智能 —— 当编译器看到不同的函数具有相同的代码的时候,编译器会让它们全部指向同一段代码,而不是多次复制这段代码。你可能会认为这不是一个会经常发生的事情,但让我们看看 nt!EmpCheckErrataList 的反汇编代码(这次使用旧办法):

1
2
3
4
5
6
7
8
u EmpCheckErrataList

nt!EmpCheckErrataList:
fffff807`4eb86010 c20000 ret 0
fffff807`4eb86013 cc int 3
fffff807`4eb86014 cc int 3
fffff807`4eb86015 cc int 3
fffff807`4eb86016 cc int 3

这实际上只是一个存根。它可能是一个尚未实现的函数(EmpCheckErrataList 可能是这种情况),也可能是出于某些原因被故意定义为存根函数。这些 APCKernelRoutine/RundownRoutine 真正对应的函数是 nt!KiSchedulerApcNop,它被故意设计成存根函数,并且已经多年了。我们可以看到它具有相同的代码并且指向同一地址:

1
2
3
4
5
6
7
8
u nt!KiSchedulerApcNop

nt!EmpCheckErrataList:
fffff807`4eb86010 c20000 ret 0
fffff807`4eb86013 cc int 3
fffff807`4eb86014 cc int 3
fffff807`4eb86015 cc int 3
fffff807`4eb86016 cc int 3

那么为什么我们会看到这么多这样的 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
2
3
4
5
6
typedef struct _EX_CALLBACK_ROUTINE_BLOCK
{
_EX_RUNDOWN_REF RundownProtect;
void* Function;
void* Context;
} EX_CALLBACK_ROUTINE_BLOCK, *PEX_CALLBACK_ROUTINE_BLOCK;

现在我们可以让 WinDbg 加载这个头文件并将里面的类型添加到 nt 模块中:

1
2
3
4
5
6
7
8
dx Debugger.Utility.Analysis.SyntheticTypes.ReadHeader("c:\\temp\\header.h", "nt")

Debugger.Utility.Analysis.SyntheticTypes.ReadHeader("c:\\temp\\header.h", "nt") : ntkrnlmp.exe(header.h)
ReturnEnumsAsObjects : false
RegisterSyntheticTypeModels : false
Module : ntkrnlmp.exe
Header : header.h
Types

这会为我们返回一个对象,通过这个对象,我们可以查看添加到此模块的所有类型。现在,类型已经定义好了,我们可以通过 CreateInstance 使用它:

1
2
3
4
5
6
dx Debugger.Utility.Analysis.SyntheticTypes.CreateInstance("_EX_CALLBACK_ROUTINE_BLOCK", *(__int64*)&nt!PspCreateProcessNotifyRoutine)

Debugger.Utility.Analysis.SyntheticTypes.CreateInstance("_EX_CALLBACK_ROUTINE_BLOCK", *(__int64*)&nt!PspCreateProcessNotifyRoutine)
RundownProtect [Type: _EX_RUNDOWN_REF]
Function : 0xfffff8074cbdff50 [Type: void *]
Context : 0x6e496c4102031400 [Type: void *]

需要注意的是 CreateInstance 只接受 __int64 作为输入,所以任何其他类型都必须强制转换。提前知道这一点很好,因为返回的错误消息并不总是那么容易理解的。

现在如果查看输出结果,特别是 Context,有些东西似乎很奇怪。实际上,如果我们试图转储 Function,我们会看到它并不指向任何代码:

1
2
3
4
5
6
dq 0xfffff8074cbdff50
fffff807`4cbdff50 ????????`???????? ????????`????????
fffff807`4cbdff60 ????????`???????? ????????`????????
fffff807`4cbdff70 ????????`???????? ????????`????????
fffff807`4cbdff80 ????????`???????? ????????`????????
fffff807`4cbdff90 ????????`???????? ????????`????????

发生了什么?

不是我们强制转换到 EX_CALLBACK_ROUTINE_BLOCK 出了问题,而是我们转换的地址有问题。如果我们转储 PspCreateProcessNotifyRoutine 中的值,我们可能会看到它的值:

1
2
3
4
5
6
7
8
9
10
11
12
dx ((void**[0x40])&nt!PspCreateProcessNotifyRoutine).Where(a => a != 0)

((void**[0x40])&nt!PspCreateProcessNotifyRoutine).Where(a => a != 0)
[0] : 0xffffb90a530504ef [Type: void * *]
[1] : 0xffffb90a532a512f [Type: void * *]
[2] : 0xffffb90a53da9d5f [Type: void * *]
[3] : 0xffffb90a53da9ccf [Type: void * *]
[4] : 0xffffb90a53e5d15f [Type: void * *]
[5] : 0xffffb90a571469ef [Type: void * *]
[6] : 0xffffb90a5714722f [Type: void * *]
[7] : 0xffffb90a571473df [Type: void * *]
[8] : 0xffffb90a597d989f [Type: void * *]

所有这些地址的低半字节都是 0xF,而我们知道 x64 机器中的指针总会对齐到 8 字节,通常是 0x10。这是因为我之前过度简化了 —— 这些不是指向 EX_CALLBACK_ROUTINE_BLOCK 的指针,它们实际上是包含 EX_RUNDOWN_REFEX_CALLBACK 结构体(另一种不在公共符号中的类型)。但是为了使这个示例更简单,我将把它们当作与 ~0xF 执行操作后的简单指针,因为这对于我们的目的来说已经足够好了。如果你选择写一个驱动程序来处理 PspCreateProcessNotifyRoutine,请不要使用这个 hack,查看 ReactOS 并正确地做事情。😊

因此,要修复我们的命令,我们只需要在对地址进行强制类型转换之前将其对齐到 0x10。我们可以像下面这样做:

1
<address> & 0xFFFFFFFFFFFFFFF0

或更好的版本:

1
<address> & ~0xF

让我们在我们的命令中使用它:

1
2
3
4
5
6
dx Debugger.Utility.Analysis.SyntheticTypes.CreateInstance("_EX_CALLBACK_ROUTINE_BLOCK", (*(__int64*)&nt!PspCreateProcessNotifyRoutine) & ~0xf)

Debugger.Utility.Analysis.SyntheticTypes.CreateInstance("_EX_CALLBACK_ROUTINE_BLOCK", (*(__int64*)&nt!PspCreateProcessNotifyRoutine) & ~0xf)
RundownProtect [Type: _EX_RUNDOWN_REF]
Function : 0xfffff8074ea7f310 [Type: void *]
Context : 0x0 [Type: void *]

看起来好些了。这次让我们确认 Function 确实指向了一个函数:

1
2
3
4
5
6
7
8
ln 0xfffff8074ea7f310
Browse module
Set bu breakpoint

(fffff807`4ea7f310) nt!ViCreateProcessCallback |
(fffff807`4ea7f330) nt!RtlStringCbLengthW
Exact matches:
nt!ViCreateProcessCallback (void)

看起来好多了!现在我们可以将这种类型转换定义为一个合成方法,并使用它来获取数组中所有例程的函数地址:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
dx -r0 @$getCallbackRoutine = (a => Debugger.Utility.Analysis.SyntheticTypes.CreateInstance("_EX_CALLBACK_ROUTINE_BLOCK", (__int64)(a & ~0xf)))

dx ((void**[0x40])&nt!PspCreateProcessNotifyRoutine).Where(a => a != 0).Select(a => @$getCallbackRoutine(a).Function)

((void**[0x40])&nt!PspCreateProcessNotifyRoutine).Where(a => a != 0).Select(a => @$getCallbackRoutine(a).Function)
[0] : 0xfffff8074ea7f310 [Type: void *]
[1] : 0xfffff8074ff97220 [Type: void *]
[2] : 0xfffff80750a41330 [Type: void *]
[3] : 0xfffff8074f8ab420 [Type: void *]
[4] : 0xfffff8075106d9f0 [Type: void *]
[5] : 0xfffff807516dd930 [Type: void *]
[6] : 0xfffff8074ff252c0 [Type: void *]
[7] : 0xfffff807520b6aa0 [Type: void *]
[8] : 0xfffff80753a63cf0 [Type: void *]

如果我们能看到符号而不是地址,这会更有趣。我们已经知道如何通过执行传统命令 ln 来获取符号,但这一次我们将使用 .printf

首先,我们将编写一个辅助函数 @$getsym,它将运行命令 printf "%y", <address>

1
dx -r0 @$getsym = (x => Debugger.Utility.Control.ExecuteCommand(".printf\"%y\", " + ((__int64)x).ToDisplayString("x"))[0])

然后我们使用这个辅助函数打印每个函数地址对应的符号:

1
2
3
4
5
6
7
8
9
10
11
12
dx ((void**[0x40])&nt!PspCreateProcessNotifyRoutine).Where(a => a != 0).Select(a => @$getsym(@$getCallbackRoutine(a).Function))

((void**[0x40])&nt!PspCreateProcessNotifyRoutine).Where(a => a != 0).Select(a => @$getsym(@$getCallbackRoutine(a).Function))
[0] : nt!ViCreateProcessCallback (fffff807`4ea7f310)
[1] : cng!CngCreateProcessNotifyRoutine (fffff807`4ff97220)
[2] : WdFilter!MpCreateProcessNotifyRoutineEx (fffff807`50a41330)
[3] : ksecdd!KsecCreateProcessNotifyRoutine (fffff807`4f8ab420)
[4] : tcpip!CreateProcessNotifyRoutineEx (fffff807`5106d9f0)
[5] : iorate!IoRateProcessCreateNotify (fffff807`516dd930)
[6] : CI!I_PEProcessNotify (fffff807`4ff252c0)
[7] : dxgkrnl!DxgkProcessNotify (fffff807`520b6aa0)
[8] : peauth+0x43cf0 (fffff807`53a63cf0)

你看,好太多了!

断点(Breakpoints)

条件断点(Conditional Breakpoint)

调试时,条件断点是一个巨大的痛点。在旧的 MASM 语法中,它们几乎不可用。我花了几个小时试图让它们以我想要的方式工作,但结果是如此糟糕,以至于我甚至不知道我在试图做什么,更不用说为什么它不过滤任何东西或者如何修复它。

好吧,这些日子已经过去了。我们现在可以在条件断点中使用 dx 查询,语法为:bp /w "dx query" <address>

例如,假设我们正在调试一个 Wow64 进程打开文件的问题。NtOpenProcess 函数一直在被调用,但是我们只关心来自 Wow64 进程的调用,这并不是现代操作系统上的大多数进程(译注: Wow64 进程指的是运行在 64 位系统上的 32 位进程,64 位进程逐渐成为主流)。因此,为了避免在走狗屎运之前无助的经历 100 次调试中断或者避免与 MASM 风格的条件断点做斗争,我们可以这样做:

1
2
bp /w "@$curprocess.KernelObject.WoW64Process != 0"
nt!NtOpenProcess

然后让系统运行起来,当再次中断时,我们可以检查它是否奏效:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
Breakpoint 3 hit
nt!NtOpenProcess:
fffff807`2e96b7e0 4883ec38 sub rsp,38h

dx $curprocess.KernelObject.WoW64Process

@$curprocess.KernelObject.WoW64Process : 0xffffc10f5163b390 [Type: _EWOW64PROCESS *]
[+0x000] Peb : 0xf88000 [Type: void *]
[+0x008] Machine : 0x14c [Type: unsigned short]
[+0x00c] NtdllType : PsWowX86SystemDll (1) [Type: _SYSTEM_DLL_TYPE]

dx @$curprocess.Name

@$curprocess.Name : IpOverUsbSvc.exe
Length : 0x10

触发断点的进程是一个 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
2
3
4
0: kd> g
Breakpoint 0 hit
CI!CiValidateImageHeader+0x51b:
fffff807`2f6fcb1b 740c je CI!CiValidateImageHeader+0x529 (fffff807`2f6fcb29)

CI!CiValidateImageHeader 在验证镜像头时会读取此全局变量。在这个特定的示例中,我们将看到读取这个变量是很频繁的,修改此变量则加有趣,因为它可以向我们展示篡改签名验证的尝试。

值得注意的是,这些命令不仅仅是设置了一个断点,实际上会返回给我们一个可以操作的对象,包含属性 IsEnabled , Condition(允许我们设置一个条件),PassCount(告诉我们这个断点已经访问了多少次),还有更多其它属性。

文件系统(FileSystem)

Debugger.Utility 下提供了 FileSystem 模块,允许我们在调试器中查询和控制主机上的文件系统(不是正在被调试的机器):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
dx -r1 Debugger.Utility.FileSystem

Debugger.Utility.FileSystem

CreateFile [CreateFile(path, [disposition]) - Creates a file at the specified path and returns a file object. 'disposition' can be one of 'CreateAlways' or 'CreateNew']

CreateTempFile [CreateTempFile() - Creates a temporary file in the %TEMP% folder and returns a file object]

CreateTextReader [CreateTextReader(file | path, [encoding]) - Creates a text reader over the specified file. If a path is passed instead of a file, a file is opened at the specified path. 'encoding' can be 'Utf16', 'Utf8', or 'Ascii'. 'Ascii' is the default]

CreateTextWriter [CreateTextWriter(file | path, [encoding]) - Creates a text writer over the specified file. If a path is passed instead of a file, a file is created at the specified path. 'encoding' can be 'Utf16', 'Utf8', or 'Ascii'. 'Ascii' is the default]

CurrentDirectory : C:\WINDOWS\system32

DeleteFile [DeleteFile(path) - Deletes a file at the specified path]

FileExists [FileExists(path) - Checks for the existance of a file at the specified path]

OpenFile [OpenFile(path) - Opens a file read/write at the specified path]

TempDirectory : C:\Users\yshafir\AppData\Local\Temp

我们可以创建文件、打开文件、写入文件、删除文件或检查某个文件是否存在于某个特定路径中。来看一个简单的例子,让我们转储当前目录(C:\Windows\System32)的内容:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
dx -r1 Debugger.Utility.FileSystem.CurrentDirectory.Files

Debugger.Utility.FileSystem.CurrentDirectory.Files
[0x0] : C:\WINDOWS\system32\07409496-a423-4a3e-b620-2cfb01a9318d_HyperV-ComputeNetwork.dll
[0x1] : C:\WINDOWS\system32\1
[0x2] : C:\WINDOWS\system32\103
[0x3] : C:\WINDOWS\system32\108
[0x4] : C:\WINDOWS\system32\11
[0x5] : C:\WINDOWS\system32\113
...
[0x44] : C:\WINDOWS\system32\93
[0x45] : C:\WINDOWS\system32\98
[0x46] : C:\WINDOWS\system32\@AppHelpToast.png
[0x47] : C:\WINDOWS\system32\@AudioToastIcon.png
[0x48] : C:\WINDOWS\system32\@BackgroundAccessToastIcon.png
[0x49] : C:\WINDOWS\system32\@bitlockertoastimage.png
[0x4a] : C:\WINDOWS\system32\@edptoastimage.png
[0x4b] : C:\WINDOWS\system32\@EnrollmentToastIcon.png
[0x4c] : C:\WINDOWS\system32\@language_notification_icon.png
[0x4d] : C:\WINDOWS\system32\@optionalfeatures.png
[0x4e] : C:\WINDOWS\system32\@VpnToastIcon.png
[0x4f] : C:\WINDOWS\system32\@WiFiNotificationIcon.png
[0x50] : C:\WINDOWS\system32\@windows-hello-V4.1.gif
[0x51] : C:\WINDOWS\system32\@WindowsHelloFaceToastIcon.png
[0x52] : C:\WINDOWS\system32\@WindowsUpdateToastIcon.contrast-black.png
[0x53] : C:\WINDOWS\system32\@WindowsUpdateToastIcon.contrast-white.png
[0x54] : C:\WINDOWS\system32\@WindowsUpdateToastIcon.png
[0x55] : C:\WINDOWS\system32\@WirelessDisplayToast.png
[0x56] : C:\WINDOWS\system32\@WwanNotificationIcon.png
[0x57] : C:\WINDOWS\system32\@WwanSimLockIcon.png
[0x58] : C:\WINDOWS\system32\aadauthhelper.dll
[0x59] : C:\WINDOWS\system32\aadcloudap.dll
[0x5a] : C:\WINDOWS\system32\aadjcsp.dll
[0x5b] : C:\WINDOWS\system32\aadtb.dll
[0x5c] : C:\WINDOWS\system32\aadWamExtension.dll
[0x5d] : C:\WINDOWS\system32\AboutSettingsHandlers.dll
[0x5e] : C:\WINDOWS\system32\AboveLockAppHost.dll
[0x5f] : C:\WINDOWS\system32\accessibilitycpl.dll
[0x60] : C:\WINDOWS\system32\accountaccessor.dll
[0x61] : C:\WINDOWS\system32\AccountsRt.dll
[0x62] : C:\WINDOWS\system32\AcGenral.dll
...

我们可以选择删除其中的一个文件:

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
2
3
4
5
6
7
dx -r0 @$getsym = (x => Debugger.Utility.Control.ExecuteCommand(".printf\"%y\", " + ((__int64)x).ToDisplayString("x"))[0])

dx @$tmpFile = Debugger.Utility.FileSystem.TempDirectory.OpenFile("log.txt")

dx @$txtWriter = Debugger.Utility.FileSystem.CreateTextWriter(@$tmpFile)ba r4 nt!PsInitialSystemProcess "

dx @$txtWriter.WriteLine(@$getsym(@$curstack.Frames[0].Attributes.InstructionOffset)); g"

我们让系统想运行多久就运行多久,当我们想停止记录日志时,我们可以禁用或清除断点并使用 dx @$tmpFile.Close() 关闭文件。

现在我们可以打开 @$tmpFile 并查看结果:

view-tempFile

就是这样!记录关于调试器的信息是多么简单啊!

这就是我们 WinDbg 系列的全部内容!本系列的所有脚本都将被上传到 github ,还有一些新的没有包含在这里的脚本。我鼓励您进一步研究这个数据模型,因为我们甚至没有涵盖它包含的所有不同方法。编写你自己的工具,并与世界分享它们 :)

译注: github 地址是 https://github.com/yardenshafir/WinDbg_Scripts)

尽管本指南很长,但这些甚至还不是新数据模型中的所有可能选项。我甚至没有提到新增的对 Javascript 的支持!

你可以在这篇精彩的文章中获得更多关于在 WinDbg 中使用 Javascript 的信息,以及对令人兴奋的 TTD (time travel debugging) 的新支持。

BianChengNan wechat
扫描左侧二维码关注公众号,扫描右侧二维码加我个人微信:)
0%