46-打造自己的 longjmp



对于 longjmp 这种不走常规路线之迷一样的函数,到底是如何实现的呢?也许从你学习 C 语言以来你都不知道代码还可以这么写。其实,一切都是套路。

通常来说,编译器为我们编译的函数,都是按正常套路来走的。这意味着,如果你用 C 语言编写函数,就会被编译器给套路掉。所以,这种不按套路走的函数,我们只能用汇编语言写了。为了能写出这种函数,需要掌握函数栈帧结构。

注意:本文所示的代码可能不会完全复原系统为我们提供的 setjmp 和 longjmp 函数的语义,但是力求能够完成最基本的功能。

1. 函数栈帧

1.1 基本概念

熟悉 C 语言函数的同学都知道,每个线程都有属于自己的一个调用栈。代码在运行的时候,通常需要栈来保存相关的变量,比如局部变量,函数返回地址等等。栈是一种具有记忆性质的结构。

在调用函数的时候,通常都意味着要跳转到另一段代码中去,当函数调用完成的时候,还得返回调用点的下一行代码。被调用的函数是怎么知道正确的返回地址的呢?这都要依赖栈。下面以一个简单的例子来说明。

例:

void bar() {
  return 5;
}
void foo() {
  bar();
  int x = 0;
}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7

函数 foo 在第一行调用了 bar,在进入 bar 的时候,同时会把 int x = 0 这一行代码的地址保存到栈里。当 bar 运行结束返回时,就跳转栈中刚刚保存的地址(也就是 int x = 0这一行的地址)

1.2 栈帧结构

一个典型的栈帧结构如图1所示。


这里写图片描述
图1 函数栈帧结构

函数 func 调用函数 foo 的时候,把 foo(a, b, c) 的下一行地址保存到了栈里(图1中下面的那个 retaddr),接下来函数 foo 将自己的 ebp 指向栈帧基址,以便使用 ebp 对参数和局部变量进行定位。

func 调用 foo 函数再到 foo 返回到 func 的过程大概有以下几个步骤:

  • func 函数通过 push 指令分别将参数 c, b, a 依次压入栈中,此时栈顶指针指向参数 a.
  • func 函数执行 call foo 指令,跳转到 foo 函数,同时将 foo(a, b, c) 的下一条指令地址(retaddr)压入栈中。此时栈顶指针指向 retaddr.
  • 到这里已经进入 foo 函数。foo 函数将 func 函数的栈帧基址压入栈中(oldebp)。
  • foo 函数将 ebp 更新为自己的栈帧基址(ebp 始终保存当前栈帧基址)。
  • foo 函数提升 esp 指针,为局部变量开辟空间。
  • foo 函数保存现场,这里只保存了 ebx, esi, edi 三个寄存器,此时 esp 指向保存 edi 的位置(实际上根据需要来保存,如果你一个寄存器都没修改,就可以不用保存,所以这一步是可选的)。
  • foo 函数开始为局部变量 local1 和 local 2 赋值。local1 和 local2 可以通过栈帧基址定位。
  • foo 调用向栈中压入参数 x,此时 esp 指向保存 x 的位置。
  • foo 调用执行 call bar 指令,跳转到 bar 函数。
  • bar 函数调用结束,执行 bar(6) 这一条指令的下一条指令,此时 esp 指向 保存 x 的位置。
  • foo 函数将 esp 的值加 4,即指向保存 edi 的那个地方。(可以发现,bar 函数执行前和执行后,栈顶指针都是指向保存 edi 的位置,这一点是必须的,专业术语叫“堆栈平衡”)
  • foo 函数调用 将 esp 的值更新为 ebp,即也指向自己的栈帧基址,此时 esp 和 ebp 指向同一位置。
  • foo 函数从栈中将 func 函数的栈帧基址弹出到 ebp,即更新当前栈帧基址。此时 esp 指向图1中下方的保存 retaddr 的位置(还记得这个 retaddr 保存的是什么值吗?)。
  • foo 函数执行 ret 汇编指令,将 retaddr 弹出到 cpu 的 eip 寄存器(eip 寄存器始终保存了即将要执行的那一行指令的地址)。
  • 此时又返回到了 func 函数的栈帧,接着执行 func 函数未完成的代码。

