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

时间:2018-04-10 09:21:28

标签: c++ assembly ubuntu-14.04 ubuntu-16.04 x86-64

我有代码:

#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;
}

我想要实现的是覆盖func1返回地址,以便在返回后开始执行func2

它在我的Ubuntu 16.04上工作正常,并产生&#34; Hello&#34; 作为输出。 但是,如果我在Ubuntu 14.04上运行相同的代码,即使使用-fno-stack-protector选项,它也会因分段错误而崩溃。

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

  

编译器:gcc 7.3.0

     

GCC选项:-fno-stack-protector

     

架构:x86-64英特尔

2 个答案:

答案 0 :(得分:4)

真正的问题是如何如何使用16.04。使用gcc -O0,您最终会在rbp main作为func2 的返回地址时使用__libc_start_main中的值。

你的程序恰好在Arch Linux上使用glibc 2.26-11软件包,因为__libc_csu_init在调用{rbpmain的地址正好__libc_csu_init {1}}。所以<__libc_start_main+234>运行一个额外的时间(从堆栈中消耗该返回地址),然后它返回到libc.so.6中的call rax(调用{{1}的main之后的指令}})。从那里开始执行就像main正常返回一样,因此清理代码会刷新stdio / iostream缓冲区,最终进行write系统调用以将Hello写入stdout。

您可以看到how the code compiles on the Godbolt compiler explorer,或者当然使用objdump查看您自己的二进制文件,或使用gdb查看自己的二进制文件。

所以,你的程序在任何地方都可以运行(因为寄存器中主要的主叫者离开了),这真是太幸运了。你应该期待它破裂。

当然,这仅适用于gcc -O0,它会启用-fno-omit-frame-pointer,并且不会内联函数。 如果您正常编译(使用-O3),,则会违反这两个假设,因此您的代码完全是假的/不安全的,并且仅对您欺骗编译器并欺骗它的愚蠢的计算机技巧有用。这种事情有时适用于-O0,因为它分别编译每个C语句,而不是在寄存器中保留任何值。

foo asm("rbp")仅保证在foo用作扩展asm()语句的操作数时执行任何操作。 Other uses of local register-asm variables are not supported.但在这种情况下似乎确实做了你想做的事。

<强>&#34;返回&#34;一个不同的功能的开始是完全虚假的。 ret弹出堆栈中的返回地址,因此您输入下一个函数,RSP指向返回地址上方堆栈上的您的调用者。目标函数当然最终会将其用作返回地址,因为它期望用call调用(相当于push ret_addr / jmp)。

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

我认为它在你的Ubuntu 14.04中断了,因为你的libc编译方式不同,并且不会像我的Arch系统那样在RBP中留下一个有用的函数指针(我猜测的类似于你的Ubuntu 16.04系统的libc确实如此。

Tailcall优化:

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

func1:
    do stuff
     ...
    jmp  func2

VS

  ...
  call  func2
  ret

请注意,输入func2的堆栈与输入func1的方式相同,因此当func2在最后运行ret指令时,它将返回func1& #39;呼叫者。您只需将call / ret替换为jmp,即可切断中间人,因为这些操作相互平衡。作为奖励,它甚至不会破坏返回地址预测器,因为ret仍然与call匹配。

使用调试器进行探索

在GDB中,我使用 display *(void **) $rsp @ 4 让gdb在每一步后打印堆栈的前4个值。使用void*可以让GDB将它们作为指针打印出来,如果它们属于已知函数,则用符号名称标记它们,因此它非常便于查看返回地址。

我查看了/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
$9 = (void *) 0x5555555549c0 <__libc_csu_init>

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

我使用layout reg将GDB置于文本UI模式,在该模式下,它会显示您在单独的&#34;窗口中单步执行的指令。从命令。 (有关更多GDB提示,请参阅https://stackoverflow.com/tags/x86/info的底部)。

经过一对si(步骤)命令单步执行一条指令后,我们位于func1的顶部。 main已运行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运行ret后,在func2进入

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

因此func2已使用0x5555555549c0 <__libc_csu_init>的返回地址进行了调用。

$rsp = 0x7fffffffe600,因此堆栈未对齐。 (它应该在 a call之前与进行16字节对齐,因此rsp与函数入口上的16字节对齐相距8个字节。(注意一个jmp尾调可以保持这个。)

我使用ni(下一条指令)来跳过call _ZStlsISt11char_traitsIcEERSt13basic_ostreamIcT_ES5_PKc@plt,因为我并不关心所有代码和懒惰的动态链接器解析内容。

来自ret 之前的func2

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实际上尾部调用了__libc_csu_init,它会在运行后返回main的调用者。 (重做iostream的初始化等等。幸运的是,这个函数并没有破坏仍然持有Hello字符串的I / O缓冲区!也许它会检查对于已经初始化的东西,如果由于其他原因被调用两次。)

TL:DR您的代码超级破解,当然在某些系统上失败。

答案 1 :(得分:1)

  

为什么会这样?

因为编译器可以自由地生成无效代码,所以当源是“未定义的行为”时,你的源代码不是定义良好的C ++,并且编译器决定生成崩溃的机器代码。

即使在我发表评论之后,您也没有理会启发我们为什么您认为它应该有效以及哪些思考过程将您带到了这个来源(您试图通过更改f1的返回地址来解决什么问题) ,所以我无法解释这一点,除非你试图运行无效的源,并且你得到崩溃的结果,这是C ++生态系统的非常正常的行为,即使你实际上试图避免它并且写有效也经常发生C ++,因为那不是 简单。

请记住rbp寄存器,堆栈指针,堆栈内存,堆栈对齐和ret指令不是C ++语言的一部分,并且没有定义要求C ++编译器使用堆栈控制代码流的预期方法,C ++编译器也可以决定你使用自修改跳转,如果它需要,所以返回地址永远不会出现在堆栈上,你必须修改跳转。虽然这是非常不可能的,并且你可以期望x86代码使用call+ret对,但是在C ++源代码中关于堆栈状态的任何其他假设都是没有意义的,底层实现可能很容易放入堆栈中你不期望的东西。 / p>

  

如何让它在14.04上运行?

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

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

您尝试做的有点类似于“retpoline”,因此您可以查看围绕该内容和Linux内核源代码的讨论,以查看实际的现实问题解决方案(在某种程度上降低了高性能价格的安全漏洞),因为我可以告诉做出类似事情的唯一正当理由,因为返回地址篡改将使现代x86 CPU中的内部返回地址缓冲区不同步,这使得下一个ret指令非常昂贵在表现方面。

EDIT :CPU会在嗅探main传入 - 从其内部返回地址缓冲区时,推测性地执行返回ret的路径,并在实际时刻{ {1}}执行它会发现地址不匹配,所以它会抛出整个推测路径并将fetch +执行正确的代码路径改为ret,这需要花费几个周期的CPU来重新加载具有意外代码路径的缓存和缓冲区,并且无法使用在推测路径上已经完成的工作)

因此,如果您的动机是避免f2中的一个ret返回f1中的maincall f2,请保存一个main由于现代x86 CPU的内部复杂性,它不像原始8086那样简单地按指令操作,因此在实践中实际上要慢一些指令,以使代码变得更快。 p>

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

ret+call

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

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

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

在这个简单的案例中,一个优化 on 的现代C ++编译器极有可能将void f2() { /* ... some code ... */ } void f1() { /* ... some code ... */ f2(); } void main() { f1(); } f1内联到f2,从而权衡利弊特殊情况下的这种内联(在避免损害而不是利润的情况下也避免过度内联)。