第13章:汇编和栈

在x86_64中,当有超过6个参数传入一个函数时,多余的参数会通过栈传递(有的情况下不是这样的,但一次一个,小蚱蜢)。但是通过栈传递到底是什么意思呢?现在是时候通过探索一些 "与栈有关的 "寄存器以及栈中的内容,从汇编的角度更深入地了解函数被调用时发生了什么。

了解栈的工作原理在你进行逆向工程时是非常有用的,因为在没有调试符号的情况下,你可以帮助推断出某个函数中正在操作哪些参数。

让我们开始吧。

重新审视栈

正如之前在第6章 "线程、框架和Stepping Around"中所讨论的,当一个程序执行时,内存的布局是这样的:栈从一个 "高地址 "开始,然后向下增长,朝向一个较低的地址;也就是说,朝向堆。

注意:在一些架构中,栈是向上增长的。但是对于x64和ARM的iOS设备,也就是你所关心的那两个设备,栈都是向下增长的。

感到困惑吗?这里有一张图片来帮助澄清栈的移动方式。

栈从一个高的地址开始。具体多高,是由操作系统的内核决定的。内核给每个正在运行的程序(嗯,每个线程)提供栈空间。

栈的大小是有限的,通过在内存地址空间中向下增长来增加。当栈的空间被用完时,栈 "顶部 "的指针就会从最高地址下移到最低地址。

一旦栈达到了内核给定的大小,或者超过了堆的界限,栈就会被称为溢出。这是一个致命的错误,通常被称为栈溢出。现在你知道你最喜欢的网站的名字是怎么来的了!

栈指针和基指针寄存器

有两个非常重要的寄存器你还没有了解到,那就是RSP和RBP。栈指针寄存器,RSP,指向特定线程的堆头。栈的头部将向下增长,所以当项目被添加到栈时,RSP将递减。RSP将始终指向栈的头部。

下面是一个函数被调用时栈指针变化的视觉效果。

在上面的图片中,栈指针的顺序如下。

  1. 栈指针目前指向第3帧。

  2. 指令指针寄存器所指向的代码调用一个新的函数。栈指针得到更新,指向一个新的帧,即第4帧,该帧有可能负责从指令指针的这个新调用的函数内部的scratchspace和数据。

  3. 执行在第4帧中完成,控制权重新回到第3帧中。栈指针之前对第4帧的引用被弹出,重新指向第3帧。

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

这里有趣的是,在基指针被设置为RSP寄存器的值之前,基指针之前的内容被保存在栈中。这是函数序言中发生的第一件事。由于基指针被保存在栈中并被设置为当前的栈指针,你可以通过了解基指针寄存器的值来遍历栈。调试器在向你显示栈跟踪时就是这样做的。

注意:有些系统不使用基指针,可以在编译应用程序时省略基指针的使用。其逻辑是有一个额外的寄存器可以使用,这可能是有益的。但这意味着你不能轻易地解开栈,这使得调试更加困难。

是的,绝对需要一个图像来帮助解释。

当一个函数的序幕设置完成后,RBP的内容将指向比它低一个栈帧的前一个RBP。

注意:当你通过点击Xcode中的一帧或使用LLDB跳到一个不同的栈帧时,RBP和RSP寄存器的值都会改变,以对应新的帧!这是在意料之中的,因为Xcode中的局部变量是不允许的。这是预期的,因为一个函数的局部变量使用RBP的偏移量来获得它们的值。

如果RBP没有变化,你将无法打印该函数的局部变量,程序甚至可能崩溃。这可能会导致在探索RBP&RSP寄存器时出现混乱的源头,所以要时刻记住这一点。你可以在LLDB中通过选择不同的帧并在LLDB控制台中输入cpx $rbp或cpx

$rsp来验证这一点。

那么,为什么这两个寄存器是需要学习的呢?当一个程序被编译为调试信息时,调试信息引用基指针寄存器的偏移量来获得一个变量。这些偏移量被赋予名称,与你在源代码中给你的变量的名称相同。

当一个程序被编译和优化后发布时,打包在二进制文件中的调试信息被删除。虽然这些变量和参数的引用名称被删除,但你仍然可以使用栈指针和基指针的偏移量来找到这些引用的存储位置。

