汇编和栈

汇编和栈

当一个函数传递了六个以上的参数时,多余的参数将通过堆栈传递。但是在堆栈上传递到底是什么意思呢?现在该通过深入研究一些“与堆栈相关的”寄存器以及堆栈中的内容,来深入探讨从程序集角度调用函数时的情况。当您进行逆向工程程序时,了解堆栈的工作方式非常有用,因为当没有可用的调试符号时,您可以帮助推断出在某个函数中正在操纵哪些参数。在下一单元中,您将使用本章中的知识在LLDB中构建命令,该命令将通过在内存中抓取函数来发现一些有趣的事情。让我们开始吧


让我们重游堆栈

正如先前在第6章“线程,框架和遍历”中所讨论的,当程序执行时,内存会被布局,因此栈从“高地址”开始并向下增长,向着低地址增长;也就是说,朝向堆。

之前说过:栈是从 高地址 -> 低地址,堆是从 低地址 -> 高地址,而Windows中栈是在堆的下方,所以Windows中内存是从 中间向两边分布 。 而Linux中 栈是在堆的上面,所以Linux中的内存是 从两边向中间分布

很迷惑吗?通过下面这个图片你可以看出栈的移动方式。

栈从高位地址开始。确切地说,它的高度取决于操作系统的内核。内核为每个正在运行的程序(每个线程)提供栈空间。

栈的大小是有限的,并且随着内存地址空间的向下增长而增加。当栈上的空间用完时,指向栈 “顶部” 的指针从最高地址向下移动到最低地址。

一旦栈达到内核给定的有限大小,或者如果栈越过了堆的边界,则称栈溢出。这是一个致命错误,通常称为栈溢出。

栈指针和基本指针寄存器

您尚未了解的两个非常重要的寄存器是RSP和RBP。栈指针寄存器RSP指向特定线程的栈头。栈的顶部将向下生长,因此将项目添加到栈时,RSP将减少。 RSP将始终指向栈的头部。下图展示了栈调用时栈指针变化的视觉效果。

在上图中,堆栈指针的顺序如下:

  • 栈指针当前指向第3帧。

  • 指令指针寄存器指向的代码调用一个新函数。堆栈指针将更新为指向Frame 4,该feame可能负责指令指针中此新调用函数中的暂存空间和数据。

  • 函数的具体执行在第4帧中完成,执行完之后指针从第四帧弹出,并继续指向第三帧.

还有一个重要的寄存器是基址指针寄存器(RBP),在执行在方法/函数内部时有多种用途,程序使用RBP的偏移量来访问局部变量或函数参数。之所以能这样是因为RBP在函数序言中的函数开始处被设置为RSP寄存器的值。

有趣的是,基本指针的之前内容在被设置为RSP寄存器的值之前就已存储在栈中。这是函数序言中发生的第一件事。由于基本指针已保存到堆栈中并设置为当前堆栈指针,因此只需知道基本指针寄存器中的值即可遍历堆栈。调试器在向您显示堆栈跟踪时会执行此操作。

请注意:某些系统不使用基本指针,而且他们在编译你的程序的时候也不会出现基础指针。 其实他们的逻辑可能是使用了其他的寄存器来当做指针寄存器。 但这意味着调试变得更加困难。

下面这个图片可以帮助解释。

当一个函数序言完成设置时,RBP的内容将指向堆栈帧下面的前一个RBP

注意:当您通过单击Xcode中的帧或使用LLDB跳到另一个堆栈帧时,RBP和RSP寄存器都将更改值以对应于新的帧! 这是可以肯定的,因为函数的局部变量是由RBP的偏移量来获取的,如果RBP不变,则您将无法向该函数打印局部变量,甚至可能导致程序崩溃。 在探索RBP和RSP寄存器时,这可能会引起混乱,因此请始终牢记这一点。 您可以通过选择不同的帧并在LLDB控制台中键入cpx $ rbp或cpx $ rsp在LLDB中对此进行验证。

那么,为什么这两个寄存器很重要? 当使用调试信息编译程序时,调试信息将引用基本指针寄存器中的偏移量以获得变量。 这些偏移量被赋予名称,与您在源代码中为变量赋予的名称相同。
编译并优化程序以进行发布时,将打包打包到二进制文件中的调试信息。 尽管删除了这些变量和参数的引用的名称,但是您仍然可以使用堆栈指针和基指针的偏移量来查找这些引用的存储位置。

