<软件调试>30.7节简要的提到了windbg调试上下文的概念:如会话/进程/寄存器等上下文。为了深入了解背后的含义,我翻开windbg帮助文档,发现其对进程/寄存器上下文的解释最为晦涩。只能通过实际的练习,趟着石子过河,最后记录下自己的总结。
windbg中与进程上下文相关的命令是.process,与寄存器上下文相关的命令是.cxr(或.thread命令),我逐一总结这两个命令的作用。
1. .cxr命令
windbg帮助文档解释.cxr的作用是获得/修改线程的寄存器上下文,并最终影响栈回溯的输出结果。初看帮助文档,还以为.cxr命令和r命令有相同的地方----都用于修改寄存器的值,然而在windbg帮助文档<Register Context>节中明确写道.cxr(及.thread,.trap)命令并不能实际修改硬件寄存器的值,原话如下:
"These commands do not change the values of the CPU registers. Instead, the debugger retrieves the specified register context from its location in memory. Actually, the debugger can retrieve only the
saved register values. (Other values are set dynamically and are not saved. The saved values are sufficient to re-create a stack trace. "
我印象中调试器修改寄存器的值(windbg r命令可能也是这样实现)是依次调用SuspendThread挂起执行中的线程,然后调用GetThreadContext/SetThreadContext获得/修改寄存器值,最终调用ResumeThread恢复线程,以影响原程序执行的过程。既然.cxr命令也用于获得/修改寄存器上下文,我推断这个命令的实现和上述过程一致。
然而,调试的经历又一次否认了我的认知,以下面一段代码为例(及部分反汇编代码):
#include <stdio.h>
int main()
{
int i=0;
i++;
_asm int 3;
i++;
printf("%d\n",i);
}
; 6 : i++;
0001f 8b 45 fc mov eax, DWORD PTR _i$[ebp]
00022 83 c0 01 add eax, 1
00025 89 45 fc mov DWORD PTR _i$[ebp], eax
; 7 : _asm int 3;
00028 cc int 3
; 8 : i++;
00029 8b 4d fc mov ecx, DWORD PTR _i$[ebp]
0002c 83 c1 01 add ecx, 1
0002f 89 4d fc mov DWORD PTR _i$[ebp], ecx
我的想法是:调试器中断前,i=1,调试器中断后,逐条指令单步运行并用.cxr命令修改偏移0x2C处ecx的值为0x09。如果最终printf显示的值为0x0A(十进制的10),则说明.cxr和r命令等效,否则需要再次研读windbg帮助文档以确认其功能。
0:000> g @让windbg运行到int 3处,触发中断
*** WARNING: Unable to verify checksum for cxr.exe
cxr!main+0x28:
00401038 cc int 3 @到这触发中断
0:000> l-t @单步逐指令执行
Source options are 0:
None
0:000> t @准备执行第二条i++语句
cxr!main+0x29:
00401039 8b4dfc mov ecx,dword ptr [ebp-4] ss:0023:0012ff7c=00000001
0:000> t @给ecx赋值
ecx=00000001
cxr!main+0x2c:
0040103c 83c101 add ecx,1
0:000> r ecx
ecx=00000001
0:000> .dvalloc 0x1000 @.cxr /w命令可以将寄存器上下文保存到指定区域,因此这里先分配这样一块区域,用于保存和修改ecx的值
Allocated 1000 bytes starting at 003f0000 @分配到的虚拟地址为 0x3f0000
0:000> dt ntdll!_CONTEXT 003f0000 @在运行.cxr /w签名保存寄存器上下文前先看下原始的内存值
+0x000 ContextFlags : 0
+0x0ac Ecx : 0
+0x0b0 Eax : 0
0:000> .cxr /w 003f0000 @保存上下文
Context written to 003f0000
0:000> dt ntdll!_CONTEXT 003f0000
+0x000 ContextFlags : 0x1003f
+0x0ac Ecx : 1
+0x0b0 Eax : 1
0:000> ed 003f0000+0x0ac 0x09 @修改保存的寄存器上下文中CONTEXT!ECX的值
0:000> .cxr 003f0000 @使用保存在0x3f0000处的寄存器上下文
eax=00000001 ebx=7ffd4000 ecx=00000009 edx=00430dc0 esi=00f8f7a0 edi=0012ff80
eip=0040103c esp=0012ff30 ebp=0012ff80 iopl=0 nv up ei pl nz na po nc
cs=001b ss=0023 ds=0023 es=0023 fs=003b gs=0000 efl=00000202
cxr!main+0x2c:
0040103c 83c101 add ecx,1
0:000> r ecx
Last set context: @注意1
ecx=00000009 @使用新的寄存器上下文后,寄存器ecx显示的值的确变成了0x09
0:000> t @再次单步运行,你会惊奇的发现,此时ecx的值又变成了0x02
eax=00000001 ebx=7ffd4000 ecx=00000002 edx=00430dc0 esi=00f8f7a0 edi=0012ff80
cxr!main+0x2f:
0040103f 894dfc mov dword ptr [ebp-4],ecx ss:0023:0012ff7c=00000001
0:000> l+t
Source options are 1:
1/t - Step/trace by source line
0:000> g
以上是调试过程,结果不是很明显,让我们来看下实际运行的结果截图,令我震惊的是,尽管我确实修改过ecx在寄存器上下文的值,但最终结果依然是2,与预期想去甚远。
记得在调试异常或dump文件时,!analyiz -v的输出中往往会保存发生异常时寄存器上下文的地址,然后用.cxr命令恢复到异常发生时的上下文并查看调用堆栈,以此来分析异常的原因。 这个过程用<软件调试>作者的话叫做穿越回异常发生时的场景。我仔细回味了这个过程若干次,突然回想到一个细节:
1.发生异常时,不切换寄存器上下文,直接kP查看函数堆栈,调用栈往往显示的是windows如何进入函数RaiseException或者KeBugCheck以及执行KeBugCheck时寄存器的值;
2.如果使用.cxr命令先切换寄存器上下文然后在调用kP命令查看调用栈,尚且能触发异常的函数调用,以及调用这个函数的寄存器。
由此,我猜测.cxr命令的作用仅仅是控制windbg的显示输出,并不能 改变程序的执行流程。为了验证我的猜测,我仍以上面的代码为例,修改寄存器上下文。这次仅修改Ebp的值以影响windbg的栈回溯输出(k命令以ebp为栈回溯的起点):
0:000> g @调试运行,使程序触发代码中的int3断点
cxr!main+0x28:
00401038 cc int 3
0:000> kP @查看调用栈
ChildEBP RetAddr
0012ff80 00401229 cxr!main+0x28 [C:\Documents and Settings\Administrator\桌面\studio\cxr.cpp @ 7]
0012ffc0 7c817067 cxr!mainCRTStartup+0xe9 [crt0.c @ 206]
0012fff0 00000000 kernel32!BaseProcessStart+0x23
0:000> r ebp @修改寄存器上下文前,ebp保存进入main函数时的栈帧
ebp=0012ff80
0:000> dd [ebp] L1
0012ff80 0012ffc0 @0012ffc0保存前一个函数的栈帧,即mainCRTStartup的栈帧
0:000> dd 0012ffc0 L1
0012ffc0 0012fff0 @0012fff0保存再前一个函数的栈帧,即kernel32!BaseProcessStart的栈帧
0:000> dd ebp L2
0012ff80 0012ffc0 00401229 @00401229是main函数执行完后的返回地址,返回到mainCRTStartup中
0:000> u 00401229 @查看00401229处的反汇编
cxr!mainCRTStartup+0xe9 [crt0.c @ 206]:
00401229 83c40c add esp,0Ch
0040122c 8945e4 mov dword ptr [ebp-1Ch],eax
0040122f 8b55e4 mov edx,dword ptr [ebp-1Ch]
0:000> .dvalloc 0x1000 @分配用于保存和修改寄存器上下文的空间
Allocated 1000 bytes starting at 00530000
0:000> .cxr /w 00530000 @将当前寄存器上下文存放到刚分配的空间中
Context written to 00530000
0:000> dt ntdll!_CONTEXT 00530000
+0x000 ContextFlags : 0x1003f
+0x0b0 Eax : 1 @前面代码中执行过i++,所以eax==1
+0x0b4 Ebp : 0x12ff80 @当前函数的栈帧
+0x0b8 Eip : 0x401038
0:000> ed 00530000+0x0b4 0012fff0 @修改_CONTEXT!Ebp的值,使得从[ebp]中取到错误的函数栈
0:000> .cxr 00530000 @使用被修改后的寄存器上下文。使得之后windbg的分析结果(注意我的用词,是分析结果,不是执行结果)都基于现在的寄存器上下文
eip=00401038 esp=0012ff30 ebp=0012fff0 iopl=0 nv up ei pl nz na po nc
cxr!main+0x28:
00401038 cc int 3
0:000> r ebp
Last set context: @注意此处,"Last set context",windbg提示我们现在的分析是基于修改后的寄存器上下文;正常的r命令是没有这样的提示的
ebp=0012fff0
0:000> kP @此时的栈回溯是不正确的,因为中间的函数帧被我跳过了。和前一个命令一样,windbg提示当前的栈回溯是基于前一次上下文修改的结果
*** Stack trace for last set context - .thread/.cxr resets it
ChildEBP RetAddr
0012fff0 00000000 cxr!main+0x28 [C:\Documents and Settings\Administrator\桌面\studio\cxr.cpp @ 7]
0:000> .cxr @将寄存器上下文恢复为默认情况(即真正的上下文)
Resetting default scope
0:000> kP @恢复后,栈回溯的输出恢复正常
ChildEBP RetAddr
0012ff80 00401229 cxr!main+0x28 [C:\Documents and Settings\Administrator\桌面\studio\cxr.cpp @ 7]
0012ffc0 7c817067 cxr!mainCRTStartup+0xe9 [crt0.c @ 206]
0012fff0 00000000 kernel32!BaseProcessStart+0x23
根据上面我的调试步骤和注释,可以看到.cxr命令其实是修改windbg分析环境,使得windbg从特定的位置取值分析并输出,但并不影响windbg的执行环境。为什么说并不影响执行环境?我们可以看看修改了寄存器上下文后(伪造的函数栈),使windbg从main函数执行返回的位置。如果修改寄存器上下文影响到windbg的执行环境,那么从main函数返回后,windbg应该返回到0x0000000处,并将ebp的值恢复为12FFF0:
0:000> .cxr 00530000 @前面的调试过程中,将寄存器上下文恢复为正常;现在要重新切换到被伪造的寄存器上下文
eax=00000001 ebx=7ffde000 ecx=00000000 edx=00430dc0 esi=00f8f7a0 edi=0012ff80
eip=00401038 esp=0012ff30 ebp=0012fff0 iopl=0 nv up ei pl nz na po nc
cs=001b ss=0023 ds=0023 es=0023 fs=003b gs=0000 efl=00000202
cxr!main+0x28:
00401038 cc int 3
0:000> kP @伪造的寄存器上下文。如果.cxr对执行流有影响,windbg将从main函数中ret到0x00处,并将ebp恢复成0x12fff0。这显然会触发访问非法内存的异常
*** Stack trace for last set context - .thread/.cxr resets it
ChildEBP RetAddr
0012fff0 00000000 cxr!main+0x28 [C:\Documents and Settings\Administrator\桌面\studio\cxr.cpp @ 7]
0:000> uf cxr!main @查找main函数的返回地址并准备下断点
cxr!main [C:\Documents and Settings\Administrator\桌面\studio\cxr.cpp @ 4]:
...
7 00401038 cc int 3
8 00401039 8b4dfc mov ecx,dword ptr [ebp-4]
8 0040103c 83c101 add ecx,1
8 0040103f 894dfc mov dword ptr [ebp-4],ecx
10 00401042 8b55fc mov edx,dword ptr [ebp-4]
10 00401045 52 push edx
10 00401046 681c004200 push offset cxr!`string' (0042001c)
10 0040104b e830000000 call cxr!printf (00401080)
10 00401050 83c408 add esp,8
...
11 00401063 c3 ret
0:000> bp 00401063 @在main函数返回处下断点,用于观察windbg的执行流是否真的受到.cxr的影响
0:000> g
Breakpoint 0 hit
cxr!main+0x53:
00401063 c3 ret
0:000> l-t @单步逐条指令运行
Source options are 0:
None
0:000> t @执行ret指令
eip=00401229 esp=0012ff88 ebp=0012ffc0 @执行ret指令后,ebp是0012ffc0,即原来mainCRTStartup函数的函数帧,并且eip不为0x00000000
cxr!mainCRTStartup+0xe9:
00401229 83c40c add esp,0Ch
0:000> ln 00401229 @查看eip附近的符号为mainCRTStartup,由此可见main函数ret后还是进入了mainCRTStartup。并没有.cxr的影响直接进入00000000
crt0.c(206)+0x19
(00401140) cxr!mainCRTStartup+0xe9 | (00401270)
如我所猜想,.cxr命令只是修改windbg进行分析的环境,并没有修改程序的执行流程。
由此可以推断,windbg中所谓的切换上下文的命令,只是切换windbg分析问题时所处的出发点。如果把windbg比作调查案件的人,切换上下文可以看做调查人以不同的视角去假设问题解释现象。但不管以怎样的观点分析问题,都无法左右程序的实际流程。
2. .process命令
windbg解释.process的作用为切换进程上下文。我的第一反应是中止进程A的执行,调度并运行进程B。没错,.process是有这样的功能,但需要其他参数的配合,这个后面再说。默认.process EPROCESS这样的形式并没有切换进程的功效,原因听我缓缓道来:a).x86 CPU切换进程必然会切换Cr3。当.process EPROCESS执行后,windbg的提示确实变了,但Cr3的值没有改变,所以可以确定进程没有切换,以切换目标机上的System.exe和calc.exe为例:
kd> !process 0 0
**** NT ACTIVE PROCESS DUMP ****
Failed to get VAD root
PROCESS 89e34830 SessionId: none Cid: 0004 Peb: 00000000 ParentCid: 0000
DirBase: 00b1f000 ObjectTable: e1000cc0 HandleCount: 229.
Image: System
Failed to get VAD root
PROCESS 89888768 SessionId: 0 Cid: 0684 Peb: 7ffd3000 ParentCid: 0688
DirBase: 109402c0 ObjectTable: e1cf45f8 HandleCount: 44.
Image: calc.exe
@calc.exe的页目录表为109402c0,System的页目录表为00b1f000
kd> r cr3
cr3=00b1f000
@切换进程上下文前,先看下当前Cr3的值是00b1f000,即当前进程是System
kd> .process /r /p 89888768
Implicit process is now 89888768
.cache forcedecodeuser done
Loading User Symbols
..........................
@切换进程上下文到calc.exe
kd> .reload /user /f
Loading User Symbols
....
Press ctrl-c (cdb, kd, ntsd) or ctrl-break (windbg) to abort symbol loads that take too long.
Run !sym noisy before .reload to track down problems loading symbols.
......................
@重新加载符号
kd> lml
start end module name
01000000 0101f000 calc (pdb symbols) C:\sym\thisPC\calc.pdb\3B7D84101\calc.pdb
...
kd> !lmi calc
Loaded Module Info: [calc]
Module: calc
Base Address: 01000000
Image Name: calc.exe
Machine Type: 332 (I386)
Time Stamp: 3b7d8410 Sat Aug 18 04:52:32 2001
Size: 1f000
CheckSum: 2073c
Characteristics: 10f
Debug Data Dirs: Type Size VA Pointer
CODEVIEW 19, 160c, a0c NB10 - Sig: 3b7d8410, Age: 1, Pdb: calc.pdb
Image Type: MEMORY - Image read successfully from loaded memory.
Symbol Type: PDB - Symbols loaded successfully from image header.
C:\sym\thisPC\calc.pdb\3B7D84101\calc.pdb
Load Report: public symbols , not source indexed
C:\sym\thisPC\calc.pdb\3B7D84101\calc.pdb
@查看模块信息,calc.exe模块加载在0x01000000处
@一下读写calc.exe的内存
kd> dd 01000000 L8
01000000 00905a4d 00000003 00000004 0000ffff
01000010 000000b8 00000000 00000040 00000000
kd> da 01000000 L8
01000000 "MZ."
kd> ed 01000000 00000000
kd> dd 01000000 L8
01000000 00000000 00000003 00000004 0000ffff
01000010 000000b8 00000000 00000040 00000000
@上面在calc进程空间做了这么多读写操作,让我们来确认一下当前进程是哪个?
kd> r cr3
cr3=00b1f000
@当前Cr3的值仍是00b1f000,仍是System,是不是很意外?
上面的例子中,我们切换进程上下文到calc.exe中,并读写内存,一切就像当前进程真的就是calc一样,直到看到Cr3的值,我们才意识到这个现实。接着我们再看看windbg真正的切换进程,切换后Cr3的值说明当前进程是calc.exe
kd> .process /i /r /p 89888768
You need to continue execution (press 'g' <enter>) for the context
to be switched. When the debugger breaks in again, you will be in
the new process context.
kd> g
Break instruction exception - code 80000003 (first chance)
nt!RtlpBreakWithStatusInstruction:
80528bdc cc int 3
@用.process /i EPROCESS做所谓的入侵式切换
kd> r cr3
cr3=109402c0
@切换后再次验证Cr3的值,Cr3=109402c0,和!process得到的DirBase的值相同
kd> !process 0 0
**** NT ACTIVE PROCESS DUMP ****
Failed to get VAD root
PROCESS 89888768 SessionId: 0 Cid: 0684 Peb: 7ffd3000 ParentCid: 0688
DirBase: 109402c0 ObjectTable: e1cf45f8 HandleCount: 44.
Image: calc.exe
上面2个例子给我的感觉是:.process EPROCESS这种形式的切换,windbg停留在原进程A空间中,借助进程B保存在内存中的页目录表(并没有将页目录表装载到Cr3中),读写B的进程空间;此时,从windbg输出的模块等信息也是B进程的。整个过程就像借用B的躯壳----进程B并没有运行----却读取了B进程的空间(说不定windbg将进程B的页目录表读到调试机上,然后自己实现一套虚拟的页面中断机制,一点点完善B的进程空间)。至于.process /i EPROCESS不仅切换了进程,还向Cr3装载了目标进程的页目录表,所以能直接读取B的进程空间。