深入X64架构(翻译)
| 本人只是原创翻译,而且翻译也不一定好,纯当锻炼。内容如果英文好的同学,建议直接去看英文原版,比较爽。
NBAOL系列2代产品是 windows平台64位的应用程序,在技术测试过程中,遇到一些crash。但是通过生成的pdb文件却无法找到崩溃的地址。后来在网上看到了X64 Deep Dive这篇文章,觉得写得非常好,也解决了我的问题。这篇文章里面的知识点需要经常拿出来研究,本人英文不太好,每次看起来非常吃力,所以决定花点时间把这篇文章翻译下,以备需要用的时候拿出来看看,内容有错误的地方欢迎大家指正。 如果文章有任何侵权的地方,请联系本人,本人会第一时间修改,谢谢!
这篇文章主要讨论X64架构体系代码执行的几个主要方面,比如编译优化、异常处理、参数传递和参数回溯以及这几个方面的内在联系。文章会介绍理解这几个概念常用的调试命令,以及理解和解释这些命令所需的背景知识,同时会揭示X64和X86 CPU体系之间的不同点,以及不同的架构体系对调试方式带来的影响。如何获取X64下函数调用的参数值一直是一个挑战,文章最后会运用这些知识去从X64调用栈当中获取基于寄存器传递的参数值。本教程会一步一步的揭示相关内容,采用图、反汇编列表和调试器输出去诠释相关要点。读者通过这篇文章可以充分理解X86 CPU 相关知识,比如寄存器、栈以及函数调用。主要内容分为四部分:编译器优化、异常处理、参数传递、参数获取。
编译器优化
本节讨论影响X64架构下影响代码生成的一些编译选项。首先介绍下X64的寄存器,然后介绍一些常用的优化,比如函数内联、尾调用消除、调用栈帧指针优化以及基于栈指针的局部变量存取。
寄存器变更
X64架构下,除了段寄存器和EFlags(状态寄存器),其他所有的寄存器都是64位的 ,也就是这些寄存器处理数据的宽度是64 bit,它们能够一次性处理64 bit的数据,所以X64是一个原生的64位处理器。X64 新增了8个新的寄存器r8-r15,不同于其他寄存器通过字母来标识,新增的8个寄存器通过数字来标识。在Windbg中,通过命令R可以显示所有寄存器的值。
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,总结如下:
非易失性寄存器(Non-volatile register)是它的内容在子程序调用过程中必须被保存的一个寄存器。如果一个程序改变了一个非易失性寄存器的值,它必须在改变这个寄存器之前在堆栈中保存旧的值和在函数返回之前恢复那个值。X64保留X86体系下的非易失性寄存器,并新增了R12-R15。在基于寄存器进行传参的调用来说,这些寄存器是非常重要的。易失寄存器是由调用方假想的临时寄存器,并要在调用过程中销毁。非易失寄存器需要在整个函数调用过程中保留其值,并且一旦使用,则必须由被调用方保存,并在调用结束之后恢复,这样对于调用者来说,原先的值保持不变,并未丢失。
- 快速调用通过寄存器进行传参。快速调用是X64架构下默认的调用约定,函数的前四个参数通过RCX, RDX, R8, R9进行传递。RAX 用来传递返回值,RCX用来传递this指针,RDX用来配合RAX,传递超长返回值。
- RBP不再作为栈帧指针,即某一调用过程在栈中的开始地址。它作为和RBX、RCX等通用寄存器一样的存在。调试器再也无法通过RBP去回溯函数调用堆栈。
- 在X86架构下, FS段寄存器用来存储线程块和内核进程控制区域。但是在X64架构下, GS寄存器在user mode的时候存储的是线程块,而在kernelmode 存储的是内核进程控制区域。WOW64 (Windows-on-Windows 64-bit)是一个Windows操作系统的子系统, 它为现有的 32 位应用程序提供了 32 位的模拟,可以使大多数 32 位应用程序在无需修改的情况下运行在 Windows 64 位版本上。所以WOW64程序,FS寄存器仍然用来存储32位程序的线程块。
Trapframe保存的都是一些系统关键的寄存器。nt!_KTRAP_FRAME 在X64架构下无法显示非易失性寄存器的有效值。如果子函数需要复写非易失性寄存器的值,该函数的前置指令会保存即将被重写的寄存器的值至栈当中,调试器可以栈当中获取这些寄存器的值,无需通过 the trap frame去获取。X64 kernel模式的debugging, 如下图所示,.trap 会给出提示,通过该命令获取的寄存器是不准确的,当然也有一些例外,比如从user mode 转换为Kernel mode的trap frames 是能够正确获取所有寄存器的值。
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特有的,但是X64非常热衷于使用内联函数。内联的优势是无需设置栈、直接切换至被调用函数以及返回调用者。当然内联会对代码进行复制,生成的可执行文件会变大,函数的扩展会导致Cache miss,从而引起更多的Page faults。在调试的时候,如果对一个编译器选择为内联的函数设置断点的时候,调试器无法找到该内联函数对应的symbol。源文件基本的内联控制可以通过编译选项 /OB来控制。编译器的内联可以通过__declspec(noinline)来禁用。图1显示了Function2和Function3在Fuction1内被内联化。
Figure 1 :Function In-lining
尾调用消除
X64编译器会将函数的最后一个函数调用替换为一个到被调用函数的跳转指令,以此来减少被调用函数栈帧的开销。调用者和被调用者共享同一个栈帧,被调用者直接返回调用者的调用者。但调用者和被调用者具有相同参数的时候,这种方式好处是非常明显的,因为相关参数已经在寄存器当中进行设置,而且没有变更,无需重新加载。 图2显示了在Function1内调用Function4所进行的尾调用消除。Function1直接跳转至Function4, 但Function4执行完毕,它直接返回调用者Function1。
Figure 2 : Tail Call Elimination
框架指针省略
X86 CPU体系采用EBP作为栈顶,用来获取当前调用栈当中的参数和局部变量,而X64 并不会利用RBP实现同样的功能,即RBP不会存放栈帧地址,更多的内容会在下个主题进行介绍。所以在X64下,RBP从它作为栈管理的职责解放出来,做为一个通用寄存器。有一种特殊情况,有些函数利用alloca()来动态分配栈空间,这些函数利用RBP作为栈帧,就像EBP寄存器在X86体系下所做的事情一样。在X86体系下,ESP在函数运行时会不断的变化,所以在进入某个函数的时候保持ESP到EBP中会方便程序员访问参数和局部变量,而且还方便调试器分析函数调用过程中的堆栈情况。EBP不是必须要有的,使用esp来访问函数参数和局部变量也是可行的,只是会很麻烦。
下图是X86 函数 KERNELBASE!Sleep的汇编代码。EBP被作为栈顶。SleepEX是参数被压入堆栈,然后通过Call指令进行调用。
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进行任何的存储、恢复和设置操作。栈帧指针被省略了,所以没有任何关于被调用者栈帧的设置。事实上Sleep()和SleepEx()最终采用相同的栈帧,一个典型的尾调用优化的例子。
0:000> uf KERNELBASE!Sleep KERNELBASE!Sleep: 000007fe`fdd21140 xor edx,edx 000007fe`fdd21142 jmp KERNELBASE!SleepEx (000007fe`fdd21150)
在X86下, EBP(扩展基址指针寄存器(extended base pointer) 其内存放一个指针,该指针指向系统栈最上面一个栈帧的底部)最大的作用就是用来获取参数和局部变量。前面说过,在X64下,RBP并不会指向当前函数的栈帧。所以,X64下RSP寄存器既要做为栈指针,又要做为栈帧的指针,所有栈的操作都基于RSP来执行。因此,X64函数依赖于RSP寄存器在整个函数体内都保持不变,用来做为栈帧的索引来获取局部变量和参数。因为Push 和Pop指令会改变RSP指针,X64函数将Push和Pop操作限制在函数的前置调用和尾端调用之内。如此一来,RSP会在函数前置端和尾端调用之间保持不变。如图3所示,函数的前置调用和尾端调用是X64函数的一大特色。
Figure 3 : Static Stack Pointer
下面是user32!DrawTestExW完整的汇编代码。该函数的前置代码以"sub rsp, 48h"结束,尾端调用以"add rsp, 48h"开始。因为在两者之间如果需要操作栈的话,需要用到RSP寄存器,可以发现在函数体内没有任何Push和Pop操作。
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
RUNTIME_FUNCTION结构
X64可执行文件格式是X86PE文件格式的一个变种,叫做PE32+。这种文件格式多了一个叫做".pdata"或者异常目录的节来保存异常处理需要的相关信息。异常目录为可执行文件内每一个非叶子函数建立一个Runtime_Function结构,非叶子函数是指那些会在函数体内调用其他函数的函数。每一个Runtime_Function结构包含对应函数第一个指令和最后一个指令的偏移,以及描述函数调用栈在发生异常时展开信息结构体的指针。图4 Module模块的Runtime_Fuction包含所有函数的开始和结束偏移。
Figure 4 : RUNTIME_FUNCTION
下面的汇编代码显示X86和X64异常处理的一些不同点。在X86下,当高级语言(c/c++)存在像__try/__except结构化的异常处理块的时候,编译器会在函数始端和末端产生一些特殊代码,这些代码用来在函数运行时在栈当中构建一个异常处理帧,如下面代码ntdll!_SEH_prolog4 and ntdll!_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的函数,从汇编代码无法发现函数使用结构化异常的迹象,因为没有基于栈的运行时异常处理代码帧。RIP寄存器当中指令对应的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)
回溯信息和回溯代码
Runtime_Function当中的BeginAddress 和 EndAddress分别包含了函数起始地址和结束地址相对于模块基地址在虚拟内存当中的偏移。当函数发生异常的时候,操作系统扫描PE文件镜像当中寻找包含当前指令的的Runtime_Function结构, Runtime_Function结构包含了Unwind_Info结构体的指针,Unwind_Info告诉操作系统如何进行栈展开。在运行时期间从函数调用栈中删除函数实体,称为栈展开。栈展开通常用于异常处理。在C++中,如果一个异常发生了,会线性的搜索函数调用栈,来寻找异常处理者,并且带有异常处理的函数之前的所有实体都会从函数调用栈中删除。所以,如果异常没有在抛出它的函数中被处理,则会激活栈展开。Unwind_Info包含了一系列的Unwind_Code结构体,这些结构体能够还原函数始端代码对栈所进行的操作,其实就是去除当前函数体,使其对上层调用者无影响。
对于动态生成的代码, 操作系统提供了RtlAddFunctionTable() 和RtlInstallFunctionTableCallback(),用来在运行时创建Runtime_Function信息。
图5展示了Runtime_Function和Unwind_Info直接的关系,以及函数在内存中的位置。
Figure 5 : Unwind Information
.Fnet命令显示了指定函数对应的Runtime_Function结构。下面展示了ntdll!RtlUserThreadStart 函数对应的”.fnet”输出。
0:000> .fnent ntdll!RtlUserThreadStart Debugger function entry 00000000`03be6580 for: (00000000`77c03260) ntdll!RtlUserThreadStart | (00000000`77c03290) ntdll!RtlRunOnceExecuteOnce Exact matches: ntdll!RtlUserThreadStart = 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加上ntdll(RtlUserThreadStart在该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结构的BeginAddress和EndAddress用来定位内存中对应的异常处理函数。当然,这不是必须的,可以在链接的时候运用该信息进行优化,这种优化会改变我们上面的看法,待会儿再讨论。
虽然Unwind_Info和Unwind_Code的主要作用是在发生异常的时候进行栈展开,但是调试器也可以利用该信息在没有模块symbols的时候遍历堆栈。每一个Unwind_Code对应着函数始端函数的一项操作。
- SAVE_NONVOL – 保存非易失性寄存器的值至堆栈。
- PUSH_NONVOL – 将非易失性寄存器的值压栈。
- ALLOC_SMALL –在栈上分配空间(最多128字节).
- ALLOC_LARGE – 在栈上分配空间(最多4G).
从本质上讲,Unwind_Code是函数前置函数的元数据表示。
图6展示函数前置函数执行的栈相关的操作以及这些操作对应的Unwind_Code结构。Unwind_Code结构出现的顺序和它对应的前置指令出现顺序相反。因为当异常发生的时候,栈展开的顺序与其创建顺序是相反的。
Figure 6 : Unwind Code
下面是X64系统自带的Notepad.exe的”.pdata”节的信息。"virtual address"字段显示.pdata节在相对于EXE文件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.exe的Unwind_Info和Unwind_Code相关的信息。Unwind_Code描述了函数前端执行的PUSH_NONVOL 或者 ALLOC_SMALL,这些操作必须在栈展开的时候被回退。”.fnet” 命令显示这些结构体的信息,但是"link -dump -unwindinfo"展示的是Unwind_Code所有的信息,而”.Fnet”只显示了部分的信息。
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 代表着函数前端当中的sub指令,该指令分配了0x28字节的栈空间。PUSH_NONVOL 代表着函数前端指令当中的push操作,该操作将非易失寄存器的内容保存至栈上,然后在函数尾端指令中通过pop操作恢复。如下图所示:这些指令在函数反汇编0x1234偏移处。
0:000> ln notepad+1234 (00000000`ff971234) notepad!StringCchPrintfW | (00000000`ff971364) notepad!CheckSave Exact matches: notepad!StringCchPrintfW = notepad!StringCchPrintfW = 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操作系统的二进制文件受到一种叫做BBT的PGO技术的影响。 PGO技术是先对程序最常用的代码和函数进行分析,然后利用分析结果对最常用的代码进行优化,谋求最佳的优化效果。而BBT是编译器处理代码片段的最小单元,没有别的分支,只有唯一的入口和出口,BBT会增加代码的空间局部性。空间局部性(Spatial Locality)是指在最近的将来将用到的信息很可能与现在正在使用的信息在空间地址上是临近的。有可能会经常执行的代码会保持在一起,尽量在一个页空间内,而其他不经常执行的代码放置到其他地方。这会减少常用代码在运行时所需的内存页的数量,进而减少其所需的物理内存的大小。为了采用这种优化技术,程序会被链接、执行然后分析,最后会根据分析数据当中程序的执行频率重新安排函数的地址。
最终生成的函数中,一些函数代码被移动到了预先由Runtime_Function扩展定义的函数主体外面,由于代码块的移动,函数主体被分为若干独立的部分,导致链接器预生成的Runtime_Function结构体再也无法精确识别这些函数的扩展。为了定位这些问题,BBT程序为每一个邻近的还有优化函数的代码块生成一个Runtime_Function结构。这些Runtime_Function被链在一起,结构链会停止在BeginAddress指向函数开始地方的Runtime_Function结构。
图7展示了有三个基本块组成的函数。当采用BBT只有 Block#2从函数主体内移出,导致原先的Runtime_Function失效。BBT 会创建一个新的Runtime_Function结构然后链到第一个Runtime_Function结构,用来描述整个函数。
Figure 7 : Performance Optimization : Basic Block Tools
当前版本调试器不会遍历链上所有的Runtime_Function结构,因此调试器无法正确展示一个优化函数的名字,该优化函数的返回地址与移动到主函数体外面代码块相对应。
下图调用堆栈当中的函数就无法正确显示函数名。函数名会显示为
"ntdll! ?? ::FNODOBFM::`string'。调试器错误的将返回地址为0x0000000077c17623的帧0x0c翻译为"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_Function,Unwind_Info以及Unwind_Codes。如果有"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 UWOP_PUSH_NONVOL 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”后面的BeingAddress显示的是原始函数的起始地址。”ln”命令显示原型函数的名字是ntdll!LdrpInitialize.
0:000> ln ntdll+000330f0 (00000000`77c030f0) ntdll!LdrpInitialize | (00000000`77c031c0) ntdll!LdrpAllocateTls Exact matches: ntdll!LdrpInitialize =
“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"命令的输出加以识别,在"!lmi"命令输出的"Characteristics"字段中会含有"perf"。
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的调试和X86的调试不存在任何差异。
图8的汇编代码描绘了参数如何从调用者传递给被调用者。
Figure 8 : Parameter Passing on X64
下面的堆栈展示的是kernel32!CreateFileWImplementation调用KERNELBASE!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 .
通过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!CreateFileW 之前调用kernel32!CreateFileWImplementation。”mov rcx,rdi", "mov
edx,ebx", "mov r8d,ebp", "mov r9,rsi"这四个指令将kernel32!CreateFileW的前四个参数放入寄存器,"mov dword ptr [rsp+20h],eax",
"mov dword ptr [rsp+28h],eax" and "mov qword ptr
[rsp+30h],rax"这四个指令将剩余的四个参数放入堆栈。
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)
归位空间
虽然函数的前四个参数通过寄存器来传递,X64仍然会在栈上预留这四个参数的空间,这空间叫做参数归位空间(我自己定义的,不知道业界纯正的翻译是什么,将就着能用),这些空间在用来存储参数,一种是在函数通过地址传递而非传值,或者函数是通过/homeparams来编译。归位空间的最小大小是32个字节或者64位的slots,就算函数少于4个参数也是如此。当归位空间未用来存储参数的时候,编译器用他来存储非易失性寄存器。
图9显示的是基于寄存器参数的栈上的归位空间以及函数的前端指令将非易失性寄存器的值保存至归位空间。
Figure 9 : Parameter Homing Space
下面的例子中,函数前置指令中的"sub rsp, 20h"在栈上分配了32个字节,这对于4个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)
下面关于Winmain前置指令的汇编代码展示了四个非易失性寄存器的值被存储在栈上的参数归位空间。
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的任意一种调用约定,调用者会通过寄存器来传递前四个参数。参数归位可以通过编译选项/homeparams开启,该选项只会影响被调用者。该选项在采用Windows开发包环境构建出来的验证或者调式版本的二进制文件中默认开启。被调用者从寄存器当中读取参数的值,然后将其保存至栈上的归位空间。
图10的汇编代码显示调用者将参数移至特定的寄存器。被调用者由于开启了/homeparams选项,会将参数值归位至栈空间。被调用者的前置指令读取从寄存器当中读取参数的值,然后将其保存至栈上的归位空间。
Figure 10 : Parameter Homing
下面的代码片段展示了寄存器的值被移动到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
stene al的意思如下:
if ZF=1 then al=0
if ZF=0 then al=1
al是ax寄存器的低8位,ah是ax寄存器的高8位
000007fe`fe667e49 test eax,eax 000007fe`fe667e4b je msvcrt!printf+0x25 (000007fe`fe67d74b)
栈的使用说明
X64函数的栈帧包含下面的元素:
- 函数返回地址
- 用来将非易失性寄存器的值放入堆栈的函数前置指令
- 函数的局部变量
- 基于栈的参数传递
- 基于寄存器传递的参数的归位空间
除了函数返回地址,其他的元素均通过函数前置指令来放置。局部变量、被调用者的栈参数以及参数的归位空间都通过"sub rsp, xxx"来分配。基于栈的参数预留空间为了满足被调用者采用了最大参数来进行设置。而基于寄存器的参数归位空间仅仅是针对非叶子函数。就算被调用的函数少于四个参数,但是栈上仍然会预留四个参数的归位空间。
图11展示了X64 CPU体系的函数栈帧分布。图中RSP的位置是函数执行完前置指令的状态。
Figure 11 : Stack Usage
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
下面的汇编代码展示了CreateFileW的前置指令,首先将R8D和EDX放入参数的归位空间,然后将rbx,rbp,esi,edi入栈,再为自己的局部变量和参数分配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)
子函数栈空间的起始地址
"k"命令输出的Child-SP寄存器的值代表了当前栈帧RSP的值,此时函数已经执行完它的前置指令。下一个入栈的值应该就是函数调用之后的返回地址。因为X64函数在执行完其前置指令之后就不会修改RSP的值,所以之后的任何栈的操作都可以通过与RSP的相对地址来寻址,比如栈当中的参数和局部变量的访问。
图12显示了f2的栈帧以及其和“k”命令输出的RSP值之间的关系。返回地址RA1是函数f2中调用f1的下一条指令,当f1返回之后,就会执行f2当中的该指令。调用栈中的这个返回地址紧挨着RSP2。
Figure 12 :Relationship between Child-SP and function frames
下图的堆栈中,frame #01的Chind-SP是00000000`0029bc00,该值是CreateFileW()的前置指令执行完毕之后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 前面的内容就是和KERNELBASE!CreateFileW+0x2cd一致的函数返回地址, 该地址是在调用ntdll!NtCreateFile的时候压入堆栈的。
0:000> dps 00000000`0029bc00-8 L1 00000000`0029bbf8 000007fe`fdd24d76 KERNELBASE!CreateFileW+0x2cd
栈遍历
在X86体系下, 调试器利用栈帧链去获取整个调用栈,从栈底到栈顶,无需获取函数所处模块的符号文件。但是这种栈帧指针链在某些情况下会失效,比如函数经过框架指针优化的情况。在这种情况下,编译器需要模块的符号来遍历整个堆栈。
然而X64函数不使用RBP寄存器作为栈帧指针,所以调试器无法使用该指针来遍历堆栈。相对应的,调试器采用栈指针和栈帧的大小来遍历堆栈。调试器通过Runtime_Function,Unwind_Info和Unwind_Codeto计算调用堆栈中每一个函数使用的栈空间的大小,将这些值与当前的Child-SPs进行累加用来计算随后的Child-SPs。
图13显示了函数栈帧的分布。当前栈帧的大小可以通过将返回地址、非易失性寄存器、局部变量、基于栈传递参数空间大小、以及基于寄存器传递的参数的归位空间的大小累加得到。Unwind_Code结构用来描述入栈的非易失性寄存器的个数以及分配给局部变量和参数的空间。
Figure 13 : Walking the x64 call stack
在下面的栈回溯中,Frame#1 CreateFileW消耗的栈空间是0x160字节。下一节会讨论如何计算消耗的栈空间以及调试器如何利用这个大小去计算Frame#2的Child-SP。请注意Frame#1当中函数消耗的栈空间显示在Frame#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对应的操作。有四个非易失性寄存器进行了入栈操作,而且为局部变量和参数分配了0x138字节的栈空间。被移动的非易失性寄存器的值,与入栈操作相反,不会使用任何的栈空间。
0:000> .fnent kernelbase!CreateFileW Debugger function entry 00000000`03be6580 for: (000007fe`fdd24ac0) KERNELBASE!CreateFileW | (000007fe`fdd24e2c) KERNELBASE!SbSelectProcedure Exact matches: KERNELBASE!CreateFileW = 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
将上面所有使用的空间大小进行累加,最终的栈空间消耗是0x158bytes.
0:000> ?138+(8*4) Evaluate expression: 344 = 00000000`00000158
将上面的大小加上返回地址所占的空间就是栈的大小0x160 bytes,这个与前面”knf”显示的大小是一致的。
0:000> ?158+8 Evaluate expression: 352 = 00000000`00000160
根据”knf”的输出,调试器将Frame#01的Child-SP的值00000000`0029bc00 加上栈帧大小0x160最终得到Frame#02的值00000000`0029bd60.
0:000> ?00000000`0029bc00+160 Evaluate expression: 2735456 = 00000000`0029bd60
所以栈的每一帧的大小我们可以通过PE文件当中的Runtime_Function、Unwind_Info、Unwind_Code结构体进行计算,所以调试器可以不依赖于符号文件对堆栈进行遍历。下面的显示了”vmswitch”模块的堆栈信息,虽然该模块的符号信息无法从官网上获取,但是编译器仍然可以遍历栈中该模块的调用信息,从而得出一个结论,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,第五或者更多的参数会直接采用栈来传递,所以获取基于栈传递的参数在任何情况下都不应该存在问题。
现场调试过程中,在函数的开始处设置断点是获取函数参数的最简单的方法,因为在函数的前置指令执行期间,RCX、RDX、R8、R9分别存储的前四个参数都是有效的。然而代码执行到函数体内的时候,参数寄存器的值被改变,导致初始值被复写了。所以,在函数执行期间去查找基于寄存器传递的参数值的时候,必须搞清楚-参数值是从何处读取的,被写到什么地方去了。这些问题的答案可以通过执行调试器的指令序列得到,总结如下:
- 确认参数是否是从内存加载进入寄存器的,如果是,通过这些内存地址可以发现参数值。
- 确认参数是否从非易失性寄存器读取的,并且这些寄存器的值被被调用的函数保存。如果是这样,被保存的非易失性寄存器的值可以用来决定参数值。
- 确认参数是否从寄存器保存至内存,如果是这样,参数可以从内存当中进行获取。
- 如果参数被保存入非易失性寄存器,这些寄存器的值被函数保存了。如果是这样的话,参数的值可以通过被保存的非易失性寄存器的值来决定。
在接下来的几节中,会通过示例来展示每一种技巧的用法。每一种技术都需要反汇编参数传递经过的调用和被调用函数。图14中,为了找到函数F2的参数,Frame2需要被反汇编来查找参数的来源,而Frame0需要被反汇编来定位参数的去处。
Figure 14 : Finding Register Based Parameters
确定参数的来源
这种技术用来定位写入参数寄存器的值的来源,包括不限于常量值,全局数据结构、栈的地址、栈当中存储的值。
如图15所示,反汇编调用函数(X64Caller)将寄存器当中的值放入RCX,RDX,R8和R9 作为X64Callee的参数,只要寄存器当中的值没变,就可以通过这些寄存器来确定参数的来源。
Figure 15 : Identifying parameter sources
下面的示例通过这种技术来查找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, );
对Frame#0的返回地址进行反汇编发现,函数的第三个参数R8寄存器中的值是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
非易失寄存器做为参数来源
如果参数寄存器的值来源于非易失性寄存器,而非易失性寄存器的值被保存在了栈上,那么可以采用下面的技术来定位参数值。
图16显示了调用函数(X64caller)和被调用者(X64Callee)的反汇编。X64Caller调用X64Callee前面的指令表明参数寄存器(RCX,RDX,R8,R9)的值来源于非易失性寄存器(RDI,R12,RBX,RBP). 右边图中X64Callee的前置指令将非易失性寄存器的值保存至栈上,这些值可以被找到,其实就是原先被写入参数寄存器的值。
Figure 16 : Non-Volatile Registers as parameter sources
下面的例子采用上面的技术来查找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, . . . );
反汇编Frame#1当中的函数返回值得到如下指令。参数1对应的寄存器RCX的值来自非易失性寄存器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)
反汇编CreateFileW可以查看该函数的前置指令。RDI寄存器的值同步“push rdi”保存至栈中,该值和参数寄存器ECX当中的值应该是保持一致的,下一步就是要从栈当中找到RDI的值。
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来编译的时候,函数前置指令默认会将参数寄存器的值写入栈上的参数归位空间。然而当函数未采用/homeparams来编译的时候,参数寄存器的内容可能会在函数体内被写入任何地址。
图17中函数将寄存器RCX,RDX,R8,R9的值写入了栈空间。参数值可以通过当前帧的栈指针的值定位的内存空间的值来定位。
Figure 17 : Identifying parameter destinations
下面的例子通过上面的技术来确定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
DispatchClientMessage的第三和第四个参数分别位于寄存器R8和R9当中,反汇编该函数,找到任何将R8或者R9寄存器内容写入内存的操作,会发现有如下两个指令"mov qword ptr [rsp+28h], r9"
and "mov qword ptr [rsp+20h], r8",表明寄存器的值被写入栈中。这些指令不是函数的前置指令,但却在整个大函数体中。需要关注的是,R8和R9的值在被写入寄存器之前是否被修改。虽然对于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)
从上面”kn”的输出,发现Frame#27的栈指针(RSP)的值是00000000`0029dd30, 加上偏移得到R8寄存器的是0000000`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
非易失性寄存器作为参数最终目的地
该技术讨论的是参数寄存器的内容被函数被保存至非易失性寄存器中,随后该寄存器的值被函数保存至栈中的情况。
图18显示的是调用者(X64Caller)和被调用者(X64Callee)。基于寄存器的参数的值被传递给函数X64Caller.X64Caller包含将参数寄存器(RCX,RDX,R8,R9)的值保存至非易失性寄存器(RDI,RSI,RBX,RBP).X64Callee的前置指令将这些非易失性寄存器的值保存至栈中,这样的话可以方便的获取他们的值,再由这些值可以轻松的得到参数寄存器的值。
Figure 18 : Non-Volatile Registers as Parameter Destinations
下面的例子采用这个技术来查找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()的反汇编代码可以发现,在函数前置指令执行之后,"mov ebx,edx", "mov rdi,rcx", mov rsi,r9" 和 "mov ebp,r8d"将参数寄存器的值保存入非易失性寄存器。必须检查到下一个函数调用之前的指令,确保非易失性寄存器的值没有被复写。虽然这里没有显示表明,这条规则已经通过检查从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 and 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() 的frame#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提供了一个扩展命令(这里注意,这个扩展命令我尝试使用过,貌似返回值还是错误的,有兴趣的同学可以尝试)可以自动完成这些操作,该命令尝试获取并显示线程栈当中所有函数的参数。在Usermode调试的时候,如果想获取指定线程的参数,可以通过"~s"命令来切换线程,这与在Kernel mode调试的时候采用".thread"来切换线程是类似的。
本文涵盖了X64编译器的优化功能,这些优化使得其与X86存在着极大差异。介绍了X64的异常处理机制,并详细解释了可执行文件格式和数据结构是如何来支持该特性的。讨论了X64是如何在运行时建立栈帧的,并利用该理论去获取函数基于寄存器传递的参数值,并最终克服了X64体系参数寻值的这一顽疾。
参考文献:
1、 http://www.codemachine.com/article_x64deepdive.html
2、 http://blog.csdn.net/xbgprogrammer/article/details/38752651
3、 http://blog.csdn.net/xbgprogrammer/article/details/45220885
4、 http://blog.csdn.net/woxiaohahaa/article/details/50564517
5、 框架指针省略FPO
https://www.cnblogs.com/awpatp/archive/2009/11/04/1595988.html