Skip to content

代码:exec

exec 是一个系统调用,它用从文件中读取的数据(称为二进制文件或可执行文件)替换进程的用户地址空间。二进制文件通常是编译器和链接器的输出,包含机器指令和程序数据。exec 使用 namei 打开名为 path 的二进制文件,这在第 8 章中有解释。然后,它读取 ELF 头。Xv6 二进制文件采用广泛使用的 ELF 格式进行格式化,该格式在 kernel/elf.h 中定义。一个 ELF 二进制文件由一个 ELF 头 struct elfhdr,后跟一系列程序段头 struct proghdr 组成。每个 proghdr 描述了必须加载到内存中的应用程序的一个段;xv6 程序有两个程序段头:一个用于指令,一个用于数据。

第一步是快速检查文件是否可能包含 ELF 二进制文件。一个 ELF 二进制文件以四字节的“魔数” 0x7F'E''L''F'ELF_MAGIC 开头。如果 ELF 头有正确的魔数,exec 就假定该二进制文件格式正确。

exec 使用 proc_pagetable 分配一个没有用户映射的新页表,使用 uvmalloc 为每个 ELF 段分配内存,并使用 loadseg 将每个段加载到内存中。loadseg 使用 walkaddr 查找分配的内存的物理地址,以写入 ELF 段的每一页,并使用 readi 从文件中读取。

/init 的程序段头,即用 exec 创建的第一个用户程序,如下所示:


# objdump -p user/_init

user/_init:     file format elf64-little

Program Header:
0x70000003 off    0x0000000000006bb0 vaddr 0x0000000000000000
                                       paddr 0x0000000000000000 align 2**0
         filesz 0x000000000000004a memsz 0x0000000000000000 flags r--
    LOAD off    0x0000000000001000 vaddr 0x0000000000000000
                                       paddr 0x0000000000000000 align 2**12
         filesz 0x0000000000001000 memsz 0x0000000000001000 flags r-x
    LOAD off    0x0000000000002000 vaddr 0x0000000000001000
                                       paddr 0x0000000000001000 align 2**12
         filesz 0x0000000000000010 memsz 0x0000000000000030 flags rw-
   STACK off    0x0000000000000000 vaddr 0x0000000000000000
                                       paddr 0x0000000000000000 align 2**4
         filesz 0x0000000000000000 memsz 0x0000000000000000 flags rw-

我们看到文本段应该加载到内存中的虚拟地址 0x0(没有写权限),其内容来自文件中的偏移量 0x1000。我们还看到数据应该加载到地址 0x1000,这是一个页边界,并且没有执行权限。

程序段头的 filesz 可能小于 memsz,这表明它们之间的差距应该用零填充(对于 C 全局变量),而不是从文件中读取。对于 /init,数据 filesz 是 0x10 字节,memsz 是 0x30 字节,因此 uvmalloc 分配了足够的物理内存来容纳 0x30 字节,但只从文件 /init 中读取 0x10 字节。

现在 exec 分配并初始化用户栈。它只分配一个栈页。exec 一次一个地将参数字符串复制到栈顶,并将指向它们的指针记录在 ustack 中。它在将传递给 mainargv 列表的末尾放置一个空指针。值 argcargv 通过系统调用返回路径传递给 mainargc 通过系统调用返回值传递,该返回值进入 a0argv 通过进程的陷阱帧的 a1 条目传递。

exec 在栈页的正下方放置一个不可访问的页面,这样试图使用多于一个页面的程序就会出错。这个不可访问的页面也允许 exec 处理过大的参数;在这种情况下,exec 用来将参数复制到栈的 copyout 函数会注意到目标页面不可访问,并返回 -1。

在准备新内存映像期间,如果 exec 检测到错误,如无效的程序段,它会跳转到标签 bad,释放新映像,并返回 -1。exec 必须等到它确定系统调用会成功后才能释放旧映像:如果旧映像不见了,系统调用就无法向其返回 -1。exec 中唯一的错误情况发生在创建映像期间。一旦映像完成,exec 就可以提交到新页表并释放旧页表。

exec 将字节从 ELF 文件加载到由 ELF 文件指定的地址的内存中。用户或进程可以将任何他们想要的地址放入 ELF 文件中。因此 exec 是有风险的,因为 ELF 文件中的地址可能会意外地或故意地引用内核。对于一个不警惕的内核来说,后果可能从崩溃到恶意颠覆内核的隔离机制(即安全漏洞利用)。Xv6 执行了许多检查来避免这些风险。例如 if(ph.vaddr + ph.memsz < ph.vaddr) 检查和是否溢出一个 64 位整数。危险在于用户可以构造一个 ELF 二进制文件,其中 ph.vaddr 指向一个用户选择的地址,而 ph.memsz 足够大,以至于和溢出到 0x1000,这看起来像一个有效值。在旧版本的 xv6 中,用户地址空间也包含内核(但在用户模式下不可读/写),用户可以选择一个对应于内核内存的地址,从而将数据从 ELF 二二进制文件复制到内核中。在 RISC-V 版本的 xv6 中,这不会发生,因为内核有自己独立的页表;loadseg 加载到进程的页表中,而不是内核的页表中。

内核开发人员很容易忽略一个关键的检查,而现实世界的内核有很长的历史,都存在着被用户程序利用以获取内核权限的缺失检查。xv6 很可能没有完全验证提供给内核的用户级数据,恶意用户程序可能会利用这一点来规避 xv6 的隔离。