当您调用克隆系统调用时,谁设置了 RIP 寄存器?

Posted

技术标签:

【中文标题】当您调用克隆系统调用时,谁设置了 RIP 寄存器?【英文标题】:Who sets the RIP register when you call the clone syscall? 【发布时间】:2021-06-25 10:35:09 【问题描述】:

我正在尝试实现一个最小内核,并且我正在尝试实现克隆系统调用。在手册页中,您可以看到这样定义的克隆系统调用:

int clone(int (*fn)(void *), void *stack, int flags, void *arg, ...
                 /* pid_t *parent_tid, void *tls, pid_t *child_tid */ );

如您所见,它接收一个函数指针。如果您仔细阅读手册页,您实际上可以看到内核中的实际系统调用实现没有收到函数指针:

long clone(unsigned long flags, void *stack,
                      int *parent_tid, int *child_tid,
                      unsigned long tls);

那么,我的问题是,谁在创建线程后修改 RIP 寄存器?是libc吗?

我在 glibc 中找到了这段代码:https://elixir.bootlin.com/glibc/latest/source/sysdeps/unix/sysv/linux/x86_64/clone.S,但我不确定该函数在什么时候被实际调用。

额外信息:

查看 clone.S 源代码时,您可以看到它在系统调用之后跳转到 thread_start 分支。在克隆系统调用之后的分支上(所以只有孩子这样做)它从堆栈中弹出函数地址和参数。谁真正将这些参数和函数地址压入堆栈?我想它必须发生在内核的某个地方,因为在 syscall 指令的时候它们不在那里。

这是一些 gdb 输出:

就在系统调用之前:

[-------------------------------------code-------------------------------------]
   0x7ffff7d8af22 <clone+34>:   mov    r8,r9
   0x7ffff7d8af25 <clone+37>:   mov    r10,QWORD PTR [rsp+0x8]
   0x7ffff7d8af2a <clone+42>:   mov    eax,0x38
=> 0x7ffff7d8af2f <clone+47>:   syscall 
   0x7ffff7d8af31 <clone+49>:   test   rax,rax
   0x7ffff7d8af34 <clone+52>:   jl     0x7ffff7d8af49 <clone+73>
   0x7ffff7d8af36 <clone+54>:   je     0x7ffff7d8af39 <clone+57>
   0x7ffff7d8af38 <clone+56>:   ret
Guessed arguments:
arg[0]: 0x3d0f00 
arg[1]: 0x7ffff8020b60 --> 0x7ffff7d3fb30 (<do_something>:  push   rbx)
arg[2]: 0x7fffffffda90 --> 0x0 
[------------------------------------stack-------------------------------------]
0000| 0x7fffffffda78 --> 0x7ffff7d3f52c (<main+172>:    pop    rsi)
0008| 0x7fffffffda80 --> 0x7fffffffda94 --> 0x73658b0000000000 
0016| 0x7fffffffda88 --> 0x7fffffffda94 --> 0x73658b0000000000 
0024| 0x7fffffffda90 --> 0x0 
0032| 0x7fffffffda98 --> 0x492e085573658b00 
0040| 0x7fffffffdaa0 --> 0x7ffff7d3f0d0 (<_init>:   sub    rsp,0x8)
0048| 0x7fffffffdaa8 --> 0x7ffff7d40830 (<__libc_csu_init>: push   r15)
0056| 0x7fffffffdab0 --> 0x7ffff7d408d0 (<__libc_csu_fini>: push   rbp)
[------------------------------------------------------------------------------]

在子线程上的系统调用指令之后(检查堆栈顶部 - 这不会发生在父线程上):

[-------------------------------------code-------------------------------------]
   0x7ffff7d8af25 <clone+37>:   mov    r10,QWORD PTR [rsp+0x8]
   0x7ffff7d8af2a <clone+42>:   mov    eax,0x38
   0x7ffff7d8af2f <clone+47>:   syscall 
=> 0x7ffff7d8af31 <clone+49>:   test   rax,rax
   0x7ffff7d8af34 <clone+52>:   jl     0x7ffff7d8af49 <clone+73>
   0x7ffff7d8af36 <clone+54>:   je     0x7ffff7d8af39 <clone+57>
   0x7ffff7d8af38 <clone+56>:   ret    
   0x7ffff7d8af39 <clone+57>:   xor    ebp,ebp
