由于使用了 C 和 Assembly,因此在阅读之前了解 C 和 Assembly 会很有用。但是,这些示例应该足够简单,任何初学者都可以从上下文中阅读和理解。
这里没什么特别的:
#include
int callMe(int num1, int num2);
int main() {
int a = 0;
int b = 2;
int c = callMe(a, b);
printf("%d\n", c);
return 0;
}
int callMe(int num1, int num2) {
int ans = num1+num2;
return ans;
}
; main()
<+0>: push rbp
<+1>: mov rbp,rsp
<+4>: sub rsp,0x10
<+8>: mov DWORD PTR [rbp-0xc],0x0
<+15>: mov DWORD PTR [rbp-0x8],0x2
<+22>: mov edx,DWORD PTR [rbp-0x8]
<+25>: mov eax,DWORD PTR [rbp-0xc]
<+28>: mov esi,edx
<+30>: mov edi,eax
<+32>: call 0x68f <callMe>
<+37>: mov DWORD PTR [rbp-0x4],eax
<+40>: mov eax,DWORD PTR [rbp-0x4]
<+43>: mov esi,eax
<+45>: lea rdi,[rip+0xb6] # 0x734
<+52>: mov eax,0x0
<+57>: call 0x530 <printf@plt>
<+62>: mov eax,0x0
<+67>: leave
<+68>: ret
; callMe()
<+0>: push rbp
<+1>: mov rbp,rsp
<+4>: mov DWORD PTR [rbp-0x14],edi
<+7>: mov DWORD PTR [rbp-0x18],esi
<+10>: mov edx,DWORD PTR [rbp-0x14]
<+13>: mov eax,DWORD PTR [rbp-0x18]
<+16>: add eax,edx
<+18>: mov DWORD PTR [rbp-0x4],eax
<+21>: mov eax,DWORD PTR [rbp-0x4]
<+24>: pop rbp
<+25>: ret
简而言之,main()通过aandb传递and callMe(),然后返回in 。ediesia + beax
现在我们可以看到程序集,我们可以准确地绘制出堆栈的外观,并更好地了解我们的代码在做什么。绘制堆栈的一个实际用途是用于调试,但它也是一个有趣的练习(这就是产生这个主题的原因)!
堆栈从高地址到低地址,从上到下增长。通常,我们对堆栈进行推理,使低地址在顶部;从这个意义上说,我们将在描述中使用“倒置堆栈”。
main(): 行<+0>和<+1> <+0>: push rbp
<+1>: mov rbp,rsp
第一个堆栈修改是push rbp,从main()。 rbp是一个 64 位的寄存器,所以在栈上占了 4 行(每行是字大小的,也就是 2 个字节):
< high addresses >
| |
|------|
| **** |
|------|
| **** |
|------|
| **** |
|------|
| **** | <-- RBP and RSP (main)
|------|
| |
< low addresses >
rbp用作执行堆栈功能的辅助寄存器。它在向上或向下遍历堆栈时用作“基础”。以rbp这种方式使用也是一种设置和使用局部变量的方法,我们稍后会谈到。
这种技术的一个常见类比是“抛锚”。 rsp随着堆栈的增长和缩小,不断变化;将其当前值复制到rbp“锚点”它以供您使用它,同时仍然允许您推送和弹出。
继续!
main(): 线<+4> <+4>: sub rsp,0x10
这条指令在堆栈上腾出空间来存储局部变量,在我们的例子中int a和int b都是 32 位的。现在我知道我说过我们不应该弄乱rsp,但是移动堆栈指针是创建局部变量的方法。
由于a和b都是整数,我们需要在堆栈上分配至少 64 位。在这一行中,我们可以看到已经分配了 16 个字节,方法是0x10从rsp的当前值中减去。这是我们的堆栈在这条指令之后的样子:
| |
|------|
| **** |
|------|
| **** |
|------|
| **** |
|------|
| **** | <-- RBP (main)
|------|
| | -0x2
|------|
| | -0x4
|------|
| | -0x6
|------|
| | -0x8
|------|
| | -0xa
|------|
| | -0xc
|------|
| | -0xe
|------|
| | <-- RSP (main)
|------|
| |
现在我们已经分配了这个空间,rsp再次“固定”到它的当前位置;我们可以使用rbp访问分配的空间rsp。
正如我们将看到的,这是通过减去一些字节数 0xn 来完成的,它[rsp-0xn]指向最后一个字节。阅读距离取决于另一个因素,我们将在下面看到。
main(): 行<+8>-<+25> <+8>: mov DWORD PTR [rbp-0xc],0x0
<+15>: mov DWORD PTR [rbp-0x8],0x2
<+22>: mov edx,DWORD PTR [rbp-0x8]
<+25>: mov eax,DWORD PTR [rbp-0xc]
在前两行中,我们分别初始化了局部变量aand b、 to0和2。
访问堆栈时,汇编器无法推断出我们要检索(或设置)的对象的大小;所以我们必须自己指定。 DWORD PTR [rbp-0xc]告诉汇编器“嘿,我们希望你查看 at 的值[rbp-0xc]并读取 4 个字节(2 个字,由 指定DWORD)”。
当我们访问堆栈时,我提到[rsp-0xn],对于某些十六进制n,指向“最后一个字节”:这特别是您指定大小的最后一个字节。因此,对于DWORD PTR [rbp-0xc],汇编程序进入[rbp-0xc]堆栈,然后“向上”读取 4 个字节。
话虽如此,现在我们的堆栈看起来像一些值已经初始化:
| |
|------|
| **** |
|------|
| **** |
|------|
| **** |
|------|
| **** | <-- RBP (main)
|------|
| | -0x2
|------|
| | -0x4
|------|
| 0000 | -0x6 \
|------| | int b = 2;
| 0002 | -0x8 /
|------|
| 0000 | -0xa \
|------| | int a = 0;
| 0000 | -0xc /
|------|
| | -0xe
|------|
| | <-- RSP (main)
|------|
| |
可以清楚地看到,我们已经在栈上初始化了一些局部变量,而没有修改rspor rbp。
其他两行不修改堆栈,而只是从中检索值。除了内存操作数是指令的第二个操作数(而不是第一个)之外,这种表示法与向堆栈添加内容的方式相同。
需要注意的重要一点是,我们已经复制a了 intoeax和binto edx。
向前!
main(): 行<+28>-<+30> <+28>: mov esi,edx
<+30>: mov edi,eax
<+32>: call 0x68f <callMe>
前两行没有什么特别之处:我们正在a进入edi和b进入esi. 这两个寄存器用于将参数传递给callMe()函数,这就是接下来发生的事情。
指令执行时call,将下一条指令的地址压入栈(rip);然后它将被调用过程的地址复制到rip. 这会修改我们的堆栈:
| |
|------|
| **** |
|------|
| **** |
|------|
| **** |
|------|
| **** | <-- RBP (main)
|------|
| | -0x2
|------|
| | -0x4
|------|
| 0000 | -0x6 \
|------| | int b = 2;
| 0002 | -0x8 /
|------|
| **** | * int a was overwritten,
|------| as it is never used again
| **** |
|------|
| **** |
|------|
| **** | <-- RIP (main)
|------|
| |
至此,我们现在处于callMe().
callMe(): 行<+0>和<+1> <+0>: push rbp
<+1>: mov rbp,rsp
这应该看起来很熟悉……
我们再次删除了一个锚点,这次保存了“old”rbp的值(因为我们在这个函数返回后再次使用它)。这是我们的堆栈现在的样子:
| |
|------|
| **** |
|------|
| **** |
|------|
| **** |
|------|
| **** |
|------|
| |
|------|
| |
|------|
| 0000 |
|------|
| 0002 |
|------|
| **** |
|------|
| **** |
|------|
| **** |
|------|
| **** | <-- RIP (main)
|------|
| **** |
|------|
| **** |
|------|
| **** |
|------|
| **** | <-- RBP (callMe)
|------|
| |
这里要注意的一件重要事情是我们没有修改rsp. 这是因为我们希望堆栈在进入和离开函数时“看起来一样”。稍后将对此进行更详细的介绍。
callMe(): 行<+4>-<+13> <+4>: mov DWORD PTR [rbp-0x14],edi
<+7>: mov DWORD PTR [rbp-0x18],esi
<+10>: mov edx,DWORD PTR [rbp-0x14]
<+13>: mov eax,DWORD PTR [rbp-0x18]
这些指令也应该看起来很熟悉,因为它们之前几乎是逐字执行的。重要的区别在于,在这里的前两行中,我们分别使用和内部的值来初始化int num1和。int num2ediesi
以下是我们的堆栈如何处理这些指令:
| |
|------|
| **** |
|------|
| **** |
|------|
| **** |
|------|
| **** |
|------|
| |
|------|
| |
|------|
| 0000 |
|------|
| 0002 |
|------|
| **** |
|------|
| **** |
|------|
| **** |
|------|
| **** | <-- RIP (main)
|------|
| **** |
|------|
| **** |
|------|
| **** |
|------|
| **** | <-- RBP, RSP (callMe)
|------|
| | -0x2
|------|
| | -0x4
|------|
| | -0x6
|------|
| | -0x8
|------|
| | -0xA
|------|
| | -0xC
|------|
| | -0xE
|------|
| | -0x10
|------|
| 0000 | -0x12
|------|
| 0000 | -0x14
|------|
| 0000 | -0x16
|------|
| 0002 | -0x18
|------|
| |
您会注意到,汇编为我们提供了比必要更多的堆栈空间;我不确定为什么会有那么多填充物,但我能找到的最佳答案是这里 14. 如果您可以进一步解释,请在评论中加入讨论。
现在到这个功能的胆量!
callMe(): 行<+16>-<+21> <+16>: add eax,edx
<+18>: mov DWORD PTR [rbp-0x4],eax
<+21>: mov eax,DWORD PTR [rbp-0x4]
这些指令非常简单:在堆栈中添加b和a存储该值:
| |
|------|
| **** |
|------|
| **** |
|------|
| **** |
|------|
| **** |
|------|
| |
|------|
| |
|------|
| 0000 |
|------|
| 0002 |
|------|
| **** |
|------|
| **** |
|------|
| **** |
|------|
| **** | <-- RIP (main)
|------|
| **** |
|------|
| **** |
|------|
| **** |
|------|
| **** | <-- RBP, RSP (callMe)
|------|
| 0000 | -0x2
|------|
| 0002 | -0x4
|------|
| | -0x6
|------|
| | -0x8
|------|
| | -0xA
|------|
| | -0xC
|------|
| | -0xE
|------|
| | -0x10
|------|
| 0000 | -0x12
|------|
| 0000 | -0x14
|------|
| 0000 | -0x16
|------|
| 0002 | -0x18
|------|
| |
然后,我们将该值拉回eax并继续前进。
callMe(): 行<+24>和<+25> <+24>: pop rbp
<+25>: ret
第一行就是我之前提到的:我们希望堆栈在离开函数时看起来和进入时一样。因为我们从未rsp在inside 进行过修改callMe(),所以它仍然指向rbp我们在 in 中使用它时的旧值main()。
另一个“很酷”的事情是不要rsp在这个过程中移动,这与ret. 执行时ret,它会将栈顶弹出到rip. 弹出后rbp,rsp指向main()的返回地址。这意味着我们刚刚存储在堆栈中的那些变量callMe()将被“清除”。
现在我把“消灭”放在引号中,因为实际发生的是rsp变化,我们无法接触到那些当地人。但是它们不会被删除(直到rsp覆盖它们),这会引发一些超出本教程范围的有趣漏洞。
如您所见,poppingrbp和returning 显着改变了我们的堆栈:
| |
|------|
| **** |
|------|
| **** |
|------|
| **** |
|------|
| **** | <-- RBP (main)
|------|
| |
|------|
| |
|------|
| 0000 |
|------|
| 0002 | <-- RSP (main)
|------|
| |
rbp已恢复到其原始 (to main()) 值,并rsp指向前面的第一个字节rip(在它被弹出之前)。
到最后的指示!
main(): 线<+37> <+37>: mov DWORD PTR [rbp-0x4],eax
callMe()将我们的答案 ( 2) 传回main()内部eax;该指令只是将该值存储在堆栈中:
| |
|------|
| **** |
|------|
| **** |
|------|
| **** |
|------|
| **** | <-- RBP (main)
|------|
| 0000 | -0x2
|------|
| 0002 | -0x4
|------|
| 0000 |
|------|
| 0002 | <-- RSP (main)
|------|
| |
繁荣!
至此,我们已经保存a+b在堆栈中,就本教程而言,我们已经完成。下一组指令是设置printf()我们的计算值,但我们现在已经涵盖了所有有趣的东西。
绘制堆栈可以帮助您调试、探索漏洞或消磨一些时间;它有助于让您对程序正在做什么有一个经验性的理解。
这个例子很简单(但仍然需要一个冗长的帖子),但你可以为任何你想要的东西做这个。
继续!