2. 实现 setjmp

弄明白函数调用原理后,我们尝试着写属于自己的 setjmp 和 longjmp。要想让 longjmp 能够跳转到 setjmp 的位置,本质上就是要让 longjmp 在返回的时候,不是正常返回到调用 longjmp 语句的下一行,而是返回到调用 setjmp 函数的下一行。同时,longjmp 还得将当前寄存器环境更改为调用 setjmp 那一刻的环境,所以 setjmp 函数的目的就是将当前寄存器环境保存起来。

为了能够模仿原始的 setjmp 函数,这里也使用了数组来保存各个寄存器的值。

  • jmp_buf 类型
typedef int jmp_buf[9]; // {0:ebx, 1:ecx, 2:edx, 3:esi, 4:edi, 5:esp, 6:ebp, 7:eflags, 8:ret}
  • 1

该类型(jmp_buf)可以用来定义大小为 9 的数组,从 0到 8 依次保存 ebx, ecx, edx, esi, edi, esp, ebp, eflags 和返回点的地址。

  • setjmp 函数代码

注意,下面的代码只能在 vs 编译器中编译,gcc 是不支持 naked 函数的(naked 函数是指不需要编译替我们生成汇编代码,所有的汇编代码完全由我们自己编写,编译器只负责把汇编代码转换成机器码就行了)。

另外一点,函数的返回值,是保存在 eax 寄存器中的,所以 eax 寄存器是并不需要保存到 jmp_buf 中去的。

__declspec(naked)
int setjmp(jmp_buf env) {
    __asm {
        push ebp
        mov ebp, esp
        sub esp, 0x40
        push esi
        push ebx

        mov eax, 0 // 函数返回值为 0
        mov ebx, [ebp + 8] // env 基址(通过栈帧基址定位参数)

        mov [ebx], eax
        mov [ebx+4], ecx
        mov [ebx+8], edx
        mov [ebx+12], esi
        mov [ebx+16], edi

        // 提取 esp(指向 ret 返回地址)
        lea esi, [ebp+4]
        mov [ebx+20], esi

        // 提取真实 ebp
        mov esi, [ebp]
        mov [ebx+24], esi


        // 保存 eflags
        pushfd
        mov esi, [esp]
        add esp, 4
        mov[ebx + 28], esi

        // 保存返回地址,栈帧基址 + 4 的位置指向的值
        mov esi, [ebp + 4]
        mov [ebx + 32], esi

        pop ebx
        pop esi
        mov esp, ebp
        pop ebp
        ret
    }
}
  • 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

3. 实现 longjmp

longjmp 就是一个不按套中出牌的函数,只要我们将 longjmp 函数的返回点修改为 setjmp 函数的返回点,当从 longjmp 函数返回的时候,感觉就好像从 setjmp 函数返回一样。所以 longjmp 主要完成 2 件事情:

  1. 根据 env 参数恢复寄存器环境。
  2. 修改返回点

longjmp 的代码并没有按照正常的函数逻辑走,也没有更新栈帧基址,在寻参的时候,直接使用 esp 定址。

  • longjmp 代码
__declspec(naked)
void longjmp(jmp_buf env, int val) {
    __asm {
        mov eax, [esp + 8] // val,直接使用 esp 寻参了。eax 保存的是函数返回值。
        mov ebx, [esp + 4] // env

        // 恢复 ecx, edx, edi
        mov ecx, [ebx + 4]
        mov edx, [ebx + 8]
        mov edi, [ebx + 16]

        // 恢复 eflags
        sub esp, 4
        mov esi, [ebx + 28]
        mov [esp], esi
        popfd 

        // 恢复 esp , ebp
        mov ebp, [ebx + 24] 
        mov esp, [ebx + 20] 

        // 构造返回地址
        mov esi, [ebx + 32]
        mov [esp], esi

        // 恢复 esi
        mov esi, [ebx + 12]

                // 恢复 ebx
        mov ebx, [ebx] 

        ret
    }
}
  • 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

4. 完整的程序

