我试图在这里稍微调整一下规则,malloc
是一个缓冲区,然后将函数复制到缓冲区。
调用缓冲函数是可行的,但当我试图调用其中的另一个函数时,该函数会抛出Segmentation错误。
有什么想法吗?
#include <stdio.h>
#include <sys/mman.h>
#include <unistd.h>
#include <stdlib.h>
int foo(int x)
{
printf("%dn", 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("%dn", ptr(3));
return 0;
}
除非我将foo
函数更改为:,否则此代码将引发segfault
int foo(int x)
{
//Anything but calling another function.
x = 4;
return x;
}
注:
代码成功地将foo
复制到了缓冲区中,我知道我做了一些假设,但在我的平台上,它们是可以的。
您的代码不是位置独立的,即使是,您也没有正确的重新定位来将其移动到任意位置。您对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("%dn", 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("%dn", ptr(3, printf));
return 0;
}
在这里,我滥用了编译器如何为函数调用生成代码的知识。通过使用函数指针,我强制它生成一个与pc无关的调用指令。此外,我自己管理内存分配,以便从一开始就获得正确的权限,而不会遇到brk
可能具有的任何限制。作为奖励,我们进行了错误处理,这实际上帮助我在这个实验的第一个版本中发现了一个错误,我还纠正了其他小错误(如缺少include),这使我能够在编译器中启用警告并发现另一个潜在问题。
如果你想更深入地研究这个问题,你可以做这样的事情。我添加了两个版本的功能:
int
oldfoo(int x)
{
printf("%dn", x);
return 42;
}
int
foo(int x, int (*fn)(const char *, ...))
{
fn("%dn", 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相关寻址,这就是它工作的原因。
编译器可以按照自己想要的方式自由生成代码,前提是可观察的结果是正确的(就像规则一样)。因此,您所做的只是一个未定义的行为调用。
Visual Studio有时会使用中继。这意味着函数的地址只指向一个相对跳跃。根据标准,这是完全允许的,因为这是规则,但它肯定会打破这种结构。另一种可能性是用相对跳跃调用局部内部函数,但在函数本身之外。在这种情况下,您的代码不会复制它们,相对调用只会指向随机内存。这意味着,使用不同的编译器(甚至同一编译器上的不同编译选项),它可能会给出预期的结果、崩溃或直接结束程序而不出错,这正是UB。
我想我可以解释一下。首先,如果两个函数中都没有返回语句,则根据标准§6.9.1/12调用未定义的行为。其次,在许多平台上最常见的是,函数的相对地址被硬编码为函数的二进制代码,显然你的平台也是如此。这意味着,如果在"foo"中调用"printf",然后从另一个位置移动(例如执行),那么应该调用"printf"的地址就会变坏。