[------------------------------------stack-------------------------------------]
0000| 0x7ffff8020b60 --> 0x7ffff7d3fb30 (<do_something>:    push   rbx)
0008| 0x7ffff8020b68 --> 0x7ffff7dd5add --> 0x4c414d0074736574 ('test')
0016| 0x7ffff8020b70 --> 0x0 
0024| 0x7ffff8020b78 --> 0x411 
0032| 0x7ffff8020b80 ("Parameters: 0x7ffff7d3fb30 4001536 0x7ffff8020b70 0x7fffffffda90 0x7ffff8000b60 0x7fffffffda94\n")
0040| 0x7ffff8020b88 ("rs: 0x7ffff7d3fb30 4001536 0x7ffff8020b70 0x7fffffffda90 0x7ffff8000b60 0x7fffffffda94\n")
0048| 0x7ffff8020b90 ("fff7d3fb30 4001536 0x7ffff8020b70 0x7fffffffda90 0x7ffff8000b60 0x7fffffffda94\n")
0056| 0x7ffff8020b98 ("30 4001536 0x7ffff8020b70 0x7fffffffda90 0x7ffff8000b60 0x7fffffffda94\n")
[------------------------------------------------------------------------------]

【问题讨论】:

链接的 libc 源代码中的第 92-95 行标记为 /* Function to call. */ ? 没错。谢谢。 【参考方案1】:

是的,libc;内核接口就像fork:它两次返回到同一个地方,但返回值不同。 (子代中的0 或父代中的PID/TID)。 The man page 记录了 glibc 包装器与内核的差异,就像其他系统调用存在差异一样。

libc 包装器将您传递的函数指针和 arg 隐藏在新线程的堆栈空间中,新线程可以在其中加载它。 (内核启动它时将其 RSP 设置为 void *stack arg 传递给 clone(),因此它无法访问堆栈内存或寄存器中的旧局部变量,并且如果使用多个全局变量,则不会是线程安全的线程同时克隆自己。)

请注意,还有一个 clone3 系统调用,它采用结构 arg,也更像是 clone 的原始内核接口。 (或者至少它没有 glibc 包装器。)

【讨论】:

【参考方案2】:

通常它的工作方式是,当计算机启动时,Linux 会设置一个 MSR(模型特定寄存器)来使用汇编指令 syscall。汇编指令syscall将使RIP寄存器跳转到MSR中指定的地址进入内核模式。正如英特尔的 64-ia-32-architectures-software-developer-vol-2b-manual 中所述:

SYSCALL 调用特权级别 0 的操作系统系统调用处理程序。 它通过从 IA32_LSTAR MSR 加载 RIP 来实现