栈相关操作码

到目前为止,你已经了解了调用惯例和内存的布局方式,但还没有真正探索x64汇编中许多操作码的实际作用。现在是时候更详细地关注几个与栈相关的操作码了。

'push'操作码

当任何东西如int、Objective-C实例、Swift类或引用需要被保存到栈中时,就会使用push操作码。push减少栈指针(记住,栈是向下增长的),然后存储分配给新RSP值所指向的内存地址的值。

在推送指令之后,最近推送的值将位于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'操作码

调用操作码负责执行一个函数。调用操作码推送了被调用函数完成后的返回地址;然后跳转到该函数。

想象一下,在内存中0x7fffb34df410的函数是这样的。

0x7fffb34de913 <+227>: call 0x7fffb34de918 <+232>: mov

0x7fffb34df410 edx, eax

当一条指令被执行时,首先RIP寄存器被递增,然后指令被执行。因此,当调用指令被执行时,RIP寄存器将递增到0x7fffb34de918,然后执行0x7fffb34de913所指向的指令。由于这是一条调用指令,RIP寄存器被推入栈(就像执行推送一样),然后RIP寄存器被设置为值0x7fffb34df410,即要执行的函数的地址。

伪代码看起来类似于下面的内容。

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

从这里开始,在0x7fffb34df410的位置继续执行。

计算机是非常酷的,不是吗?

'ret'操作码

ret操作码与调用操作码相反,它从栈中弹出最高值(这将是由调用操作码推送的返回地址,只要汇编的推送和弹出匹配),然后将RIP寄存器设置为这个地址。这样,执行就回到了函数被调用的地方。

现在你已经对这四个重要的操作码有了基本的了解,是时候看看它们的作用了。

让所有的push操作码与pop操作码相匹配是非常重要的,否则栈会失去同步性。例如,如果push没有对应的pop,当ret发生在函数的最后,错误的值就会被弹出。执行将返回到某个随机的地方,甚至可能不是程序中的有效位置。

幸运的是,编译器将负责同步你的push和pop操作码。你只需要在编写自己的汇编时担心这个问题。

观察运行中的RBP和RSP

现在你已经了解了RBP和RSP寄存器,以及操作栈的四个操作码,现在是时候看看它的运行情况了。

在Registers应用程序中,有一个名为StackWalkthrough(int)的函数。这个C函数以一个整数为参数,用汇编编写(AT&T汇编,记住要能发现源操作数和目的操作数的正确位置),位于StackWalkthrough.s中。打开这个文件,看一看;现在没有必要全部理解。一会儿你就会知道它是如何工作的。

这个函数是通过桥接头文件Registers-BridgingHeader.h提供给Swift的,所以你可以从Swift调用这个用汇编编写的方法。

现在来利用这个。

打开ViewController.swift,并在viewDidLoad()下面添加以下内容。

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

这将调用StackWalkThrough,参数为5。5只是一个用来显示栈如何工作的值。

在深入探索RSP和RBP之前,最好先快速了解一下StackWalkthrough中发生了什么。在StackWalkthrough函数上创建一个符号断点。

一旦创建,构建并运行。

Xcode将在StackWalkthrough上中断。请确保通过 "源码 "查看StackWalkthrough函数(尽管它是汇编)。通过源码查看该函数将显示AT&T汇编(因为它是用AT&T ASM编写的)。

Xcode将显示以下汇编。

为了帮助理解正在发生的事情,我们添加了注释。如果可以的话,请通读并尝试理解它。你已经熟悉了mov指令,其余的汇编由你刚刚学到的与函数相关的操作码组成。

这个函数接收传入的整数参数(你会记得,第一个参数是用RDI传入的),将其存入RDX寄存器,并将该参数推入栈。然后RDX被设置为0x0,然后从栈中弹出的值又被存储到RDX寄存器中。

请确保你对这个函数中发生的事情有一个很好的理解,因为你将在接下来探索LLDB中的寄存器。

