深入 x64

  本篇原文为 X64 Deep Dive,如果有良好的英文基础的能力,可以点击该链接进行阅读。本文为我个人:寂静的羽夏(wingsummer) 中文翻译,非机翻,著作权归原作者所有。
  由于原文十分冗长,也十分干货,采用机翻辅助,人工阅读比对修改的方式进行,如有翻译不得当的地方,欢迎批评指正。翻译不易,如有闲钱,欢迎支持。注意在转载文章时注意保留原文的作者链接,我(译者)的相关信息。话不多说,正文开始:

关于 X64 平台执行和调试关键方面的深度教程,例如编译器优化、异常处理、参数传递、堆栈布局以及参数的获取。

深入 x64

  本教程讨论在X64 CPU上执行代码的一些重要内容,例如编译器优化、异常处理、参数传递和参数的获取,并解释它们的密切联系。本篇文章涵盖了重要调试器命令,并介绍理解这些命令的输出结果的必要前置知识,强调X64 CPUX86 CPU的不同之处以及它如何影响X64上的调试。篇末我们还会将所有内容串在一起,说明如何利用这些知识从X64调用堆栈中获取基于寄存器的参数,克服在调试X64代码时无法绕过的困难。本教程将逐步介绍上面所述内容,并利用图表、反汇编和调试器输出结果来深入了解关键点。希望读者能够很好地理解X86 CPU上的工作原理,包括寄存器使用、堆栈使用和函数布局,以完成本教程的大部分内容。

编译器优化

  本节讨论影响X64代码生成方式的编译器一些优化。从X64寄存器的说明开始,进而介绍编译器优化方面的内容,如函数内联、尾函数调用平栈、帧指针优化和基于堆栈指针的局部变量访问。

寄存器的变化

  X64 CPU上的所有寄存器,除了段寄存器和EFlags寄存器,都是64位的,这意味着从内存中提取的所有内容都是64位大小的。此外,X64指令能够一次处理64位,使得x64能够作为本机64位的处理器。此外,它还增添了八个新寄存器,即R8 - R15。它们用数字标记,而不是用字母标记的其他寄存器。以下调试器输出显示了X64上的寄存器:

1: kd> r
rax=fffffa60005f1b70 rbx=fffffa60017161b0 rcx=000000000000007f
rdx=0000000000000008 rsi=fffffa60017161d0 rdi=0000000000000000
rip=fffff80001ab7350 rsp=fffffa60005f1a68 rbp=fffffa60005f1c30
 r8=0000000080050033  r9=00000000000006f8 r10=fffff80001b1876c
r11=0000000000000000 r12=000000000000007b r13=0000000000000002
r14=0000000000000006 r15=0000000000000004
iopl=0         nv up ei ng nz na pe nc
cs=0010  ss=0018  ds=002b  es=002b  fs=0053  gs=002b             efl=00000282
nt!KeBugCheckEx:
fffff800`01ab7350 48894c2408      mov     qword ptr [rsp+8],rcx ss:0018:fffffa60`005f1a70=000000000000007f

  从X86来说,其中一些寄存器的用法也发生了变化。它们的变化可以用以下几点来分组总结:

  • 非易失性寄存器在跨函数调用时必须保存。 X64 有一个扩展的非易失性寄存器集,其中还包括所有旧的 X86 非易失性寄存器。这组中的新寄存器是 R12 到 R15。从获取基于寄存器的函数参数的角度来看,这十分重要。
  • 快速调用寄存器用于将参数传递给函数。 Fastcall 是 X64 上的默认调用约定,其中前 4 个参数通过寄存器 RCX、RDX、R8、R9 传递。
  • RBP 不再用作帧指针。它现在是一个通用寄存器,就像任何其他寄存器(如 RBX、RCX 等)一样。调试器不能再使用 RBP 寄存器来遍历调用堆栈。
  • 在 X86 CPU 上,FS 段寄存器指向线程环境块 (TEB) 和处理器控制区域 (KPCR),但在 X64 上,GS 寄存器在用户模式下指向 TEB,在内核中指向 KPCR 的新模式。然而,当运行 WOW64 应用程序(即 X64 系统上的 32 位应用程序)时,FS 寄存器继续指向 32 位版本的 TEB。

  X64上的陷阱帧结构体nt!_KTRAP_FRAME不包含非易失性寄存器的有效内容。如果打算修改非易失性寄存器,X64函数的prolog会保存非易失性寄存器的值。调试器始终可以从堆栈中提取这些非易失性寄存器的保存值,而不必从陷阱帧中获取它们。 在X64上的内核模式调试期间,.trap命令的输出会打印一条注释,突出显示从陷阱中检索到的所有寄存器的值可能不准确的事实,如下所示。此规则有例外,例如,为用户到内核模式转换生成的陷阱帧确实包含所有寄存器的正确值。

