不同Ubuntu版本二进制执行差异

Different Ubuntu versions binary execution differences

我有代码:

#include <iostream>

void func3()
{
  std::cout << "World!" << std::endl;
}

void func2()
{
  std::cout << "Hello ";
}

void func1()
{
  register long int a asm("rbp");
  long int* ptr = (long int*)(a);
  *(ptr+1) = (long int)&func2;
}

int main()
{
  func1();
  func3();

  return 0;
}

我想要实现的是覆盖 func1return 地址 所以它在 [=31= 之后开始执行 func2 ].

它在我的 Ubuntu 16.04 上工作得很好,并产生 "Hello " 作为输出。 但是,如果我 运行 Ubuntu 14.04 上的相同代码,即使使用 -fno-stack-protector 选项,它也会因 Segmentation Fault 而崩溃。

为什么会这样?如何在 14.04 上 运行?

Compiler: gcc 7.3.0

GCC options: -fno-stack-protector

Architecture: x86-64 Intel

Why does this happen?

因为编译器可以自由生成无效代码,所以当源代码为 "undefined behaviour" 时,您的源代码不是 well-defined C++,编译器决定生成会崩溃的机器代码。

即使在我发表评论之后,您也没有费心去启发我们为什么您认为它应该起作用以及是哪个思维过程将您带到了这个来源(您试图通过更改 return 地址来解决什么问题f1),所以我无法解释更多,除非你试图 运行 无效源,结果导致崩溃,这是 C++ 生态系统的正常行为,甚至经常发生当您真正尝试避免它并编写有效的 C++ 时,因为这并不 简单。

请记住 rbp 寄存器、堆栈指针、堆栈内存、堆栈对齐和 ret 指令不是 C++ 语言的一部分,并且没有定义要求 C++ 编译器使用以预期的方式堆栈以控制代码流,C++ 编译器可能会决定您使用 self-modifying 跳转,如果需要的话,因此 return 地址根本不会出现在堆栈上并且你将不得不修补跳跃。虽然这不太可能,并且您可以期望 x86 代码使用 call+ret 对,但任何其他关于 C++ 源代码中堆栈状态的假设都是毫无意义的,底层实现可能很容易将您不期望的东西放入堆栈。

How to make it run on 14.04?

在调试器中启动它并检查原始代码(不修改堆栈内容)是如何工作的,以及必须在堆栈上修改什么才能使 f1 跳转到 f2。这可能是一些填充或无用的保留堆栈 space 使 f1f2 之间的堆栈结构不兼容,因此通过对堆栈内容进行一些修补,您肯定可以实现您想要的(特别是具有特定编译选项的源代码)。

这当然不是一个稳定的解决方案,即在 f1f2main 中添加更多代码后它肯定会崩溃,可能每个中只有几个局部变量打破你的篡改,甚至不提切换 on 优化器,这可能会完全删除空函数。


您尝试做的事情与 "retpoline" 有点相似,因此您可以查看相关讨论和 linux 内核源代码以查看实际的现实世界问题解决方案(在某种程度上缓解安全漏洞对于高昂的性能价格),据我所知,这是做这样的事情的唯一合法理由,因为 return 地址篡改将使现代 x86 中的内部 return 地址缓冲区 CPU out-of-sync,这使得下一条 ret 指令在性能方面非常昂贵。

(编辑: CPU 将在嗅探 ret 传入时推测性地执行 return 进入 main 的路径 - 来自它是内部 return 地址缓冲区,在实际 ret 执行时它会发现地址不匹配,因此它会抛出整个推测路径并获取并执行正确的代码路径相反,进入 f2,这需要几个 CPU 周期来重新加载具有意外代码路径的缓存和缓冲区,并且无法使用在推测路径上已经完成的工作)

因此,如果您的动机是避免 f1 中的 ret 到 return 到 mainmain 中的 call f2 ,节省了一对 ret+call 指令,使代码更快,但由于现代 x86 CPU 的内部复杂性 CPU,它实际上并不像 per-instruction,和原来的 8086 一样。