堆栈相关的操作码

到目前为止,您已经了解了调用约定以及内存的布局方式,但是还没有真正探究许多操作码在x64汇编中的实际作用。 现在是时候更详细地介绍几种与堆栈相关的操作码了。

操作码 push

当需要将诸如int,Objective-C实例,Swift类或引用之类的任何内容保存到堆栈时,将使用 push 操作码。 push递减堆栈指针(请记住,因为堆栈向下增长),然后存储到新RSP指针所指向的内存地址里面。

push 指令后,最新推送的值将位于RSP指向的地址。 而先前的值应为RSP加上最近推送的值的大小—–对于64位体系结构,通常为8个字节。
要查看具体示例,请考虑以下操作码:

  • push 0x5

这将使RSP递减,然后将值5存储在RSP指向的内存地址中。 因此,用C伪代码:

RSP = RSP - 0x8 
*RSP = 0x5

操作码 pop

pop 操作码与 push 操作码完全相反。 pop从RSP寄存器中获取值并将其存储到目的地。 接下来,RSP递增0x8,还是那句话 栈是从大到小的增长,所以递增
以下是 pop 的示例:

  • pop rdx

这将RSP寄存器的值存储到RDX寄存器中,然后递增RSP寄存器。 这是下面的伪代码:

RDX = *RSP
RSP = RSP + 0x8

操作码 call

call 操作码负责执行功能。 call 将在被调用函数完成后将要返回的地址压入; 然后跳转到该函数。
想象一下内存中位于0x7fffb34df410的函数,如下所示:

0x7fffb34de913 <+227>: call   0x7fffb34df410            
0x7fffb34de918 <+232>: mov    edx, eax

当执行一条指令时,首先将RIP寄存器递增,然后执行该指令。 因此,当执行调用指令时,RIP寄存器将递增至0x7fffb34de918,然后执行0x7fffb34de913指向的指令。 由于这是一条调用指令,因此将RIP寄存器压入堆栈(就像执行了压入一样),然后将RIP寄存器设置为值0x7fffb34df410,即要执行的功能的地址。
伪代码类似于以下内容:

RIP = 0x7fffb34de918
RSP = RSP - 0x8
*RSP = RIP
RIP = 0x7fffb34df410

之后,在位置0x7fffb34df410处继续。

操作码 ret

ret 操作码与 call 相反,它从栈顶弹出栈顶值(如果程序集的push和pops匹配,它将是调用操作码推入的返回地址),然后将RIP寄存器设置为此地址。 因此,该操作可以返回到调用该函数的位置。

现在,您已经对这四个重要的操作码有了基本的了解,是时候看看它们在起作用了。确保所有 push 操作码都与您的 pop 相匹配非常重要,否则堆栈将不同步。 例如,如果没有相应的 pop 消息用于弹出,则当在函数末尾执行 ret 时将弹出错误的值。 该操作将返回到某个随机位置,甚至可能不在程序中的有效位置。幸运的是,编译器将负责同步您的 pushpop 操作码。 您只需要在编写自己的程序集时担心这一点。

在一些操作中观察RBP和RSP寄存器

现在,您已经了解了RBP和RSP寄存器以及操纵堆栈的四个操作码,现在是时候看看它们的作用了。
在Registers应用程序中,存在一个名为StackWalkthrough(int)的函数。此C函数将一个整数作为参数,并用汇编语言编写(AT&T汇编语言,记住要能够找到源操作数和目标操作数的正确位置),并且位于StackWalkthrough.s中。打开此文件,环顾四周;无需立即了解所有内容。您将在一分钟内了解其工作原理。
通过桥接标头Registers-Bridging-Header.h,Swift可以使用此函数,因此您可以从Swift调用以汇编方式编写的此方法。
现在利用这一点。
打开ViewController.swift,并在viewDidLoad()下面添加以下内容:

viewDidLoad():
override func awakeFromNib() {
super.awakeFromNib()
StackWalkthrough(5)
}

这将给StackWalkThrough传入了参数5。5仅是一个用于显示堆栈工作方式的值。
在深入研究RSP和RBP之前,最好快速了解一下StackWalkthrough中发生的事情。在StackWalkthrough函数上创建一个符号断点。