回到Xcode中,使用Xcode的GUI在ViewController.swift的awakeFromNib函数中的StackWalkthrough(5)行创建一个断点。保留之前的StackWalkthrough符号断点,因为在探索寄存器时,你想在StackWalkthrough函数的开始处停止。

建立并运行,等待GUI断点的触发。

现在点击Debug ▸ Debug Work FLOW ▸Always Show Disassembly,以显示反汇编。迎接你的将是可怕的东西!

哇! 看看这个! 你正好落在一个调用操作码的指令上。你想知道你将要进入的是什么函数吗?

注意:如果您没有使用Xcode的GUI断点,您可以使用LLDB的线程步进,或者更简单,si来单步通过汇编指令。另外,您可以在调用StackWalkthrough函数的内存地址上创建一个GUI断点。

从这里开始,你将逐步完成每一条汇编指令,同时打印出四个感兴趣的寄存器。RBP、RSP、RDI和RDX。为了帮助你,在LLDB中键入以下内容。

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

这就创建了dumpreg命令,将转储四个感兴趣的寄存器。现在执行dumpreg。

(lldb) dumpreg

你会看到与下面类似的内容。

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

在本节中,dumpreg的输出将叠加在每条汇编指令上,以显示每条指令中每个寄存器的确切情况。同样,尽管这些值是为你提供的,但你自己执行和理解这些指令是非常重要的。

你的屏幕将看起来与下面类似。

一旦你跳入函数调用,要非常密切地注意RSP寄存器,因为一旦RIP跳到StackWalkthrough的开头,它就要发生变化。正如你前面所学到的,RDI寄存器将包含第一个参数的值,在本例中是0x5。

在LLDB中,键入以下内容。

(lldb) si

这是线程step-inst的一个别名,它告诉LLDB执行下一条指令,然后暂停调试器。

现在你已经步入了StackWalkthrough。同样对于每一步,使用dumpreg转储出寄存器。

注意到RSP寄存器的不同。RSP所指向的值现在将包含前一个函数的返回地址。对于这个特殊的例子,RSP,指向0x7fff5fbfe758,将包含值0x100002455--紧接着awakeFromNib的调用的地址。

现在通过LLDB验证这一点。

(lldb) x/gx $rsp

输出将与awakeFromNib中调用操作码后的地址相匹配。

接下来,执行一个si,然后为下一条指令dumpreg。

RBP的值被推到栈中。这意味着下面两条指令将产生相同的输出。执行这两条来验证。

(lldb) x/gx $rsp

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

注意:等等,我只是在没有背景的情况下向你扔了一个新命令。x命令是内存读取命令的一个快捷方式。