出于性能原因,请保持源代码如下:

void f1() { /* ... some code ... */ }

void f2() { /* ... some code ... */ }

void main() {
  f1();
  f2();
}

void f2() { /* ... some code ... */ }

void f1() {
  /* ... some code ... */
  f2();
}

void main() {
  f1();
}

在这种微不足道的情况下,优化 on 的现代 C++ 编译器很可能会将 f1f2 内联到 main 中,加权这种内联在特定情况下的优缺点(也避免过度内联造成的损害大于利润)。

真正的问题是 如何与 16.04 一起工作。使用 gcc -O0,您最终会在进入 main 时使用 rbp 中的值作为来自 func2[=178= 的 return 地址].

你的程序恰好在 Arch Linux 上为我工作,使用他们的 glibc 2.26-11 包,因为它的 __libc_start_main 恰好在 [=19] 中留下了 __libc_csu_init 的地址=] 当它调用 main 时。所以 __libc_csu_init 运行s 一个额外的时间(从堆栈中消耗那个 return 地址),然后它 returns 到 libc.so 中的 <__libc_start_main+234>。 6(调用maincall rax之后的指令)。从那里开始,执行就像 main 正常进行 returned 一样,因此清理代码刷新 stdio / iostream 缓冲区,最终使 write 系统调用写入 Hello到标准输出。

你可以看到how the code compiles on the Godbolt compiler explorer,当然也可以通过gdb.

查看你自己的二进制文件objdump或single-step

因此,您的程序能够在任何地方运行(因为 main 的调用者在寄存器中留下的东西)纯属幸运。你应该预料到它会破裂。

当然,这仅适用于启用 -fno-omit-frame-pointergcc -O0,而不适用于内联函数。 如果你正常编译(-O3),这两个假设都被违反了,所以你的代码完全是伪造的/不安全的,只对你欺骗编译器的愚蠢的计算机技巧有用并欺骗它。这种事情有时与 -O0 一起工作,因为它单独编译每个 C 语句,不在寄存器中保留任何值。

foo asm("rbp") 仅当 foo 用作扩展 asm() 语句 的操作数时才能保证执行任何操作。 Other uses of local register-asm variables are not supported. 不过,在这种情况下,它确实符合您的要求。

"Returning"以不同函数开头完全是伪造的ret 从堆栈中弹出 return 地址,所以你进入下一个函数 RSP 指向任何 你的 调用者留在你上面的堆栈中return 地址。目标函数当然最终会使用它作为 return 地址,因为它期望用 call 调用(相当于 push ret_addr / jmp)。

在这种情况下,大多数版本的 gcc 不会在 main 中分配任何额外的 space,只会执行 push rbp / ... / call func1 .在进入 func1 时,堆栈保存 return 地址(进入 main 的中间),以及 main 保存的 RBP 值。

