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函数更改为:

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

笔记:

代码成功复制foo到缓冲区,我知道我做了一些假设,但在我的平台上它们没问题。

4

3 回答 3

38

您的代码与位置无关,即使是,您也没有正确的重定位将其移动到任意位置。您对printf(或任何其他函数)的调用将通过 pc 相对寻址(通过 PLT,但这不是这里的重点)来完成。这意味着生成调用 printf 的指令不是对静态地址的调用,而是“从当前指令指针调用函数 X 字节”。由于您移动了代码,因此调用已完成到错误的地址。(我在这里假设 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 无关的调用指令。此外,我自己管理内存分配,以便我们从一开始就获得正确的权限,而不会遇到任何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 相对寻址,这就是它起作用的原因。

于 2016-05-11T07:56:30.747 回答
3

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

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

于 2016-05-11T08:06:46.773 回答
1

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

于 2016-05-11T08:00:03.843 回答