• 绘制函数堆栈


    介绍

    我对下面演示的所有内容都很陌生,因此请注意潜在的错误。这种绘制堆栈的技术是今年一位教授在课堂上采用的方法。让我们潜入吧!

    所需技能

    由于使用了 C 和 Assembly,因此在阅读之前了解 C 和 Assembly 会很有用。但是,这些示例应该足够简单,任何初学者都可以从上下文中阅读和理解。

    C

    这里没什么特别的:

    #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;
    }
    
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18

    集会

       ; 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    
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21

    ; 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
    
    • 1
    • 2
    • 3

    第一个堆栈修改是push rbp,从main()rbp是一个 64 位的寄存器,所以在栈上占了 4 行(每行是字大小的,也就是 2 个字节):

    < high addresses >
    |      |
    |------|
    | **** |
    |------|
    | **** |
    |------|
    | **** |
    |------|
    | **** | <-- RBP and RSP (main)
    |------|
    |      |
    < low addresses >
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13

    rbp用作执行堆栈功能的辅助寄存器。它在向上或向下遍历堆栈时用作“基础”。以rbp这种方式使用也是一种设置和使用局部变量的方法,我们稍后会谈到。

    这种技术的一个常见类比是“抛锚”。 rsp随着堆栈的增长和缩小,不断变化;将其当前值复制到rbp锚点”它以供您使用它,同时仍然允许您推送和弹出。

    继续!


    main(): 线<+4>
       <+4>:	sub    rsp,0x10
    
    • 1
    • 2

    这条指令在堆栈上腾出空间来存储局部变量,在我们的例子中int aint b都是 32 位的。现在我知道我说过我们不应该弄乱rsp,但是移动堆栈指针是创建局部变量的方法。

    由于ab都是整数,我们需要在堆栈上分配至少 64 位。在这一行中,我们可以看到已经分配了 16 个字节,方法是0x10rsp的当前值中减去。这是我们的堆栈在这条指令之后的样子:

    |      |
    |------|
    | **** |
    |------|
    | **** |
    |------|
    | **** |
    |------|
    | **** | <-- RBP (main)
    |------|
    |      | -0x2
    |------|
    |      | -0x4
    |------|
    |      | -0x6
    |------|
    |      | -0x8
    |------|
    |      | -0xa
    |------|
    |      | -0xc
    |------|
    |      | -0xe
    |------|
    |      | <-- RSP (main)
    |------|
    |      |
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25
    • 26
    • 27

    现在我们已经分配了这个空间,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]
    
    • 1
    • 2
    • 3
    • 4
    • 5

    在前两行中,我们分别初始化了局部变量aand b、 to02

    访问堆栈时,汇编器无法推断出我们要检索(或设置)的对象的大小;所以我们必须自己指定。 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)
    |------|
    |      |
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25
    • 26
    • 27
    • 注意:出于本教程的目的,我们不会考虑字节序

    可以清楚地看到,我们已经在栈上初始化了一些局部变量,而没有修改rspor rbp

    其他两行不修改堆栈,而只是从中检索值。除了内存操作数是指令的第二个操作数(而不是第一个)之外,这种表示法与向堆栈添加内容的方式相同。

    需要注意的重要一点是,我们已经复制a了 intoeaxbinto edx

    向前!


    main(): 行<+28>-<+30>
       <+28>:	mov    esi,edx
       <+30>:	mov    edi,eax
       <+32>:	call   0x68f <callMe>
    
    • 1
    • 2
    • 3
    • 4

    前两行没有什么特别之处:我们正在a进入edib进入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)
    |------|
    |      |
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25
    • 26
    • 27

    至此,我们现在处于callMe().


    callMe(): 行<+0><+1>
       <+0>:	push   rbp
       <+1>:	mov    rbp,rsp
    
    • 1
    • 2
    • 3

    这应该看起来很熟悉……

    我们再次删除了一个锚点,这次保存了“old”rbp的值(因为我们在这个函数返回后再次使用它)。这是我们的堆栈现在的样子:

    |      |
    |------|
    | **** |
    |------|
    | **** |
    |------|
    | **** |
    |------|
    | **** |
    |------|
    |      | 
    |------|
    |      | 
    |------|
    | 0000 | 
    |------|        
    | 0002 | 
    |------|
    | **** |         
    |------|  
    | **** |
    |------|
    | **** |
    |------|
    | **** | <-- RIP (main)
    |------|
    | **** |
    |------|
    | **** |
    |------|
    | **** |
    |------|
    | **** | <-- RBP (callMe)
    |------|
    |      |
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25
    • 26
    • 27
    • 28
    • 29
    • 30
    • 31
    • 32
    • 33
    • 34
    • 35

    这里要注意的一件重要事情是我们没有修改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]
    
    • 1
    • 2
    • 3
    • 4
    • 5

    这些指令应该看起来很熟悉,因为它们之前几乎是逐字执行的。重要的区别在于,在这里的前两行中,我们分别使用和内部的值来初始化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
    |------|
    |      |
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25
    • 26
    • 27
    • 28
    • 29
    • 30
    • 31
    • 32
    • 33
    • 34
    • 35
    • 36
    • 37
    • 38
    • 39
    • 40
    • 41
    • 42
    • 43
    • 44
    • 45
    • 46
    • 47
    • 48
    • 49
    • 50
    • 51
    • 52
    • 53
    • 54
    • 55
    • 56
    • 57
    • 58
    • 59

    您会注意到,汇编为我们提供了必要更多的堆栈空间;我不确定为什么会有那么多填充物,但我能找到的最佳答案是这里 14. 如果您可以进一步解释,请在评论中加入讨论。

    现在到这个功能的胆量!


    callMe(): 行<+16>-<+21>
       <+16>:	add    eax,edx
       <+18>:	mov    DWORD PTR [rbp-0x4],eax
       <+21>:	mov    eax,DWORD PTR [rbp-0x4]
    
    • 1
    • 2
    • 3
    • 4

    这些指令非常简单:在堆栈中添加ba存储该值:

    |      |
    |------|
    | **** |
    |------|
    | **** |
    |------|
    | **** |
    |------|
    | **** |
    |------|
    |      | 
    |------|
    |      | 
    |------|
    | 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
    |------|
    |      |
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25
    • 26
    • 27
    • 28
    • 29
    • 30
    • 31
    • 32
    • 33
    • 34
    • 35
    • 36
    • 37
    • 38
    • 39
    • 40
    • 41
    • 42
    • 43
    • 44
    • 45
    • 46
    • 47
    • 48
    • 49
    • 50
    • 51
    • 52
    • 53
    • 54
    • 55
    • 56
    • 57
    • 58
    • 59

    然后,我们将该值拉回eax并继续前进。


    callMe(): 行<+24><+25>
       <+24>:	pop    rbp
       <+25>:	ret
    
    • 1
    • 2
    • 3

    第一行就是我之前提到的:我们希望堆栈在离开函数时看起来和进入时一样。因为我们从未rsp在inside 进行过修改callMe(),所以它仍然指向rbp我们在 in 中使用它时的旧值main()

    另一个“很酷”的事情是不要rsp在这个过程中移动,这与ret. 执行时ret,它会将栈顶弹出到rip. 弹出后rbprsp指向main()的返回地址。这意味着我们刚刚存储在堆栈中的那些变量callMe()将被“清除”。

    现在我把“消灭”放在引号中,因为实际发生的是rsp变化,我们无法接触到那些当地人。但是它们不会被删除(直到rsp覆盖它们),这会引发一些超出本教程范围的有趣漏洞。

    如您所见,poppingrbpreturning 显着改变了我们的堆栈:

    |      |
    |------|
    | **** |
    |------|
    | **** |
    |------|
    | **** |
    |------|
    | **** | <-- RBP (main)
    |------|
    |      | 
    |------|
    |      | 
    |------|
    | 0000 | 
    |------|        
    | 0002 | <-- RSP (main)
    |------|
    |      |
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19

    rbp已恢复到其原始 (to main()) 值,并rsp指向前面的第一个字节rip(在它被弹出之前)。

    到最后的指示!


    main(): 线<+37>
       <+37>:	mov    DWORD PTR [rbp-0x4],eax
    
    • 1
    • 2

    callMe()将我们的答案 ( 2) 传回main()内部eax;该指令只是将该值存储在堆栈中:

    |      |
    |------|
    | **** |
    |------|
    | **** |
    |------|
    | **** |
    |------|
    | **** | <-- RBP (main)
    |------|
    | 0000 | -0x2
    |------|
    | 0002 | -0x4
    |------|
    | 0000 | 
    |------|        
    | 0002 | <-- RSP (main)
    |------|
    |      |
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19

    繁荣!

    至此,我们已经保存a+b在堆栈中,就本教程而言,我们已经完成。下一组指令是设置printf()我们的计算值,但我们现在已经涵盖了所有有趣的东西。


    结论

    绘制堆栈可以帮助您调试、探索漏洞或消磨一些时间;它有助于让您对程序正在做什么有一个经验性的理解。

    这个例子很简单(但仍然需要一个冗长的帖子),但你可以为任何你想要的东西做这个。

    继续!

  • 相关阅读:
    【Visual Leak Detector】库的 22 个 API 使用说明
    GoogleTest环境配置以及应用
    二叉树的锯齿形层序遍历[分层遍历方式之一 -> 前序遍历+level]
    按钮变换及通用方式(雪碧图使用)
    【数据结构】期中测试1
    利用人工智能和大数据技术,优化IT运维流程和策略
    iOS使用CMMotionActivityManager获取用户状态
    字符串变形
    Linux中sudo命令的添加和操作
    【微服务35】分布式事务Seata源码解析三:从Spring Boot特性来看Seata Client 启动时都做了什么【云原生】
  • 原文地址:https://blog.csdn.net/luoganttcc/article/details/126829034