【问题标题】:Changing the return address of a function not working as expected更改未按预期工作的函数的返回地址
【发布时间】:2023-02-24 08:03:15
【问题描述】:

最近学习了Assembly x86,里面的函数是怎么实现的,栈程序是怎么工作的。但是,我尝试编写这个程序,它通过改变当前被调用函数(f1)的返回地址来调用函数f2,这样指令指针在完成f1时就开始f2,因此不会直接返回到main。它似乎不稳定,有时我会遇到分段错误,而在另一种情况下它可以工作但不返回 0。这是为什么?我的猜测是程序堆栈在运行时没有在内存中分配连续的空间,因此它的行为不是恒定的。有时,如果更改“v[2] = (uintptr_t) f2;”,它会起作用。进入“v[another_index_greater_than_2] = (uintptr_t) f2;”。这很奇怪,因为理论上 v[1] 应该是压入堆栈的旧基指针,而 v[2] 应该是函数的返回地址。

#include <iostream>

using namespace std;

int main();

void f2()
{
    int v[1];
    cout << "f2\n";
    v[2] = (uintptr_t) main;
}

void f1()
{
    int v[1];
    cout << "f1\n";
    v[2] = (uintptr_t) f2;
}

int main()
{
    f1();
    cout << "Back to main";
    return 0;
}

我希望看到按顺序打印的 3 个字符串 (f1、f2、main) 和程序返回 0,但程序的行为似乎是随机的。

【问题讨论】:

  • 也许堆栈上的数据比您预期的要多?您使用的编译器是什么?什么是目标系统?使用的 ABI 是什么?堆栈框架是什么样的?
  • 另请注意,目前确实没有具有 64 位 int 类型的系统,而 64 位系统上的指针是 64 位的。将 64 位存储在 32 位类型中效果不佳。
  • 我在 Windows CodeBlocks 中编写代码并使用 GNU GCC 编译
  • 作为测试,编译代码#include &lt;iostream&gt; int main() { std::cout &lt;&lt; sizeof(int*); }。如果该值为 8,那么您正在为 x64 进行编译,并且指针值不适合 int 给您带符号整数溢出和未定义的行为。
  • 这显然是未定义的行为,因此任何期望都是不正确的。例如,编译器可以看到越界访问并完全忽略它。它可能适用于特定 ABI 上特定版本的特定编译器,但通常不能以任何方式移植。

标签: c++ assembly


【解决方案1】:

正如@sklott 所说,编译器可能会通过优化之类的方式产生意想不到的代码。这个答案假定它是预期的输出。

在函数序言中,首先压入rbp/ebp寄存器。因此,返回地址前有一个压入的rbp/ebp寄存器。 如果你编译x64,推送的rbp寄存器是8字节但是int可能是4字节.

在这种情况下,您可能会覆盖已保存的 rbp 上的高 4 字节。可能会导致f2不会运行,会返回main。避免的rbp会被覆盖,在某些情况下会导致段错误。

Assumed stack (without canary):
+-------------------------+------+
|rsp+0x8 |                | v[0] |
+-------------------------+------+
|rsp+0x10| saved rbp      | v[1] |
+-------------------------+------+
|rsp+0x18| return address | v[2] |
+-------------------------+------+

Actual Stack (without canary):
+-------------------------+------+
|rsp+0x8 |                | v[0] |
+-------------------------+------+
|rsp+0x10| saved rbp      | v[1] |
|rsp+0x14|                | v[2] |
+-------------------------+------+
|rsp+0x18| return address |      |
+-------------------------+------+

要解决它,请将int v[1] 替换为uintptr_t v[1];。 但是,(uintptr_t) main; 会再次调用f1();,所以这将是一个无限循环。


请注意,如果不在 GCC 中添加 -fno-stack-protector,代码将无法运行,因为默认情况下启用了金丝雀。但是,由于它似乎在问题中起作用,所以我认为它已被添加。

【讨论】:

  • 您的图表应该适用于 x86-64 吗?注册名称为RBP; EBP 只是它的低半部分。此外,如果 GCC 或 Clang 为本地保留额外空间,则 GCC 或 Clang 将始终保持 RSP 至少对齐 8,通常为 16 字节边界。因此,将返回地址留在 [rsp+0x14] 的最终图表是不正确的。此外,8 字节 RBP 不能拆分为从 0x8 和 0x10 开始的两个不连续的块。也许你的意思是 0xc 和 0x10。
  • 无论如何,是的,GCC 或 clang 可能会在堆栈上保留额外的空间,以使其保持 16 位对齐(或者甚至浪费额外的 16 个字节:Why does GCC allocate more space than necessary on the stack, beyond what's needed for alignment?)。但是你实际的第二张图是不合理的。 godbolt.org/z/EfE71eGYT 展示了 GCC12 的实际作用;在push rbp 之后,它执行sub rsp, 16 以保持 RSP 对齐,但int v[] 位于保存的 RBP 正下方,因此 v[2] = ... 存储到 [rbp+4]。所以是的,uintptr_t v[1];,然后覆盖v[1] = main
  • 启用优化后,v[2] = ... 将被完全优化掉。它只是存储到一个立即超出范围的局部变量。 GCC 警告它已超出数组末尾,但这是未定义的行为;没有保证 GCC 必须尊重的语义。覆盖返回地址不是 C++ 抽象机中的事情,只是编译时没有针对 x86-64 进行优化的实现细节。
  • @PeterCordes 谢谢,ebp 是我的错误。我解决了。
猜你喜欢
  • 2015-08-01
  • 2019-09-15
  • 1970-01-01
  • 1970-01-01
  • 1970-01-01
  • 2018-02-01
  • 2012-01-21
  • 2018-06-16
  • 2019-10-30
相关资源
最近更新 更多