C语言 为什么允许我使用 ret. 退出主目录



我即将弄清楚程序堆栈是如何设置的。 我了解到调用函数

call pointer;

实际上与以下相同:

mov register, pc ;programcounter
add register, 1 ; where 1 is one instruction not 1 byte ...
push register
jump pointer

但是,这意味着当 Unix 内核调用 main 函数时,堆栈基础应指向调用 main 的内核函数中的重新进入。

因此,在 C 代码中跳转 "*rbp-1" 应该重新进入主函数。

但是,这不是以下代码中发生的情况:

#include <stdlib.h>
#include <unistd.h>
extern void ** rbp(); //pointer to stack pointing to function
int main() {
void ** p = rbp();
printf("Main: %pn", main);
printf("&Main: %pn", &main); //WTF
printf("*Main: %pn", *main); //WTF
printf("Stackbasepointer: %pn", p);
int (*c)(void) = (*p)-4;
asm("movq %rax, 0");
c();
return 0;        //should never be executed...
}

程序集文件: rsp.asm

.intel_syntax
.text:
.global _rbp
_rbp:
mov rax, rbp
ret;

这是不允许的,不出所料,也许是因为此时的指令不是正好是 64 位,也许是因为 UNIX 不允许这样做......

但也不允许此调用:

void (*c)(void) = (*p);
asm("movq %rax, 0"); //Exit code is 11, so now it should be 0
c(); //this comes with stack corruption, when successful

这意味着我没有义务退出主调用函数。

那么我的问题是:为什么当我使用每个 GCC 主函数末尾看到的 ret 时,它应该有效地与上面的代码相同。Unix - 系统如何有效地检查此类尝试... 我希望我的问题很清楚...

谢谢。 PS:代码只能在macOS上编译,更改Linux的汇编

Cmain是从 CRT 启动代码(间接)调用的,而不是直接从内核调用的。

main返回后,该代码调用atexit函数来执行刷新 stdio 缓冲区等操作,然后将 main 的返回值传递给原始_exit系统调用。 或者exit_group退出所有线程。