这个例子,仍然使用了上一篇博文中的例子,只不过这里的 setjmp 和 longjmp 完全是我们自己编写的。

请将下面的代码粘贴到 vs 中运行。

  • 代码
#include <stdio.h>

typedef int jmp_buf[9]; // {0:ebx, 1:ecx, 2:edx, 3:esi, 4:edi, 5:esp, 6:ebp, 7:eflags, 8:ret}

jmp_buf jmpbuf;


__declspec(naked)
int setjmp(jmp_buf env) {
    __asm {
        push ebp
        mov ebp, esp
        sub esp, 0x40
        push esi
        push ebx

        mov eax, 0
        mov ebx, [ebp + 8] // env 基址

        mov [ebx], eax
        mov [ebx+4], ecx
        mov [ebx+8], edx
        mov [ebx+12], esi
        mov [ebx+16], edi

        // 提取 esp(指向 ret 返回地址)
        lea esi, [ebp+4]
        mov [ebx+20], esi

        // 提取真实 ebp
        mov esi, [ebp]
        mov [ebx+24], esi


        // 保存 eflags
        pushfd
        mov esi, [esp]
        add esp, 4
        mov[ebx + 28], esi

        // 保存返回地址,栈帧基址 + 4 的位置指向的值
        mov esi, [ebp + 4]
        mov [ebx + 32], esi

        pop ebx
        pop esi
        mov esp, ebp
        pop ebp
        ret
    }
}


// {0:ebx, 1:ecx, 2:edx, 3:esi, 4:edi, 5:esp, 6:ebp, 7:eflags, 8:ret}
__declspec(naked)
void longjmp(jmp_buf env, int val) {
    __asm {
        mov eax, [esp + 8] // val
        mov ebx, [esp + 4] // env

        // 恢复 ecx, edx, edi
        mov ecx, [ebx + 4]
        mov edx, [ebx + 8]
        mov edi, [ebx + 16]

        // 恢复 eflags
        sub esp, 4
        mov esi, [ebx + 28]
        mov [esp], esi
        popfd 

        // 恢复 esp , ebp
        mov ebp, [ebx + 24] 
        mov esp, [ebx + 20] 

        // 构造返回地址
        mov esi, [ebx + 32]
        mov [esp], esi

        // 恢复 esi
        mov esi, [ebx + 12]

        mov ebx, [ebx] // 恢复 ebx

        ret
    }
}

void doSomething() {
    int n = 0;
    scanf("%d", &n);
    if (n == 100) {
        longjmp(jmpbuf, 1);
    }

    if (n == 200) {
        longjmp(jmpbuf, 2);
    }

}

int global = 100;

int main() {

    int res = 0;
    if ((res = setjmp(jmpbuf)) != 0) {
        printf("hello! res = %d\n", res);
    }


    while (1) {
        doSomething();
    }
}
  • 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
  • 60
  • 61
  • 62
  • 63
  • 64
  • 65
  • 66
  • 67
  • 68
  • 69
  • 70
  • 71
  • 72
  • 73
  • 74
  • 75
  • 76
  • 77
  • 78
  • 79
  • 80
  • 81
  • 82
  • 83
  • 84
  • 85
  • 86
  • 87
  • 88
  • 89
  • 90
  • 91
  • 92
  • 93
  • 94
  • 95
  • 96
  • 97
  • 98
  • 99
  • 100
  • 101
  • 102
  • 103
  • 104
  • 105
  • 106
  • 107
  • 108
  • 109
  • 110
  • 111
  • 112
  • 113
  • 114
  • 115
  • 运行结果


这里写图片描述
图2 运行结果

5. 总结

  • 掌握函数调用原理,理解什么是栈帧。
  • 了解 setjmp 和 longjmp 函数实现原理。

练习:
1. 哪个寄存器保存了栈帧基址?
2. 编译器寻参和寻局部变量是依据是什么?

本文要求读者对汇编语言有一定的基础,不然阅读起来会十分困难。所以,多加练习。如果还有不明白的地方,请在博客下方留言。记住,读 10 行代码,不如手写一行。

猜你喜欢

转载自blog.csdn.net/weixin_38054045/article/details/80898275
今日推荐