[TOC]

fs/exec.c 程序加载与执行(Program Loading and Execution) execve系统调用的核心实现

历史与背景

这项技术是为了解决什么特定问题而诞生的?

fs/exec.c 中的代码是为了解决操作系统中最基本的一个需求:如何在一个正在运行的进程上下文中启动一个全新的程序。这个过程被称为“执行”一个程序。它与创建新进程(由 fork() 实现)是分离的,这种“创建”与“执行”的分离是Unix哲学的核心之一。具体来说,它解决了以下问题:

  • 进程复用:当一个进程完成了它的使命,但需要启动另一个程序来接替它时(例如shell执行用户输入的命令),没有必要销毁当前进程再创建一个全新的进程。execve 允许重用现有的进程结构(如PID),只将内存中的程序镜像替换为新的程序,这大大提高了效率。
  • 程序解耦:它使得任何程序都可以调用任何其他程序,而无需了解其内部实现。一个简单的程序可以通过 exec 来调用一个复杂的工具来完成特定任务。
  • 支持多种可执行格式:操作系统需要能够运行不同格式的可执行文件,例如经典的 a.out 格式、COFF 格式,以及现代Linux系统标准的 ELF 格式。fs/exec.c 提供了一个通用的框架来识别并加载这些不同的格式。
  • 脚本执行:除了编译后的二进制文件,系统还需要能够直接执行脚本文件(如Shell脚本、Python脚本)。该机制需要能够解析文件开头的“shebang”(#!)行,并调用指定的解释器来执行脚本。

它的发展经历了哪些重要的里程碑或版本迭代?

exec 的概念源于早期的Unix系统,并在Linux中得到了继承和发展。

  • fork()/exec() 分离:这是Unix设计中的一个关键里程碑,将进程创建和程序执行解耦,提供了极大的灵活性。
  • binfmt 框架的引入:为了摆脱对特定可执行文件格式(如 a.out)的硬编码,Linux 内核引入了二进制格式(binfmt)处理程序框架。fs/exec.c 成为了一个分发中心,它本身不解析文件格式,而是按优先级查询一系列注册的 binfmt 模块(如 binfmt_elf, binfmt_script, binfmt_misc),由能识别该文件格式的模块来负责具体的加载工作。这是一个重要的架构演进,使得内核具有高度的可扩展性。
  • 脚本支持 (binfmt_script):专门为支持 #! 语法的脚本文件而创建的 binfmt 模块,使得执行脚本和执行二进制文件对用户来说没有区别。
  • 安全增强:随着安全需求的提高,execve 流程中集成了越来越多的安全检查。例如,与Linux安全模块(LSM)的挂钩(hooks),地址空间布局随机化(ASLR)的实现,以及 no_new_privs 标志的引入,用于阻止子进程获得比父进程更高的权限。

目前该技术的社区活跃度和主流应用情况如何?

execve 系统调用及其在 fs/exec.c 中的实现是Linux内核中最核心、最稳定、最关键的部分之一。它是所有用户空间程序得以运行的基础。虽然其核心逻辑已经非常成熟,但社区仍在持续对其进行维护,主要集中在以下方面:

  • 安全性:不断添加新的安全特性和缓解措施,以应对新的攻击技术。
  • 新架构支持:当Linux支持新的CPU架构时,需要确保ELF加载器等相关部分能够正确工作。
  • 性能优化:对参数传递、内存页复制等环节进行细微的性能优化。
    该技术被用于Linux系统上每一次程序的启动,从简单的shell命令到大型的桌面应用程序和服务器守护进程。

核心原理与设计

它的核心工作原理是什么?

execve 的核心工作可以概括为“原地替换”。当进程调用 execve 时,内核会执行一个单向的、不可逆转的过程,用一个新程序的镜像替换当前进程的内存镜像。

  1. 系统调用入口:用户空间的程序调用 execve(),触发软中断进入内核态,最终调用到核心函数 do_execve()
  2. 参数准备:内核首先将要传递给新程序的参数(argv)、环境变量(envp)以及程序文件名从当前的用户空间内存中复制到内核空间的内存中。这是至关重要的一步,因为当前的用户空间内存马上就要被销毁。
  3. 寻找加载器 (binfmt):内核会遍历一个已注册的二进制格式处理程序列表 (binfmt 链表)。它会逐个尝试这些处理程序,将文件的前几个字节传递给它们。
    • binfmt_script 会检查文件是否以 #! 开头。
    • binfmt_elf 会检查文件是否包含有效的ELF魔数(\x7fELF)。
    • binfmt_misc 允许管理员自定义规则,例如根据文件扩展名来启动Java虚拟机或Wine。
  4. 加载程序镜像:一旦找到一个能够识别该文件格式的处理程序,该处理程序就会接管后续工作。以最常见的ELF格式为例(由 binfmt_elf 处理):
    • 清空旧内存空间:内核会调用 flush_old_exec,彻底丢弃当前进程的所有内存映射(代码段、数据段、堆、栈等),相当于进行了一次内存“大扫除”。
    • 建立新内存空间:加载器会读取ELF文件的头部信息,根据程序头(Program Headers)的指示,为新程序的代码段(.text)、数据段(.data)等创建新的虚拟内存区域(VMA),并将文件的相应部分映射到内存中。
    • 创建新栈:为新程序创建一个全新的用户空间栈。
  5. 填充新栈:内核将第2步中保存到内核空间的参数(argv)和环境变量(envp)复制到新创建的用户空间栈的顶部。
  6. 最终设置
    • 更新进程的各种状态,例如重置大部分信号处理器、更新与文件描述符相关的 close-on-exec 标志。
    • 设置CPU的寄存器,最关键的是将**指令指针寄存器(IP)**指向新程序在ELF头中指定的入口点地址。
  7. 返回用户空间:内核从系统调用中“返回”。但此时,它不会返回到调用 execve 的旧程序代码中,而是返回到了新程序的入口点。至此,新程序开始执行,旧程序彻底消失。

它的主要优势体现在哪些方面?

  • 高效性:重用了进程的大部分内核数据结构(如 task_struct、PID),避免了完全销毁和创建进程的开销。
  • 可扩展性binfmt 框架使得支持新的可执行格式变得非常容易,只需编写并注册一个新的处理模块即可,无需改动核心逻辑。
  • 隔离性:新程序在一个干净的、由其自身文件定义的内存空间中启动,与旧程序完全隔离,保证了执行环境的纯净。

它存在哪些已知的劣势、局限性或在特定场景下的不适用性?

  • 破坏性操作execve 是一个“有去无回”的操作。一旦调用成功,旧程序的内存镜像就永远丢失了。
  • 参数大小限制:传递给新程序的参数和环境变量的总大小是有限制的(由 ARG_MAX 定义),如果超出这个限制,execve 调用会失败。
  • 复杂性execve 的内部实现非常复杂,涉及文件系统、内存管理、进程管理和安全等多个子系统,是内核中一个微妙且关键的部分。

使用场景

在哪些具体的业务或技术场景下,它是首选解决方案?请举例说明。

execve 是在一个进程中启动另一个程序的唯一标准方法。它的使用场景通常与 fork() 结合。

  • Shell 命令执行:这是最经典最普遍的场景。当你在bash中输入 ls -l 并回车时:
    1. bash 调用 fork() 创建一个子进程。
    2. 子进程(它此刻仍然是bash的副本)调用 execve("/bin/ls", ["ls", "-l", NULL], envp)
    3. 子进程的内存镜像被替换为 ls 程序的镜像,然后开始执行 ls 的代码。父进程(bash)则可以继续等待子进程结束或接受新的用户输入。
  • 执行脚本:当你运行一个Python脚本 ./myscript.py,且该文件开头有 #!/usr/bin/python3 时:
    1. 内核的 binfmt_script 处理程序被激活。
    2. 它解析出解释器是 /usr/bin/python3
    3. 内核实际上将 exec 调用转换为了 execve("/usr/bin/python3", ["/usr/bin/python3", "./myscript.py"], envp),即让Python解释器去执行你的脚本。
  • 守护进程更新:一些守护进程在需要升级到新版本时,可以执行新版本的二进制文件来替换自己,从而实现在不改变PID的情况下进行“热更新”。

是否有不推荐使用该技术的场景?为什么?

这个问题本身有些不恰当,因为如果你的目标是在当前进程中启动一个新程序,execve 就是唯一的工具。关键问题在于何时使用它,特别是是否应该先 fork()

  • 如果你希望当前程序在启动新程序后继续存在并执行后续代码,那么你必须先调用 fork(),然后在子进程中调用 execve
  • 如果你希望当前程序完全被新程序替代,那么直接在当前进程中调用 execve 即可。

对比分析

请将其 与 其他相似技术 进行详细对比。

特性 execve() (系统调用) fork() (系统调用) dlopen() (库函数)
核心功能 替换当前进程的程序镜像。 创建一个当前进程的副本。 在当前进程的地址空间中加载一个动态链接库。
进程数量 进程数量不变。PID不变。 进程数量加一。子进程获得新的PID。 进程数量不变
内存空间 完全替换。旧的地址空间被销毁,根据新程序文件创建新的地址空间。 复制。子进程获得父进程地址空间的写时复制(Copy-on-Write)副本。 扩展。将新的库文件映射到当前进程地址空间的未使用区域。
执行流 单向的,不可返回。执行流从新程序的入口点开始。 返回两次,一次在父进程,一次在子进程。父子进程都从fork()之后继续执行。 调用后程序继续从dlopen()的下一行代码执行,可以调用新库中的函数。
实现层次 内核核心功能 内核核心功能 用户空间C库函数glibc),底层依赖mmap等系统调用。
典型用途 fork()配合,用于启动新程序(如shell执行命令)。 创建新的并发执行实体,用于多任务处理。 实现插件系统、动态加载功能模块。