构建并运行。Xcode会在StackWalkthrough中中断。一定要通过source”查看StackWalkthrough函数(即使它是汇编)。通过源代码查看函数将显示AT&T汇编(因为它是用AT&T ASM编写的)。
Xcode将显示以下程序集:

push  %rbp       ; Push contents of RBP onto the stack (*RSP = RBP, RSP decreases)

movq  %rsp, %rbp ; RBP = RSP
movq  $0x0, %rdx ; RDX = 0
movq  %rdi, %rdx ; RDX = RDI
push  %rdx       ; Push contents of RDX onto the stack (*RSP = RDX, RSP decreases)

movq  $0x0, %rdx ; RDX = 0
pop   %rdx       ; Pop top of stack into RDX (RDX = *RSP, RSP increases)

pop   %rbp       ; Pop top of stack into RBP (RBP = *RSP, RSP increases)

ret              ; Return from function (RIP = *RSP, RSP increases)

上面的输出中已经为您添加了注释来帮助理解发生了什么。通读一遍,如果可以的话,试着理解它。您已经熟悉了mov指令,程序集的其余部分由您刚刚了解的与函数相关的操作码组成。
这个函数接受传入的整型参数(您还记得,第一个参数是在RDI中传入的),将其存储到RDX寄存器中,并将该参数压入堆栈。然后将RDX设置为0x0,然后将从堆栈中 pop 的值存储回RDX寄存器。
请确保您在心里很好地理解这个函数中发生了什么,因为接下来您将研究LLDB中的寄存器。
回到Xcode中,在ViewController.swift的awakeFromNib函数的StackWalkthrough(5)行中使用Xcode的GUI创建一个断点。保留前面的StackWalkthrough符号断点,因为在研究寄存器时,您需要在StackWalkthrough函数的开始处停止。
构建和运行并等待GUI断点触发。

现在通过 Debug\Debug Workflow\Always Show Disassembly 菜单让他以汇编形式展示,您将看到很可怕的东西:

哇! 看那个! 您已经正确进入了 call 操作码指令。 您是否想知道要输入什么功能?

从这里开始,您将逐步完成每条汇编指令,同时打印出感兴趣的四个寄存器:RBP,RSP,RDI和RDX。 为了解决这个问题,在LLDB中输入以下内容

  • (lldb) command alias dumpreg register read rsp rbp rdi rdx

这将创建命令dumpreg,它将dump四个感兴趣的寄存器。现在执行dumpreg:

  • (lldb) dumpreg

然后你将看到一些熟悉的东西

rsp = 0x00007fff5fbfe820
rbp = 0x00007fff5fbfe850
rdi = 0x0000000000000005
rdx = 0x0040000000000000

在本节中,dumpreg的输出将覆盖在每个汇编指令上,以准确显示每个指令期间每个寄存器发生的情况。 同样,即使为您提供了这些值,您自己执行和理解这些命令也很重要。
您的屏幕将类似于以下内容:

一旦跳入函数调用,请密切注意RSP寄存器,因为一旦RIP跳到StackWalkthrough的开头,它就会发生变化。 如您先前所知,RDI寄存器将包含第一个参数的值,在这种情况下为0x5。
在LLDB中,键入以下内容:

  • (lldb) si

这个命令是单步调试的命令,它告诉LLDB执行下一条指令,然后暂停调试器。
现在,您已进入StackWalkthrough。 对于每一步,再次使用dumpreg转储寄存器。

请注意RSP寄存器中的差异。 RSP指向的值现在将包含前一个函数的返回地址。 对于此特定示例,指向0x7fff5fbfe758的RSP将包含值0x100002455-awakeFromNib中紧随调用之后的地址。
现在通过LLDB进行验证:

  • (lldb) x/gx $rsp

输出将与awakeFromNib中调用操作码之后的地址立即匹配。接下来,执行si,然后执行下一条指令的dumpreg。

RBP的值被压入堆栈。 这意味着以下两个命令将产生相同的输出。 执行两个都进行验证。

  • (lldb) x/gx $rsp

这将查看栈指针寄存器所指向的内存地址。