我假设它在您的 Ubuntu 14.04 上中断,因为您的 libc 编译方式不同,并且不会像我的 Arch 系统那样在 RBP 中留下有用的函数指针(我猜这类似于你的 Ubuntu 16.04 系统的 libc 做了什么。


尾调用优化:

通常情况下,如果您想从一个函数直接转到另一个函数而不执行 ret / call,您可以使用 jmp func2 而不是 ret 结束一个函数。无法通过内联 asm 获得此信息,因为编译器不会将您的代码放在 pop rbp.

之后
func1:
    do stuff
     ...
    jmp  func2

对比

  ...
  call  func2
  ret

请注意,func2 进入堆栈时与进入 func1 时的堆栈相同,因此当 func2 运行 末尾有一条 ret 指令时,它将 return 到 func1 的来电者。您只是通过将 call / ret 替换为 jmp 来切断中间人,因为这些操作相互平衡。作为奖励,它甚至不会破坏 return-address 预测器,因为 rets 仍然与 calls 匹配。


使用调试器探索:

在 GDB 中,我使用 display *(void **) $rsp @ 4 让 gdb 在每个 single-step 之后打印堆栈上的前 4 个值。使用 void* 让 GDB 将它们打印为指针,如果它们在已知函数内则用符号名称标记它们,因此查看 return 地址真的很方便。

我查看了 /proc/PID/maps,发现 0x7ffff7157f4a <__libc_start_main+234>/usr/lib/libc-2.26.so 中。

我在 main 开始的 push rbp 上设置了一个断点(而不是在 b main 会放置一个的函数序言之后)。那时:

Breakpoint 2, main () at ret-frob.cpp:41
1: *(void **) $rsp @ 4 = {0x7ffff7157f4a <__libc_start_main+234>, 0x11c00, 0x7fffffffe6e8, 0x1ffffe6f8}
(gdb) p (void*)$rbp
 = (void *) 0x5555555549c0 <__libc_csu_init>

如您所见,main 的正常 return 地址是 0x7ffff7157f4a <__libc_start_main+234>。这就是调用 main 的 libc 函数告诉它 return 的地方。做任何其他事情都违反了调用约定。 (除了调用 exit_exit,或其他一些永远不会 returning 的方式)。

我使用 layout reg 将 GDB 置于 text-UI 模式,在该模式下,它会在与命令分开的 "window" 中显示您正在逐步执行的指令。 (请参阅 https://whosebug.com/tags/x86/info 的底部以获取更多 GDB 技巧)。

在通过一条指令 si (stepi) 命令到 single-step 之后,我们位于 func1 的顶部。主要有 运行 push rbp / call func1:

func1 () at ret-frob.cpp:27
1: *(void **) $rsp @ 4 = {0x55555555494c <main()+9>, 0x5555555549c0 <__libc_csu_init>, 0x7ffff7157f4a <__libc_start_main+234>, 0x11c00}

当func1是关于ret:

1: *(void **) $rsp @ 4 = {0x555555554909 <func2()>, 0x5555555549c0 <__libc_csu_init>, 0x7ffff7157f4a <__libc_start_main+234>, 0x11c00}

func1 运行s ret 之后,在进入 func2 时:

1: *(void **) $rsp @ 4 = {0x5555555549c0 <__libc_csu_init>, 0x7ffff7157f4a <__libc_start_main+234>, 0x11c00, 0x7fffffffe6e8}

所以 func2 调用了 return 地址 0x5555555549c0 <__libc_csu_init>

$rsp = 0x7fffffffe600,所以栈错位了。 (它应该是 16 字节对齐的before a call,所以 rsp 距离函数入口的 16 字节对齐有 8 个字节。 (请注意,jmp 尾调用维护了这一点。)

我使用 ni (next-instruction) 跨过 call _ZStlsISt11char_traitsIcEERSt13basic_ostreamIcT_ES5_PKc@plt 因为我不关心所有的代码和惰性动态链接器解析的东西。

之前 retfunc2:

1: *(void **) $rsp @ 4 = {0x5555555549c0 <__libc_csu_init>, 0x7ffff7157f4a <__libc_start_main+234>, 0x11c00, 0x7fffffffe6e8}

进入__libc_csu_init:

1: *(void **) $rsp @ 4 = {0x7ffff7157f4a <__libc_start_main+234>, 0x11c00, 0x7fffffffe6e8, 0x1ffffe6f8}

所以 main+func1+func2 有效地 tail-called __libc_csu_init,它会在 运行ning 之后 return 到 main 的调用者。 (并重做 iostream 的初始化,等等。幸运的是,这个函数没有破坏仍然保存 Hello 字符串的 I/O 缓冲区!也许它会检查已经初始化的东西,以防它是由于其他原因被调用了两次。)

TL:DR 你的代码非常糟糕,当然它在某些系统上会失败。