调用位于堆中的函数时出现分段错误

时间:2016-05-11 07:23:45

标签: c linux mprotect

我试图稍微调整规则,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函数更改为:

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

注:

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

3 个答案:

答案 0 :(得分:38)

您的代码与位置无关,即使它是,您也没有正确的重定位将其移动到任意位置。您对printf(或任何其他功能)的调用将通过pc相对寻址完成(通过PLT,但除了此处之外)。这意味着为调用printf而生成的指令不是对静态地址的调用,而是从当前指令指针调用函数X字节&#34;。由于您移动了代码,因此调用了一个错误的地址。 (我在这里假设i386或amd64,但通常它是一个安全的假设,那些在奇怪平台上的人通常会提到这一点。)

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

您可能还会对可执行文件mprotect遇到一些限制。检查mprotect的返回值,在我的系统中,您的代码无法正常工作:mprotect不允许我这样做。可能是因为malloc的后端内存分配器具有阻止其内存可执行保护的附加限制。这让我想到了下一点:

您将通过在您未管理的内存上调用mprotect来解决问题。这包括你从malloc得到的记忆。您应该只通过mprotect自己mmap从内核获得的内容。

这是一个演示如何使其工作的版本(在我的系统上):

#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案例中函数调用的指令是&#34; e8 91 fe ff ff&#34;。这是一个与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>

这是神奇的假冒功能&#34;我之前提到过。让我们不要了解这是如何工作的。共享库的工作是必要的,这是我们现在需要知道的全部内容。

第二个函数生成函数调用指令&#34; ff d2&#34;。这意味着&#34;在存储在rdx寄存器内的地址调用该函数&#34;。没有与pc相关的寻址以及它的工作原理。

答案 1 :(得分:3)

如果可观察结果正确(如规则),编译器可以按照自己想要的方式自由生成代码。所以你所做的只是一个未定义的行为调用。

Visual Studio有时会使用中继。这意味着函数的地址只指向相对跳跃。这是完全允许每个标准,因为按照规则,但它肯定会打破这种结构。另一种可能性是使用相对跳转调用本地内部函数但在函数本身之外。在这种情况下,您的代码不会复制它们,相对调用只会指向随机内存。这意味着使用不同的编译器(甚至是同一编译器上的不同编译选项),它可以给出预期的结果,崩溃或直接结束程序而没有错误,这正好是UB。

答案 2 :(得分:1)

我想我可以解释一下。首先,如果您的函数都没有返回语句,则根据标准§6.9.1/ 12调用未定义的行为。其次,在很多平台上最常见,而你的显然也是如此:函数的相对地址被硬编码为函数的二进制代码。这意味着,如果您在“foo”中调用“printf”然后从另一个位置移动(例如执行),则应该调用“printf”的地址变坏。