1: kd> kv
Child-SP          RetAddr           : Args to Child
. 
. 
.
nt!KiDoubleFaultAbort+0xb8 (TrapFrame @ fffffa60`005f1bb0)
. 
. 
.

1: kd> .trap  fffffa60`005f1bb0
NOTE: The trap frame does not contain all registers.
Some register values may be zeroed or incorrect

函数内联

  X64编译器执行函数的内联扩展,如果满足某些条件,它将用被调用者的主体替换对函数的调用。尽管内联并不是X64的专属,但它对内联这东西十分关注。内联的优点是它避免了提升堆栈、分配给被调用者使用,最后返回给调用者的开销。内联的缺点是由于代码重复,可执行文件膨胀。并且,函数代码的重复内联扩展导致增大代码缓存的缺失的数量和提高缺页异常的频数。函数内联给调试带来了不便,因为当尝试在编译器选择内联的函数上设置断点时,调试器无法找到内联函数的符号。源文件级别的内联由编译器的/Ob标志控制,并且可以通过__declspec(noinline)在每个函数上禁用内联。在Function1中内联function2Function3这两个函数的示意图如下:

尾函数调用平栈

  X64编译器可以通过将函数替换为跳转到被调用者来优化函数的最后一次调用。这避免了为被调用者配置堆栈帧的开销。调用者和被调用者共享同一个栈帧,被调用者直接返回到调用者的调用函数。当调用者和被调用者具有相同的参数时,这尤其有用,因为如果相关参数已经在所需的寄存器中并且这些寄存器没有更改,则不必重新加载它们。下图展示了了在调用Function4Function1中的尾函数调用平栈。Function1跳转到Function4,当Function4执行完毕后,直接返回给Function1的调用者。

帧指针优化

  与X86 CPU使用EBP寄存器访问堆栈上的参数和局部变量不同,X64函数不会使用RBP寄存器来取参和局部变量,即不使用EBP寄存器作为帧指针。相反,它使用RSP寄存器作为堆栈指针和帧指针,在下一个话题中将详细介绍它是如何工作的。因此,在X64上,RBP寄存器从堆栈的维护工作中解脱出来,可以作为通用寄存器使用。但有一个例外是使用alloca函数,它的作用是在堆栈上动态分配空间。此类函数将使用RBP寄存器作为帧指针,就像在X86上使用EBP一样。
  以下汇编代码片段展示了X86函数KERNELBASE!Sleep。 对EBP寄存器的引用表明它被用作帧指针。 在调用函数SleepEx时,参数被压入堆栈并通过call指令调用SleepEx

0:009> uf KERNELBASE!Sleep
KERNELBASE!Sleep:
75ed3511 8bff            mov     edi,edi
75ed3513 55              push    ebp
75ed3514 8bec            mov     ebp,esp
75ed3516 6a00            push    0
75ed3518 ff7508          push    dword ptr [ebp+8]
75ed351b e8cbf6ffff      call    KERNELBASE!SleepEx (75ed2beb)
75ed3520 5d              pop     ebp
75ed3521 c20400          ret     4.

  下一个代码片段显示了相同的函数,即X64上的 kernelbase!Sleep。有一些显着的差异,X64版本更加紧凑,因为没有RBP寄存器的保存、恢复和配置,即帧指针的使用被省略,也没有任何堆栈帧为被调用者负责,即SleepEx。事实上,SleepSleepEx最终使用相同的堆栈帧,这是尾调用优化的一个示例。

0:000> uf KERNELBASE!Sleep
KERNELBASE!Sleep:
000007fe`fdd21140 xor     edx,edx
000007fe`fdd21142 jmp     KERNELBASE!SleepEx (000007fe`fdd21150)

基于堆栈指针的局部变量访问

  在X86 CPU上,帧指针EBP寄存器最重要的功能是提供对基于堆栈的参数和局部变量的访问。如前所述,在X64 CPU上,RBP寄存器并不指向当前函数的堆栈帧。所以在X64上,RSP寄存器必须同时用作堆栈指针和帧指针。所以X64上的所有堆栈引用都是基于RSP执行的。因此,X64上的函数依赖于整个函数体中的静态RSP寄存器,作为访问局部变量和参数的参考。由于pushpop指令会改变堆栈指针,因此X64函数将pushpop指令分别限制为函数prologepilog。堆栈指针在prologepilog之间一定保持不变这一事实是X64函数的一个特征,如下图所示:

  以下代码片段展示了函数user32!DrawTestExW的完整内容。该函数的prolog以指令sub rsp, 48h结束,epilog以指令add rsp, 48h开始。由于prologepilog之间的指令使用RSP作为参考访问堆栈内容,因此函数体中间没有pushpop指令。

0:000> uf user32!DrawTextExW
user32!DrawTextExW:
00000000`779c9c64 sub     rsp,48h
00000000`779c9c68 mov     rax,qword ptr [rsp+78h]
00000000`779c9c6d or      dword ptr [rsp+30h],0FFFFFFFFh
00000000`779c9c72 mov     qword ptr [rsp+28h],rax
00000000`779c9c77 mov     eax,dword ptr [rsp+70h]
00000000`779c9c7b mov     dword ptr [rsp+20h],eax
00000000`779c9c7f call    user32!DrawTextExWorker (00000000`779ca944)
00000000`779c9c84 add     rsp,48h
00000000`779c9c88 ret

异常处理

  本节讨论X64函数用于异常处理的底层机制和数据结构,以及调试器如何利用这些结构来遍历调用堆栈,并指出了X64调用堆栈的一些特色。

RUNTIME_FUNCTION

  X64可执行文件使用一种文件格式,它是用于X86PE文件格式的变体,称为PE32+。此类文件有一个称为.pdata或异常目录的额外部分,其中包含用于处理异常的信息。该异常目录包含可执行文件中每个非叶函数的RUNTIME_FUNCTION结构。非叶函数是调用其他函数的函数,每个RUNTIME_FUNCTION结构包含函数中第一条和最后一条指令的偏移量(即函数范围)和一个指向展开信息结构的指针,该结构描述了在发生异常时如何展开函数的调用堆栈。下图展示了一个模块的RUNTIME_FUNCTION结构,该结构包含该模块中函数开头和结尾的偏移量。

  以下汇编代码片段展示了与X86X64上的异常处理相关的代码生成方面的一些差异。在x86上,当高级语言C/C++代码包含结构化异常处理结构,如__try/__except时,编译器会在运行时在堆栈上构建异常帧的函数的prologepilog中生成特殊代码。这可以在下面调用ntdll!_SEH_prolog4ntdll!_SEH_epilog4的代码片段中观察到。

0:009> uf ntdll!__RtlUserThreadStart
ntdll!__RtlUserThreadStart:
77009d4b push    14h
77009d4d push    offset ntdll! ?? ::FNODOBFM::`string'+0xb5e (76ffc3d0)
77009d52 call    ntdll!_SEH_prolog4 (76ffdd64)
77009d57 and     dword ptr [ebp-4],0
77009d5b mov     eax,dword ptr [ntdll!Kernel32ThreadInitThunkFunction (770d4224)]
77009d60 push    dword ptr [ebp+0Ch]
77009d63 test    eax,eax
77009d65 je      ntdll!__RtlUserThreadStart+0x25 (77057075)

ntdll!__RtlUserThreadStart+0x1c:
77009d6b mov     edx,dword ptr [ebp+8]
77009d6e xor     ecx,ecx
77009d70 call    eax
77009d72 mov     dword ptr [ebp-4],0FFFFFFFEh
77009d79 call    ntdll!_SEH_epilog4 (76ffdda9)
77009d7e ret     8

  然而,在该函数的x64版本中,没有迹象表明该函数使用结构化异常处理,因为在运行时没有构建基于堆栈的异常帧。RUNTIME_FUNCTION结构连同指令指针寄存器RIP的当前值用于从可执行文件本身定位异常处理信息。

0:000> uf ntdll!RtlUserThreadStart
Flow analysis was incomplete, some code may be missing
ntdll!RtlUserThreadStart:
00000000`77c03260 sub     rsp,48h
00000000`77c03264 mov     r9,rcx
00000000`77c03267 mov     rax,qword ptr [ntdll!Kernel32ThreadInitThunkFunction (00000000`77d08e20)]
00000000`77c0326e test    rax,rax
00000000`77c03271 je      ntdll!RtlUserThreadStart+0x1f (00000000`77c339c5)

ntdll!RtlUserThreadStart+0x13:
00000000`77c03277 mov     r8,rdx
00000000`77c0327a mov     rdx,rcx
00000000`77c0327d xor     ecx,ecx
00000000`77c0327f call    rax
00000000`77c03281 jmp     ntdll!RtlUserThreadStart+0x39 (00000000`77c03283)

ntdll!RtlUserThreadStart+0x39:
00000000`77c03283 add     rsp,48h
00000000`77c03287 ret

ntdll!RtlUserThreadStart+0x1f:
00000000`77c339c5 mov     rcx,rdx
00000000`77c339c8 call    r9
00000000`77c339cb mov     ecx,eax
00000000`77c339cd call    ntdll!RtlExitUserThread (00000000`77bf7130)
00000000`77c339d2 nop
00000000`77c339d3 jmp     ntdll!RtlUserThreadStart+0x2c (00000000`77c53923)

UNWIND_INFO 和 UNWIND_CODE

  RUNTIME_FUNCTION结构的BeginAddressEndAddress字段分别存储函数代码在虚拟内存中从模块开始的偏移量。当函数产生异常时,操作系统会扫描PE文件的内存映射副本,寻找包含当前指令地址的RUNTIME_FUNCTION结构体。RUNTIME_FUNCTION结构的UnwindData字段包含另一个结构的偏移量,它告诉操作系统运行时它应该如何展开堆栈,这是UNWIND_INFO结构。UNWIND_INFO结构包含数量不定的UNWIND_CODE结构,每个结构都存储着回滚恢复函数prolog执行的单个堆栈相关操作影响的信息。
  对于动态生成的代码,操作系统支持函数RtlAddFunctionTableRtlInstallFunctionTableCallback用于在运行时创建RUNTIME_FUNCTION信息。
  下图展示了RUNTIME_FUNCTIONUNWIND_INFO结构之间的关系以及函数在内存中的位置:

  调试器的.fnent命令显示有关给定函数的RUNTIME_FUNCTION结构的信息。以下示例显示函数ntdll!RtlUserThreadStart.fnent命令的输出。

0:000> .fnent ntdll!RtlUserThreadStart
Debugger function entry 00000000`03be6580 for:
(00000000`77c03260)   ntdll!RtlUserThreadStart   |  (00000000`77c03290)   ntdll!RtlRunOnceExecuteOnce
Exact matches:
    ntdll!RtlUserThreadStart = <no type information>

BeginAddress      = 00000000`00033260
EndAddress        = 00000000`00033290
UnwindInfoAddress = 00000000`00128654

Unwind info at 00000000`77cf8654, 10 bytes
  version 1, flags 1, prolog 4, codes 1
  frame reg 0, frame offs 0
  handler routine: ntdll!_C_specific_handler (00000000`77be50ac), data 3
  00: offs 4, unwind op 2, op info 8    UWOP_ALLOC_SMALL

  如果将上面显示的BeginAddress添加到模块的基部,即包含函数RtlUserThreadStartntdll.dll,则结果地址0x0000000077c03260是函数RtlUserThreadStart的开始,如下所示:

0:000> ?ntdll+00000000`00033260
Evaluate expression: 2009084512 = 00000000`77c03260

0:000> u ntdll+00000000`00033260
ntdll!RtlUserThreadStart:
00000000`77c03260 sub     rsp,48h
00000000`77c03264 mov     r9,rcx
00000000`77c03267 mov     rax,qword ptr [ntdll!Kernel32ThreadInitThunkFunction (00000000`77d08e20)]
00000000`77c0326e test    rax,rax
00000000`77c03271 je      ntdll!RtlUserThreadStart+0x1f (00000000`77c339c5)
00000000`77c03277 mov     r8,rdx
00000000`77c0327a mov     rdx,rcx
00000000`77c0327d xor     ecx,ecx

  如果以相同的方式使用EndAddress,则结果地址将指向函数末尾,如下所示:

0:000> ?ntdll+00000000`00033290
Evaluate expression: 2009084560 = 00000000`77c03290

0:000> ub 00000000`77c03290 L10
ntdll!RtlUserThreadStart+0x11:
00000000`77c03271 je      ntdll!RtlUserThreadStart+0x1f (00000000`77c339c5)
00000000`77c03277 mov     r8,rdx
00000000`77c0327a mov     rdx,rcx
00000000`77c0327d xor     ecx,ecx
00000000`77c0327f call    rax
00000000`77c03281 jmp     ntdll!RtlUserThreadStart+0x39 (00000000`77c03283)
00000000`77c03283 add     rsp,48h
00000000`77c03287 ret
00000000`77c03288 nop
00000000`77c03289 nop
00000000`77c0328a nop
00000000`77c0328b nop
00000000`77c0328c nop
00000000`77c0328d nop
00000000`77c0328e nop
00000000`77c0328f nop 

  因此RUNTIME_FUNCTION结构的BeginAddressEndAddress字段描述了相应函数在内存中的位置。然而,有一个优化可以在模块链接后应用于模块,这可能会改变上述观察结果,稍后会详细介绍。
  尽管UNWIND_INFOUNWIND_CODE结构的主要目的是描述堆栈在异常期间是如何展开的,但调试器使用此信息来遍历调用堆栈,而无需访问模块的符号。每个UNWIND_CODE结构都可以描述函数prolog执行的以下操作之一:

  • SAVE_NONVOL - 在堆栈上保存一个非易失性寄存器。
  • PUSH_NONVOL - 将非易失性寄存器压入堆栈。
  • ALLOC_SMALL - 在堆栈上分配空间(最多 128 个字节)。
  • ALLOC_LARGE - 在堆栈上分配空间(最多 4GB)。

  因此,本质上UNWIND_CODE是函数prolog的元数据表示。
  下图展示函数prolog执行的与堆栈相关的操作之间的关系以及这些操作在UNWIND_CODE结构中的描述。UNWIND_CODE结构以它们所代表的指令的相反顺序出现,因此在异常期间,堆栈可以在它创建的相反方向上展开。

  以下示例显示X64系统上notepad.exe本机版本的PE文件中的.pdata节头部。VirtualAddress字段表明.pdata段位于可执行文件开头的0x13000偏移处。

T:\link -dump -headers c:\windows\system32\notepad.exe
.
.
.
SECTION HEADER #4
  .pdata name
     6B4 virtual size
   13000 virtual address (0000000100013000 to 00000001000136B3)
     800 size of raw data
    F800 file pointer to raw data (0000F800 to 0000FFFF)
       0 file pointer to relocation table
       0 file pointer to line numbers
       0 number of relocations
       0 number of line numbers
40000040 flags
         Initialized Data
         Read Only
.
.
.

  下一个示例介绍来自同一可执行文件,即notepad.exeUNWIND_INFOUNWIND_CODE结构。每个UNWIND_CODE结构都描述了函数的prolog执行的类似PUSH_NONVOLALLOC_SMALL的操作,并且在堆栈展开时必须回滚,如下所示。调试器的.fnent命令也显示了这两个结构的内容。但是,link -dump -unwindinfo的输出解码了.fnent没有的UNWIND_CODE结构的全部内容。

T:\link -dump -unwindinfo c:\windows\system32\notepad.exe
.
.
.
  00000018 00001234 0000129F 0000EF68
    Unwind version: 1
    Unwind flags: None
    Size of prologue: 0x12
    Count of codes: 5
    Unwind codes:
      12: ALLOC_SMALL, size=0x28
      0E: PUSH_NONVOL, register=rdi
      0D: PUSH_NONVOL, register=rsi
      0C: PUSH_NONVOL, register=rbp
      0B: PUSH_NONVOL, register=rbx.
.
.
.

  上面输出中的ALLOC_SMALL代表函数prolog中的sub指令,它分配0x28字节的堆栈空间。每个PUSH_NONVOL对应于函数序言中的push指令,该指令将非易失性寄存器保存在堆栈上,并由函数Epilog中的pop指令恢复。这些指令可以在偏移量0x1234处的函数反汇编中看到,如下所示:

0:000> ln notepad+1234
(00000000`ff971234)   notepad!StringCchPrintfW   |  (00000000`ff971364)   notepad!CheckSave
Exact matches:
    notepad!StringCchPrintfW = <no type information>
    notepad!StringCchPrintfW = <no type information>

0:000> uf notepad!StringCchPrintfW
notepad!StringCchPrintfW:
00000001`00001234 mov     qword ptr [rsp+18h],r8
00000001`00001239 mov     qword ptr [rsp+20h],r9
00000001`0000123e push    rbx
00000001`0000123f push    rbp
00000001`00001240 push    rsi
00000001`00001241 push    rdi
00000001`00001242 sub     rsp,28h
00000001`00001246 xor     ebp,ebp
00000001`00001248 mov     rsi,rcx
00000001`0000124b mov     ebx,ebp
00000001`0000124d cmp     rdx,rbp
00000001`00001250 je      notepad!StringCchPrintfW+0x27 (00000001`000077b5)
...
notepad!StringCchPrintfW+0x5c:
00000001`00001294 mov     eax,ebx
00000001`00001296 add     rsp,28h
00000001`0000129a pop     rdi
00000001`0000129b pop     rsi
00000001`0000129c pop     rbp
00000001`0000129d pop     rbx
00000001`0000129e ret

性能优化

  Windows操作系统二进制文件经过称为基本块工具 (Basic Block ToolsBBT) 的配置文件引导优化,这增加了代码的空间局部性。经常执行的功能部分被保存在一起,可能在同一页面中,不经常使用的部分被移动到其他位置。这减少了需要为最常执行的代码路径保留在内存中的页面数量,最终导致整体工作集减少。为了应用这种优化,二进制文件被链接、执行、分析,然后分析数据用于根据执行频率重新排列函数的各个部分。
  在最终的函数中,函数的一些代码块被移到函数主体之外,该主体最初由RUNTIME_FUNCTION结构的范围定义。由于代码块的移动,函数体被分解为多个不连续的部分,因此最初由链接器生成的RUNTIME_FUNCTION结构不再能够准确识别这些函数的范围。为了解决这个问题,BBT进程添加了多个新的RUNTIME_FUNCTION结构,每个结构定义了一个具有优化功能的连续代码块。这些RUNTIME_FUNCTION结构与终止于原始RUNTIME_FUNCTION结构的链连接在一起,该结构的BeginAddress始终指向函数的开头。
  下图展示由三个基本块组成的函数。应用BBT进程块#2后移出函数体,导致原始RUNTIME_FUNCTION中的信息变为无效。因此,BBT进程创建了第二个RUNTIME_FUNCTION结构并将其链接到第一个结构,从而描述了整个函数。

  当前公共版本的调试器不会遍历完整的 RUNTIME_FUNCTION 结构链。 因此调试器无法显示优化函数的正确名称,其中返回地址映射到已移出主函数体的代码块。
  以下示例显示了调用堆栈中名称显示不正确的函数。 相反,名称以ntdll! ?? ::FNODOBFM::'string' 的形式显示。调试器错误地将帧0x0c中的返回地址0x0000000077c17623转换为名称ntdll! ?? ::FNODOBFM::'string'+0x2bea0

0:000> kn
 # Child-SP          RetAddr           Call Site
00 00000000`0029e4b8 000007fe`fdd21726 ntdll! ?? ::FNODOBFM::`string'+0x6474
01 00000000`0029e4c0 000007fe`fdd2dab6 KERNELBASE!BaseSetLastNTError+0x16
02 00000000`0029e4f0 00000000`77ad108f KERNELBASE!AccessCheck+0x64
03 00000000`0029e550 00000000`77ad0d46 kernel32!BasepIsServiceSidBlocked+0x24f
04 00000000`0029e670 00000000`779cd161 kernel32!LoadAppInitDlls+0x36
05 00000000`0029e6e0 00000000`779cd42d user32!ClientThreadSetup+0x22e
06 00000000`0029e950 00000000`77c1fdf5 user32!_ClientThreadSetup+0x9
07 00000000`0029e980 000007fe`ffe7527a ntdll!KiUserCallbackDispatcherContinue
08 00000000`0029e9d8 000007fe`ffe75139 gdi32!ZwGdiInit+0xa
09 00000000`0029e9e0 00000000`779ccd1f gdi32!GdiDllInitialize+0x11b
0a 00000000`0029eb40 00000000`77c0c3b8 user32!UserClientDllInitialize+0x465
0b 00000000`0029f270 00000000`77c18368 ntdll!LdrpRunInitializeRoutines+0x1fe
0c 00000000`0029f440 00000000`77c17623 ntdll!LdrpInitializeProcess+0x1c9b
0d 00000000`0029f940 00000000`77c0308e ntdll! ?? ::FNODOBFM::`string'+0x2bea0
0e 00000000`0029f9b0 00000000`00000000 ntdll!LdrInitializeThunk+0xe

  下一个示例使用上面的返回地址0x0000000077c17623来显示名称不正确的函数的RUNTIME_FUNCTIONUNWIND_INFOUNWIND_CODE。显示的信息包含一个标题为Chained Info:的部分,这表明该函数的某些代码块位于函数主体之外。

0:000> .fnent 00000000`77c17623
Debugger function entry 00000000`03b35da0 for:
(00000000`77c55420)   ntdll! ?? ::FNODOBFM::`string'+0x2bea0   |  (00000000`77c55440)   ntdll! ?? ::FNODOBFM::`string'

BeginAddress      = 00000000`000475d3
EndAddress        = 00000000`00047650
UnwindInfoAddress = 00000000`0012eac0

Unwind info at 00000000`77cfeac0, 10 bytes
  version 1, flags 4, prolog 0, codes 0
  frame reg 0, frame offs 0

Chained info:
BeginAddress      = 00000000`000330f0
EndAddress        = 00000000`000331c0
UnwindInfoAddress = 00000000`0011d08c

Unwind info at 00000000`77ced08c, 20 bytes
  version 1, flags 1, prolog 17, codes a
  frame reg 0, frame offs 0
  handler routine: 00000000`79a2e560, data 0
  00: offs f0, unwind op 0, op info 3   UWOP_PUSH_NONVOL
  01: offs 3, unwind op 0, op info 0    UWOP_PUSH_NONVOL
  02: offs c0, unwind op 1, op info 3   UWOP_ALLOC_LARGE FrameOffset: d08c0003
  04: offs 8c, unwind op 0, op info d   
  05: offs 11, unwind op 0, op info 0   UWOP_PUSH_NONVOL
  06: offs 28, unwind op 0, op info 0   UWOP_PUSH_NONVOL
  07: offs 0, unwind op 0, op info 0    UWOP_PUSH_NONVOL
  08: offs 0, unwind op 0, op info 0    UWOP_PUSH_NONVOL
  09: offs 0, unwind op 0, op info 0    UWOP_PUSH_NONVOL

  上面Chained Info后面显示的BeginAddress指向原函数的开头。下面ln命令的输出显示,打乱的函数名实际上是ntdll!LdrpInitialize

0:000> ln ntdll+000330f0
(00000000`77c030f0)   ntdll!LdrpInitialize   |  (00000000`77c031c0)   ntdll!LdrpAllocateTls
Exact matches:
    ntdll!LdrpInitialize = <no type information>

  调试器的uf命令显示整个函数的汇编代码,给定函数内的任何地址。它通过遵循每个代码块中的jmp/jCC指令访问函数中的所有不同代码块来实现。以下输出显示了函数ntdll!LdrpInitialize的完整汇编程序列表。函数主体从地址00000000'77c030f0开始,到地址00000000'77c031b3结束。但是,有一个代码块属于地址00000000'77bfd1a4处的函数。此代码移动是BBT过程的结果。调试器尝试将此地址映射到最近的符号,并得出不正确的符号ntdll!?? ::FNODOBFM::'string'+0x2c01c,在前面的堆栈跟踪中看到。

0:000> uf 00000000`77c030f0
ntdll! ?? ::FNODOBFM::`string'+0x2c01c:
00000000`77bfd1a4 48c7842488000000206cfbff mov qword ptr [rsp+88h],0FFFFFFFFFFFB6C20h
00000000`77bfd1b0 443935655e1000  cmp     dword ptr [ntdll!LdrpProcessInitialized (00000000`77d0301c)],r14d
00000000`77bfd1b7 0f856c5f0000    jne     ntdll!LdrpInitialize+0x39 (00000000`77c03129)
.
.
.
ntdll!LdrpInitialize:
00000000`77c030f0 48895c2408      mov     qword ptr [rsp+8],rbx
00000000`77c030f5 4889742410      mov     qword ptr [rsp+10h],rsi
00000000`77c030fa 57              push    rdi
00000000`77c030fb 4154            push    r12
00000000`77c030fd 4155            push    r13
00000000`77c030ff 4156            push    r14
00000000`77c03101 4157            push    r15
00000000`77c03103 4883ec40        sub     rsp,40h
00000000`77c03107 4c8bea          mov     r13,rdx
00000000`77c0310a 4c8be1          mov     r12,rcx
.
.
.
ntdll!LdrpInitialize+0xac:
00000000`77c0319c 488b5c2470      mov     rbx,qword ptr [rsp+70h]
00000000`77c031a1 488b742478      mov     rsi,qword ptr [rsp+78h]
00000000`77c031a6 4883c440        add     rsp,40h
00000000`77c031aa 415f            pop     r15
00000000`77c031ac 415e            pop     r14
00000000`77c031ae 415d            pop     r13
00000000`77c031b0 415c            pop     r12
00000000`77c031b2 5f              pop     rdi
00000000`77c031b3 c3              ret

  已经过BBT优化的模块可以通过调试器!lmi命令输出的Characteristics字段中的perf一词来识别,如下所示。

0:000> !lmi notepad
Loaded Module Info: [notepad] 
         Module: notepad
   Base Address: 00000000ff4f0000
     Image Name: notepad.exe
   Machine Type: 34404 (X64)
     Time Stamp: 4a5bc9b3 Mon Jul 13 16:56:35 2009
           Size: 35000
       CheckSum: 3e749
Characteristics: 22  perf
Debug Data Dirs: Type  Size     VA  Pointer
             CODEVIEW    24,  b74c,    ad4c RSDS - GUID: {36CFD5F9-888C-4483-B522-B9DB242D8478}
               Age: 2, Pdb: notepad.pdb
                CLSID     4,  b748,    ad48 [Data not mapped]
     Image Type: MEMORY   - Image read successfully from loaded memory.
    Symbol Type: PDB      - Symbols loaded successfully from symbol server.
                 c:\symsrv\notepad.pdb\36CFD5F9888C4483B522B9DB242D84782\notepad.pdb
    Load Report: public symbols , not source indexed 
                 c:\symsrv\notepad.pdb\36CFD5F9888C4483B522B9DB242D84782\notepad.pdb

参数传递

  本节讨论如何将参数传递给X64函数,如何构造函数堆栈帧以及调试器如何使用此信息来遍历调用堆栈。

基于寄存器的参数传递

  在X64上,前4个参数始终通过寄存器传递,其余参数通过堆栈传递。这是调试期间比较头痛的主要原因之一,因为寄存器值往往会随着函数的执行而改变,并且很难确定传递给函数的原始参数值,在其执行过程中,除了获取参数的这一问题之外,x64调试与x86调试没有什么不同。
  下图展示了X64汇编代码,描述了调用者如何将参数传递给被调用者。

  以下调用堆栈显示了调用KERNELBASE!CreateFileW的函数kernel32!CreateFileWImplementation

0:000> kn
 # Child-SP          RetAddr           Call Site
00 00000000`0029bbf8 000007fe`fdd24d76 ntdll!NtCreateFile
01 00000000`0029bc00 00000000`77ac2aad KERNELBASE!CreateFileW+0x2cd
02 00000000`0029bd60 000007fe`fe5b9ebd kernel32!CreateFileWImplementation+0x7d
.
.
.

  从MSDN文档中,函数CreateFileW有七个参数,它的原型如下:

HANDLE WINAPI 
CreateFile(
  __in      LPCTSTR lpFileName,
  __in      DWORD dwDesiredAccess,
  __in      DWORD dwShareMode,
  __in_opt  LPSECURITY_ATTRIBUTES lpSecurityAttributes,
  __in      DWORD dwCreationDisposition,
  __in      DWORD dwFlagsAndAttributes,
  __in_opt  HANDLE hTemplateFile );

  从前面显示的调用堆栈中,包含函数KERNELBASE!CreateFileW的帧的返回地址是00000000'77ac2aad。从这个返回地址向后反汇编显示了kernel32!CreateFileWImplementation中的指令,就在调用kernel32!CreateFileW之前。指令mov rcx,rdimov edx,ebxmov r8d,ebpmov r9,rsi显示前4个参数被移动到寄存器中,为调用kernel32!CreateFileW做准备。类似地,指令mov dword ptr [rsp+20h],eaxmov dword ptr [rsp+28h],eaxmov qword ptr [rsp+30h],rax显示其余参数,即57被放到栈中。

0:000> ub  00000000`77ac2aad L10
kernel32!CreateFileWImplementation+0x35:
00000000`77ac2a65 lea     rcx,[rsp+40h]
00000000`77ac2a6a mov     edx,ebx
00000000`77ac2a6c call    kernel32!BaseIsThisAConsoleName (00000000`77ad2ca0)
00000000`77ac2a71 test    rax,rax
00000000`77ac2a74 jne     kernel32!zzz_AsmCodeRange_End+0x54fc (00000000`77ae7bd0)
00000000`77ac2a7a mov     rax,qword ptr [rsp+90h]
00000000`77ac2a82 mov     r9,rsi
00000000`77ac2a85 mov     r8d,ebp
00000000`77ac2a88 mov     qword ptr [rsp+30h],rax
00000000`77ac2a8d mov     eax,dword ptr [rsp+88h]
00000000`77ac2a94 mov     edx,ebx
00000000`77ac2a96 mov     dword ptr [rsp+28h],eax
00000000`77ac2a9a mov     eax,dword ptr [rsp+80h]
00000000`77ac2aa1 mov     rcx,rdi
00000000`77ac2aa4 mov     dword ptr [rsp+20h],eax
00000000`77ac2aa8 call    kernel32!CreateFileW (00000000`77ad2c88)

预留空间

  虽然前四个参数是通过寄存器传递的,但堆栈上仍然为这四个参数分配了空间。这称为参数预留空间 (homing space),如果函数通过地址而不是值访问参数,或者如果使用/homeparams标志编译函数,则用于存储参数值。这个预留空间的最小大小是0x20字节或四个64位插槽,即使该函数采用少于4个参数也是如此。当预留空间不用于存储参数值时,编译器使用它来保存非易失性寄存器。
  下图展示了堆栈上基于寄存器的参数的归位空间,以及函数prolog如何在此参数预留空间中存储非易失性寄存器。

  在下面的示例中,sub rsp, 20h指令显示了在堆栈上分配0x20字节的函数的prolog,这对于四个64位值来说是足够的归位空间。示例的下一部分显示函数msvcrt!malloc是一个非叶函数,因为它调用了一堆其他函数。

0:000> uf msvcrt!malloc
msvcrt!malloc:
000007fe`fe6612dc mov     qword ptr [rsp+8],rbx
000007fe`fe6612e1 mov     qword ptr [rsp+10h],rsi
000007fe`fe6612e6 push    rdi
000007fe`fe6612e7 sub     rsp,20h
000007fe`fe6612eb cmp     qword ptr [msvcrt!crtheap (000007fe`fe6f1100)],0
000007fe`fe6612f3 mov     rbx,rcx
000007fe`fe6612f6 je      msvcrt!malloc+0x1c (000007fe`fe677f74)
.
.
.

0:000> uf /c msvcrt!malloc
msvcrt!malloc (000007fe`fe6612dc)
  msvcrt!malloc+0x6a (000007fe`fe66132c):
    call to ntdll!RtlAllocateHeap (00000000`77c21b70)
  msvcrt!malloc+0x1c (000007fe`fe677f74):
    call to msvcrt!core_crt_dll_init (000007fe`fe66a0ec)
  msvcrt!malloc+0x45 (000007fe`fe677f83):
    call to msvcrt!FF_MSGBANNER (000007fe`fe6ace0c)
  msvcrt!malloc+0x4f (000007fe`fe677f8d):
    call to msvcrt!NMSG_WRITE (000007fe`fe6acc10)
  msvcrt!malloc+0x59 (000007fe`fe677f97):
    call to msvcrt!_crtExitProcess (000007fe`fe6ac030)
  msvcrt!malloc+0x83 (000007fe`fe677fad):
    call to msvcrt!callnewh (000007fe`fe696ad0)
  msvcrt!malloc+0x8e (000007fe`fe677fbb):
    call to msvcrt!errno (000007fe`fe661918)
.
.
.

  以下WinMainprolog的汇编代码片段展示了四个非易失性寄存器保存在指定为参数预留区域的堆栈上的位置。

0:000> u notepad!WinMain
notepad!WinMain:
00000000`ff4f34b8 mov     rax,rsp
00000000`ff4f34bb mov     qword ptr [rax+8],rbx
00000000`ff4f34bf mov     qword ptr [rax+10h],rbp
00000000`ff4f34c3 mov     qword ptr [rax+18h],rsi
00000000`ff4f34c7 mov     qword ptr [rax+20h],rdi
00000000`ff4f34cb push    r12
00000000`ff4f34cd sub     rsp,70h
00000000`ff4f34d1 xor     r12d,r12d

参数预留位置

  如上一节所述,所有X64非叶函数都在其堆栈帧中分配了参数归位区域。根据X64调用约定,调用者将始终使用寄存器将前4个参数传递给被调用者。当使用编译器的/homeparams标志启用参数归位时,只有被调用者的代码受到影响。在使用Windows驱动程序工具包 (WDK) 构建环境构建的二进制文件的检查调试版本中始终启用此标志。被调用者的prolog从寄存器中读取参数值并将这些值存储在堆栈中的参数预留区域中。
  下图显示了调用者的汇编代码,其中将参数值移动到相应的寄存器中。它还显示已使用/homeparams标志编译的被调用者的prolog,这会导致它将参数值按照指定预留位置到堆栈中。被调用者的prolog从寄存器中读取参数值并将这些值存储在参数预留区域的堆栈中。

  下面的代码片段展示了寄存器值被移动到由printf的调用者分配的堆栈上的预留区域。

0:000> uf msvcrt!printf
msvcrt!printf:
000007fe`fe667e28 mov     rax,rsp
000007fe`fe667e2b mov     qword ptr [rax+8],rcx
000007fe`fe667e2f mov     qword ptr [rax+10h],rdx
000007fe`fe667e33 mov     qword ptr [rax+18h],r8
000007fe`fe667e37 mov     qword ptr [rax+20h],r9
000007fe`fe667e3b push    rbx
000007fe`fe667e3c push    rsi
000007fe`fe667e3d sub     rsp,38h
000007fe`fe667e41 xor     eax,eax
000007fe`fe667e43 test    rcx,rcx
000007fe`fe667e46 setne   al
000007fe`fe667e49 test    eax,eax
000007fe`fe667e4b je      msvcrt!printf+0x25 (000007fe`fe67d74b)
.
.
.

堆栈使用

  X64函数的堆栈帧包含以下项目:

  • 调用者的返回地址。
  • 由函数 prolog 压入堆栈的非易失性寄存器。
  • 函数使用的局部变量。
  • 传递给被调用者的基于堆栈的参数。
  • 传递给被调用者的基于寄存器的参数的预留空间。

  除了返回地址之外,堆栈上的所有项目都由函数的prolog放置在那里。局部变量占用的堆栈空间、被调用者的基于堆栈的参数以及参数的预留空间都在单个sub rsp, xxx指令中分配。为基于堆栈的参数保留的空间适合具有最多参数的被调用者。基于寄存器的参数预留空间仅存在于非叶函数中。即使没有一个被调用者接受这么多参数,它也包含四个参数的空间。
  下图显示了X64 CPU上函数堆栈帧的布局。在函数prolog完成执行后,RSP寄存器指向图中所示的位置。

  调试器的knf命令显示调用堆栈以及堆栈中每一帧使用的堆栈空间量,此堆栈空间利用率列在Memory列出。

0:000> knf
 #   Memory  Child-SP          RetAddr           Call Site
00           00000000`0029bbf8 000007fe`fdd24d76 ntdll!NtCreateFile
01         8 00000000`0029bc00 00000000`77ac2aad KERNELBASE!CreateFileW+0x2cd
02       160 00000000`0029bd60 000007fe`fe5b9ebd kernel32!CreateFileWImplementation+0x7d
03        60 00000000`0029bdc0 000007fe`fe55dc08 usp10!UniStorInit+0xdd
04        a0 00000000`0029be60 000007fe`fe5534af usp10!InitUnistor+0x1d8

  下面的汇编代码片段显示了函数CreateFileWprolog,它将非易失性寄存器r8dedx保存到参数归位区,将rbxrbpesiedi压入堆栈,并为本地分配0x138字节的堆栈空间提供给要传递给被调用者的变量和参数。

0:000> uf KERNELBASE!CreateFileW
KERNELBASE!CreateFileW:
000007fe`fdd24ac0 mov     dword ptr [rsp+18h],r8d
000007fe`fdd24ac5 mov     dword ptr [rsp+10h],edx
000007fe`fdd24ac9 push    rbx
000007fe`fdd24aca push    rbp
000007fe`fdd24acb push    rsi
000007fe`fdd24acc push    rdi
000007fe`fdd24acd sub     rsp,138h
000007fe`fdd24ad4 mov     edi,dword ptr [rsp+180h]
000007fe`fdd24adb mov     rsi,r9
000007fe`fdd24ade mov     rbx,rcx
000007fe`fdd24ae1 mov     ebp,2
000007fe`fdd24ae6 cmp     edi,3
000007fe`fdd24ae9 jne     KERNELBASE!CreateFileW+0x449 (000007fe`fdd255ff)

