网上的教程 1 大多使用老版本内核,许多内容已经不再适用了。本文依托于我的项目 mycall 进行讲解,旨在把自己踩过的坑全部记录下来,具体实现请参考源码。实验在 Ubuntu 20.04 amd64 虚拟机(内核版本 5.15.0-105-generic)中进行。
本文将先后介绍给内核添加自定义系统调用的两种方式:
- 通过内核模块将系统调用插入正在运行的内核中。
- 将系统调用添加到内核源码中,再重新编译安装内核。
通过内核模块添加系统调用
这个方法最简单但是坑也最多,因为内核开发组显然不希望我们通过内核模块修改/覆盖系统调用 2,并且做了诸多限制,为此我们只能用一些 trick。
定义系统调用
从 Linux 4.17 开始,x86 下系统调用服务例程只接收 struct pt_regs *
一个参数 3。因此系统调用的定义为如下形式:
|
|
根据 Linux x86 calling convention,Linux 系统调用通过寄存器传递参数:rax
存储系统调用号,rdi
存储第一个参数, etc. pt_regs
就是一个包含了寄存器值的结构体 4,需要从中读取参数。
获取系统调用表地址
要插入系统调用,首先需要能够找到系统调用表的地址 sys_call_table
。可惜 2.6 版本以后内核就不再 export sys_call_table
了 5,只能寻求其他办法:
- 从 System.map 读取
编译内核时生成的内核符号表中包含系统调用表的地址,可以通过以下命令获取:
|
|
但是若内核开启了 KASLR (Kernel Address Space Layout Randomization),实际地址会和 System.map 中记录的不同,如果非要用这个方法就得关掉内核的 KASLR 特性。
- 从 /proc/kallsyms 读取
其中包含当前运行内核的符号表,通过以下命令获取系统调用表地址:
|
|
但是每次主机重启该地址都会发生变化,所以不要把里面的地址硬编码到代码里,而是先手动执行上述命令获取系统调用表地址,再在 insmod
时通过 module param 传递进内核模块中。
然而更好的方式是让模块在加载时通过 kallsyms_lookup_name()
函数自动去获取系统调用表地址。这个函数可以在运行时查询到内核中所有符号的地址,包括 non-exported symbols 如 sys_call_table
。但是模块绕过内核的 export system 去访问 non-exported symbols 很容易被滥用,所以从内核 5.7 开始不再 export 这个函数了 6。不过这条路并没有被封死,我们还是可以通过 kprobes 来提取该地址。
- 放置 kprobes(推荐)
利用 kprobes 可以追踪到 kallsyms_lookup_name()
函数的地址 7 8。其实也可以直接追踪 sys_call_table
,不过根据 8 的描述,我这里还是先获取到 kallsyms_lookup_name()
函数,再利用该函数去查询系统调用表地址。以下是一个完整的 module 示例:
|
|
选择系统调用号
注意:自定义 syscall 的调用号必须在范围内。因为一旦内核编译完成后,其系统调用表大小已确定下来,如果在其后追加,很容易造成内存溢出问题,所以只能拦截替换现有的 syscall 9 10。
那么如何确定系统调用表的大小呢?首先需要下载当前运行内核的源码。
- 内核源码 arch/x86/entry/syscalls/syscall_64.tbl 中有定义的系统调用表。
- 编译内核后,arch/x86/include/generated/uapi/asm/unistd_64.h 中的
__NR_syscalls
就是系统调用表大小,这个宏只是表示这个表的大小,并不是真正的系统调用个数。 - 编译内核后,arch/x86/include/generated/asm/syscalls_64.h 中有完整的系统调用表(行数等于
__NR_syscalls
,如果对应序号的系统调用不存在,那么就是初始值sys_ni_syscall
,表示没有实现的系统调用,调用该系统调用号直接返回错误码-ENOSYS
)。
可以选择在系统调用表长度范围内且没有定义的系统调用号(虽然也可以拦截已定义的系统调用,但是有造成系统不稳定的风险)作为我们系统调用的插入位置,如 335
。
为了模块的灵活性,我选择将系统调用号作为 module param 传入,并且设置为可读:
|
|
如此一来,插入模块后测试程序就可以直接从 /sys/module/mymod/parameters/MYCALL_NUM 读取系统调用号,而不需要硬编码或手动传入。
插入系统调用
修改系统调用表需要关闭内存的写保护:
- 将 cr0 的 Write Protect 关闭 (第17位是WP位)。
- 修改系统调用表。
- 恢复 cr0。
将系统调用编译进内核
我选择了和当前运行内核版本相近的 linux-5.15.157 版本内核源码进行实验。
- 下载内核源码。
|
|
- 创建系统调用 sys_mycall。
|
|
创建 mycall/mycall.c,包含系统调用的实现。注意:这里定义系统调用需要用 SYSCALL_DEFINEx
宏 11 12。
|
|
创建 mycall/Makefile,内容如下:
|
|
- 将
mycall/
添加到内核 Makefile 中core-y
的末尾(6.x 内核变了,需要改 Kbuild 13)。
|
|
- 将 sys_mycall 添加到系统调用表 arch/x86/entry/syscalls/syscall_64.tbl。
|
|
- 将 sys_mycall 添加到头文件 include/linux/syscalls.h 末尾但
#endif
之前。
|
|
- 编译内核。
首先安装编译所需的包:
|
|
配置内核:由于我们需要使用 kprobes 和 kallsyms 特性,所以需要检查确认 CONFIG_KPROBES=y
, CONFIG_KALLSYMS=y
8。
|
|
编译内核:
|
|
- 安装内核。
|
|
若重启后报错 error: the initrd is too big
无法进入系统,可以使用 INSTALL_MOD_STRIP=1
减小 initrd 大小:
|
|
https://medium.com/anubhav-shrimal/adding-a-hello-world-system-call-to-linux-kernel-dad32875872 ↩︎
https://lists.kernelnewbies.org/pipermail/kernelnewbies/2017-July/018091.html ↩︎
https://www.torch-fan.site/2023/05/03/pt-regs%E5%B0%8F%E7%AC%94%E8%AE%B0/ ↩︎
https://unix.stackexchange.com/questions/424119/why-is-sys-call-table-predictable ↩︎
https://github.com/xcellerator/linux_kernel_hacking/issues/3 ↩︎
https://stackoverflow.com/questions/70930059/proper-way-of-getting-the-address-of-non-exported-kernel-symbols-in-a-linux-kern ↩︎ ↩︎ ↩︎
https://stackoverflow.com/questions/2394985/linux-kernel-add-system-call-dynamically-through-module?rq=4 ↩︎
https://stackoverflow.com/questions/66800646/unable-to-add-a-custom-hello-system-call-on-x64-ubuntu-linux ↩︎
https://stackoverflow.com/questions/53735886/how-to-pass-parameters-to-linux-system-call ↩︎
https://stackoverflow.com/questions/76262123/adding-a-system-call-to-linux-kernel-6 ↩︎