/gx说将内存格式化为一个巨大的字(8字节,还记得第12章 "汇编与内存 "中的术语吗?

这个奇怪的格式是由于这个命令在gdb中的流行,它看到这个命令的语法被移植到ldb中,以使从调试器的过渡更容易。

现在看一下基数指针寄存器中的值。

(lldb) p/x $rbp

接下来,步入下一条指令,再次使用si。

基准指针被分配给栈指针的值。使用dumpreg以及下面的LLDB命令验证两者的值是否相同。

(lldb) p (BOOL)(``rbp == ``rsp)

重要的是你要在表达式周围加上圆括号,否则LLDB不会正确解析它。

再次执行si和dumpreg。这一次看起来像下面这样。

RDX被清空为0。

再次执行 si 和 dumpreg。这一次的输出看起来如下。

RDX 被设置为 RDI。你可以再次用dumpreg验证两者的值是否相同。

执行 si 和 dumpreg。这一次看起来如下。

RDX 被推到了栈上。这意味着栈指针被递减,RSP指向一个值,这个值将指向0x5的值。现在确认一下。

(lldb) p/x $rsp

这就给出了指向RSP的当前值。这里的值指的是什么?

(lldb) x/gx $rsp

你会得到预期的0x5。再次输入si来执行下一条指令。

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

栈的顶部被弹出到RDX中,你知道它最近被设置为0x5。RSP被增加了0x8。 再次输入si和dumpreg。

基准指针被从栈中弹出,并被重新赋值到它在进入这个函数时的原始值。调用惯例规定了RBP在不同的函数调用中应保持一致。也就是说,一旦离开一个函数,RBP就不能改变为不同的值,所以我们要做一个好公民,恢复它的值。

再来看看ret操作码。留意RSP的值是否要改变。再输入si和dumpreg。

返回地址被推离栈并被设置为RIP寄存器;你知道这一点是因为你已经回到了函数被调用的地方。然后在awakeFromNib中恢复了控制。

哇塞! 这很有趣! 一个简单的函数,但它说明了栈是如何通过调用、推送、弹出和撤退指令工作的。

栈和7+参数

如第11章所述,x86_64的调用惯例将按顺序使用以下寄存器作为函数参数。rdi, rsi, rdx, rcx, r8, r9。当一个函数需要6个以上的参数时,需要使用栈。

注意:当一个大的结构体被传递给一个函数时,也可能需要使用栈。每个参数寄存器只能容纳8个字节(在64位架构上),所以如果结构体需要超过8个字节,它也需要被传递到栈中。在调用约定中,有严格的规定,所有编译器都必须遵守这些规定。

打开ViewController.swift,找到名为executeLotsOfArguments(one:two:three:four:five:seven:eight:nine:ten:)。你在第11章中使用了这个函数来探索寄存器。现在你将再次使用它,看看参数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,在你刚刚添加的那一行创建一个断点。建立并运行应用程序,并等待这个断点的出现。你应该再次看到反汇编视图,但如果你没有看到,请使用总是显示反汇编选项。

正如你在栈相关操作码部分学到的,调用负责执行一个函数。由于在RIP现在的位置和viewDidLoad的结束之间只有一个调用操作码,这意味着这个调用一定是负责调用executeLotsOfArguments(one:two:three:four:five:six:seven:eight:nine:ten:)。

但是,在调用之前的其他指令是什么呢?让我们来找找看。

这些指令在必要时设置了栈,以传递额外的参数。正如RIP之前的指令所显示的那样,从mov edx, 0x1开始,你的6个参数被放入了相应的寄存器。

但参数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所指向的内存地址"。以此类推。

这就是在栈中放置数值。但是请注意,这些值没有明确地使用推送指令来推送,而推送指令会减少RSP寄存器。为什么会这样呢?

嗯,正如你所学到的,在调用指令中,返回地址被推入栈。然后,在函数序言中,基指针被推入栈,然后基指针被设置为栈指针。

你还没有学到的是,编译器实际上会在栈上为 "划痕空间 "留出空间。也就是说,编译器会根据需要在栈上为函数中的局部变量分配空间。

你可以通过查找函数序言中的sub rsp, VALUE指令,轻松确定是否为栈帧分配了额外的划痕空间。例如,点击viewDidLoad栈帧并滚动到顶部。观察一下有多少划痕空间被创建。

编译器在这里表现得有点聪明;它没有做大量的推送,而是知道在栈上为自己分配了一些空间,并在函数调用之前填入传递这些额外参数的值。单独的推送指令将涉及更多的写入RSP,这将是不太有效的。

是时候更深入地研究一下这个划痕空间了。

栈和调试信息

栈不仅在调用函数时使用,而且还被用作一个函数的局部变量的抓取空间。说到这里,调试器如何知道在打印出属于该函数的变量名称时要引用哪些地址呢?

让我们来弄清楚

清除你设置的所有断点,在executeLotsOfArguments上创建一个新的符号断点。

建立并运行应用程序,然后等待断点的到来。

正如预期的那样,控制应该停止在一个永远那么短的函数名称上。executeLotsOfArguments(one:two:three:four:five:six:seven:eight:nine:ten:),从这里开始,现在被称为executeLotsOfArguments,因为其全名有点因为它的全名有点拗口!

在Xcode的右下角,点击Show the Variables View。

在那里,看一下那个变量所指向的值......它现在肯定没有保持0x1的值。这个值似乎是胡言乱语!

为什么会引用一个看似随机的值呢?

答案是由嵌入到Registers应用程序调试构建中的DWARF调试信息存储的。你可以转储这些信息,以帮助你了解一个变量在内存中引用的内容。

在LLDB中,键入以下内容。

(lldb) image dump symfile Registers

你会得到大量的输出。搜索(Cmd + F)"one "这个词;在你的搜索中包括引号。

下面是一个(非常)截断的输出,包括相关信息。

根据输出结果,名为one的变量属于Swift.Int类型,在executeLotsOfArguments中找到,其位置可以在DW_OP_fbreg(-32)找到。这段相当模糊的代码实际上是指基础指针减去40,即RBP-32。或者在十六进制中,RBP - 0x20。

这是很重要的信息。它告诉调试器,在这个内存地址中总是可以找到被称为 "一 "的变量。好吧,并不总是这样,但总是在该变量有效的时候,也就是说,它在范围内。

你可能想知道为什么它不能是RDI,因为它是传递给函数的值,而且它也是第一个参数。好吧,RDI可能需要在以后的函数中重复使用,所以使用栈来存储是更安全的选择。

调试器还是应该在executeLotsOfArguments上停止。确保你正在查看总是显示反汇编的输出,并猎取汇编。

mov qword ptr [rbp - 0x20], rdi

一旦你在executeLotsOfArguments的汇编输出中找到它,在这行汇编上建立一个断点。

继续执行,所以LLDB会在这一行的汇编上停止。

试着在LLDB中打印出one的输出。

(lldb) po one

胡言乱语,仍然如此。哼哼。

记住,RDI将包含传入函数的第一个参数。因此,为了使调试器能够看到 "一 "应该是什么值,RLI需要被写到 "一 "的存储地址。在本例中,RBP - 0x20。

现在,在LLDB中执行一个汇编指令步骤。

(lldb) si

再次打印one的值。

(lldb) po one

Awwww.... yeah! 开始工作了! one所引用的值正确地保持着0x1的值。

你可能想知道如果one改变了会怎样。那么,RBP - 0x20在这种情况下也需要改变。这有可能是需要另一条指令来把它写在那里,以及在使用该值的地方。这就是为什么调试构建比发布构建要慢得多。

栈探索的启示

不要担心。这一章快结束了。但是有一些非常重要的收获,应该从你的栈探索中记住。

只要你在一个函数中,并且该函数已经完成了执行函数的序幕,以下项目将在x64汇编中成立。

接下来去哪里?

现在你已经熟悉了RBP和RSP寄存器,你有一个家庭作业了

将LLDB连接到一个程序(任何程序,无论是否有源),只使用RBP寄存器遍历栈框架。在一个容易触发的方法上建立一个断点。一个很好的例子是-[NSView hitTest:],如果你附加到一个macOS应用程序,如Xcode,并点击一个视图。

重要的是要确保你选择添加的断点不是一个Swift函数。你要检查寄存器,--记得你不能(很容易)在Swift上下文中这样做。一旦触发了断点,通过在LLDB中输入以下内容来确保你在第0帧上。

(lldb) f 0

f命令是帧选择的一个别名。

你应该在这个函数的顶部看到以下两条指令。

push mov
rbp rbp, rsp

这些指令构成了函数序幕的开始,将RBP推入栈,然后将RBP设置为RSP。

用si步过这两条指令。

现在为这个栈帧设置了基指针,你可以通过检查基指针来自己遍历栈帧。

在LLDB中执行以下内容。

(lldb) p uintptr_t ``Previous_RBP = *(uintptr_t *)``rsp

所以现在$Previous_RBP等于旧的RBP,也就是调用这个函数的栈帧的开始。

回顾一下,栈框架上的第一件事是函数应该返回的地址。因此,你可以找出前一个函数将返回的位置。因此,这将是调试器在第2帧停止的地方。

为了弄清这一点并检查你是否正确,请在LLDB中执行以下程序。

(lldb) x/gx '$Previous_RBP + 0x8'

这将打印出类似这样的内容。

0x7fff5fbfd718: 0x00007fffa83ed11b

用LLDB确认这个地址等于帧1的返回地址。

(lldb) f 2

它看起来会像这样,取决于你决定将初始断点设置在什么地方。

它吐出的第一个地址应该与你先前的x/gx命令的输出一致。

祝您好运,愿汇编与您同在!


上一章 目录 下一章