Child-SP

  调试器的k命令显示的Child-SP寄存器的值表示堆栈指针 (RSP) 指向的地址,作为该帧中显示的函数的点,已完成其prolog的执行。将被压入堆栈的下一项将是函数调用其被调用者时的返回地址。由于X64函数不会在函数序言之后修改RSP的值,因此函数其余部分执行的任何堆栈访问都是相对于堆栈指针的此位置完成的。这包括访问基于堆栈的参数和局部变量。
  下图显示了函数f2的堆栈帧及其与堆栈k命令输出中显示的RSP寄存器的关系。返回地址RA1指向函数f2call f1指令之后的指令。此返回地址出现在RSP2指向的位置旁边的调用堆栈上。

  在下面的调用堆栈中,帧#01Child-SP的值为00000000'0029bc00。这是在CreateFileWprolog刚刚完成时执行点的RSP寄存器的值。

0:000> knf
 #   Memory  Child-SP          RetAddr           Call Site
00           00000000`0029bbf8 000007fe`fdd24d76 ntdll!NtCreateFile
01         8 00000000`0029bc00 00000000`77ac2aad KERNELBASE!CreateFileW+0x2cd
02       160 00000000`0029bd60 000007fe`fe5b9ebd kernel32!CreateFileWImplementation+0x7d
03        60 00000000`0029bdc0 000007fe`fe55dc08 usp10!UniStorInit+0xdd
04        a0 00000000`0029be60 000007fe`fe5534af usp10!InitUnistor+0x1d8
.
.
.

  如上所述,地址00000000'0029bc00之前的堆栈内容是返回地址000007fe'fdd24d76,它对应于KERNELBASE!CreateFileW+0x2cd,并通过调用ntdll!NtCreateFile被推送到那里。