get_arg_pageput_arg_page: bprm参数块的页管理

这两个函数是内核执行新程序(execve)过程中, 用于管理参数和环境变量所在内存页的底层辅助函数。get_arg_page 负责按需获取或分配这些内存页, 而 put_arg_page 在此特定实现中是一个空操作, 因为这些页的生命周期是统一管理的。
这个机制使得内核的上层代码(如 binfmt_script)可以像操作一个连续的内存缓冲区一样来构建参数列表, 而底层则由这两个函数透明地处理了物理内存页的分配和管理, 这种抽象使得代码能够很好地工作在没有MMU的简单嵌入式系统上。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
/*
* get_arg_page: 获取或分配用于存储程序参数的物理页.
*
* @bprm: 指向 linux_binprm 结构体的指针, 它包含了 execve 过程中的所有上下文信息.
* @pos: 要在参数区中访问的线性地址.
* @write: 一个整型标志, 如果非零, 表示调用者打算写入该页.
* @return: 成功时返回指向目标物理页的 'struct page' 指针, 失败时返回 NULL.
*/
static struct page *get_arg_page(struct linux_binprm *bprm, unsigned long pos,
int write)
{
/*
* 定义一个指向 struct page 的指针, 用于存储结果.
*/
struct page *page;

/*
* 计算 'pos' 地址应该位于 bprm->page 数组中的哪个索引.
* bprm->page 是一个 struct page* 数组, 它将一个连续的参数地址空间映射到一组(可能不连续的)物理页上.
* 这本质上是一个由软件实现的页表.
*/
page = bprm->page[pos / PAGE_SIZE];
/*
* 检查计算出的索引处的页指针是否为NULL, 并且调用者是否请求了写权限.
*/
if (!page && write) {
/*
* 如果页不存在且需要写入, 我们就需要按需分配一个新的物理页.
* 调用 alloc_page 来从内核的伙伴系统分配器中获取一个空闲页.
* GFP_HIGHUSER: 表示为用户空间数据分配内存. 在无MMU系统中, 这通常等同于GFP_USER.
* __GFP_ZERO: 表示分配到的页必须被清零.
*/
page = alloc_page(GFP_HIGHUSER|__GFP_ZERO);
/*
* 检查页是否分配成功.
*/
if (!page)
/*
* 如果分配失败 (通常是内存不足), 返回 NULL.
*/
return NULL;
/*
* 如果分配成功, 将新分配的页的指针存入 bprm->page 数组的相应位置.
* 这样, 下次再访问这个地址范围时, 就能直接找到这个页了.
*/
bprm->page[pos / PAGE_SIZE] = page;
}

/*
* 返回找到的或新分配的页的指针.
* 如果页不存在且调用者只是读取(write为0), 这里会返回 NULL.
*/
return page;
}

