第三章:程序运行
到目前为止,我们已经介绍了 CPU 如何执行从可执行文件加载的机器代码、什么是基于环的安全性以及系统调用如何工作。在本节中,我们将深入了解 Linux 内核,首先了解程序是如何加载和运行的。
我们将专门研究 x86-64 上的 Linux。为什么?
- Linux 是一款功能齐全的生产操作系统,适用于桌面、移动和服务器用例。Linux 是开源的,因此只需阅读其源代码就可以非常容易地进行研究。我将在本文中直接引用一些内核代码!
- x86-64 是大多数现代台式计算机使用的体系结构,也是许多代码的目标体系结构。我提到的特定于 x86-64 的行为子集可以很好地概括。
我们学到的大部分内容都可以很好地推广到其他操作系统和体系结构,即使它们在各种特定方面有所不同。
Exec 系统调用的基本行为
演示 exec 系统调用的流程图。 左边是一组标记为“用户空间”的流程图项,右边是一组标记为“内核空间”的流程图项。 从用户空间组开始:用户在终端中运行 ./file.bin,然后运行系统调用 execve("./file.bin", ...)。 这流向正在执行的 SYSCALL 指令,该指令随后指向内核空间组中的第一项:“加载并设置二进制文件”,该指令指向“尝试 binfmt”。 如果支持 binfmt,它将启动新进程(替换当前进程)。 如果没有,它会再次尝试 binfmt。
让我们从一个非常重要的系统调用开始:execve
。它加载一个程序,如果成功,则用该程序替换当前进程。还存在其他几个系统调用(execlp
、execvpe
等),但它们都以各种方式分层execve
。
在旁边:execveat
execve
实际上是建立在 之上的execveat
,这是一个更通用的系统调用,它运行带有一些配置选项的程序。为了简单起见,我们主要讨论execve
;唯一的区别是它提供了一些默认值execveat
。好奇
ve
代表什么?表示v
一个参数是参数 ( ) 的向量(列表)argv
,表示e
另一个参数是环境变量 ( ) 的向量envp
。各种其他 exec 系统调用具有不同的后缀来指定不同的调用签名。in只是“at”,因为它指定了运行的at
位置。execveat
`execve`
的调用签名execve
是:
int execve(const char *filename, char *const argv[], char *const envp[]);
- 参数
filename
指定要运行的程序的路径。 argv
是程序的一个以 null 结尾的(意味着最后一项是空指针)参数列表。您通常会看到传递给 C 主函数的参数argc
实际上是稍后由系统调用计算的,因此是空终止。- 该
envp
参数包含另一个以 null 结尾的环境变量列表,用作应用程序的上下文。他们……按照惯例KEY=VALUE
是成对的。按照惯例。我喜欢电脑。
有趣的事实!您知道程序的第一个参数是程序名称的约定吗?这纯粹是一个约定,实际上并不是由execve
系统调用本身设置的!execve
第一个参数将是作为参数中第一项传递的任何内容argv
,即使它与程序名称无关。
有趣的是,execve
确实有一些代码假设argv[0]
是程序名称。稍后我们讨论解释型脚本语言时会详细介绍这一点。
第0步:定义
我们已经知道系统调用是如何工作的,但我们从未见过现实世界的代码示例!让我们看一下 Linux 内核的源代码,看看execve
其底层是如何定义的:
2105
2106
2107
2108
2109
2110
2111
SYSCALL_DEFINE3(execve,
const char __user *, filename,
const char __user *const __user *, argv,
const char __user *const __user *, envp)
{
return do_execve(getname(filename), argv, envp);
}
SYSCALL_DEFINE3
是一个用于定义 3 参数系统调用代码的宏。
我很好奇为什么元数被硬编码在宏名称中;我用谷歌搜索了一下,了解到这是修复某些安全漏洞的解决方法。
文件名参数被传递给一个getname()
函数,该函数将字符串从用户空间复制到内核空间并执行一些使用情况跟踪操作。它返回一个filename
结构体,该结构体在include/linux/fs.h
. 它在用户空间中存储一个指向原始字符串的指针,以及一个指向复制到内核空间的值的新指针:
2294
2295
2296
2297
2298
2299
2300
struct filename {
const char *name; /* pointer to actual string */
const __user char *uptr; /* original userland pointer */
int refcnt;
struct audit_names *aname;
const char iname[];
};
然后系统execve
调用调用一个do_execve()
函数。反过来,这会调用do_execveat_common()
一些默认值。我之前提到的系统调用execveat
也调用do_execveat_common()
,但会传递更多用户提供的选项。
do_execve
在下面的代码片段中,我包含了和 的定义do_execveat
:
2028
2029
2030
2031
2032
2033
2034
2035
2036
2037
2038
2039
2040
2041
2042
2043
2044
2045
2046
static int do_execve(struct filename *filename,
const char __user *const __user *__argv,
const char __user *const __user *__envp)
{
struct user_arg_ptr argv = { .ptr.native = __argv };
struct user_arg_ptr envp = { .ptr.native = __envp };
return do_execveat_common(AT_FDCWD, filename, argv, envp, 0);
}
static int do_execveat(int fd, struct filename *filename,
const char __user *const __user *__argv,
const char __user *const __user *__envp,
int flags)
{
struct user_arg_ptr argv = { .ptr.native = __argv };
struct user_arg_ptr envp = { .ptr.native = __envp };
return do_execveat_common(fd, filename, argv, envp, flags);
}
[间距原文如此]
在 中execveat
,文件描述符(指向某个资源的 id 类型)被传递到系统调用,然后传递到do_execveat_common
。这指定了相对于执行程序的目录。
在 中execve
,文件描述符参数使用了一个特殊值AT_FDCWD
。这是 Linux 内核中的一个共享常量,它告诉函数将路径名解释为相对于当前工作目录的路径名。接受文件描述符的函数通常包括手动检查,例如if (fd == AT_FDCWD) { /* special codepath */ }
.
第 1 步:设置
现在我们已经到达了do_execveat_common
处理程序执行的核心函数。我们将从盯着代码的角度后退一步,以更全面地了解该函数的作用。
第一个主要工作do_execveat_common
是建立一个名为 的结构linux_binprm
。我不会包含整个结构定义的副本,但有几个重要的字段需要讨论:
mm_struct
定义诸如和之类的数据结构vm_area_struct
是为了为新程序准备虚拟内存管理。argc
并被envc
计算并存储以传递给程序。filename
并interp
分别存储程序及其解释器的文件名。它们一开始彼此相同,但在某些情况下可能会发生变化:其中一种情况是使用 shebang 运行解释脚本时)。例如,当执行Python程序时,filename
指向源文件,但interp
它是Python解释器的路径。buf
是一个数组,其中填充了要执行的文件的前 256 个字节。它用于检测文件的格式并加载脚本shebangs。
(TIL : binprm代表二进制程序。)
让我们仔细看看这个缓冲区buf
:
linux_binprm @ include/linux/binfmts.h
64
char buf[BINPRM_BUF_SIZE];
正如我们所看到的,它的长度被定义为常数BINPRM_BUF_SIZE
。通过在代码库中搜索该字符串,我们可以在以下位置找到该字符串的定义include/uapi/linux/binfmts.h
:
18
19
/* sizeof(linux_binprm->buf) */
#define BINPRM_BUF_SIZE 256
因此,内核将执行文件的开头 256 字节加载到该内存缓冲区中。
旁白:什么是 UAPI?您可能会注意到上面代码的路径包含
/uapi/
. 为什么长度没有在与linux_binprm
结构体相同的文件中定义include/linux/binfmts.h
?UAPI 代表“用户空间 API”。在这种情况下,这意味着有人决定缓冲区的长度应该成为内核公共 API 的一部分。理论上,所有 UAPI 都暴露给用户空间,所有非 UAPI 都是内核代码私有的。
内核和用户空间代码最初共存为一团混乱的代码。2012年,UAPI代码被重构到一个单独的目录中,试图提高可维护性。
第2步:Binfmts
内核的下一个主要工作是迭代一堆“binfmt”(二进制格式)处理程序。fs/binfmt_elf.c
这些处理程序在和 等文件中定义fs/binfmt_flat.c
。内核模块还可以将自己的 binfmt 处理程序添加到池中。
每个处理程序都公开一个load_binary()
函数,该函数接受一个linux_binprm
结构并检查处理程序是否理解程序的格式。
这通常涉及在缓冲区中查找幻数、尝试解码程序的开头(也来自缓冲区)和/或检查文件扩展名。)如果处理程序确实支持该格式,它将准备执行程序并返回成功代码。否则,它会提前退出并返回错误代码。
内核会尝试load_binary()
每个 binfmt 的函数,直到找到一个成功的函数。有时这些会递归运行;例如,如果一个脚本指定了一个解释器,并且该解释器本身就是一个脚本,则层次结构可能是binfmt_script
>> (其中 ELF 是链末尾的可执行格式)。binfmt_script
`binfmt_elf`
格式亮点:脚本
在 Linux 支持的众多格式中,binfmt_script
我想具体谈谈第一个。
您读过或写过Shebang)吗?某些脚本开头的那一行指定解释器的路径?
1
#!/bin/bash
我一直以为这些是由 shell 处理的,但事实并非如此!Shebang 实际上是内核的一项功能,脚本是使用与其他程序相同的系统调用来执行的。电脑太酷了。
看看如何fs/binfmt_script.c
检查文件是否以 shebang 开头:
load_script @ fs/binfmt_script.c
40
41
42
/* Not ours to exec if we don't start with "#!". */
if ((bprm->buf[0] != '#') || (bprm->buf[1] != '!'))
return -ENOEXEC;
如果文件确实以 shebang 开头,则 binfmt 处理程序会读取解释器路径以及路径后面的任何以空格分隔的参数。当它到达换行符或缓冲区末尾时它会停止。
这里发生了两件有趣的、奇怪的事情。
首先,还记得其中的缓冲区linux_binprm
填充了文件的前 256 个字节吗?它用于可执行格式检测,但同样的缓冲区也是在binfmt_script
.
在我的研究过程中,我读到一篇文章,将缓冲区描述为 128 字节长。在那篇文章发表后的某个时候,长度增加了一倍,达到 256 字节!奇怪的是,我检查了 Git 责任 - 编辑某一行代码的每个人的日志 - 查找BINPRM_BUF_SIZE
Linux 源代码中定义的行。你瞧……
Visual Studio Code 编辑器中的 Git 责任窗口的屏幕截图。 git Blame 显示行“#define BINPRM_BUF_SIZE 128”更改为 256。提交由 Oleg Nesterov,主要文本为“exec:将 BINPRM_BUF_SIZE 增加到 256。大型企业客户端通常在网络文件系统外运行应用程序,其中IT 强制的项目卷布局最终可能会导致路径长度超过 128 个字符。将其提高到下一个两个字符的顺序可以解决除最令人震惊的情况之外的所有问题,同时仍然适合 512b 板。” 该承诺由 Linus Torvalds 等人签署。
电脑太酷了!
由于 shebang 由内核处理,并且从文件中提取buf
而不是加载整个文件,因此它们总是被截断为buf
. 显然,4 年前,有人对内核截断超过 128 个字符的路径感到恼火,他们的解决方案是通过将缓冲区大小加倍来将截断点加倍!今天,在您自己的 Linux 机器上,如果您的 shebang 行长度超过 256 个字符,则超过 256 个字符的所有内容都将完全丢失。
描绘 shebang 截断的图表。 来自名为 file.bin 的文件的大字节数组。 前 256 个字节突出显示并标记为“已加载到 buf”,而其余字节是半透明的并标记为“已忽略,过去 256 个字节”。
想象一下因此而出现错误。想象一下试图找出破坏代码的根本原因。想象一下,当发现问题出在 Linux 内核深处时,会是什么感觉。大型企业中的下一个 IT 人员发现路径的一部分神秘地丢失了,这对他们来说是不幸的。
第二件奇怪的事情:还记得它是程序名称的唯一约定argv[0]
吗?调用者如何将argv
他们想要的任何内容传递给 exec 系统调用,并且它将不受监管地传递?
碰巧这是假定程序名称的binfmt_script
地方之一。它总是删除,然后将以下内容添加到 的开头: argv[0]
`argv[0]`argv
- 通向解释器的路径
- 向口译员提出的论据
- 脚本的文件名
示例:参数修改
让我们看一个示例
execve
调用:// Arguments: filename, argv, envp execve("./script", [ "A", "B", "C" ], []);
该假设
script
文件的第一行包含以下 shebang:脚本
1
#!/usr/bin/node --experimental-module
最终传递给 Node 解释器的修改
argv
将是:[ "/usr/bin/node", "--experimental-module", "./script", "B", "C" ]
更新后,处理程序通过设置解释器路径(在本例中为 Node 二进制文件)argv
完成执行文件的准备。linux_binprm.interp
最后,它返回 0 表示成功准备程序执行。
格式亮点:杂项口译员
另一个有趣的处理程序是binfmt_misc
. 它提供了通过用户空间配置添加一些有限格式的能力,通过在/proc/sys/fs/binfmt_misc/
. 程序可以对此目录中的文件执行特殊格式的写入以添加自己的处理程序。每个配置条目指定:
- 如何检测其文件格式。这可以指定某个偏移量处的幻数或要查找的文件扩展名。
- 解释器可执行文件的路径。无法指定解释器参数,因此如果需要,则需要包装器脚本。
- 一些配置标志,包括指定如何
binfmt_misc
更新的标志argv
。
该binfmt_misc
系统通常由 Java 安装使用,配置为通过0xCAFEBABE
魔术字节检测类文件,通过扩展名检测 JAR 文件。在我的特定系统上,配置了一个处理程序,通过其 .pyc 扩展名检测 Python 字节码并将其传递给适当的处理程序。
这是一种非常酷的方式,可以让程序安装程序添加对自己格式的支持,而无需编写高特权的内核代码。
最后(不是林肯公园的歌曲)
exec 系统调用始终会出现在以下两个路径之一:
- 也许经过几层脚本解释器之后,它最终将达到它可以理解的可执行二进制格式,并运行该代码。至此,旧代码已被替换。
- …或者它将用尽所有选项并向调用程序返回错误代码。
如果您曾经使用过类 Unix 系统,您可能会注意到,从终端运行的 shell 脚本如果没有 shebang 行或.sh
扩展名,仍然会执行。如果您手头有非 Windows 终端,您现在就可以测试一下:
外壳会话
$ echo "echo hello" > ./file
$ chmod +x ./file
$ ./file
hello
(chmod +x
告诉操作系统文件是可执行文件。否则您将无法运行它。)
那么,为什么shell脚本会以shell脚本的形式运行呢?内核的格式处理程序应该没有明确的方法来检测没有任何可辨别标签的 shell 脚本!
好吧,事实证明这种行为不是内核的一部分。这实际上是shell处理故障情况的常见方法。
当您使用 shell 执行文件并且 exec 系统调用失败时,大多数 shell 将通过执行以文件名作为第一个参数的 shell 来重试将文件作为 shell 脚本执行。Bash 通常会使用自身作为解释器,而 ZSH 使用任何sh
解释器,通常是Bourne shell。
这种行为非常常见,因为它是在POSIX中指定的,POSIX 是一种旧标准,旨在使代码在 Unix 系统之间可移植。虽然大多数工具或操作系统并未严格遵循 POSIX,但它的许多约定仍然是共享的。
如果 [an exec syscall] 由于与该错误等效的错误而失败[ENOEXEC]
,则 shell 应执行一个命令,相当于使用该命令名称作为其第一个操作数调用 shell,并将任何剩余参数传递给新 shell。如果可执行文件不是文本文件,shell 可能会绕过此命令执行。在这种情况下,它应写入一条错误消息并返回退出状态 126。
电脑真是太酷了!