0:000> dps 00000000`0029bc00-8 L1
00000000`0029bbf8  000007fe`fdd24d76 KERNELBASE!CreateFileW+0x2cd

跟踪调用堆栈

  在X86 CPU上,调试器遵循帧指针 (EBP) 链将调用堆栈从最近的函数帧遍历到最近的函数帧。调试器通常可以做到这一点,而无需访问其函数出现在堆栈上的模块的符号。但是,在某些情况下,可能会破坏此帧指针链,例如当函数省略其帧指针 (frame pointer omitted,FPO) 时。在这些情况下,调试器需要模块的符号才能准确地遍历调用堆栈。
  另一方面,X64函数不使用RBP寄存器作为帧指针,因此调试器没有可遵循的帧指针链。相反,调试器使用堆栈指针和堆栈帧的大小来遍历堆栈。调试器定位RUNTIME_FUNCTIONUNWIND_INFOUNWIND_CODE结构来计算调用堆栈中每个函数的堆栈空间利用率,并将这些值添加到Child-SP以计算后续Child-SP的值。
  下图显示了函数堆栈框架的布局。堆栈帧的总大小(或堆栈空间利用率)可以通过将返回地址的大小(8 字节)和非易失性寄存器、局部变量、基于堆栈的占用的堆栈空间量相加来计算被调用者的参数和为四个基于寄存器的参数(0x20字节)分配的归位空间。UNWIND_CODE结构表示被压入堆栈的非易失性寄存器的数量以及为局部变量和参数分配的空间量。

  在下面的堆栈跟踪中,第1帧(即CreateFileW)中函数消耗的堆栈空间量为0x160字节。下一节展示了如何计算这个数字以及调试器如何使用它来计算第2帧的Child-SP的值。请注意,第1帧中列出的函数占用的堆栈空间显示在第2帧的Memory列出。

0:000> knf
 #   Memory  Child-SP          RetAddr           Call Site
00           00000000`0029bbf8 000007fe`fdd24d76 ntdll!NtCreateFile
01         8 00000000`0029bc00 00000000`77ac2aad KERNELBASE!CreateFileW+0x2cd
02       160 00000000`0029bd60 000007fe`fe5b9ebd kernel32!CreateFileWImplementation+0x7d
03        60 00000000`0029bdc0 000007fe`fe55dc08 usp10!UniStorInit+0xdd
04        a0 00000000`0029be60 000007fe`fe5534af usp10!InitUnistor+0x1d8
.
.
.

  以下输出显示了UNWIND_CODE结构描述的操作。总共有4个非易失性寄存器被压入堆栈,并为局部变量和参数分配0x138字节。被移动数据的非易失性寄存器 (UWOP_SAVE_NONVOL),与压入堆栈的 (UWOP_PUSH_NONVOL) 不同,不会消耗堆栈空间。

0:000> .fnent kernelbase!CreateFileW
Debugger function entry 00000000`03be6580 for:
(000007fe`fdd24ac0)   KERNELBASE!CreateFileW   |  (000007fe`fdd24e2c)   KERNELBASE!SbSelectProcedure
Exact matches:
    KERNELBASE!CreateFileW = <no type information>

