前言
又是繁忙的一月,期间总是提不起精气神端坐电脑前正儿八经的学习学习,记录记录,又伴随着天气逐渐转冷,就更别提晚上抽出时间写文章了,一股脑往被窝钻裹紧被子,刷刷手机,可谓是十分自在,现在总算能消停一会儿,就紧接着上篇文章出个番外篇,记录一下如何理解LoudSunRun中CalculateFunctionStackSize函数,也就是如何计算某一函数的栈帧大小。
在开始之前,我们照例需要了解一些基本概念。
正文
函数类型
据微软官方文档描述,函数具有两种基本类型,一种是需要开栈帧的函数,成为帧函数(Frame Function),另外一种则不需要栈帧,称为叶函数(Leaf Function)。
帧函数会分配栈帧,保存非易失性寄存器,使用异常,需要序言和尾声,使用帧指针
叶函数不会修改非易失性寄存器包括RSP,不会调用其他函数,不会分配栈帧
寄存器类型
在x64的调用约定中规定易失性寄存器RAX, RCX, RDX, R8, R9, R10, R11, XMM0-XMM5 为易失性寄存器,RBX, RBP, RDI, RSI, RSP, R12, R13, R14, R15, XMM6-XMM15为非易失性寄存器,那么非易失性寄存器为什么是非易失性寄存器,原因在于,被调用的非叶子函数在序言和尾声分别会对一些寄存器进行保存和恢复,所以在函数返回时这些函数的值是不会发生改变的,而这些寄存器就成了非易失性寄存器,相反被调用函数可能会修改某些寄存器,而这些寄存器就成了易失性寄存器。所以易失性寄存器应当由调用函数保存。
下图显示了某个64位应用的某个函数的序言部分,可以看到保存的寄存器全部为非易失性寄存器,(不就是因为保存了才叫非易失性寄存器吗:)
prolog(序言)
任何分配栈空间,调用其他函数,保存非易失性寄存器或者使用异常处理函数的都必须有序言
序言可能完成的动作包括保存参数寄存器到home address(x64下为前四个参数的预留空间),将非易失性寄存器压栈,为局部变量分配栈空间,选择性的创建帧指针(frame pointer),比如下面的lea R13, 128[RSP],R13充当帧指针。
典型的序言如下:
mov [RSP + 8], RCX
push R15
push R14
push R13
sub RSP, fixed-allocation-size
lea R13, 128[RSP]
...
Epilog(尾声)
尾声部分代码通常位于一个函数的退出位置,不同于只有一个序言,一个函数可能有多个尾声部分
尾声部分完成的动作包括“释放”序言中分配的固定大小栈空间,弹出非易失性寄存器,随后返回。
尾声的形式必须由add RSP,constant或者lea RSP,constant[FPReg] 以及随后的寄存器的出栈,返回/跳转组成。
与上述序言相对应的尾声如下所示:
lea RSP, -128[R13]
; epilogue proper starts here
add RSP, fixed-allocation-size
pop R13
pop R14
pop R15
ret
在了解基本概念之后,为了理解如何获取函数栈帧大小还是得从函数调用栈或者栈回溯说起,这实际上是对函数帧(Frame)的遍历,我们将函数帧理解为函数在运行过程中保存其运行状态的一段栈空间。当函数A调用函数B的时候,他需要保存当前状态比如局部变量到某一位置以及将下一条指令地址(返回地址)保存到栈中,以便从函数B返回时可以顺利执行剩下的指令。
众所周知在32位程序中,EBP总是指向某一函数帧的起始位置,我们通过EBP和指定偏移来访问函数的局部变量,而通过ESP来访问最后一个被压入栈中的数据。所以我们是如何通过帧指针(Frame Pointer)也就是EBP实现对栈回溯的?实际上,如果函数使用帧指针,则在函数开头我们总是能看到类似如下指令,首先将上个函数的帧指针保存在栈上,随后将帧指针指向此函数帧起始位置:
push ebp
mov ebp,esp
当函数返回时,则会恢复帧指针的值,此时EBP指向调用函数帧的起始位置,随后调用ret返回指令进行返回
pop ebp
ret
又因为返回地址总是紧随帧指针之后存储在栈上,这使得在堆栈上遍历函数调用非常容易。
当然MSVC编译器存在这样一个编译选项 /OY ,这个选项用于是否在函数中省略帧指针寄存器(“Frame Pointer Omission” (FPO).),据微软描述其不适用于x64编译器。下图中展示了x32编译器在分别启用和禁用此选项后所编译的代码之间的差别
在编译x64代码时是默认没有帧寄存器的(也就是启用了FPO),当然这仅限于windows平台的 x64代码,而对于编译ARM64代码 FPO则默认是被禁用的。所以帧寄存器的有无并不是绝对的,而是区别于平台,架构甚至是系统。
所以在windows平台上x64是如何进行堆栈展开的呢?换句话说,如何从当前帧找到上个函数的帧?
x64可执行文件中存在一个名为 .pdata的区段,区别于x32其属于x64独有区段,值的注意的是.pdata的RVA和异常目录表的RVA是相同。pdata中的数据由 多个 _IMAGE_RUNTIME_FUNCTION_ENTRY 结构体组成,具体的声明如下:
typedef struct _IMAGE_RUNTIME_FUNCTION_ENTRY {
DWORD BeginAddress;
DWORD EndAddress;
union {
DWORD UnwindInfoAddress;
DWORD UnwindData;
} DUMMYUNIONNAME;
} RUNTIME_FUNCTION, *PRUNTIME_FUNCTION, _IMAGE_RUNTIME_FUNCTION_ENTRY, *_PIMAGE_RUNTIME_FUNCTION_ENTRY;
从每个字段类型位DWORD可以看出,其表示的都是RVA,所以在使用时都需要加上模块基地址,BeginAddress代表函数的起始地址RVA,EndAddress代表函数的结束地址RVA,UnwindInfoAddress指向 _UNWIND_INFO结构体
typedef struct _UNWIND_INFO {
UBYTE Version : 3;
UBYTE Flags : 5;
UBYTE SizeOfProlog;
UBYTE CountOfCodes;
UBYTE FrameRegister : 4;
UBYTE FrameOffset : 4;
UNWIND_CODE UnwindCode[1];
/* UNWIND_CODE MoreUnwindCode[((CountOfCodes + 1) & ~1) - 1];
* union {
* OPTIONAL ULONG ExceptionHandler;
* OPTIONAL ULONG FunctionEntry;
* };
* OPTIONAL ULONG ExceptionData[]; */
} UNWIND_INFO, *PUNWIND_INFO;
Version默认为1,Flags总共包含四个值,UNW_FLAG_NHANDLER UNW_FLAG_EHANDLER UNW_FLAG_UHANDLER UNW_FLAG_CHAININFO,SizeOfProlog表示序言大小(字节),CountOfCodes代表序言操作中所有指令总共占用的”槽“数量,FrameRegister用到的帧寄存器,FrameOffset帧寄存器距离栈顶的偏移
UnwindCode表示的是 _UNWIND_CODE联合体,大小为两个字节,其声明如下:
typedef union _UNWIND_CODE {
struct {
UBYTE CodeOffset;
UBYTE UnwindOp : 4;
UBYTE OpInfo : 4;
};
USHORT FrameOffset;
} UNWIND_CODE, *PUNWIND_CODE;
CodeOffset紧跟序言的代码起始偏移,UnwindOp操作码,Opinfo对应操作码的附加操作信息。
为了更好的理解字段含义,如下借用了 TimDbg 文章中的例子,也就对是Kernel32!Module32NextW栈帧分析。
Module32NextW序言如下
KERNEL32!Module32NextW:
00007ffa`2bee1010 4c8bdc mov r11,rsp
00007ffa`2bee1013 49895b08 mov qword ptr [r11+8],rbx
00007ffa`2bee1017 57 push rdi
00007ffa`2bee1018 4883ec50 sub rsp,50h
00007ffa`2bee101c 33ff xor edi,edi
00007ffa`2bee101e 488bda mov rbx,rdx
对应的 _UNWIND_CODE 如下所示
0:000> db kernel32+98428+4 L8
00007ffa`2bf7842c 0c 34 0c 00 0c 92 08 70
第一个字节0c代表CodeOffset也就是紧跟前言的代码偏移,得到00007ffa`2bee101c。
Opcode 对应0x4代表UWOP_SAVE_NONVOL。
OpInfo对应0x3代表寄存器代号RBX。
UWOP_SAVE_NONVOL使用了两个”槽“,所以紧随其后的两个字节(0c 00)也属UWOP_SAVE_NONVOL指令,0x000c * 0x8 = 0x60表示RBX相对于栈顶的偏移,也就是在序言执行完之后rbx可以通过 [rsp + 0x60] 获得。
到这里,我们基本上了解几个堆栈展开的关键结构,接下里看看哪些UnwindOp会导致堆栈大小的变化,以便更好的理解LoudSunRun中计算堆栈大小的函数CalculateFunctionStackSize。
上面实例中,我们知道了操作码UWOP_SAVE_NONVOL,实际上除此之外还有其他八个不同的操作码,然而我们只需要了解其中会导致栈帧发生变化的几种即可,这会影响到我们计算栈帧的大小:
UWOP_PUSH_NONVOL:将某一寄存器压入栈中,操作码(Opcode)为0,需要一个”槽“,栈帧抬升8字节,操作信息(Opinfo)寄存器代号。
UWOP_ALLOC_LARGE:在栈上分配大内存,操作码(Opcode)为1,需要2-3个”槽“,最大为4G-8字节,如果操作信息(Opinfo)为0,分配的大小/8被存放在下一个”槽“中,操作信息(Opinfo)为1,分配的大小被存放在紧随其后的两个”槽“中。
UWOP_ALLOC_SMALL:在栈上分配小内存,范围8-128字节,操作码(Opcode)为2,需要一个“槽”,操作信息(Opinfo)为(分配大小-8)/8。
UWOP_SAVE_XMM128:保存XMM寄存器到栈上,操作码(Opcode)为8,需要两个”槽“,操作信息对应寄存器编号,下一个”槽“存放寄存器保存位置相对栈顶偏移 / 16,类似上面的UWOP_SAVE_NONVOL提到的0x000C
UWOP_SAVE_XMM128_FAR:保存XMM寄存器到栈上,操作码(Opcode)为9,需要三个”槽“,操作信息(Opinfo)对应寄存器编号,随后的两个槽存放未经缩放的偏移,而不再类似上面的 /8 或者 /16经过缩放的偏移。
另外如果UNWIND_INFO中的Flags具有UNW_FLAG_CHAININFO,则表示其为次要的展开信息,只是和主展开信息共享一个异常处理,可以通过如下公式获得主展开信息,获得之后再用来进行栈展开操作。
PRUNTIME_FUNCTION primaryUwindInfo = (PRUNTIME_FUNCTION)&(unwindInfo->UnwindCode[( unwindInfo->CountOfCodes + 1 ) & ~1]);
借用 深入了解x64代码 这篇文章中的一幅图,能更直观的理解每条指令会产生什么样的操作码
通过叠加不同操作码对栈帧修改的大小,最终就可以获得某个函数的栈帧大小,记得最后加上一个返回地址大小即可。
所以这基本就是CalculateFunctionStackSize做的所有操作了,当然这个函数在递归计算栈帧大小是存在问题的,大家可以思考一下。
引用
[1]:
https://www.timdbg.com/posts/writing-a-debugger-from-scratch-part-6/#fpo-speed
[2]:https://codemachine.com/articles/x64_deep_dive.html
原文始发于微信公众号(无名之):调用栈欺骗技术(番外篇)
- 左青龙
- 微信扫一扫
-
- 右白虎
- 微信扫一扫
-
评论