/*
* put_arg_page: 释放一个通过 get_arg_page 获取的页.
*
* @page: 指向要释放的页的指针.
*/
static void put_arg_page(struct page *page)
{
/*
* 这个函数是空的. 这是一个明确的设计.
* 所有为 bprm 分配的页都在 execve 流程结束时被统一释放,
* 而不是每次 get/put 配对时释放.
* 这样做效率更高, 也简化了页的生命周期管理.
* 这个空函数的存在主要是为了代码结构的对称和完整性.
*/
}

copy_string_kernel: 将内核空间字符串复制到新程序的参数区

此函数是内核执行新程序(execve)过程中的一个核心辅助函数。它的作用是安全地将一个位于内核空间、以\0结尾的字符串(如解释器路径或参数), 复制到正在为新用户程序准备的参数/环境块中。这个过程是以“向下增长的栈”的方式进行的, 并且能正确处理跨越物理内存页边界的长字符串。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
/*
* 将一个参数/环境字符串从内核空间复制到进程的栈上(即参数区).
*/
/*
* @arg: 指向要复制的、位于内核空间的源字符串的指针.
* @bprm: 指向 linux_binprm 结构体的指针, 包含 execve 的上下文.
* @return: 成功时返回0, 失败时返回负值的错误码.
*/
int copy_string_kernel(const char *arg, struct linux_binprm *bprm)
{
/*
* 使用 strnlen 计算源字符串的长度, 最长不超过 MAX_ARG_STRLEN, 以防止安全问题.
* 结果加 1 是为了包含字符串末尾的空字符 ('\0').
*/
int len = strnlen(arg, MAX_ARG_STRLEN) + 1 /* 结尾的 NUL */;
/*
* 将 bprm->p (当前参数区的"栈顶"指针) 的值保存到 pos.
* pos 将作为计算页内偏移的基准.
*/
unsigned long pos = bprm->p;

/*
* 如果字符串长度为0 (或仅包含'\0'), 这是一个无效的参数.
*/
if (len == 0)
return -EFAULT;
/*
* 检查加入这个新字符串后, 参数列表是否会过长.
*/
if (!valid_arg_len(bprm, len))
return -E2BIG;

/*
* 我们将从后向前进行操作.
*/
/* 将源指针移动到字符串的末尾 (即'\0'之后). */
arg += len;
/* 将参数区的"栈顶"指针向下移动len个字节, 为新字符串"分配"空间. */
bprm->p -= len;
/* 检查移动后的指针是否超出了进程栈的允许范围. */
if (bprm_hit_stack_limit(bprm))
return -E2BIG;

/*
* 使用一个循环来复制数据, 直到所有字节(len)都被复制完毕.
* 这个循环设计用于处理跨页复制的情况.
*/
while (len > 0) {
/*
* 计算本次循环需要复制的字节数. 这是为了确保一次复制不会跨越页的边界.
* offset_in_page(pos) 计算 pos 地址在当前页中的偏移量.
* min_not_zero() 确保我们得到一个非零的块大小.
* min_t() 取 "len" 和 "到页末尾的字节数" 中较小的一个.
*/
unsigned int bytes_to_copy = min_t(unsigned int, len,
min_not_zero(offset_in_page(pos), PAGE_SIZE));
/*
* 声明一个指向 struct page 的指针.
*/
struct page *page;

/* 更新下一次循环的起始位置. */
pos -= bytes_to_copy;
/* 将源指针也向前移动相同的字节数. */
arg -= bytes_to_copy;
/* 更新剩余待复制的字节数. */
len -= bytes_to_copy;

/*
* 获取 pos 地址对应的物理页. '1'表示我们需要写入该页, 如果页不存在, 内核会为我们分配一个新的.
*/
page = get_arg_page(bprm, pos, 1);
/* 如果获取/分配页失败, 返回错误. */
if (!page)
return -E2BIG;
/*
* 冲刷(flush)参数页的缓存. 在带有数据缓存的CPU上(如Cortex-M7),
* 这一步确保内核的写操作对CPU是可见的, 保证缓存一致性.
*/
flush_arg_page(bprm, pos & PAGE_MASK, page);
/*
* 将数据从内核源地址(arg)复制到目标页(page)的正确偏移处.
*/
memcpy_to_page(page, offset_in_page(pos), arg, bytes_to_copy);
/*
* "释放"我们对页的引用. 在此实现中, put_arg_page 是一个空操作.
*/
put_arg_page(page);
}

/*
* 所有数据复制完成, 返回0表示成功.
*/
return 0;
}
/*
* 导出 copy_string_kernel 符号, 使其他内核模块(如 binfmt_script)能调用它.
*/
EXPORT_SYMBOL(copy_string_kernel);

bprm_change_interp: 更改待执行程序的解释器路径

此函数是内核执行新程序(execve)过程中的一个辅助函数, 主要被二进制格式处理器(binfmt handler, 如 binfmt_script)调用。它的核心作用是安全地更新 linux_binprm 结构体中记录的“解释器”路径。当一个 binfmt 处理器决定需要由另一个程序(解释器)来执行当前文件时, 它就会调用此函数来记录新的解释器路径。