BeginAddress      = 00000000`00004ac0
EndAddress        = 00000000`00004b18
UnwindInfoAddress = 00000000`00059a48

Unwind info at 000007fe`fdd79a48, 10 bytes
  version 1, flags 0, prolog 14, codes 6
  frame reg 0, frame offs 0
  00: offs 14, unwind op 1, op info 0   UWOP_ALLOC_LARGE FrameOffset: 138
  02: offs d, unwind op 0, op info 7    UWOP_PUSH_NONVOL
  03: offs c, unwind op 0, op info 6    UWOP_PUSH_NONVOL
  04: offs b, unwind op 0, op info 5    UWOP_PUSH_NONVOL
  05: offs a, unwind op 0, op info 3    UWOP_PUSH_NONVOL

  将上面列出的大小相加会产生0x138 + (8*4) = 0x158字节的堆栈空间消耗。

0:000> ?138+(8*4)
Evaluate expression: 344 = 00000000`00000158

  将返回地址的大小(8 字节)与上述数字相加,总堆栈帧大小为0x160字节。这与前面显示的调试器的knf命令显示的数字相同。

0:000> ?158+8
Evaluate expression: 352 = 00000000`00000160

  参考knf命令的输出,调试器将帧大小(0x160)添加到帧#01中的Child-SP值,即00000000'0029bc00,以获得帧#02中的Child-SP值,即00000000'0029bd60