一旦进入内核模式,内核将查看传递给常规寄存器(RAX、RBX 等)的参数以确定系统调用的要求。然后内核将调用原型在 linux/syscalls.h (https://elixir.bootlin.com/linux/latest/source/include/linux/syscalls.h#L217) 中的 sys_XXX 函数之一。 sys_clone 的定义在 kernel/fork.c 中。

SYSCALL_DEFINE5(clone, unsigned long, clone_flags, unsigned long, newsp,
         int __user *, parent_tidptr,
         int __user *, child_tidptr,
         unsigned long, tls)
#endif

    return _do_fork(clone_flags, newsp, 0, parent_tidptr, child_tidptr, tls);

SYSCALLDEFINE5 宏采用第一个参数并为其添加前缀 sys_。这个函数实际上是sys_clone,它调用了_do_fork。

这意味着实际上并没有被 glibc 调用以调用内核的 clone() 函数。内核使用syscall 指令调用,它跳转到 MSR 中指定的地址,然后调用 sys_call_table 中的系统调用之一。

x86 内核的入口点在这里:https://github.com/torvalds/linux/blob/16f73eb02d7e1765ccab3d2018e0bd98eb93d973/arch/x86/entry/entry_64.S。如果向下滚动,您将看到以下行:call *sys_call_table(, %rax, 8)。基本上,调用 sys_call_table 的函数之一。 sys_call_table 的实现在这里:https://elixir.bootlin.com/linux/latest/source/arch/x86/entry/syscall_64.c#L20。

// SPDX-License-Identifier: GPL-2.0
/* System call table for x86-64. */

#include <linux/linkage.h>
#include <linux/sys.h>
#include <linux/cache.h>
#include <linux/syscalls.h>
#include <asm/unistd.h>
#include <asm/syscall.h>

#define __SYSCALL_X32(nr, sym)
#define __SYSCALL_COMMON(nr, sym) __SYSCALL_64(nr, sym)

#define __SYSCALL_64(nr, sym) extern long __x64_##sym(const struct pt_regs *);
#include <asm/syscalls_64.h>
#undef __SYSCALL_64

#define __SYSCALL_64(nr, sym) [nr] = __x64_##sym,

asmlinkage const sys_call_ptr_t sys_call_table[__NR_syscall_max+1] = 
    /*
     * Smells like a compiler bug -- it doesn't work
     * when the & below is removed.
     */
    [0 ... __NR_syscall_max] = &__x64_sys_ni_syscall,
#include <asm/syscalls_64.h>
;

我建议您阅读以下内容:https://0xax.gitbooks.io/linux-insides/content/SysCall/linux-syscall-2.html。在这个网站上声明

如您所见,我们在数组末尾包含了 asm/syscalls_64.h 标头。该头文件由位于 arch/x86/entry/syscalls/syscalltbl.sh 的特殊脚本生成,并从 syscall 表 (https://github.com/torvalds/linux/blob/16f73eb02d7e1765ccab3d2018e0bd98eb93d973/arch/x86/entry/syscalls/syscall_64.tbl) 生成我们的头文件。

...

...

因此,在此之后,我们的 sys_call_table 采用以下形式:

asmlinkage const sys_call_ptr_t sys_call_table[__NR_syscall_max+1] = 
   [0 ... __NR_syscall_max] = &sys_ni_syscall,
   [0] = sys_read,
   [1] = sys_write,
   [2] = sys_open,
   ...
   ...
   ...
;

一旦您生成了表格,当您使用syscall 汇编指令时,就会跳转到其中一个条目。对于 clone(),它将调用 sys_clone(),它本身调用 _do_fork()。是这样定义的:

long _do_fork(unsigned long clone_flags,
          unsigned long stack_start,
          unsigned long stack_size,
          int __user *parent_tidptr,
          int __user *child_tidptr,
          unsigned long tls)

    struct task_struct *p;
    int trace = 0;
    long nr;

    /*
     * Determine whether and which event to report to ptracer.  When
     * called from kernel_thread or CLONE_UNTRACED is explicitly
     * requested, no event is reported; otherwise, report if the event
     * for the type of forking is enabled.
     */
    if (!(clone_flags & CLONE_UNTRACED)) 
        if (clone_flags & CLONE_VFORK)
            trace = PTRACE_EVENT_VFORK;
        else if ((clone_flags & CSIGNAL) != SIGCHLD)
            trace = PTRACE_EVENT_CLONE;
        else
            trace = PTRACE_EVENT_FORK;

        if (likely(!ptrace_event_enabled(current, trace)))
            trace = 0;
    

    p = copy_process(clone_flags, stack_start, stack_size,
             child_tidptr, NULL, trace, tls);
    /*
     * Do this prior waking up the new thread - the thread pointer
     * might get invalid after that point, if the thread exits quickly.
     */
    if (!IS_ERR(p)) 
        struct completion vfork;
        struct pid *pid;

        trace_sched_process_fork(current, p);

        pid = get_task_pid(p, PIDTYPE_PID);
        nr = pid_vnr(pid);

        if (clone_flags & CLONE_PARENT_SETTID)
            put_user(nr, parent_tidptr);

        if (clone_flags & CLONE_VFORK) 
            p->vfork_done = &vfork;
            init_completion(&vfork);
            get_task_struct(p);
        

        wake_up_new_task(p);

        /* forking complete and child started to run, tell ptracer */
        if (unlikely(trace))
            ptrace_event_pid(trace, pid);

        if (clone_flags & CLONE_VFORK) 
            if (!wait_for_vfork_done(p, &vfork))
                ptrace_event_pid(PTRACE_EVENT_VFORK_DONE, pid);
        

        put_pid(pid);
     else 
        nr = PTR_ERR(p);
    
    return nr;

它调用wake_up_new_task() 将任务放入运行队列并唤醒它。我很惊讶它甚至立即唤醒了任务。我会猜到调度程序会这样做,并且它会被赋予高优先级以尽快运行。内核本身不必接收函数指针,因为如克隆()的手册页所述:

原始的 clone() 系统调用更接近于 fork(2) 在那个孩子的处决从那个点继续 称呼。因此,clone() 包装器的 fn 和 arg 参数 函数被省略。

子进程继续执行系统调用。我不完全了解机制,但最终孩子将继续在新线程中执行。父线程(创建新子线程)返回,子线程跳转到指定函数。

我认为它适用于以下几行(在您提供的链接上):

testq   %rax,%rax
jl  SYSCALL_ERROR_LABEL
jz  L(thread_start) //Child jumps to thread_start

ret //Parent returns to where it was

因为 rax 是一个 64 位的寄存器,所以他们使用 GNU 语法汇编指令测试的 'q' 版本。他们测试 rax 是否为零。如果它小于零,则存在错误。如果为零,则跳转到 thread_start。如果它不为零也不为负(在父线程的情况下),继续执行并返回。新线程以 rax 为 0 创建。它允许区分父线程和子线程。

编辑

如您提供的链接所述,

The parameters are passed in register and on the stack from userland:
rdi: fn
rsi: child_stack
rdx: flags
rcx: arg
r8d: TID field in parent
r9d: thread pointer

所以当你的程序执行以下几行时:

/* Insert the argument onto the new stack.  */
subq    $16,%rsi
movq    %rcx,8(%rsi)

/* Save the function pointer.  It will be popped off in the
      child in the ebx frobbing below.  */
movq    %rdi,0(%rsi)

它将函数指针和参数插入到新堆栈中。然后它调用内核,内核本身不必将任何东西压入堆栈。它只是接收新堆栈作为参数,然后让子线程的 RSP 寄存器指向它。我猜这发生在 copy_process() 函数(从 fork() 调用)中,如下所示:

retval = copy_thread_tls(clone_flags, stack_start, stack_size, p, tls);
if (retval)
    goto bad_fork_cleanup_io;

似乎是在本身调用copy_thread() 的copy_thread_tls() 函数中完成的。 copy_thread() 在 include/linux/sched.h 中有它的原型,它是基于架构定义的。我不确定它是在哪里为 x86 定义的。

【讨论】:

嘿@user123 谢谢你的详细回答。不过,我仍然有一点困惑。克隆系统调用返回后,如果 rax 的值为 0,那么它实际上会跳转到 thread_start 标签。 thread_start 标签执行两次弹出(一个是函数的地址,另一个是参数),然后它通过执行call %rax 开始执行该函数。问题是,在系统调用之前,这两个值不在堆栈上而是在寄存器上,因此内核必须将它们放入。你知道这发生在哪里吗? 我在帖子中添加了更多信息。 最后在这里:elixir.bootlin.com/linux/latest/source/arch/x86/kernel/… (堆栈指针在 task_struct 中设置)。 wake_up_new_task() 函数可能会根据 task_struct 中代表 stack_pointer 的字段设置处理器的 RSP 寄存器。最后,它是一个调用另一个函数的函数,它调用另一个函数,因此您无法真正向上遍历整个链,因为您必须查看整个内核。 是的,这也是我的结论。谢谢!

以上是关于当您调用克隆系统调用时,谁设置了 RIP 寄存器?的主要内容,如果未能解决你的问题,请参考以下文章

linux 汇编 gdb报错:Invalid register `eip‘(64位系统没有eip只有rip寄存器)

RTX——第19章 SVC 中断方式调用用户函数(后期补历程)

当您在 php 中调用函数时,内部会发生啥

逆向x64-small-trick

Linux 系统调用 —— fork 内核源码剖析

Socket与系统调用深度分析