bprm_change_interp` 函数在该平台上的工作原理和作用如下:

  1. 动态内存管理: binfmt_script 在解析脚本的 #! 行后, 提取出的解释器路径是一个临时的、位于栈上或只读数据段的字符串。为了让这个路径在整个 execve 流程中都有效, 必须将其复制到一块新的、动态分配的内存中。kstrdup 函数正是为此而生, 它使用 kmalloc 从内核的堆(slab/slob分配器管理的主SRAM)中申请一小块内存, 然后将解释器路径字符串复制进去。
  2. 资源所有权与释放: bprm->interp 字段“拥有”其指向的内存。此函数首先检查 bprm->interp 是否已经被其他 binfmt 处理器修改过(通过判断它是否还指向原始的 bprm->filename)。如果已经被修改, 意味着 bprm->interp 指向的是一块之前由 kstrdup 分配的内存。在这种情况下, 必须先调用 kfree 将旧的内存释放掉, 以防止内存泄漏。这个检查和释放的逻辑对于维护系统长时间运行的稳定性至关重要, 即使在内存相对较小的嵌入式系统上也是如此。

这个函数本质上是一个健壮的、包含了内存安全检查的字符串更新操作, 是确保内核在处理复杂的 execve 流程(特别是涉及解释器时)不会发生内存泄漏的关键一环。


1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
/*
* bprm_change_interp: 更改 linux_binprm 结构体中的解释器(interp)字段.
*
* @interp: 指向新的解释器路径字符串的指针. 这个字符串位于内核空间.
* @bprm: 指向 linux_binprm 结构体的指针, 它包含了 execve 的上下文信息.
* @return: 成功时返回0, 内存分配失败时返回 -ENOMEM.
*/
int bprm_change_interp(const char *interp, struct linux_binprm *bprm)
{
/*
* 检查 bprm->interp 字段是否已经被其他 binfmt 处理器修改过.
* 初始状态下, bprm->interp 和 bprm->filename 指向同一个字符串.
* 如果它们不相等, 说明 bprm->interp 指向的是一块之前通过 kstrdup 动态分配的内存.
*/
if (bprm->interp != bprm->filename)
/*
* 如果是, 就必须先调用 kfree 释放掉这块旧的内存, 以防止内存泄漏.
*/
kfree(bprm->interp);
/*
* 调用 kstrdup 复制新的解释器路径字符串.
* kstrdup 会:
* 1. 使用 kmalloc(GFP_KERNEL) 分配一块大小合适的内核内存.
* 2. 将 interp 指向的字符串内容复制到这块新分配的内存中.
* 3. 返回指向新内存的指针.
* GFP_KERNEL 是内存分配标志, 表示这是一个常规的内核分配, 在需要时可以睡眠.
*/
bprm->interp = kstrdup(interp, GFP_KERNEL);
/*
* 检查 kstrdup 是否成功. 如果内存不足, 它会返回 NULL.
*/
if (!bprm->interp)
/*
* 如果分配失败, 返回 -ENOMEM (内存不足) 错误码.
*/
return -ENOMEM;
/*
* 操作成功, 返回 0.
*/
return 0;
}
/*
* 使用 EXPORT_SYMBOL 将 bprm_change_interp 函数导出到内核符号表.
* 这使得其他内核模块(如 binfmt_script)可以调用它.
*/
EXPORT_SYMBOL(bprm_change_interp);

remove_arg_zero: 从待执行程序的参数列表中移除首个参数(argv)

此函数是内核执行新程序(execve系统调用)过程中的一个辅助函数。它的核心作用是从传递给新程序的参数列表中移除第一个参数(即argv[0]), 并更新参数指针和参数计数器。这个操作通常是为了替换 argv[0], 例如在binfmt_script模块中, 内核需要将argv[0]从脚本文件名替换为解释器的路径。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
/*
* 从 linux_binprm 结构体中移除第一个参数 (argv[0]).
* 参数是以'\0'分隔的字符串, 存放在 bprm->p 指向的位置;
* 通过将 bprm->p 重新定位到第一个'\0'之后的位置来切掉第一个参数.
*
* @bprm: 指向 linux_binprm 结构体的指针, 它包含了新程序执行所需的所有信息.
* @return: 成功时返回0, 失败时返回负值的错误码.
*/
int remove_arg_zero(struct linux_binprm *bprm)
{
/*
* 定义一个无符号长整型变量 offset, 用于存储 bprm->p 在一个内存页内的偏移量.
*/
unsigned long offset;
/*
* 定义一个字符指针 kaddr, 用于存储内核可以直接访问的内存页的地址.
*/
char *kaddr;
/*
* 定义一个指向 struct page 的指针, struct page 是内核管理物理内存页的单位.
*/
struct page *page;

/*
* 检查参数计数器 argc. 如果为0, 说明没有参数, 无需操作, 直接返回成功.
*/
if (!bprm->argc)
return 0;

/*
* 使用一个do-while循环来查找第一个参数的结尾.
* 这个循环结构的设计是为了正确处理一个参数字符串跨越多个物理页边界的情况.
*/
do {
/*
* 计算 bprm->p 在其所在物理页中的偏移量.
* PAGE_MASK 是一个像 0xFFFFF000 这样的掩码, ~PAGE_MASK 就是 0x00000FFF.
* 整个操作等效于 bprm->p % PAGE_SIZE.
*/
offset = bprm->p & ~PAGE_MASK;
/*
* 调用 get_arg_page 获取 bprm->p 地址所在的物理页的 struct page 描述符.
* 第二个参数'0'表示这是一个读操作.
* 在无MMU系统上, 它将用户空间地址转换为对应的物理页描述符.
*/
page = get_arg_page(bprm, bprm->p, 0);
/*
* 如果 get_arg_page 失败(例如, 地址无效), 则返回 NULL.
*/
if (!page)
/*
* 返回 -EFAULT, 表示地址错误.
*/
return -EFAULT;
/*
* 调用 kmap_local_page, 将物理页转换为内核可以直接访问的地址.
* 在无MMU系统上, 这通常是一个简单的地址计算, 并处理缓存相关事宜.
*/
kaddr = kmap_local_page(page);

/*
* 使用 for 循环在当前页内扫描, 寻找字符串的结尾.
* 循环条件: 偏移量未超出页边界 (offset < PAGE_SIZE) 并且当前字符不是字符串结束符'\0'.
*/
for (; offset < PAGE_SIZE && kaddr[offset];
/*
* 在循环的每一步中, 同时增加页内偏移量(offset)和全局参数指针(bprm->p).
*/
offset++, bprm->p++)
; // 循环体为空, 所有操作都在循环的增量部分完成.

/*
* 解除 kmap_local_page 创建的映射.
*/
kunmap_local(kaddr);
/*
* 减少页的引用计数, 表示我们已使用完毕.
*/
put_arg_page(page);
/*
* do-while的循环条件: 如果 for 循环是因为到达页的末尾(offset == PAGE_SIZE)而终止的,
* 这意味着字符串还未结束, 需要继续到下一个页去查找.
*/
} while (offset == PAGE_SIZE);

/*
* 当循环结束时, bprm->p 正好指向第一个参数结尾的'\0'字符.
* 将 bprm->p 再加1, 使它指向下一个参数的开始.
*/
bprm->p++;
/*
* 参数计数器减1, 因为我们已经移除了一个参数.
*/
bprm->argc--;

/*
* 返回0, 表示操作成功.
*/
return 0;
}
/*
* 使用 EXPORT_SYMBOL 将 remove_arg_zero 函数导出到内核符号表.
* 这使得其他内核模块(如此处的 binfmt_script)可以调用这个函数.
*/
EXPORT_SYMBOL(remove_arg_zero);

open_exec: 为执行目的打开一个文件

此函数是一个封装器(wrapper), 其核心作用是接收一个位于内核空间的路径名字符串, 然后以执行为目的打开该文件。它处理了路径名字符串的内存管理, 并调用底层的VFS(虚拟文件系统)函数来执行实际的文件打开操作, 最终返回一个代表该打开文件的struct file对象。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
/**
* open_exec - 为执行目的打开一个路径名
*
* @name: 打算要执行的文件的路径名.
*
* 成功时返回一个已分配的 struct file 指针, 失败时返回一个错误指针(ERR_PTR).
*
* 因为这个函数是内部函数 do_open_execat() 的一个封装, 调用者
* 必须在释放文件对象(调用fput())之前调用 exe_file_allow_write_access().
* 也可以参考 do_close_execat() 的用法.
*/
struct file *open_exec(const char *name)
{
/*
* 定义一个指向 struct filename 的指针. struct filename 是内核中用于封装路径名字符串的结构体.
* 调用 getname_kernel 函数, 它会从内核内存中分配空间并安全地复制 name 指向的路径字符串.
*/
struct filename *filename = getname_kernel(name);
/*
* 定义一个指向 struct file 的指针 f. struct file 是内核中代表一个已打开文件的对象.
* 使用 ERR_CAST 进行初始化. 如果 getname_kernel 失败 (例如内存不足), 它会返回一个错误指针(ERR_PTR).
* ERR_CAST 会将这个错误指针安全地转换类型并赋值给 f.
*/
struct file *f = ERR_CAST(filename);

/*
* IS_ERR 是一个宏, 用于检查一个指针是否是一个编码了错误码的特殊指针.
* 如果 filename 不是一个错误指针, 说明 getname_kernel 成功了.
*/
if (!IS_ERR(filename)) {
/*
* 调用 do_open_execat 函数, 这是实际执行文件打开操作的核心函数.
* @ AT_FDCWD: 一个特殊值, 表示路径名是相对于当前工作目录来解析的.
* @ filename: 包含了要打开的路径名的结构体.
* @ 0: 传递给 openat2() 的扩展标志, 这里为0, 表示无特殊标志.
* 这个函数会执行路径查找、权限检查, 并最终创建 file 对象.
*/
f = do_open_execat(AT_FDCWD, filename, 0);
/*
* 调用 putname 释放由 getname_kernel 分配的内存.
* 这是一个必须的清理步骤, 以防止内核内存泄漏.
*/
putname(filename);
}
/*
* 返回最终的结果.
* 如果成功, f 指向一个有效的 struct file 对象.
* 如果失败 (在 getname_kernel 或 do_open_execat 阶段), f 将是一个错误指针.
*/
return f;
}
/*
* 使用 EXPORT_SYMBOL 将 open_exec 函数导出到内核符号表.
* 这使得其他内核模块(如 binfmt_script)可以调用这个函数.
*/
EXPORT_SYMBOL(open_exec);

do_open_execat: 执行文件打开操作的核心实现

此函数是内核VFS(虚拟文件系统)层中, 负责以执行为目的打开文件的核心函数。它接收一个已经封装好的路径名对象, 并执行完整的路径查找、权限检查和一系列专门针对程序执行的安全检查, 最终返回一个可供程序加载器使用的文件对象。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
/*
* 成功时, 调用者必须对返回的 struct file 调用 do_close_execat() 来关闭它.
* (do_close_execat 内部会处理 exe_file_allow_write_access 和 fput).
*/
/*
* @fd: 文件描述符, 路径名 name 是相对于这个描述符所代表的目录来解析的. AT_FDCWD表示相对于当前工作目录.
* @name: 指向 filename 结构体的指针, 包含了要打开的路径名.
* @flags: 修改打开行为的标志, 如 AT_SYMLINK_NOFOLLOW.
* @return: 成功时返回指向已打开文件的 struct file 指针, 失败时返回错误指针(ERR_PTR).
*/
static struct file *do_open_execat(int fd, struct filename *name, int flags)
{
/*
* err: 用于存储错误码.
* file: 指向将要创建的文件对象的指针.
* __free(fput): 这是一个GCC的cleanup属性, 是内核中的一种自动资源管理技术.
* 它保证了无论函数从哪个路径退出, 只要 file 变量不为NULL, fput(file) 就会被自动调用.
* 这极大地简化了错误处理, 防止了文件句柄的泄漏.
*/
int err;
struct file *file __free(fput) = NULL;
/*
* 定义并初始化一个 open_flags 结构体, 用于向底层VFS函数传递打开文件的意图和参数.
*/
struct open_flags open_exec_flags = {
/* .open_flag: 传递给最终文件打开操作的标志. */
/* O_LARGEFILE: 在32位系统上支持大文件. */
/* O_RDONLY: 以只读方式打开, 因为执行程序只需要读取其内容. */
/* __FMODE_EXEC:一个特殊的内核内部标志, 表明此次打开是为了执行, VFS会据此进行特殊处理. */
.open_flag = O_LARGEFILE | O_RDONLY | __FMODE_EXEC,
/* .acc_mode: 指定要检查的访问权限. MAY_EXEC 表示必须检查执行权限. */
.acc_mode = MAY_EXEC,
/* .intent: 表明此次路径查找的意图是打开文件. */
.intent = LOOKUP_OPEN,
/* .lookup_flags: 控制路径查找的行为. LOOKUP_FOLLOW 表示默认跟随符号链接. */
.lookup_flags = LOOKUP_FOLLOW,
};

/*
* 检查传入的 flags 是否包含未知的比特位. 这是一个健壮性检查.
*/
if ((flags &
~(AT_SYMLINK_NOFOLLOW | AT_EMPTY_PATH | AT_EXECVE_CHECK)) != 0)
return ERR_PTR(-EINVAL);
/* 如果调用者指定了 AT_SYMLINK_NOFOLLOW, 则不跟随符号链接. */
if (flags & AT_SYMLINK_NOFOLLOW)
open_exec_flags.lookup_flags &= ~LOOKUP_FOLLOW;
/* 如果调用者指定了 AT_EMPTY_PATH (用于/proc/self/fd/N), 则允许路径名为空. */
if (flags & AT_EMPTY_PATH)
open_exec_flags.lookup_flags |= LOOKUP_EMPTY;

/*
* 调用 do_filp_open, 这是VFS中执行路径解析和文件打开的主要工作函数.
*/
file = do_filp_open(fd, name, &open_exec_flags);
/*
* 检查返回值. 如果打开失败, IS_ERR 会返回true.
*/
if (IS_ERR(file))
/* 直接返回这个错误指针. 此时, __free(fput)不会起作用, 因为file本身就是错误. */
return file;

/*
* 检查文件所在的挂载点是否设置了 "noexec" 标志.
* 如果是, 则不允许在此挂载点上执行任何程序, 即使文件有执行权限.
*/
if (path_noexec(&file->f_path))
return ERR_PTR(-EACCES); /* 返回访问被拒绝错误. */

/*
* 在过去, 检查文件是否为普通文件是在这里进行的. 后来它被移到了更早的 may_open() 中.
* 因此, 从那以后, 任何非普通文件在到达这里之前都应该已经出错了, 这是一个不变的约定.
*/
/*
* WARN_ON_ONCE 是一个调试宏. 如果一个文件到达这里却不是普通文件(S_ISREG),
* 说明内核逻辑中可能存在bug. 它会打印一次警告信息, 以帮助开发者定位问题.
*/
if (WARN_ON_ONCE(!S_ISREG(file_inode(file)->i_mode)))
return ERR_PTR(-EACCES);

/*
* 调用 exe_file_deny_write_access 来阻止对此文件的写访问.
* 这是一个重要的安全措施, 它会检查是否有其他进程以写入方式打开了此文件,
* 如果有, 则返回错误. 如果没有, 它会设置一个标志, 防止后续的写打开请求.
*/
err = exe_file_deny_write_access(file);
if (err)
/* 如果阻止写访问失败, 返回相应的错误. */
return ERR_PTR(err);

/*
* no_free_ptr 是 __free(fput) 的配对操作.
* 它告诉编译器, 我们不希望再对此指针进行自动清理.
* 这是因为我们打算将这个有效的 file 指针返回给调用者, 由调用者来负责其生命周期.
* 这一步 "解除了" 自动释放机制.
*/
return no_free_ptr(file);
}

begin_new_exec: 开始新程序的执行流程(无法回头的阶段)

此函数在内核的execve流程中扮演着承上启下的核心角色。它的主要职责是:在确认要执行的新程序及其解释器都合法有效之后,彻底销毁当前进程的旧执行上下文(如内存映射、信号处理、文件描述符等),并为新程序建立一个干净、隔离的运行环境。一旦这个函数开始执行,旧的程序镜像就注定要被摧毁,即使后续步骤失败,也无法恢复,进程只能被终结。
execve系统调用使得当前进程停止执行它自己的代码,并用新程序(用户程序)的代码和数据来完全替换自己,然后从新程序的入口点开始继续执行。这个过程是在同一个进程的生命周期内完成的,没有创建新进程,也没有销毁旧进程,而是对一个进程进行了彻底的“重塑”和“覆盖”。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
/*
* 调用此函数即是无法回头的开始. 任何后续的失败都不会被用户空间看到,
* 因为进程要么已经在接收一个致命信号 (通过 de_thread() 或 coredump),
* 要么将会在 search_binary_handler 中被引发一个段错误(SEGV).
*/
int begin_new_exec(struct linux_binprm * bprm)
{
/*
* me: 一个指向当前进程的 task_struct 的指针, 这是一个常用的别名.
*/
struct task_struct *me = current;
/*
* retval: 用于存储函数调用的返回值.
*/
int retval;

/*
* 凭证计算:
* 一旦我们决定要执行新程序, 就根据文件的属性(如SUID/SGID位)计算新程序的凭证(用户ID, 组ID等).
*/
retval = bprm_creds_from_file(bprm);
if (retval)
return retval;

/*
* 跟踪点(Tracepoint): 这是一个内核调试和性能分析的钩子.
* 它标记了一个时间点: 即将冲刷掉旧的执行环境, 但当前任务还未改变.
* 此时开始, 任何错误都将是致命的.
*/
trace_sched_prepare_exec(current, bprm);

/*
* 设置一个标志, 明确表示我们已经越过了"无法回头的点".
* 内核的其他部分可能会检查这个标志.
*/
bprm->point_of_no_return = true;

/*
* 线程组处理:
* de_thread() 会处理多线程程序. 它确保当前线程成为线程组中唯一的幸存者,
* 其他所有线程都会被终结. 在单线程程序中, 它只做一些清理工作.
* 这是 execve 语义的一部分: exec 会替换整个进程, 包括其所有线程.
*/
retval = de_thread(me);
if (retval)
goto out;
/*
* 重置 in_exec 标志, 这是 check_unsafe_exec() 的配对操作.
*/
current->fs->in_exec = 0;
/*
* 如果使用了 io_uring (一种高性能异步I/O接口), 在 execve 时取消所有挂起的活动.
*/
io_uring_task_cancel();

/*
* 文件描述符表处理:
* unshare_files() 确保当前进程拥有一个私有的文件描述符表.
* 如果之前这个表是与父进程共享的(例如, 通过 CLONE_FILES 标志创建的线程),
* 这个函数会为当前进程复制一份新的、独立的表.
*/
retval = unshare_files();
if (retval)
goto out;

/*
* 将新进程的内存描述符(mm_struct)与可执行文件的'struct file'对象关联起来.
* 这对于 /proc/self/exe 等功能是必需的.
* 必须在 exec_mmap() 之前调用.
*/
retval = set_mm_exe_file(bprm->mm, bprm->file);
if (retval)
goto out;

/*
* dumpable 状态设置:
* would_dump() 检查如果二进制文件不可读, 就强制设置进程不可被核心转储(dump).
*/
would_dump(bprm, bprm->file);
if (bprm->have_execfd)
would_dump(bprm, bprm->executable);

/*
* 释放旧的内存映射: 这是最核心的清理步骤之一.
* acct_arg_size() 用于进程记账.
* exec_mmap() 会遍历当前进程的所有虚拟内存区域(VMA), 并将它们全部解除映射.
* 在STM32H750上, 这意味着释放掉所有由 vm_mmap 分配的物理内存块.
* 这一步之后, 旧的程序代码、数据、栈、堆都从内存中被清除了.
*/
acct_arg_size(bprm, 0);
retval = exec_mmap(bprm->mm);
if (retval)
goto out;

/*
* bprm 不再拥有 mm_struct 的所有权, 将其置为NULL.
*/
bprm->mm = NULL;

/*
* 命名空间处理:
* 如果需要, 为新程序设置新的命名空间.
*/
retval = exec_task_namespaces();
if (retval)
goto out_unlock;

#ifdef CONFIG_POSIX_TIMERS
/*
* 定时器清理:
* 停止并清理所有与旧进程相关的POSIX定时器和间隔定时器(itimer).
*/
spin_lock_irq(&me->sighand->siglock);
posix_cpu_timers_exit(me);
spin_unlock_irq(&me->sighand->siglock);
exit_itimers(me);
flush_itimer_signals();
#endif

/*
* 信号处理表处理:
* unshare_sighand() 确保当前进程拥有一个私有的信号处理表.
* 如果之前是共享的, 就复制一份新的.
*/
retval = unshare_sighand(me);
if (retval)
goto out_unlock;

/*
* 进程标志清理:
* 清除一系列与旧进程状态相关的标志, 如地址空间随机化(PF_RANDOMIZE)、
* 禁止exec后fork(PF_FORKNOEXEC)等.
* flush_thread() 会清理掉所有与架构相关的旧线程状态 (如浮点单元状态).
* me->personality &= ~bprm->per_clear; 清理掉需要改变的"个性化"标志.
*/
me->flags &= ~(PF_RANDOMIZE | PF_FORKNOEXEC |
PF_NOFREEZE | PF_NO_SETAFFINITY);
flush_thread();
me->personality &= ~bprm->per_clear;

/*
* 清理与系统调用分发相关的缓存工作.
*/
clear_syscall_work_syscall_user_dispatch(me);

/*
* 关闭所有被标记为"close-on-exec"的文件描述符.
* 这是POSIX标准的要求, 也是一个重要的安全特性, 防止文件句柄意外泄漏给新程序.
*/
do_close_on_exec(me->files);

/*
* 安全执行(Secure exec)处理:
* 当执行一个SUID/SGID程序时, secureexec 为真.
*/
if (bprm->secureexec) {
/*
* 防止父进程向这个新获得的特权进程发送信号.
*/
me->pdeath_signal = 0;

/*
* 为安全起见, 将栈大小的rlimit重置为一个理智的默认值,
* 防止旧的rlimit设置导致不好的行为.
*/
if (bprm->rlim_stack.rlim_cur > _STK_LIM)
bprm->rlim_stack.rlim_cur = _STK_LIM;
}

/*
* 清理信号备用栈(Signal Alternate Stack)的设置.
*/
me->sas_ss_sp = me->sas_ss_size = 0;

/*
* 再次确定新进程是否可被核心转储, 这与SUID/SGID的设置有关.
*/
if (bprm->interp_flags & BINPRM_FLAGS_ENFORCE_NONDUMP ||
!(uid_eq(current_euid(), current_uid()) &&
gid_eq(current_egid(), current_gid())))
set_dumpable(current->mm, suid_dumpable);
else
set_dumpable(current->mm, SUID_DUMP_USER);

/*
* 通知性能事件(perf_event)子系统, 一个exec发生了.
*/
perf_event_exec();

/*
* 设置进程名(在ps, top等命令中显示的名称).
* 如果原始文件名是空的, 就从dentry中获取一个合理的名字.
* 否则, 就使用基本文件名(不含路径)作为进程名.
*/
if (bprm->comm_from_dentry) {
rcu_read_lock();
__set_task_comm(me, smp_load_acquire(&bprm->file->f_path.dentry->d_name.name),
true);
rcu_read_unlock();
} else {
__set_task_comm(me, kbasename(bprm->filename), true);
}

/*
* 一个exec改变了我们的身份域. 我们不再是旧线程组的一部分.
* 增加 self_exec_id, 这是一个执行计数器, 用于追踪exec的发生.
* flush_signal_handlers() 冲刷掉所有非默认的信号处理器, 恢复为默认行为.
*/
WRITE_ONCE(me->self_exec_id, me->self_exec_id + 1);
flush_signal_handlers(me, 0);

/*
* 应用新的用户计数限制.
*/
retval = set_cred_ucounts(bprm->cred);
if (retval < 0)
goto out_unlock;

/*
* 安装新凭证: 这是改变进程用户身份的关键步骤.
* security_bprm_committing_creds() 是一个安全模块(如SELinux)的钩子.
* commit_creds() 实际将 bprm->cred 中计算好的新凭证应用到当前进程.
* 之后, bprm->cred 置为NULL, 表示所有权已转移.
*/
security_bprm_committing_creds(bprm);
commit_creds(bprm->cred);
bprm->cred = NULL;

/*
* 在执行setuid程序时, 为普通用户禁用性能监控.
* 必须在commit_creds()之后调用.
*/
if (get_dumpable(me->mm) != SUID_DUMP_USER)
perf_event_exit_task(me);
/*
* 安全模块的另一个钩子.
*/
security_bprm_committed_creds(bprm);

/*
* 如果有 execfd (一个指向可执行文件的打开的文件描述符),
* 将它安装到一个新的、未使用的文件描述符槽中, 以便解释器可以访问它.
*/
if (bprm->have_execfd) {
retval = get_unused_fd_flags(0);
if (retval < 0)
goto out_unlock;
fd_install(retval, bprm->executable);
bprm->executable = NULL;
bprm->execfd = retval;
}
/*
* 所有清理和设置工作完成, 返回成功.
*/
return 0;

/*
* 错误处理标签. 在发生错误时, 跳转到这里来释放锁并返回错误码.
*/
out_unlock:
up_write(&me->signal->exec_update_lock);
if (!bprm->cred)
mutex_unlock(&me->signal->cred_guard_mutex);

out:
return retval;
}
EXPORT_SYMBOL(begin_new_exec);

finalize_exec: 在启动新线程前完成最后的执行设置

此函数是在execve系统调用的最后阶段,紧接着create_elf_fdpic_tables之后、start_thread之前被调用的。它的核心作用是将在execve流程中对资源限制(rlimit)所做的任何更改,最终、永久地应用到当前进程的信号与资源限制结构中。
Linux内核同样为每个进程维护着一套资源限制(rlimit)。其中,RLIMIT_STACK定义了进程栈可以增长的最大大小。这个限制对于防止因无限递归或栈上过大对象分配而导致的栈溢出(stack overflow)至关重要。栈溢出会破坏相邻的内存区域(在no-MMU系统上通常是堆),是导致程序崩溃和安全漏洞的主要原因之一。

finalize_exec函数确保了新程序将以正确的栈大小限制来运行。


1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
/*
* Runs immediately before start_thread() takes over.
* 在 start_thread() 接管之前立即运行.
*/
/*
* @bprm: 指向 linux_binprm 结构体的指针, 它包含了在 execve 流程中计算出的新的资源限制.
*/
void finalize_exec(struct linux_binprm *bprm)
{
/*
* 在启动新线程之前, 存储任何对栈资源限制(rlimit)的更改.
*/
/*
* 调用 task_lock 获取线程组领导者(通常是进程本身)的锁.
* 这确保了在更新资源限制这个关键数据时, 不会与其他可能访问该数据的操作
* (例如来自另一个线程的 setrlimit 系统调用)发生竞争.
* 即使在单核系统上, 这也是一个良好的编程实践, 可以防止潜在的并发问题.
*/
task_lock(current->group_leader);
/*
* 将 bprm->rlim_stack 中存储的、经过 execve 流程计算和调整后的新栈限制,
* 永久地复制到当前进程的信号结构体(current->signal)中的 rlim 数组的相应位置.
*
* 回顾: 在 begin_new_exec 中, 如果是安全执行(secureexec), 内核可能会
* 将一个过大的栈限制缩小到一个安全的默认值, 这个更改就保存在 bprm->rlim_stack 中.
* 这个 finalize_exec 调用就是将这个临时的、安全的设置最终固化下来.
*/
current->signal->rlim[RLIMIT_STACK] = bprm->rlim_stack;
/*
* 释放之前获取的锁.
*/
task_unlock(current->group_leader);
}
/*
* 将 finalize_exec 函数导出到内核符号表, 使得其他内核代码可以调用它.
*/
EXPORT_SYMBOL(finalize_exec);

总结:
finalize_exec可以被看作是execve流程中的“存档点”。在它执行完毕后,所有关于新程序环境的设置(内存布局、栈内容、资源限制等)都已经完成并被永久保存。剩下的唯一步骤就是start_thread,它将像拨动开关一样,将CPU的控制权交给新程序,让其开始生命周期的第一次执行。

Sysctl接口创建:suid_dumpable核心转储策略控制

本代码片段的功能是为Linux内核创建一个关键的安全相关的sysctl接口:/proc/sys/fs/suid_dumpable。这个接口允许系统管理员在运行时控制当设置了SUID(Set-User-ID)或SGID(Set-Group-ID)位的特权程序发生崩溃时,内核应如何处理其核心转储(core dump)文件。这是一个重要的安全参数,用于防止潜在的敏感信息通过核心转储文件泄露。

实现原理分析

该功能的实现依赖于内核的sysctl和procfs框架,并采用了一种安全的“包装器”设计模式。

  1. Sysctl表定义 (fs_exec_sysctls):

    • 代码的核心是fs_exec_sysctls数组,它定义了suid_dumpable这个sysctl条目的所有属性。
    • .procname: 指定了在procfs中创建的文件名为 “suid_dumpable”。
    • .data: 将此接口与内核中的全局变量suid_dumpable进行绑定。
    • .mode = 0644: 设置文件权限为可读写。
    • .extra1 = SYSCTL_ZERO, .extra2 = SYSCTL_TWO: 这两个字段为底层的proc_dointvec_minmax处理函数提供了参数,将允许写入的值严格限制在0、1、2三个整数之内。
    • .proc_handler = proc_dointvec_minmax_coredump: 指定了一个自定义的处理函数,这是实现特殊逻辑的关键。
  2. 包装器处理函数 (proc_dointvec_minmax_coredump):

    • 这个函数体现了一种高效且安全的设计模式。它并没有重新发明轮子去解析用户输入的字符串或进行范围检查。
    • 第一步:委托。它首先调用了内核提供的标准处理函数proc_dointvec_minmax。这个标准函数会完成所有通用的工作:处理文件偏移量、将用户提供的ASCII字符串转换为整数、检查该整数是否在.extra1.extra2定义的范围(0-2)内,并在所有检查通过后,将新值写入到suid_dumpable变量中。
    • 第二步:验证。只有当proc_dointvec_minmax成功返回(error为0),即内核变量已经被安全地修改后,它才会执行额外的、特定于此功能的逻辑:调用validate_coredump_safety()函数。
    • 这个validate_coredump_safety函数(其定义在别处)可能包含一些在suid_dumpable值改变后必须执行的安全检查或状态更新,确保整个系统的核心转储行为与新的策略保持一致。
  3. 注册与初始化 (init_fs_exec_sysctls):

    • register_sysctl_init("fs", fs_exec_sysctls)函数将fs_exec_sysctls表注册到”fs”命名空间下,从而在/proc/sys/fs/目录中创建出suid_dumpable文件。
    • fs_initcall宏确保了这个初始化过程在内核启动期间、在文件系统子系统准备就绪之后被执行。

代码分析

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
// proc_dointvec_minmax_coredump: suid_dumpable的专用proc handler,是一个包装函数。
static int proc_dointvec_minmax_coredump(const struct ctl_table *table, int write,
void *buffer, size_t *lenp, loff_t *ppos)
{
// 第一步:调用标准的min/max整数处理函数。
// 这个函数负责完成所有通用的工作:ASCII到整数的转换、范围检查和写入内核变量。
int error = proc_dointvec_minmax(table, write, buffer, lenp, ppos);

// 第二步:如果通用处理函数成功执行(没有返回错误),则调用特定的验证函数。
if (!error)
validate_coredump_safety();

return error;
}

// 定义在 /proc/sys/fs/ 目录下的sysctl条目。
static const struct ctl_table fs_exec_sysctls[] = {
{
.procname = "suid_dumpable", // 文件名,将在 /proc/sys/fs/ 下创建。
.data = &suid_dumpable, // 关联到内核的 suid_dumpable 全局变量。
.maxlen = sizeof(int), // 变量的大小。
.mode = 0644, // 文件的权限,允许root读写。
.proc_handler = proc_dointvec_minmax_coredump, // 使用上面定义的专用包装处理函数。
.extra1 = SYSCTL_ZERO, // 传递给处理函数的附加参数:允许的最小值为0。
.extra2 = SYSCTL_TWO, // 传递给处理函数的附加参数:允许的最大值为2。
},
};

// init_fs_exec_sysctls: 初始化函数,用于注册上述sysctl表。
static int __init init_fs_exec_sysctls(void)
{
// 将 fs_exec_sysctls 表注册到 "fs" 命名空间下。
register_sysctl_init("fs", fs_exec_sysctls);
return 0;
}

// 将初始化函数注册为fs_initcall,确保在文件系统初始化后执行。
fs_initcall(init_fs_exec_sysctls);