0:000> ?00000000`0029bc00+160
Evaluate expression: 2735456 = 00000000`0029bd60

  因此,可以使用RUNTIME_FUNCTIONUNWIND_INFOUNWIND_CODE结构从PE文件本身中的信息计算为每个帧在堆栈上分配的空间。因此,调试器可以遍历调用堆栈,而不需要堆栈上存在的模块的符号(公共或私有)。以下调用堆栈显示模块vmswitch,其符号在Microsoft的公共符号服务器上不可用,但不会阻止调试器准确显示调用堆栈,这是X64调用堆栈可以在没有符号的情况下的遍历示例。

1: kd> kn
 # Child-SP          RetAddr           Call Site
00 fffffa60`005f1a68 fffff800`01ab70ee nt!KeBugCheckEx
01 fffffa60`005f1a70 fffff800`01ab5938 nt!KiBugCheckDispatch+0x6e
.
.
.
21 fffffa60`01718840 fffffa60`0340b69e vmswitch+0x5fba
22 fffffa60`017188f0 fffffa60`0340d5cc vmswitch+0x769e
23 fffffa60`01718ae0 fffffa60`0340e615 vmswitch+0x95cc
24 fffffa60`01718d10 fffffa60`009ae31a vmswitch+0xa615
.
.
.
44 fffffa60`0171aed0 fffffa60`0340b69e vmswitch+0x1d286
45 fffffa60`0171af60 fffffa60`0340d4af vmswitch+0x769e
46 fffffa60`0171b150 fffffa60`034255a0 vmswitch+0x94af
47 fffffa60`0171b380 fffffa60`009ac33c vmswitch+0x215a0
.
.
.

参数获取

  在上一节中,解释了X64堆栈的内部工作原理以及有关如何解释调试器显示的堆栈跟踪输出中的每个细节的信息。在本节中,该理论将用于演示检索传递给X64函数的基于寄存器的参数的技术。不幸的是,没有通吃一切的获取参数的大招。这里的所有技术都严重依赖于编译器生成的X64汇编指令。如果参数不在“可访问的内存”中,则根本无法获取它们。调用堆栈中出现的模块和函数的私有符号也没有太大帮助。私有符号确实告诉函数采用的参数的数量和类型,但仅此而已。它不告诉那些参数值是什么。

技术概述

  本节中的讨论假定X64函数已在没有/homeparams标志的情况下编译。当使用/homeparams标志编译时,获取基于寄存器的参数是没啥作用的,因为它们保证被调用者定位到堆栈中。此外,无论函数是否使用/homeparams编译,第五个和更高编号的参数始终通过堆栈传递,因此在任何情况下获取这些参数都不应该成为问题。
  在实时调试期间,在函数开头设置断点是检索调用者传入的参数的最简单方法,因为在函数的序言期间,前4个参数保证在寄存器RCXRDX、分别为R8R9
  但是,随着函数体中的执行,参数寄存器的内容会发生变化,并且初始参数值会被覆盖。因此,要在函数执行期间的任何时候确定这些基于寄存器的参数的值,需要明确从哪里读取参数的值以及写入的参数值在哪里。可以通过在调试器中执行一系列步骤来找到这些问题的答案,这些步骤可以分为如下:

  • 确定参数是否从内存加载到寄存器中。如果是这样,可以检查内存位置以确定参数值。
  • 确定参数是否从非易失性寄存器加载,以及这些寄存器是否由被调用者保存。如果是这样,可以检查保存的非易失性寄存器值以确定参数值。
  • 确定参数是否从寄存器保存到内存中。如果是这样,可以检查内存位置以确定参数值。
  • 确定参数是否保存到非易失性寄存器中,以及这些寄存器是否由被调用者保存。如果是这样,可以检查保存的非易失性寄存器值以确定参数值。

  在接下来的几节中,将详细描述上述每一种技术,并通过示例说明如何使用它们。每一种技术都需要分解参数传递中涉及的调用者和被调用者函数。在下图中,如果要查找传递给函数f2的参数,则必须反汇编第2帧以从源中查找参数,并且必须反汇编第0帧以从其目标中查找参数。