注意:等等,我只是在没有上下文的情况下向您抛出了一条新命令。 x命令是内存读取命令的快捷方式。
/ gx表示以十六进制格式将内存格式化为一个巨大的字(8个字节,还记得第11章“汇编和内存”中的术语吗?)。
奇怪的格式是由于该命令在gdb中的常用,您看到此命令语法已移植到lldb中,从而使从调试器的转换更加容易。

现在看一下基础指针寄存器的值

  • (lldb) p/x $rbp

接下来让我继续单步调试

基础指针被分配给堆栈指针的值。 使用dumpreg以及以下LLDB命令验证两者的值相同:

  • (lldb) p (BOOL)($rbp == $rsp)

请务必在表达式两边加上括号,否则LLDB无法正确解析它。
再次执行si和dumpreg。 这次看起来像这样:

RDX 寄存器被清零了,我们继续单步调试

RDX 被设置为RDI,你可以用dumper继续验证

RDX被推入堆栈。 这意味着堆栈指针已递减,并且RSP指向一个值,该值将指向0x5的值。 确认下:

  • (lldb) p/x $rsp

这显示了指向RSP的当前值。 这意味着什么?

  • (lldb) x/gx $rsp

您将得到 0x5。 再次输入si以执行下一条指令:

RDX设置为0x0。 这里没有什么太令人兴奋的,继续前进…继续前进。 再次输入si和dumpreg:

堆栈的顶部 pop 到RDX中,您知道最近将其设置为0x5。 RSP递增0x8。 再次输入si和dumpreg:

基本指针从堆栈中 pop,并重新分配回它进入该函数时的原始值。 调用规则指定RBP在函数调用之间应保持一致。 也就是说,RBP离开职能后便无法更改为其他值,所以我们做一个好公民,恢复它的原来的值。

进入ret操作码。 注意即将更改的RSP值。 再次输入si和dumpreg:

返回地址从堆栈中pop并设置为RIP寄存器; 您知道这一点,因为您已经回到了调用该函数的位置。 然后,控制会在awakeFromNib中恢复,
哇! 那很有趣! 一个简单的功能,但是它说明了堆栈如何通过调用,推入,弹出和退出指令工作。

栈和7个以上的参数

如第10章所述,x86_64的调用规则将按顺序使用以下寄存器作为函数参数:RDI,RSI,RDX,RCX,R8,R9。 当一个函数需要六个以上的参数时,需要使用堆栈。
注意:当将大型结构传递给函数时,可能还需要使用堆栈。 每个参数寄存器只能保存8个字节(在64位体系结构上),因此,如果该结构需要8个以上的字节,则也需要在堆栈上传递该结构。 有严格的规则规定他们的调用方式,所有编译器都必须遵守。

打开ViewController.swift并找到名为executeLotsOfArguments的函数。 您在第10章中使用了此功能来浏览寄存器。 现在,您将再次使用它,以了解如何将参数7及其以后的参数传递给该函数。
将以下代码添加到viewDidLoad的末尾:

_ = self.executeLotsOfArguments(one: 1, two: 2, three: 3,
                                four: 4, five: 5, six: 6,
                                seven: 7, eight: 8, nine: 9,
                                ten: 10)

接下来,使用Xcode GUI在刚添加的行上创建一个断点。 生成并运行该应用程序,然后等待该断点出现。 您应该再次看到反汇编视图,但如果没有,请使用“始终显示反汇编”选项。

正如您在与堆栈相关的操作码一节中了解到的,call负责函数的执行。因为在RIP现在的位置和viewDidLoad的结束之间只有一个调用操作码,这意味着这个调用必须负责调用executeLotsOfArguments
但是调用前的其他指令是什么呢?让我们找出答案。
这些指令根据需要设置堆栈以传递附加参数。你有你通常的6个参数被放入适当的寄存器,正如看到的指令在哪里RIP现在,从mov edx, 0x1开始。
但是参数7和以上需要在堆栈上传递。这可以通过以下说明来完成:

0x1000013e2 <+178>: mov    qword ptr [rsp], 0x7
0x1000013ea <+186>: mov    qword ptr [rsp + 0x8], 0x8
0x1000013f3 <+195>: mov    qword ptr [rsp + 0x10], 0x9
0x1000013fc <+204>: mov    qword ptr [rsp + 0x18], 0xa

