我的私有二进制世界:Linux Kernel Modules 的个性化介绍
我的私有二进制世界
Linux Kernel Modules 的个性化介绍
起源
几年前,我花了很多时间研究如何制作非常小的 ELF 可执行文件。我开始研究这个是因为我一直对我的程序,无论多么短小,都无法小于 4k 左右感到恼火。我认为对于 C 来说,这太过分了,所以我开始研究 ELF 文件包含的内容,以及其中有多少是真正需要的。(然后,过了一段时间,有多少应该是存在的,但仍然可以被移除。)总之,我最终设法将一个可执行文件缩小到 45 字节,并且我能够证明,至少在 x86 Linux 下,这是一个 ELF 可执行文件可以运行的最小尺寸。
我写下了我的发现,一些人觉得它很有趣,并且我得到了积极的反馈。当然,有些人指出,完成同样事情的 shell 脚本比 45 字节短得多,对此我的回应始终是,shell 脚本不是可执行文件,如果你想考虑脚本,那么你需要包括解释器二进制文件的大小以及脚本大小。
但是后来一个 Internet Random Person™ 指出,如果我创建了一个 aout binary,我可以创建一个更小的可执行文件。如果你不知道什么是 aout 文件,不用担心——这只是意味着你还不够老。(它们也被称为 "a.out files",但这很容易与名为 "a.out" 的文件混淆,所以我更喜欢将格式的名称拼写为 "aout"。)曾经,aout 格式在 Linux 上被广泛使用,因为它是 Linux 唯一的二进制格式。直到 2.0 版本(或者更准确地说,在 1.x 的实验性内核中),ELF 才被引入 Linux。aout 过去是,而且现在仍然是一种非常简单的格式。它拥有一个 32 字节的头部,以及少量的其他元数据。不幸的是,aout 格式在动态链接方面存在一些烦人的限制,因此 Linux 早早地放弃它并不令人惊讶。ELF 对于一个成熟的系统来说是一个更好的格式。但是,即使 aout 二进制文件不再流行,它们仍然可以工作。
然而,当我第一次尝试运行这个人发给我的 aout 可执行文件时,我收到了 "Exec format error" 消息——即不支持此文件格式。事实证明,有人发现了一个涉及 aout core dumps 的安全问题。(你知道吗?可执行文件格式带有自己匹配的 core dump 文件格式。)我对细节有些模糊,但结果是,大多数发行版开始编译他们的内核时没有 aout 支持。该格式在当时被认为已经完全过时,因此做出这个决定并不难。
(然而,对 aout 的支持仍然存在于内核源代码树中,如果你编译自己的内核,你可以自由地包含它。在撰写本文时,有传言说要完全删除它,但显然一些架构仍然需要它。有些人建议只删除对 aout core dumps 的支持,但在没有紧迫问题的情况下,它仍然保持原样。整个对话很好地提醒我们,向软件添加功能通常比删除功能容易得多。)
所以我确实编译了一个带有 aout 支持的内核,并验证了 35 字节的二进制文件确实可以工作。整个事情让我开始思考,Linux 实际上支持多少种可执行文件格式? 我对此进行了研究,发现 Linux 处理二进制文件格式的方式是内核的一个动态特性。也就是说,添加对新格式的支持相对简单,无需重新编译内核,甚至无需重新启动机器。
顺便说一句:我想澄清一下,我不是在谈论内核的 "miscellaneous binary format" 功能。该功能允许你动态地指定一个解释器,当用户尝试执行某些文件时运行。因此,例如,运行以 .jar
结尾的文件可以自动为你调用 JavaVM。该功能通过 /proc/sys/fs/binfmt_misc
系统控制,所以如果你好奇,可以查看 binfmt_misc
文档。然而,我在这里不感兴趣的是解释器;我只关注实际的二进制文件。
我暗中希望发现的是是否存在 "flat" 格式——也就是说,一种根本没有元数据的二进制文件格式。显然,这种格式将允许创建更小的可执行文件。但是,没有支持这种格式。但这并不太令人惊讶,因为这种格式不是很有用。在没有元数据的地方,就没有功能,没有选项。Flat 格式是一种一刀切的方法,而这并不是大多数人对他们的二进制格式标准的需求。
为了避免混淆,我应该在这里提到,Linux 内核确实支持一种_名为_ "flat" 的格式,但这只是 uClinux 本机二进制格式的名称。它实际上比它所衍生的 aout 格式更大且功能更丰富。大概这种格式在其他方面是 flat 的。
尽管存在这些缺点,但在另一个流行的操作系统上,碰巧有一种 flat 的、没有元数据的可执行文件格式得到了很好的支持。如果你还没有猜到,我指的是 MS Windows 支持的 .com
文件格式,它从 MS-DOS 继承而来,而 MS-DOS 又从 CP/M 继承而来。它是真正 flat 的。当你运行一个 .com
文件时,操作系统会将整个文件加载到内存中的一个标准地址并运行它。这种方法在像 MS-DOS 这样的单任务系统上运行良好。(或者更确切地说,在 MS Windows 提供给正在运行的 .com
文件的类 MS-DOS 子系统上。)在那种环境下,操作系统可以说:"给你,程序。你有 640 KB 的 RAM。玩得开心!"
因此,我自然而然地问自己,让 .com
文件格式在 Linux 下工作需要什么? 我的意思是,是的,它不会非常有用……但它会让我制作有史以来最小的可执行文件。当然,我永远无法希望将对这种格式的支持添加到实际的 Linux 内核中。但我_可以_ 将它添加到我自己的内核中,至少我可以在那里自己使用它。我可以生活在我的私有二进制世界里。
Kernel Modules
Linux 通过 loadable kernel modules 使执行此类操作变得容易。 到底什么是 kernel module? 基本上,kernel module 是一个为内核构建但尚未链接的目标文件。 诀窍在于,您可以将其链接到正在运行的内核中,而无需停止内核甚至无需退出。 (这与通常所说的“动态链接”并不完全相同,尽管在精神上非常相似。)Kernel modules 主要允许用户动态管理对各种硬件的支持,但它们允许您添加对所有各种硬件的支持,而无需重新编译内核。 因此,让我们花一些时间看看如何创建一个 kernel module。
在我们开始之前,我们需要确保内核的头文件存在于机器上。 对于基于 Debian 的系统的用户,通常使用 shell 命令来完成:
uname(1)
用于确保您获得的文件与您当前正在运行的内核的特定版本匹配。
$ **sudo apt install linux-headers-$(uname -r)**
正在读取软件包列表... 完成
正在构建依赖关系树
正在读取状态信息... 完成
下列【新】软件包将被安装:
linux-headers-4.15.0-156-generic
已升级 0 个软件包,新安装 1 个软件包,要卸载 0 个软件包,有 0 个软件包未被升级。
**⋮**
/etc/kernel/header_postinst.d/dkms:
* dkms: 为内核 4.15.0-156-generic 运行自动安装服务...完成。
这将在 /usr/src
下安装一个与您当前的内核版本相对应的目录。 (事实上,您可能会发现您已经在那里有几个目录,每个目录对应于您可以启动的内核版本。)该目录包含我们需要构建 kernel modules 的所有头文件。
我们将从一个非常简单的 kernel module 开始,可预测地命名为 "hello kernel"。
hello.c
#include <linux/module.h>
#include <linux/kernel.h>
#include <linux/init.h>
MODULE_LICENSE("GPL");
MODULE_AUTHOR("me");
MODULE_DESCRIPTION("hello kernel");
MODULE_VERSION("0.1");
static int __init hello_init(void)
{
printk(KERN_INFO "hello, kernel\n");
return 0;
}
static void __exit hello_exit(void)
{
printk(KERN_INFO "goodbye, kernel\n");
}
module_init(hello_init);
module_exit(hello_exit);
对这里正在做的事情进行快速回顾:
#include <linux/module.h>
#include <linux/kernel.h>
#include <linux/init.h>
| 头文件 linux/module.h
定义了 MODULE_*
宏,linux/init.h
定义了 __init
和 __exit
宏,linux/kernel.h
提供了 printk()
函数。 | --- |
---|---
MODULE_LICENSE("GPL");
MODULE_AUTHOR("me");
MODULE_DESCRIPTION("hello kernel");
MODULE_VERSION("0.1");
| 这些宏只是将一些元数据插入到我们的 module 中。 您可以通过 modprobe(1)
实用程序查看此信息。 除此之外,内核还会跟踪非自由软件的存在。 | ⋮
module_init(hello_init);
module_exit(hello_exit);
| 然后是两个特殊的函数,init 函数和 exit 函数。 module_init()
和 module_exit()
宏标记了哪个函数是哪个,因此内核可以找到它们。 第一个在将 module 插入到正在运行的内核时被调用,第二个在将 module 从内核中移除时被调用。 |
内核团队使构建 kernel modules 变得非常容易。 这是 makefile:
Makefile
obj-m = hello.o
kver = $(shell uname -r)
all:
make -C /lib/modules/$(kver)/build/ M=$(PWD) modules
clean:
make -C /lib/modules/$(kver)/build M=$(PWD) clean
您所做的只是将您的目标文件的名称放在第一行,其他一切都为您完成。
因此,如果我们运行 make(1)
,将会创建一堆东西:
$ **ls**
hello.c Makefile
$ **make**
make -C /lib/modules/4.15.0-156-generic/build/ M=/home/breadbox/km/hello modules
make[1]: 进入目录“/usr/src/linux-headers-4.15.0-156-generic”
CC [M] /home/breadbox/km/hello/hello.o
Building modules, stage 2.
MODPOST 1 modules
CC /home/breadbox/km/hello/hello.mod.o
LD [M] /home/breadbox/km/hello/hello.ko
make[1]: 离开目录“/usr/src/linux-headers-4.15.0-156-generic”
$ **ls**
hello.c hello.mod.c hello.o modules.order
hello.ko hello.mod.o Makefile Module.symvers
有一个 hello.o
,一个常规的目标文件,但是我们也有一个名为 hello.ko
的目标文件。 这是 kernel module。 我们已经成为内核开发人员。
insmod(8)
工具可用于将此 module 加载到正在运行的内核中:
$ **sudo insmod ./hello.ko**
$ **lsmod | head**
Module Size Used by
hello 16384 0
nls_iso8859_1 16384 0
uas 24576 0
usb_storage 69632 1 uas
btrfs 1155072 0
zstd_compress 163840 1 btrfs
xor 24576 1 btrfs
raid6_pq 114688 1 btrfs
ufs 77824 0
当我们键入 lsmod(8)
以列出活动的 module 时,它就在顶部,作为最近添加的 module。
此 module 的实际效果只是输出一些日志消息。 我们可以使用 dmesg(8)
验证我们的 module 确实已加载:
$ **dmesg | tail -n4**
[2419362.787463] e1000e: eth3 NIC Link is Up 1000 Mbps Full Duplex, Flow Control: Rx/Tx
[2448541.604799] EXT4-fs (sda1): re-mounted. Opts: (null)
[2474408.687761] usb 5-1: USB disconnect, device number 14
[2478627.755269] hello, kernel
然后,我们可以随时使用 rmmod(8)
从正在运行的内核中删除该 module:
$ **sudo rmmod hello**
$ **dmesg | tail -n4**
[2448541.604799] EXT4-fs (sda1): re-mounted. Opts: (null)
[2474408.687761] usb 5-1: USB disconnect, device number 14
[2478627.755269] hello, kernel
[2478639.607702] goodbye, kernel
这就是编写 kernel modules 的全部内容。
不,当然不是真的。 编写有用的 kernel module 确实需要一些专门的知识。 例如,kernel modules 不能访问 libc
,因为 libc
本身主要是一个位于内核之上的抽象层。 也就是说,作为 C 程序员,您习惯于轻松访问的大部分功能也存在于内核中,尽管有时以稍微不同的形式出现。 (例如,文件系统是我们作为 Unix 程序员经常忽略的一个细节,但显然是内核内部的主要问题。)
但是不要让所有这些阻止你。 编写 kernel modules 的回报值得这些不便。 在 kernel module 内部可以做很多在外部根本不可能做的事情。 请记住,Linux 是一个单内核——这意味着一旦加载,您就拥有了王国的钥匙。 您不起眼的、非标准的 kernel module 可以做_任何事情_。 当然,这是一把双刃剑,因为这也意味着您可以_意外地_做任何事情。 碰巧的是,很多坏事实际上很难意外地做,例如破坏其他进程的代码。 您需要跳过几个障碍才能获得指向其他人内存的指针。 但是有些不幸的事情非常容易做到。 例如,有一次在处理我的 kernel module 时,我意外地在我的 for
循环的迭代器中输入了 --i
而不是 ++i
。 我将该 module 插入到我的内核中进行测试,我的鼠标光标消失了,我的音乐停止了播放……然后是时候重新启动我的计算机了。
但是,这种风险不应该吓跑你。 使用现代的 journaling 文件系统等,你永远不会有丢失数据的真正风险。 (我的意思是,除非你实际上正在从事文件系统的实现工作,在这种情况下,请定期备份你的文件。)我鼓励你尝试实验并尝试你自己的 kernel modules 的想法。
Linux 下的二进制文件格式
好吧,但这与制作更小的可执行文件有什么关系呢? 嗯,正如我之前提到的,内核接受的二进制文件格式列表是动态的。 具体来说,这意味着内核中有一些函数允许代码从该列表中添加和删除二进制文件格式。
这是通过注册一组回调函数来完成的,当内核被要求执行二进制文件时,会调用这些回调函数。 内核会调用此列表中的回调函数,第一个声明可以识别该文件的回调函数会负责将其正确加载到内存中。 如果列表中没有人接受它,那么作为最后的手段,内核会尝试将其视为没有 shebang 行的 shell 脚本。 如果仍然失败,那么您将收到上面描述的“Exec format error”消息。
有趣的旁注:内核会根据文件的前几百个字节中是否包含换行符来决定是否尝试将文件解析为 shell 脚本——具体来说,它会检查在第一个零字节之前是否包含换行符。 因此,如果一个数据文件碰巧在顶部附近有一个“\n
”,如果您尝试执行它,可能会产生一些奇怪的错误消息。
因此,我们发现自己拥有以下事实:
- Linux 内核可以动态引入新的二进制文件格式。
- Kernel modules 可以添加到正在运行的内核中。
- 能够运行 flat 二进制文件将非常棒。
显然,对这种情况只有一种可能的反应。
0.1 版:瞧,没有元数据
我们希望编写一个 kernel module,为 Linux 实现一种 flat 的、没有元数据的二进制文件格式。 所以,我就是这么做的。
comfile.c
#include <linux/module.h>
#include <linux/kernel.h>
#include <linux/init.h>
#include <linux/fs.h>
#include <linux/mm.h>
#include <linux/mman.h>
#include <linux/string.h>
#include <linux/errno.h>
#include <linux/binfmts.h>
#include <linux/personality.h>
#include <linux/processor.h>
#include <linux/ptrace.h>
#include <linux/sched/task_stack.h>
MODULE_DESCRIPTION("Linux command executable files");
MODULE_AUTHOR("Brian Raiter <breadbox@muppetlabs.com>");
MODULE_VERSION("0.1");
MODULE_LICENSE("GPL");
/* 给定一个地址或大小,向上舍入到下一个页面边界。
*/
#define pagealign(n) (((n) + PAGE_SIZE - 1) & PAGE_MASK)
static struct linux_binfmt comfile_fmt;
static int load_comfile_binary(struct linux_binprm *lbp)
{
long const loadaddr = 0x00010000;
char const *ext;
loff_t filesize;
int r;
ext = strrchr(lbp->filename, '.');
if (!ext || strcmp(ext, ".com"))
return -ENOEXEC;
r = flush_old_exec(lbp);
if (r)
return r;
set_personality(PER_LINUX);
set_binfmt(&comfile_fmt);
setup_new_exec(lbp);
filesize = generic_file_llseek(lbp->file, 0, SEEK_END);
generic_file_llseek(lbp->file, 0, SEEK_SET);
current->mm->start_code = loadaddr;
current->mm->end_code = current->mm->start_code + filesize;
r = setup_arg_pages(lbp, STACK_TOP, EXSTACK_DEFAULT);
if (r)
return r;
r = vm_mmap(lbp->file, loadaddr, filesize,
PROT_READ | PROT_WRITE | PROT_EXEC,
MAP_FIXED | MAP_PRIVATE, 0);
if (r < 0)
return r;
install_exec_creds(lbp);
/*finalize_exec(lbp);*/
start_thread(current_pt_regs(), loadaddr,
current->mm->start_stack);
return 0;
}
static struct linux_binfmt comfile_fmt = {
.module = THIS_MODULE,
.load_binary = load_comfile_binary,
.load_shlib = NULL,
.core_dump = NULL,
.min_coredump = 0
};
static int __init comfile_start(void)
{
register_binfmt(&comfile_fmt);
return 0;
}
static void __exit comfile_end(void)
{
unregister_binfmt(&comfile_fmt);
}
module_init(comfile_start);
module_exit(comfile_end);
与我们的第一个 kernel module 不同,这个 kernel module 实际上正在做一些有趣的工作。 所以让我们花时间仔细阅读这段代码并了解发生了什么。
static int __init comfile_start(void)
{
register_binfmt(&comfile_fmt);
return 0;
}
static void __exit comfile_end(void)
{
unregister_binfmt(&comfile_fmt);
}
| 我们的非常短的 init 函数只是调用 register_binfmt()
,同样,我们的 exit 函数调用 unregister_binfmt()
。 你可能已经猜到了,这些是添加和删除对新二进制格式的支持的函数。 两个函数的参数是指向 linux_binfmt
类型的静态结构的指针。 | --- |
---|---
static struct linux_binfmt comfile_fmt = {
.module = THIS_MODULE,
.load_binary = load_comfile_binary,
.load_shlib = NULL,
.core_dump = NULL,
.min_coredump = 0
};
| linux_binfmt
结构的重要字段是三个函数指针。 它们提供了用于加载可执行文件、加载共享对象库和转储 core 文件的回调函数。 值得庆幸的是,后两个功能是可选的,因此我们可以让它们不实现,而只提供第一个回调函数。 |
static int load_comfile_binary(struct linux_binprm *lbp)
| 这个函数是所有工作完成的地方。 每次有人尝试执行我们的文件时,内核都会调用它,其目的是将文件的内容加载到内存中并运行。 该函数传递一个参数 lbp
,该参数是指向一个名为 linux_binprm
的结构的指针,该结构包含我们的实际参数。 它有十几个左右的字段,总结了内核了解的关于我们的文件的所有信息。 回调函数返回一个 int
值,这对于内部内核函数来说很典型。 如果一切顺利,则返回值为零。 发生错误时,该函数应返回一个负数,该负数对应于取反的 errno
值。 |
回想一下程序如何在 Unix 下启动:首先使用 fork
系统调用来复制该进程,然后 execve
系统调用将该进程的当前程序替换为新程序。
请注意,fork
系统调用与 libc
提供的 fork()
函数并不完全相同,尽管后者只是对前者的一个简单包装。 类似地,libc
提供了一组七个不同的“exec”函数,但它们最终都会调用 execve
系统调用。
这个系统的好处是,我们永远不必担心从头开始实际创建一个进程。 这是为我们完成的。 每个程序的进程都是 pid 1 的副本,通过一系列 fork
复制。 我们的回调函数将在 execve
系统调用期间被调用。 实际上,当内核调用我们时,它会问:“嘿,我这里有一个文件。 用户声称这是一个可执行的二进制文件,但它不是 ELF 文件。 你想处理它吗?" 已在 register_binfmt()
中注册的每个回调函数都会按顺序被调用,直到有人负责该文件。
因此,我们的回调函数需要做的第一件事是:它需要确定这是否真的是一个 .com
文件。 这引出了一个明显的问题:我们甚至如何做到这一点? 大多数二进制文件格式都会在元数据的前几个字节中查找幻数标识符——但我们没有元数据。 那么怎么办?
好吧,MS Windows 如何识别 .com
文件? 答案:它查看文件名。 当您尝试执行名称以“.com
”结尾的文件时,这才是 MS Windows 真正关心的。 “哦,你是一个 .com
文件,是吗? 好的:这里有 640k 和一个中断表。 完成后给我打电话。”
ext = strrchr(lbp->filename, '.');
if (!ext || strcmp(ext, ".com"))
return -ENOEXEC;
| 这也是我们所做的。 linux_binprm
结构的一个字段是文件名,因此我们检查它,如果没有 ".com
" 扩展名,则我们返回负数 ENOEXEC
,这是 errno
等效于我们的 "Exec format error" 消息。 此错误通常表示“这不是一个可执行文件”,但在这种特定情况下,它实际上意味着“这不是我的可执行文件之一。” 当内核获得此返回值时,它将继续尝试其他格式。 如果所有回调都返回此值,则实际上将从 execve
本身返回 ENOEXEC
,然后 libc
将其打包并存储在 errno
中。 但是,如果它以 ".com
" 结尾,则我们的回调将继续。 | --- |
我们现在要做的就是加载并运行该文件。 没有压力,对吧? 幸运的是,内核提供了许多函数,这些函数将为我们完成几乎所有的繁重工作。 我们只需要监督整个过程。 因此,让我们快速回顾一下事件的顺序。
r = flush_old_exec(lbp);
if (r)
return r;
| 我们要做的第一件事是调用 flush_old_exec()
。 轰。 现在,几乎所有特定于旧进程的东西都消失了。 该过程现在是一个空旷的盐碱地,毫无特色地延伸到地平线。 等等,这有点惨淡。 相反,让我们将其想象成一片休耕的田地,准备播种。 另请注意,如果返回非零值,则发生了故障,在这种情况下,我们将尽职尽责地将取反的 errno
值传递回调用链。 | --- |
set_personality(PER_LINUX);
| Personality 是一种晦涩的功能,它允许内核的某些行为在每个进程的基础上变化。 不管出于什么原因,它都不会被 flush 重置。 |
set_binfmt(&comfile_fmt);
| set_binfmt()
函数明确声明此二进制文件是我们自己的。 据我所知,这仅用于调试目的。 |
setup_new_exec(lbp);
| setup_new_exec()
将进程初始化为一些基本默认值,并允许进行任何特定于架构的初始化。 |
filesize = generic_file_llseek(lbp->file, 0, SEEK_END);
generic_file_llseek(lbp->file, 0, SEEK_SET);
| 在这一点上,我们现在可以开始定义我们的内存映像,该映像目前非常空。 因此,我们要做的第一件事是确定文件的大小,因为这也是程序的大小。 在内核内部,我们没有熟悉的文件描述符。 相反,我们有文件对象。 你可能期望的是,linux_binprm
结构包含一个已经打开的文件对象,并且内核函数 generic_file_llseek()
的工作方式与 libc
更熟悉的 lseek()
函数几乎相同,用于检索文件大小。 |
current->mm->start_code = loadaddr;
current->mm->end_code = current->mm->start_code + filesize;
| current
是一个全局变量,指向当前的任务。 任务就像进程或线程,只不过它不是一个数值标识符,而是实际的东西本身——本体, ding-an-sich。 这是一个包含数百个字段的结构。 它真的非常大。 你可能想知道的关于进程的几乎任何信息都在这里面的某个地方。 其中一件事情是任务的内存管理器。 现在,内存管理器渴望知道进程的组成部分将位于何处。 由于我们的格式非常简单——我们所拥有的只是一个代码 blob——我们主要需要提供一个有效的加载地址。 对此地址没有太多要求。 它只需要与页面对齐,远离堆栈,并且不为零。 我选择 0x10000
作为我们的加载地址,因为没有特别的理由不这样做。 |
r = setup_arg_pages(lbp, STACK_TOP, EXSTACK_DEFAULT);
if (r)
return r;
| 我们没有设置进程通常包含的任何其他内容,因为我们只是如此脚踏实地,因此我们可以直接调用 setup_arg_pages()
。 此函数最终确定了堆栈的位置和访问权限。 |
r = vm_mmap(lbp->file, loadaddr, filesize,
PROT_READ | PROT_WRITE | PROT_EXEC,
MAP_FIXED | MAP_PRIVATE, 0);
if (r < 0)
return r;
| 现在这已经正式确定了,让我们实际将一些东西加载到内存中。 是的,伙计们,终于该调用 vm_mmap()
了。 此函数与 libc
的 mmap()
基本相同,并且是将文件加载到(与页面对齐的)内存中的自然方式。 当然,通常当你使用固定的加载地址调用 mmap()
时,你需要处理该地址已被使用的情况。 我们无需在此处担心这一点,因为当前没有任何内容在使用中。 我们要求将内存标记为可读、可写和可执行。 传统上,程序会将它们的代码放入不可写的内存中,并将变量数据存储在可写但不可执行的内存中。 这绝对是更安全的方式,但我们不能为所有这些烦恼。 毕竟,.com
格式可以追溯到一个更简单的时代,那时 RAM 就是 RAM,并且没有像保护这样的花招。 忠于这种方法是一种尊重我们的根源的方式。 此外,如果没有元数据,基本上不可能知道文件的哪些部分是代码,哪些部分是数据,因此我们实际上没有选择,但如果我们声称这是因为我们的传统,听起来会更好。 |
install_exec_creds(lbp);
| 我们现在调用 install_exec_creds()
,它将设置正确的用户 ID 与有效用户 ID,以防需要更改它。 |
/*finalize_exec(lbp);*/
| 函数 finalize_exec()
对堆栈的 rlimit
值执行某些操作。 我对它的目的有点模糊,因为它有点新。 事实上,它甚至不存在于我的内核版本中,这就是为什么它在我的代码中被注释掉的原因。 如果您运行的是 5.x 或更高版本的内核,请随意恢复它。 |
start_thread(current_pt_regs(), loadaddr,
current->mm->start_stack);
return 0;
| 最后,我们调用 start_thread()
。 这是最重要的事情。 我们将进程的当前寄存器值的结构指针、指向堆栈顶部的指针以及最重要的是指令指针的地址(对于我们来说,这与加载地址相同)传递给它。 该过程现在已准备好进行调度。 而且,由于我们确实已经走了这么远,因此我们返回零以表示成功。 |
呼。 设置进程的过程绝对不简单。 但正如我所说,所有真正的工作都由其他代码完成。
$ **make**
make -C /lib/modules/4.15.0-156-generic/build/ M=/home/breadbox/km/com modules
make[1]: 进入目录“/usr/src/linux-headers-4.15.0-156-generic”
CC [M] /home/breadbox/km/com/comfile.o
Building modules, stage 2.
MODPOST 1 modules
CC /home/breadbox/km/com/comfile.mod.o
LD [M] /home/breadbox/km/com/comfile.ko
make[1]: 离开目录“/usr/src/linux-headers-4.15.0-156-generic”
$ **sudo insmod comfile.ko**
$ **lsmod | head -n3**
Module Size Used by
comfile 16384 0
nls_iso8859_1 16384 0
现在为了真正测试这个 kernel module,我们将需要一个要执行的程序。 具体来说,我们需要以我们的 flat 的、没有元数据的格式创建一个二进制文件。 一个实际做一些事情的程序。
创建我们自己的二进制文件格式的缺点是,我们常用的工具都对此一无所知。 如果我们想以这种格式构建程序,那么我们将依靠自己。 但是,我们的格式非常简单,因此这不应该很难。 但是,这确实意味着我们需要使用汇编代码。
按照惯例,我们最小的测试程序将是以状态码 42 退出。 为了在 64 位 Linux 下进行系统调用,我们需要将 rax
设置为系统调用号,将 rdi
设置为(第一个)参数,然后使用 syscall
指令。 exit
系统调用被分配了 ID 号 60,因此这应该就是我们所需要的:
$ **cat >tiny.asm**
BITS 64
mov rax, 60
mov rdi, 42
syscall
$ **nasm -f bin -o tiny.com tiny.asm**
$ **chmod +x tiny.com**
$ **wc -c tiny.com**
12 tiny.com
bin
格式是 nasm
对其 flat 二进制输出格式的名称,因此我们在输出文件中获得的只是我们指定的汇编代码。
$ **./tiny.com**
$ **echo $?**
42
我们的项目已经取得了成果。 请看:它可以工作,并且大小为 12 字节。 我们可以验证它实际上是我们的 kernel module,它实际上正在加载和运行它:
$ **sudo rmmod comfile**
$ **./tiny.com**
bash: ./tiny.com: 无法执行二进制文件: Exec format error
$ **sudo insmod ./comfile.ko**
$ **./tiny.com**
$ **echo $?**
42
这种令人愉悦的纯二进制文件几乎是最小的 ELF 可执行文件大小的四分之一,并且小于启发此(公认是荒谬的)探索的 aout 可执行文件大小的三分之一。 并且我们的文件格式中没有零字节的开销,我们可以确信没有使用包含元数据的格式的二进制文件可以触及此二进制文件。
当然,如果我们要开始吹嘘尺寸,那么我们可能应该继续使用尽可能小的指令。
tiny.asm
BITS 64
push 42
pop rdi
mov al, 60
syscall
由于 rdi
只能用三个字节的机器代码初始化,并且由于 rax
预先初始化为零,因此可以用更少的字节初始化 rax
。
$ **nasm -f bin -o tiny.com tiny.asm**
$ **chmod +x tiny.com**
$ **./tiny.com**
$ **echo $?**
42
$ **wc -c tiny.com**
7 tiny.com
七个字节。 七个!
需要明确的是,这是一种非常规的二进制文件,因此它绝不会使我的 45 字节 ELF 可执行文件(或 aout 可执行文件)失效或取代它。 但它确实让我感到非常高兴。
进一步测试
我们应该尝试编写更多程序,以验证我们的 kernel module 是否真的可以普遍运行。 让我们尝试一个合适的 hello-world 程序。
hello.asm
BITS 64
org 0x10000
mov eax, 1 ; rax = 1: write system call
mov edi, eax ; rdi = 1: stdout file desc
lea rsi, [rel str] ; rsi = pointer to string
mov edx, strlen ;