确认参数源

  该技术涉及确定加载到参数寄存器中的值的来源。它适用于常量值、全局数据结构、堆栈地址、存储在堆栈上的值等源。
  如下图所示,反汇编调用程序 (X64caller) 显示正在加载到RCXRDXR8R9中以作为参数传递给函数X64callee的值是从可以在调试器中检查的源加载的,只要值没有改变。

  下面的示例应用此技术来查找函数NtCreateFile的第三个参数的值,如下面的调用堆栈所示。

0:000> kn
 # Child-SP          RetAddr           Call Site
00 00000000`0029bbf8 000007fe`fdd24d76 ntdll!NtCreateFile
01 00000000`0029bc00 00000000`77ac2aad KERNELBASE!CreateFileW+0x2cd
02 00000000`0029bd60 000007fe`fe5b9ebd kernel32!CreateFileWImplementation+0x7d
.
.
.

  如下所示,从函数NtCreateFile的原型来看,第三个参数的参数类型为POBJECT_ATTRIBUTES

NTSTATUS NtCreateFile(
  __out     PHANDLE FileHandle,
  __in      ACCESS_MASK DesiredAccess,
  __in      POBJECT_ATTRIBUTES ObjectAttributes,
  __out     PIO_STATUS_BLOCK IoStatusBlock,
.
.
. );

  使用#0帧中的返回地址反汇编调用程序显示以下指令。加载到R8的值,即分配给参数3的寄存器是rsp+0xc8。上面kn命令的输出显示,在调用者即KERNELBASE!CreateFileW正在执行时,RSP寄存器的值是00000000'0029bc00

0:000> ub 000007fe`fdd24d76
KERNELBASE!CreateFileW+0x29d:
000007fe`fdd24d46 and     ebx,7FA7h
000007fe`fdd24d4c lea     r9,[rsp+88h]
000007fe`fdd24d54 lea     r8,[rsp+0C8h]
000007fe`fdd24d5c lea     rcx,[rsp+78h]
000007fe`fdd24d61 mov     edx,ebp
000007fe`fdd24d63 mov     dword ptr [rsp+28h],ebx
000007fe`fdd24d67 mov     qword ptr [rsp+20h],0
000007fe`fdd24d70 call    qword ptr [KERNELBASE!_imp_NtCreateFile]

  根据上述信息手动重构加载到R8寄存器中的值会产生一个可以类型转换为OBJECT_ATTRIBUTE结构的值。

0:000> dt ntdll!_OBJECT_ATTRIBUTES 00000000`0029bc00+c8
   +0x000 Length           : 0x30
   +0x008 RootDirectory    : (null) 
   +0x010 ObjectName       : 0x00000000`0029bcb0 _UNICODE_STRING "\??\C:\Windows\Fonts\staticcache.dat"
   +0x018 Attributes       : 0x40
   +0x020 SecurityDescriptor : (null) 
   +0x028 SecurityQualityOfService : 0x00000000`0029bc68

非易失性寄存器作为参数源

  该技术涉及查找是否正在从非易失性寄存器中读取加载到参数寄存器中的值,以及是否正在将非易失性寄存器保存在堆栈中。
  下图展示了调用者(X64caller)和被调用者(X64Callee)的反汇编。 调用者调用被调用者之前的指令(左侧)显示正在加载到参数寄存器(RCX、RDX、R8 和 R9)的值正在从非易失性寄存器(RDI、R12、RBX 、R9)中读取。被调用者的prolog(图右侧)中的指令显示这些非易失性寄存器正在保存到堆栈中,可以获取这些保存的值,从而间接产生之前加载到参数寄存器中的值。

  以下示例应用此技术来查找函数CreateFileW的第一个参数的值,如下面的调用堆栈所示。

0:000> kn
 # Child-SP          RetAddr           Call Site
00 00000000`0029bbf8 000007fe`fdd24d76 ntdll!NtCreateFile
01 00000000`0029bc00 00000000`77ac2aad KERNELBASE!CreateFileW+0x2cd
02 00000000`0029bd60 000007fe`fe5b9ebd kernel32!CreateFileWImplementation+0x7d
.
.
.

  如下所示,从函数CreateFile的原型来看,第一个参数的类型是LPCTSTR

HANDLE WINAPI 
CreateFile(
  __in      LPCTSTR lpFileName,
  __in      DWORD dwDesiredAccess,
  __in      DWORD dwShareMode,
  __in_opt  LPSECURITY_ATTRIBUTES lpSecurityAttributes,
.
.
. );

  使用第1帧中的返回地址反汇编调用程序显示以下说明。加载到RCX中的值,即分配给参数1的寄存器正在从非易失性寄存器RDI中读取。下一步是查找被调用方CreateFileW是否保存EDI