看起来很吓人,我会解释。
包含RSP和可选值的方括号表示取消引用,就像C编程中的*一样。上面的第一行说“将0x7放入RSP指向的内存地址中。”第二行说“将0x8放入RSP所指向的内存地址加0x8。”等等。
这会将值放入堆栈。但是请注意,没有使用push指令显式推送这些值,这会减少RSP寄存器。这是为什么?

嗯,如您所知,在调用指令期间,返回地址被压入堆栈。然后,在函数序言中,将基本指针压入堆栈,然后将基本指针设置为堆栈指针。
您还没有学到的是,编译器实际上会在堆栈上留出“暂存空间”的空间。也就是说,编译器根据需要在堆栈上为局部变量分配空间。
通过在函数序言中查找sub rsp,VALUE指令,可以轻松确定是否为堆栈帧分配了额外的暂存空间。例如,单击viewDidLoad堆栈框架并滚动到顶部。观察已创建多少暂存空间:

看看一个变量指向的值……它现在肯定不能保持0x1的值.为什么一个引用一个看似随机的值?
答案是由嵌入到寄存器应用程序的调试构建中的DWARF调试信息存储。你可以把这些信息转储到内存中,帮助你了解一个变量在引用什么。LLDB中输入以下

  • (lldb) image dump symfile Registers

你会得到大量的输出。搜索(Cmd + F)单词“one”;在搜索的时候加上引号。然后会有如下输出

Swift.String, type_uid = 0x300000222
0x7f9b4633a988:     Block{0x300000222}, ranges = [0x1000035e0-0x100003e7f)
0x7f9b48171a20:       Variable{0x30000023f}, name = "one", type = {d50e000003000000} 0x00007f9b4828d2a0 (Swift.Int), scope = parameter, decl = ViewController.swift:39, location =  DW_OP_fbreg(-32)

根据输出,名为execute.Int的变量位于executeLotsOfArguments中,其位置可以在DW_OP_fbreg(-32)中找到。 这个相当模糊的代码实际上意味着基本指针减去40,即RBP-32。或者以十六进制表示,RBP-0x20。

这是重要的信息。 它告诉调试器,始终可以在此内存地址中找到名为one的变量。 嗯,并非总是如此,但总是在该变量有效时(即它在范围内)。

您可能想知道为什么它不能只是RDI,因为那是将值传递给函数的地方,并且它也是第一个参数。 好了,RDI稍后可能需要在函数中重用,因此使用堆栈是更安全的选择。

调试器仍应在executeLotsOfArguments上停止。 确保您正在查看“始终显示汇编”输出并寻找汇编。 应该是第16行:

mov    qword ptr [rbp - 0x20], rdi

一旦在executeLotsOfArguments的汇编输出中找到它,就在该程序行上创建一个断点。
继续执行,以使LLDB停止在这一行汇编上。

打印一个输出

  • (lldb) po one

还是乱码。 mph
记住,RDI将包含传递给函数的第一个参数。 因此,为了使调试器能够看到应该为1的值,需要将RDI写入存储1的地址。 在这种情况下,RBP-0x20。
现在,在LLDB中执行汇编指令步骤:

  • (lldb) si
  • (lldb) po one

噢!…是的! 工作正常! 所引用的值1正确持有值0x1。
您可能想知道如果改变一个会发生什么。 好吧,在这种情况下,RBP-0x20也需要更改。 这可能是需要在其中写入该值以及在何处使用它的另一条指令。 这就是为什么调试版本比发行版本要慢得多的原因。

栈的探索

不用担心 本章即将完成。 但是,在堆栈探索中应该记住一些非常重要的要点。
如果您已经在使用函数,并且该函数已经完成了函数序言,则以下各项将适用于x64程序集:

  • RBP将指向此功能的堆栈帧的开始地方。
  • RBP将包含前一个堆栈帧的起始地址。 (在LLDB中使用x / gx $ rbp进行查看)。
  • (RBP + 0x8)将指向堆栈跟踪中前一个函数的返回地址(在LLDB中使用x / gx’$ rbp + 0x8’进行查看)。
  • (RBP + 0x10)将指向第7个参数(如果有)。
  • (RBP + 0x18)将指向第8个参数(如果有)。
  • (RBP + 0x20)将指向第9个参数(如果有)。
  • (RBP + 0x28)将指向第十个参数(如果有)。
  • RBP-X,其中X是0x8的倍数,将引用该函数的局部变量。
0%