# 汇编和栈

当一个函数传递了六个以上的参数时,多余的参数将通过堆栈传递。但是在堆栈上传递到底是什么意思呢?现在该通过深入研究一些 “与堆栈相关的” 寄存器以及堆栈中的内容,来深入探讨从程序集角度调用函数时的情况。当您进行逆向工程程序时,了解堆栈的工作方式非常有用,因为当没有可用的调试符号时,您可以帮助推断出在某个函数中正在操纵哪些参数。在下一单元中,您将使用本章中的知识在 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 的倍数,将引用该函数的局部变量。
更新于 阅读次数

请我喝[茶]~( ̄▽ ̄)~*

Molier 微信支付

微信支付

Molier 支付宝

支付宝