为什么我可以使用 ret 退出 main?

2024-05-01

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

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: %p\n", main);
   printf("&Main: %p\n", &main); //WTF
   printf("*Main: %p\n", *main); //WTF
   printf("Stackbasepointer: %p\n", 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 不允许这样做......

But also不允许此调用:

   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 系统如何有效地检查此类尝试...... 我希望我的问题很清楚......

谢谢。 P.S.:代码仅在 macOS 上编译,请更改 Linux 的程序集


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

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


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

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

    ret isn't magic, it's basically just pop %rip and doesn't let you jump anywhere you couldn't jump to with other instructions. Also doesn't change privilege level1.

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

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

  • 对于进程来说,内核堆栈与用户空间堆栈是分开的。 (在内核中,每个任务(也称为线程)还有一个小的内核堆栈,在用户空间线程运行时在系统调用/中断期间使用。至少 Linux 是这样做的,不知道其他的。)

  • 内核并不是字面上的意思call用户空间代码;用户空间堆栈不会将任何返回地址保留回内核。内核->用户转换涉及交换堆栈指针以及更改特权级别。例如用类似的指令iret https://www.felixcloutier.com/x86/iret:iretd(中断返回)。

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

脚注 1:(编译器生成的ret永远是正常的附近ret, not a retf可以通过调用门或其他方式返回给特权者cs价值。 x86 通过 CS 的低 2 位处理权限级别,但没关系。 MacOS / Linuxdon't设置用户空间可以用来调用内核的调用门;完成了syscall or int 0x80指示。)


在一个新鲜的过程中(经过execve系统调用用新的 PID 替换了前一个进程),执行从进程入口点开始(通常标记为_start), not在Cmain直接运行。

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

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

If you try to ret from _start,你就会弹出argc进入RIP,然后从绝对地址取码1 or 2(或其他少量)将出现段错误。例如,_start 中 RET 上的 Nasm 分段错误 https://stackoverflow.com/questions/19760002/nasm-segmentation-fault-on-ret-in-start表明尝试ret从进程入口点(链接withoutCRT 启动代码)。它有一个手写的_start刚刚落入main.


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

  • gcc 预处理器 (CPP) 和编译+程序集main.c to main.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。这实际上是简化的a lot,省略了一些 CRT 文件,并对路径进行了规范化以删除../../lib部分。另外,它不运行ld直接运行collect2这是一个包装器ld。但无论如何,静态链接在那些.oCRT 文件包含_start和其他一些东西,并动态链接 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告诉它寄存器名称没有前缀%。 (是的,就是你can有 Intel op dst、src 顺序,但仍然有%rsp注册名称。没有你不应该这样做!)当然,GNU/Linux 不使用前导下划线。

不过,如果你调用它,它并不总是会做你想做的事!如果你编译了main没有优化(所以-fno-omit-frame-pointer有效),那么是的,您会得到一个指向返回地址下方堆栈槽的指针。


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

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

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

如果没有,你可能必须尝试看看什么看起来是正常的; x86 机器代码无法明确地向后解码,因为它是可变长度的。您无法区分指令中的字节(例如立即数或 ModRM)与指令的开头之间的区别。这完全取决于你在哪里start拆解自.如果您尝试几个字节偏移,通常只有一个会产生看起来正常的结果。


   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: %p\n", main);
   printf("&Main: %p\n", &main); //WTF

main and &main是同一件事,因为main是一个函数。这就是 C 语法对函数名称的作用。main不是一个可以获取其地址的对象。

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

本文内容由网友自发贡献,版权归原作者所有,本站不承担相应法律责任。如您发现有涉嫌抄袭侵权的内容,请联系:hwhale#tublm.com(使用前将#替换为@)

为什么我可以使用 ret 退出 main? 的相关文章

随机推荐