0:000> ub 00000000`77ac2aad L B
kernel32!CreateFileWImplementation+0x4a:
00000000`77ac2a7a mov     rax,qword ptr [rsp+90h]
00000000`77ac2a82 mov     r9,rsi
00000000`77ac2a85 mov     r8d,ebp
00000000`77ac2a88 mov     qword ptr [rsp+30h],rax
00000000`77ac2a8d mov     eax,dword ptr [rsp+88h]
00000000`77ac2a94 mov     edx,ebx
00000000`77ac2a96 mov     dword ptr [rsp+28h],eax
00000000`77ac2a9a mov     eax,dword ptr [rsp+80h]
00000000`77ac2aa1 mov     rcx,rdi
00000000`77ac2aa4 mov     dword ptr [rsp+20h],eax
00000000`77ac2aa8 call    kernel32!CreateFileW (00000000`77ad2c88)

  反汇编被调用者会在函数的序言中显示以下说明。RDI寄存器被指令push rdi保存在堆栈中。保存的值与加载到RCX中的值相同。下一步是查找EDI保存的内容。

0:000> u KERNELBASE!CreateFileW
KERNELBASE!CreateFileW:
000007fe`fdd24ac0 mov     dword ptr [rsp+18h],r8d
000007fe`fdd24ac5 mov     dword ptr [rsp+10h],edx
000007fe`fdd24ac9 push    rbx
000007fe`fdd24aca push    rbp
000007fe`fdd24acb push    rsi
000007fe`fdd24acc push    rdi
000007fe`fdd24acd sub     rsp,138h
000007fe`fdd24ad4 mov     edi,dword ptr [rsp+180h]

  调试器的.frame /r命令在执行特定函数时显示非易失性寄存器的值。如前所述,它通过检索被调用者的序言保存的非易失性寄存器值来实现这一点。当CreateFileWImplementation调用CreateFileW时,以下命令显示EDI的值为000000000029beb0。此值可用于显示传递给CreateFile的文件名参数。

0:000> .frame /r 2
02 00000000`0029bd60 000007fe`fe5b9ebd kernel32!CreateFileWImplementation+0x7d
rax=0000000000000005 rbx=0000000080000000 rcx=000000000029bc78
rdx=0000000080100080 rsi=0000000000000000 rdi=000000000029beb0
rip=0000000077ac2aad rsp=000000000029bd60 rbp=0000000000000005
 r8=000000000029bcc8  r9=000000000029bc88 r10=0057005c003a0043
r11=00000000003ab0d8 r12=0000000000000000 r13=ffffffffb6011c12
r14=0000000000000000 r15=0000000000000000

0:000> du /c 100 000000000029beb0
00000000`0029beb0  "C:\Windows\Fonts\staticcache.dat"

识别参数目标

  该技术涉及查找参数寄存器中的值是否在函数内写入内存。当使用/homeparams编译函数时,函数的prolog将始终将参数寄存器的内容保存到堆栈上的参数归位区域。但是,对于未使用/homeparams编译的函数,参数寄存器的内容可以写入函数体中的任何位置的内存。
  下图展示了函数体的反汇编,其中寄存器RCXRDXR8R9中的参数值被写入堆栈。可以通过使用当前帧的堆栈指针的值显示内存位置的内容来确定参数。

  以下示例应用此技术来查找函数DispatchClientMessage的第三个和第四个参数的值,如下面的调用堆栈所示。

0:000> kn
 # Child-SP          RetAddr           Call Site
. 
. 
.
26 00000000`0029dc70 00000000`779ca01b user32!UserCallWinProcCheckWow+0x1ad
27 00000000`0029dd30 00000000`779c2b0c user32!DispatchClientMessage+0xc3
28 00000000`0029dd90 00000000`77c1fdf5 user32!_fnINOUTNCCALCSIZE+0x3c
29 00000000`0029ddf0 00000000`779c255a ntdll!KiUserCallbackDispatcherContinue
. 
. 
.

  函数的第三个和第四个参数分别在R8R9寄存器中。反汇编函数DispatchClientMessage并查找从R8R9到内存的任何写入,会导致指令mov qword ptr [rsp+28h], r9mov qword ptr [rsp+20h], r8指示的第三个和第四个参数被写入堆栈。这些指令不是函数序言的一部分,而是更大的函数体的一部分。请务必注意这一点,因为R8R9寄存器的值可能在写入堆栈之前已被修改。尽管在DispatchClientMessage的情况下不会发生这种情况,但在使用此技术时始终验证参数寄存器覆盖是很重要的。

0:000> uf user32!DispatchClientMessage
user32!DispatchClientMessage:
00000000`779c9fbc sub     rsp,58h
00000000`779c9fc0 mov     rax,qword ptr gs:[30h]
00000000`779c9fc9 mov     r10,qword ptr [rax+840h]
00000000`779c9fd0 mov     r11,qword ptr [rax+850h]
00000000`779c9fd7 xor     eax,eax
00000000`779c9fd9 mov     qword ptr [rsp+40h],rax
00000000`779c9fde cmp     edx,113h
00000000`779c9fe4 je      user32!DispatchClientMessage+0x2a (00000000`779d7fe3)

user32!DispatchClientMessage+0x92:
00000000`779c9fea lea     rax,[rcx+28h]
00000000`779c9fee mov     dword ptr [rsp+38h],1
00000000`779c9ff6 mov     qword ptr [rsp+30h],rax
00000000`779c9ffb mov     qword ptr [rsp+28h],r9
00000000`779ca000 mov     qword ptr [rsp+20h],r8
00000000`779ca005 mov     r9d,edx
00000000`779ca008 mov     r8,r10
00000000`779ca00b mov     rdx,qword ptr [rsp+80h]
00000000`779ca013 mov     rcx,r11
00000000`779ca016 call    user32!UserCallWinProcCheckWow (00000000`779cc2a4)
.
.
.

  使用#27帧的堆栈指针 (RSP) 的值,即00000000'0029dd30,来自上面kn命令的输出,并添加存储R8寄存器的偏移量,显示00000000'00000000,即第三个参数传递给DispatchClientMessage

0:000> dp 00000000`0029dd30+20 L1
00000000`0029dd50  00000000`00000000

  类似地,添加存储R9寄存器的偏移量显示00000000'0029de70,这是传递给DispatchClientMessage的第四个参数的值。

0:000> dp 00000000`0029dd30+28 L1
00000000`0029dd58  00000000`0029de70

非易失性寄存器作为参数目标

  该技术涉及查找参数寄存器的内容是否由所讨论的函数保存到非易失性寄存器中,然后这些非易失性寄存器是否由被调用者保存在堆栈中。
  下图显示了调用者 (X64Caller) 和被调用者 (X64Callee) 的反汇编。目的是查找传递给函数X64Caller的基于寄存器的参数的值。函数X64Caller的主体(显示在左侧)包含将参数寄存器(RCX、RDX、R8 和 R9)保存到非易失性寄存器(RDI、RSI、RBX、RBP)中的指令。函数X64Callee的序言包含将这些非易失性寄存器保存到堆栈中的指令(显示在右侧),从而可以检索它们的值,从而间接产生参数寄存器的值。

  以下示例应用此技术来查找函数CreateFileWImplementation的所有四个基于寄存器的参数的值。

0:000> kn
 # Child-SP          RetAddr           Call Site
00 00000000`0029bbf8 000007fe`fdd24d76 ntdll!NtCreateFile
01 00000000`0029bc00 00000000`77ac2aad KERNELBASE!CreateFileW+0x2cd
02 00000000`0029bd60 000007fe`fe5b9ebd kernel32!CreateFileWImplementation+0x7d
03 00000000`0029bdc0 000007fe`fe55dc08 usp10!UniStorInit+0xdd

  函数CreateFileWImplementation()的完全反汇编表明,在函数prolog之后,参数寄存器通过mov ebx,edxmov rdi,rcxmov rsi,r9mov ebp,r8d保存到非易失性寄存器中。重点检查指令直到调用下一个函数,即CreateFileW,以确定这些非易失性寄存器没有被覆盖。虽然这里没有明确显示,但这个已经通过检查CreateFileWImplementation中调用CreateFileW的所有代码路径进行验证了。下一步是反汇编函数CreateFileW的序言,以确定它是否保存了这些包含寄存器的基于堆栈的参数非易失性寄存器。

0:000> uf kernel32!CreateFileWImplementation
kernel32!CreateFileWImplementation:
00000000`77ac2a30 mov     qword ptr [rsp+8],rbx
00000000`77ac2a35 mov     qword ptr [rsp+10h],rbp
00000000`77ac2a3a mov     qword ptr [rsp+18h],rsi
00000000`77ac2a3f push    rdi
00000000`77ac2a40 sub     rsp,50h
00000000`77ac2a44 mov     ebx,edx
00000000`77ac2a46 mov     rdi,rcx
00000000`77ac2a49 mov     rdx,rcx
00000000`77ac2a4c lea     rcx,[rsp+40h]
00000000`77ac2a51 mov     rsi,r9
00000000`77ac2a54 mov     ebp,r8d
00000000`77ac2a57 call    qword ptr [kernel32!_imp_RtlInitUnicodeStringEx (00000000`77b4cb90)]
00000000`77ac2a5d test    eax,eax
00000000`77ac2a5f js      kernel32!zzz_AsmCodeRange_End+0x54ec (00000000`77ae7bc0)
.
.
.

  以下输出显示函数CreateFileW将非易失性寄存器(rbx、rbp、rsi 和 edi)保存到堆栈中,这使调试器的.frame /r命令能够显示它们的值。

0:000> u KERNELBASE!CreateFileW
KERNELBASE!CreateFileW:
000007fe`fdd24ac0 mov     dword ptr [rsp+18h],r8d
000007fe`fdd24ac5 mov     dword ptr [rsp+10h],edx
000007fe`fdd24ac9 push    rbx
000007fe`fdd24aca push    rbp
000007fe`fdd24acb push    rsi
000007fe`fdd24acc push    rdi
000007fe`fdd24acd sub     rsp,138h
000007fe`fdd24ad4 mov     edi,dword ptr [rsp+180h]

  在包含函数CreateFileWImplementation的第2帧上运行命令.frame /r会显示这些非易失性寄存器在该帧处于活动状态时的值。

0:000> .frame /r 02
02 00000000`0029bd60 000007fe`fe5b9ebd kernel32!CreateFileWImplementation+0x7d
rax=0000000000000005 rbx=0000000080000000 rcx=000000000029bc78
rdx=0000000080100080 rsi=0000000000000000 rdi=000000000029beb0
rip=0000000077ac2aad rsp=000000000029bd60 rbp=0000000000000005
 r8=000000000029bcc8  r9=000000000029bc88 r10=0057005c003a0043
r11=00000000003ab0d8 r12=0000000000000000 r13=ffffffffb6011c12
r14=0000000000000000 r15=0000000000000000
iopl=0         nv up ei pl zr na po nc
cs=0033  ss=002b  ds=002b  es=002b  fs=0053  gs=002b             efl=00000244
kernel32!CreateFileWImplementation+0x7d:
00000000`77ac2aad mov     rbx,qword ptr [rsp+60h] ss:00000000`0029bdc0={usp10!UspFreeForUniStore (000007fe`fe55d8a0)}

  根据前面显示的mov指令将非易失性寄存器映射到参数寄存器会产生以下结果。

  • P1 = RCX = RDI = 000000000029beb0
  • P2 = EDX = EBX = 0000000080000000
  • P3 = R8D = EBP = 0000000000000005
  • P4 = R9 = RSI = 0000000000000000

  尝试从X64调用堆栈中检索参数时,应用本节中讨论的四个步骤可能既耗时又麻烦。CodeMachine提供了一个调试器扩展命令!cmkd.stack -p来自动化整个过程。此命令尝试检索并显示出现在线程的X64调用堆栈上的所有函数的参数。为了在用户模式调试期间使用该命令检索任何线程的参数,请使用~s命令切换到该特定线程。同样在内核模式调试期间使用.thread命令。
  本文介绍了编译器在X64上执行的一些优化,这些优化使生成的代码与在X86上生成的代码大不相同。它讨论了X64上的异常处理机制,并展示了如何修改可执行文件格式和数据结构以支持此功能。然后讨论了如何在运行时构建X64堆栈帧,以及如何应用这些知识来检索传递给X64函数的基于寄存器的函数参数,从而克服X64上的这个痛苦障碍。

posted @ 2022-03-30 19:13  寂静的羽夏  阅读(1410)  评论(0编辑  收藏  举报