调用堆中函数时出现“分段错误”

15

我在这里试图微调规则,使用malloc分配一个缓冲区,然后将函数复制到缓冲区中。

调用缓冲的函数可以工作,但是当我尝试调用另一个函数时,该函数会抛出分段错误。

有什么想法为什么会这样?

#include <stdio.h>
#include <sys/mman.h>
#include <unistd.h>
#include <stdlib.h>

int foo(int x)
{
    printf("%d\n", x);
}

int bar(int x)
{
}

int main()
{
    int foo_size = bar - foo;

    void* buf_ptr;

    buf_ptr = malloc(1024);

    memcpy(buf_ptr, foo, foo_size);

    mprotect((void*)(((int)buf_ptr) & ~(sysconf(_SC_PAGE_SIZE) - 1)),
             sysconf(_SC_PAGE_SIZE),
             PROT_READ|PROT_WRITE|PROT_EXEC);

    int (*ptr)(int) = buf_ptr;

    printf("%d\n", ptr(3));

    return 0;
}

如果我不改变foo函数,这段代码将会引发“段错误(Segmentation fault)”:

int foo(int x)
{
    //Anything but calling another function.
    x = 4;
    return x;
}

注意:

该代码成功地将foo复制到缓冲区中,我知道我做了一些假设,但在我的平台上它们是正确的。


2
我认为你可以使用位置无关代码来执行你的代码,因为你的代码正在执行函数重定位。这是由于相对跳转到“printf”。 - LPs
3
可能是因为你的代码不是位置无关的。尝试使用调试器逐步执行汇编代码,你就会看到发生了什么。顺便说一下,你所尝试的做法可能是未定义行为。 - Jabberwocky
1
说句实话,这段代码的任何版本都无法在OS X上的Clang上运行。顺便提一下,你在foo()bar()中漏了返回语句。 - John Zwinck
1
@Medals,也许在你的平台上不是UB,但通常情况下它是。如果调用与foo相关,则foo的代码不是位置无关的。调用地址相对于原始foo函数,但由于你只是将原始foo的代码复制到缓冲区中,所以printf的相对地址在复制的foo函数中将会出错。 - Jabberwocky
2
@Medals 我将这些指针强制转换为指向相同对象的 'point'。 这根本没有任何意义。 - 2501
显示剩余12条评论
3个回答

38

你的代码不是位置无关的,即使是这样,你也没有正确的重定位来将其移动到任意位置。你对printf(或任何其他函数)的调用将使用pc相对寻址进行(通过PLT,但这与此处的重点无关)。这意味着生成的调用printf的指令不是对静态地址的调用,而是“从当前指令指针开始调用函数X个字节”。由于你移动了代码,所以调用是在错误的地址上完成的。(我假设这里是i386或amd64,但通常这是一个安全的假设,使用奇怪平台的人通常会提到)。

更具体地说,x86有两个不同的函数调用指令。其中一个是相对于指令指针的调用,它通过将一个值添加到当前指令指针来确定函数调用的目的地。这是最常用的函数调用。第二个指令是调用寄存器或内存位置内部的指针。编译器很少使用它,因为它需要更多的内存间接引用并阻止管道。共享库的实现方式(你对printf的调用实际上将转到共享库中)是,对于你在自己的代码之外进行的每个函数调用,编译器都会在你的代码附近插入假函数(这就是我上面提到的PLT)。你的代码对此假函数进行了正常的pc相对调用,假函数会找到printf的真实地址并调用它。虽然这并不重要。几乎所有正常的函数调用都将是pc相对的,并且将失败。在这种代码中唯一的希望是函数指针。

你可能还会遇到对可执行mprotect的限制。检查mprotect的返回值,在我的系统上,你的代码不起作用的另一个原因是: mprotect 不允许我这样做。可能是因为malloc的后端内存分配器有额外的限制,防止其内存受到执行保护。这引出了下一个问题:

通过调用mprotect来破坏未被你管理的内存,包括从malloc获取的内存。你应该只对通过mmap从内核自己获取的东西进行mprotect

以下是演示如何使此代码工作的版本(在我的系统上):

#include <stdio.h>
#include <sys/mman.h>
#include <unistd.h>
#include <string.h>
#include <err.h>

int
foo(int x, int (*fn)(const char *, ...))
{
        fn("%d\n", x);
        return 42;
}

int
bar(int x)
{
        return 0;
}

int
main(int argc, char **argv)
{
        size_t foo_size = (char *)bar - (char *)foo;
        int ps = getpagesize();

        void *buf_ptr = mmap(NULL, ps, PROT_READ|PROT_WRITE|PROT_EXEC, MAP_ANON|MAP_PRIVATE, -1, 0);

        if (buf_ptr == MAP_FAILED)
                err(1, "mmap");

        memcpy(buf_ptr, foo, foo_size);

        int (*ptr)(int, int (*)(const char *, ...)) = buf_ptr;

        printf("%d\n", ptr(3, printf));

        return 0;
}

我在这里滥用了编译器生成函数调用代码的知识。通过使用函数指针,我强制它生成一个不是pc-relative的调用指令。此外,我自己管理内存分配,以便从一开始就获得正确的权限,并且不会遇到任何可能由brk引起的限制。作为奖励,我们进行错误处理,实际上帮助我找到了第一个版本中的一个错误,并纠正了其他一些小错误(如缺少包含文件),这使我能够在编译器中启用警告并捕获另一个潜在的问题。

如果您想深入了解此内容,可以尝试以下方法。我添加了两个版本的函数:

int
oldfoo(int x)
{
        printf("%d\n", x);
        return 42;
}

int
foo(int x, int (*fn)(const char *, ...))
{
        fn("%d\n", x);
        return 42;
}

编译整个东西并反汇编它:

$ cc -Wall -o foo foo.c
$ objdump -S foo | less

我们现在可以看一下生成的两个函数:


0000000000400680 <oldfoo>:
  400680:       55                      push   %rbp
  400681:       48 89 e5                mov    %rsp,%rbp
  400684:       48 83 ec 10             sub    $0x10,%rsp
  400688:       89 7d fc                mov    %edi,-0x4(%rbp)
  40068b:       8b 45 fc                mov    -0x4(%rbp),%eax
  40068e:       89 c6                   mov    %eax,%esi
  400690:       bf 30 08 40 00          mov    $0x400830,%edi
  400695:       b8 00 00 00 00          mov    $0x0,%eax
  40069a:       e8 91 fe ff ff          callq  400530 <printf@plt>
  40069f:       b8 2a 00 00 00          mov    $0x2a,%eax
  4006a4:       c9                      leaveq
  4006a5:       c3                      retq

00000000004006a6 <foo>:
  4006a6:       55                      push   %rbp
  4006a7:       48 89 e5                mov    %rsp,%rbp
  4006aa:       48 83 ec 10             sub    $0x10,%rsp
  4006ae:       89 7d fc                mov    %edi,-0x4(%rbp)
  4006b1:       48 89 75 f0             mov    %rsi,-0x10(%rbp)
  4006b5:       8b 45 fc                mov    -0x4(%rbp),%eax
  4006b8:       48 8b 55 f0             mov    -0x10(%rbp),%rdx
  4006bc:       89 c6                   mov    %eax,%esi
  4006be:       bf 30 08 40 00          mov    $0x400830,%edi
  4006c3:       b8 00 00 00 00          mov    $0x0,%eax
  4006c8:       ff d2                   callq  *%rdx
  4006ca:       b8 2a 00 00 00          mov    $0x2a,%eax
  4006cf:       c9                      leaveq
  4006d0:       c3                      retq

对于printf函数调用的指令是“e8 91 fe ff ff”。这是一个相对于程序计数器(pc)的函数调用,偏移量为0xfffffe91字节,它被视为带符号32位值,计算中使用的指令指针是下一条指令的地址。因此,0x40069f(下一条指令)-0x16f(0xfffffe91在前面,带符号数学表示为0x16f字节后退)得到地址0x400530,在反汇编代码中我找到了这个地址处的内容:

0000000000400530 <printf@plt>:
  400530:       ff 25 ea 0a 20 00       jmpq   *0x200aea(%rip)        # 601020 <_GLOBAL_OFFSET_TABLE_+0x20>
  400536:       68 01 00 00 00          pushq  $0x1
  40053b:       e9 d0 ff ff ff          jmpq   400510 <_init+0x28>

这是我之前提到的神奇的“假函数”,不要深究它是如何工作的。对于共享库的运行,它是必需的,我们现在只需要知道这些。

第二个函数生成函数调用指令“ff d2”。这意味着“调用存储在rdx寄存器内地址的函数”。没有PC相对寻址,这就是它能够工作的原因。


这段代码只是为了好玩而写的,所以检查函数返回值并不是我首要考虑的事情,因为它在我的系统上运行良好。但是我明白了,谢谢。 - Delights
2
可能由于W^X的原因,mprotect()在您的系统上无法正常工作:https://en.wikipedia.org/wiki/W%5EX - Dietrich Epp
3
在我看来,检查错误可以让调试变得不那么令人沮丧,并且更加有趣。 - Art
@DietrichEpp 正如发明这个术语的Theo多次表示的那样,即使是最近,w^x也不能被强制执行。许多知名程序(例如Firefox)将在我们开始严格执行它时失败。目前,w^x只是一个“不要编写这样的代码”的策略,而不是“内核会阻止你这样做”的策略。我怀疑Linux刚刚开始对我们从brk获得的堆施加最大保护(这应该是安全的)。 - Art
@EmilJeřábek 没有任何问题。这不是关于使其可靠(为此,我们至少需要链接器脚本),而是探索正在发生的事情。正如 OP 解释的那样,他这样做只是为了好玩看看会发生什么。我甚至没有尝试计算我们在这里遇到的标准违规和未定义行为,但它们是众多的。 - Art
显示剩余6条评论

3
编译器在遵循“如同规则”(即可观察结果正确)的前提下自由生成代码。因此,你所做的只是一次未定义行为调用。
Visual Studio有时会使用中转。这意味着函数的地址只指向相对跳转。这在标准中完全被允许,因为符合如同规则,但它肯定会破坏那种结构。另一个可能性是将局部内部函数与函数本身外部的相对跳转结合使用。在这种情况下,你的代码不会复制它们,而相对调用只会指向随机内存。这意味着使用不同的编译器(甚至是同一编译器的不同编译选项)可能会产生预期的结果、崩溃或直接结束程序而不出现错误,这正是未定义行为。

1
我想我可以解释一下。首先,如果你的两个函数都没有返回语句,则根据标准§6.9.1/12会调用未定义的行为。其次,这在许多平台上是最常见的情况,包括你的平台:函数的相对地址被硬编码到函数的二进制代码中。这意味着,如果你在“foo”函数中调用“printf”,然后从另一个位置移动(例如执行),那么应该调用“printf”的地址就会变得不正确。

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接