你做了几个错误的假设,我认为所有这些都是基于对内核工作原理的误解。

  • 内核以与用户空间不同的权限级别运行(环 0 与环 3 在 x86 上)。 即使用户空间知道要跳转到的正确地址,它也无法跳转到内核代码。 (即使可以,它也不会以内核权限级别运行)。

    ret不是魔法,它基本上只是pop %rip,不会让你跳到任何你不能跳到其他指令的地方。 也不会更改权限级别1

  • 运行用户空间代码时,内核地址不被映射/访问;这些页表条目被标记为仅限主管。 (或者它们根本没有映射到缓解 Meltdown 漏洞的内核中,因此进入内核会通过更改 CR3 的"包装器"代码块。

    虚拟内存是内核保护自己免受用户空间影响的方式。用户空间不能直接修改页表,只能通过mmapmprotect系统调用要求内核来修改。 (并且用户空间无法执行特权指令(如mov cr3, rax来安装新的页表)。 这就是环 0(内核模式)与环 3(用户模式)的目的。

  • 内核堆栈独立于进程的用户空间堆栈。 (在内核中,每个任务(又名线程)还有一个小的内核堆栈,在该用户空间线程运行时的系统调用/中断期间使用。 至少Linux是这样做的,IDK是关于其他人的。

  • 内核并没有从字面上call用户空间代码;用户空间堆栈不会将任何返回地址保留回内核。内核>用户转换涉及交换堆栈指针以及更改权限级别。 例如,使用像iret(中断-返回)这样的指令。

    另外,在用户空间可以看到的地方留下内核代码地址会破坏内核 ASLR。

脚注 1:(编译器生成的ret将始终是正常的近ret,而不是可以通过调用门或其他东西返回到特权cs值的retf。 x86 通过低 2 位 CS 处理权限级别,但没关系。 MacOS/Linux没有设置用户空间可以用来调用内核的呼叫门;这是通过syscallint 0x80说明完成的。


一个新的进程中(在execve系统调用用新 PID 替换以前的进程之后),执行从进程入口点(通常标记为_start)开始,而不是直接在 Cmain函数开始。

C 实现附带 CRT(C 运行时)启动代码,该代码具有(除其他外)手写的 asm 实现_start,它(间接)调用main,根据调用约定将参数传递给 main。

_start本身不是一个函数。在进程进入时,RSP 指向argc,高于用户空间堆栈的argv[0]argv[1]等。 (即char *argv[]数组按值就在那里,envp数组高于该值。_startargc加载到寄存器中,并将指向 argv 和 envp 的指针放入寄存器中。 (MacOS 和 Linux 都使用的 x86-64 System V ABI 记录了所有这些,包括进程启动环境和调用约定。

如果您尝试_startret,您只需将argc弹出到 RIP 中,然后从绝对地址12(或其他少量数字)获取代码将出现段错误。 例如,_start 中 RET 上的 Nasm 分段错误显示尝试从进程入口点(在没有 CRT 启动代码的情况下链接)ret。 它有一个手写的_start,刚刚落入main


当您运行gcc main.c时,gcc前端运行多个其他程序(使用gcc -v显示详细信息)。 以下是 CRT 启动代码链接到流程的方式:

  • gcc 预处理 (CPP) 并编译 + 汇编main.cmain.o(或临时文件)。 在MacOS上,gcc命令实际上是clang,它有一个内置的汇编器,但真正的gcc确实编译为asm,然后在上面运行as。 (不过,C 预处理器内置于编译器中。
  • GCC 运行类似ld -dynamic-linker /lib64/ld-linux-x86-64.so.2 -pie /usr/lib/Scrt1.o /usr/lib/gcc/x86_64-pc-linux-gnu/9.1.0/crtbeginS.o main.o -lc -lgcc /usr/lib/gcc/x86_64-pc-linux-gnu/9.1.0/crtendS.o. 这实际上简化了很多,省略了一些 CRT 文件,并且路径规范化以删除../../lib部分。 此外,它不直接运行ld,它运行collect2这是ld的包装器。 但无论如何,它会静态链接那些包含_start和其他一些东西的.oCRT 文件,并动态链接 libc (-lc) 和 libgcc(用于 GCC 辅助函数,如实现__int128乘除与 64 位寄存器,以防您的程序使用这些
  • )。

.intel_syntax
.text:
.global _rbp
_rbp:
mov rax, rbp
ret;

这是不允许的,...

不组装的唯一原因是您尝试将.text:声明为标签,而不是使用.text指令。 如果删除尾随:它确实会与 clang 组装(.intel_syntax处理与.intel_syntax noprefix相同)。

对于GCC/GAS组装它,您还需要noprefix告诉它寄存器名称不以%为前缀。 (是的,您可以使用英特尔操作 dst、src 顺序,但仍具有%rsp寄存器名称。 不,你不应该这样做! 当然,GNU/Linux 不使用前导下划线。

不过,并不是说如果你叫它,它总是会做你想做的事! 如果您在没有优化的情况下编译main(因此-fno-omit-frame-pointer有效),那么是的,您将获得指向返回地址下方堆栈插槽的指针。


而且您肯定错误地使用了该值(*p)-4;加载保存的RBP值(*p),然后偏移四个8字节的空指针。 (因为这就是 C 指针数学的工作方式;*p具有类型void*,因为p具有类型void **)。

我认为您正在尝试获取自己的返回地址并重新运行到达 main 的call指令(在 main 的调用者中),最终导致推送更多返回地址导致堆栈溢出。 在 GNU C 中,使用void * __builtin_return_address (0)获取您自己的退货地址。

x86call rel32指令为 5 个字节,但调用 main 的call可能是间接调用,使用寄存器中的指针。 所以它可能是 2 字节call *%rax或 3 字节call *%r12,除非您拆卸调用方,否则您不知道。 (我建议在反汇编模式下使用调试器在main结束时按指令(GDB/LLDBstepi)单步执行。 如果它有任何主调用方的符号信息,您将能够向后滚动并查看上一条指令是什么。

如果没有,你可能不得不尝试看看什么看起来很合理;x86机器码不能明确地向后解码,因为它是可变长度的。 您无法区分指令中的字节(如即时或 ModRM)与指令的开头。 这完全取决于您从哪里开始拆卸。 如果您尝试几个字节偏移量,通常只有一个会产生任何看起来正常的东西。


asm("movq %rax, 0"); //Exit code is 11, so now it should be 0

这是 RAX 到绝对地址0的存储,采用 AT&T 语法。这当然是段错误。 退出代码 11 来自 SIGSEGV,即信号 11。 (使用kill -l查看信号编号)。

也许你想要mov $0, %eax. 尽管这在这里仍然毫无意义,但您将通过函数指针进行调用。 在调试模式下,编译器可能会将其加载到 RAX 中并单步执行您的值。

此外,当您不告诉编译器您正在修改哪些寄存器(使用约束)时,在asm语句中编写寄存器从来都不安全。


printf("Main: %pn", main);
printf("&Main: %pn", &main); //WTF

main&main是一回事,因为main是一个函数。 这就是 C 语法对函数名称的工作方式。main不是可以获取其地址的对象。 和运算符在函数指针赋值中可选

数组也是如此:数组的裸名称可以分配给指针或作为指针参数传递给函数。 但&array也是相同的指针,与&array[0]相同。 这仅适用于像int array[10]这样的数组,不适用于像int *ptr这样的指针;在后一种情况下,指针对象本身具有存储空间,并且可以获取自己的地址。

我认为你在这里有很多误解。首先,main不是内核所称的。内核分配一个进程并将我们的二进制文件加载到内存中 - 如果您使用的是基于 Unix 的操作系统,通常来自 ELF 文件。这个ELF文件包含所有需要映射到内存中的部分和一个地址,该地址是ELF中代码的"入口点"(以及其他内容)。ELF 可以指定加载器跳转到的任何地址,以便开始启动程序。在使用 GCC 构建的应用程序中,这是一个称为_start的函数。 然后_start设置堆栈并在调用之前执行任何其他初始化__libc_start_main这是一个 libc 函数,可以在调用主main之前进行额外的设置。

下面是一个启动函数的示例:

00000000000006c0 <_start>:

6c0:   31 ed                   xor    %ebp,%ebp
6c2:   49 89 d1                mov    %rdx,%r9
6c5:   5e                      pop    %rsi
6c6:   48 89 e2                mov    %rsp,%rdx
6c9:   48 83 e4 f0             and    $0xfffffffffffffff0,%rsp
6cd:   50                      push   %rax
6ce:   54                      push   %rsp
6cf:   4c 8d 05 0a 02 00 00    lea    0x20a(%rip),%r8        # 8e0 <__libc_csu_fini>
6d6:   48 8d 0d 93 01 00 00    lea    0x193(%rip),%rcx        # 870 <__libc_csu_init>
6dd:   48 8d 3d 7c ff ff ff    lea    -0x84(%rip),%rdi        # 660 <main>
6e4:   ff 15 f6 08 20 00       callq  *0x2008f6(%rip)        # 200fe0 <__libc_start_main@GLIBC_2.2.5>
6ea:   f4                      hlt    
6eb:   0f 1f 44 00 00          nopl   0x0(%rax,%rax,1)

如您所见,此函数设置堆栈和堆栈基指针的值。因此,此函数中没有有效的堆栈帧。堆栈帧甚至不会设置为 0 以外的任何值,直到您调用main(至少通过此编译器)

现在这里要看到的重要一点是,堆栈是在此代码中初始化的,并且由加载器初始化,它不是内核堆栈的延续。每个程序都有自己的堆栈,这些堆栈都与内核的堆栈不同。事实上,即使您知道内核中堆栈的地址,也无法从程序中读取或写入堆栈,因为您的进程只能看到由内核控制的 MMU 分配给它的内存页。

澄清一下,当我说堆栈是"创建"的时,我并不是说它被分配了。我的意思只是堆栈指针和堆栈基础设置在这里。它的内存在加载程序时分配,每当写入堆栈的未分配部分触发页面错误时,都会根据需要将页面添加到程序中。进入 start 后,显然存在一些堆栈作为来自pop rsi指令的证据,但这不是程序将使用的最终堆栈值的堆栈。这些是在_start中设置的变量(也许这些变量稍后会在__libc_start_main中更改,我不确定。

但是,这意味着当 Unix 内核调用 main 函数时,堆栈基础应该指向调用 main 的内核函数中的重新进入。

绝对不行。

这个特定问题涵盖了MacOS的详细信息,请查看。在任何情况下,main 最有可能返回 C 标准库的启动功能。 实现细节因不同的 *nix 操作系统而异。

因此,在 C 代码中跳转 "*rbp-1" 应该重新进入主函数。

您无法保证编译器将发出什么以及调用rbp()函数时 rsp/rbp 的状态。你不能做出这样的假设。

顺便说一句,如果您想以 64 位访问堆栈条目,您将以 +-8 的增量执行此操作(分别rbp+8rbp-8rsp+8rsp-8)。

最新更新