Create a Linux Kernel Module
创建一个 Linux
内核模块,为 kernel PWN
的学习补充一些基础知识。
英文原文链接:
代码带库:
理论
内核模块概述
宏内核虽然比微内核要快,但模块化不足,可扩展性低。在现代宏内核中,通过引入内核模块机制,这个问题被很好地解决了。一个内核模块(或者叫可加载内核层)是一个包含可执行指令的对象文件,在需要时被加载,从而达到在运行时扩展内核功能的目的。当不再需要这个内核模块时,可以把它卸载掉。大部分设备驱动以内核模块的形式被使用。
对于Linux
设备驱动的开发,一般建议既下载内核源码、配置并编译,同时下载编译好的版本,以便进行测试和开发。
内核模块示例
下面是一个非常简单的内核模块示例。当被加载到内核中时,它会生成一个消息 “HI”,当被从卸载时,会生成一个消息 “Bye”。
译者注:模块文件名称不能是 module,本文使用 modul
1 |
|
生成的消息不会在终端中展示,而是会被保存到一个特殊的内存区域,我们可以使用日志守护进程 syslog
获取它们。为了展示内核消息,可以使用 dmesg
命令或者查看系统日志。
1 | dmesg | tail -2 |
编译内核模块
编译内核模块和编译用户程序有所不同。首先,要使用一些不同的头文件,且模块不应该链接到任何的库。同时,不能不提的是,模块编译选项必须和目标内核编译时用的选项保持一致。出于这些原因,我们可以使用一个标准的内核模块编译方法(kbuild)。这种方法使用到两个文件:一个 Makefile
和一个 Kbuild
文件。
下面是一个 Makefile
的示例:
1 | KDIR = /lib/modules/`uname -r`/build |
一个 Kbuild
示例:
1 | EXTRA_CFLAGS = -Wall -g |
正如所见,对 Makefile
调用 make
会导致在内核源码目录(KDIR)中调用 make
,并引用当前目录(M=pwd
)。这个过程最终导致从当前目录中读取 Kbuild
文件,并按照该文件中的指示编译模块。
Note.
当使用从其他地方下载来的Linux源码,而不是本机源码时,需调整 KDIR 至对应内核源码目录。
如:KDIR = /home/student/src/linux
Kbuild
文件中包含一条或多条用于编译内核模块的指令,最简单的指令示例如:obj-m = module.o 根据这条指令,一个内核模块(内核对象ko - kernel object),会从 module.o
文件开始创建。module.o
会从 module.c
或 module.S
文件中读取。这些文件都应能在 Kbuild
所在目录中找到。
一个使用多个子模块的 Kbuild
文件示例如下:
1 | EXTRA_CFLAGS = -Wall -g |
对于上面的示例,编译步骤如下:
- 编译
module-a.c
和module-b.c
源码文件,得到module-a.c
和module-b.o
对象文件 - 将
module-a.c
和module-b.o
链接成supermodule.o
- 最后从
supermodule.o
可以创建supermodule.ko
模块
Kbuild
中目标名称的后缀,决定了它们会被如何使用,规则如下:
M(module)指可加载内核模块目标
Y(yes)表示一个编译得到的,且还会被链接到内核模块($(module_name)-y)或链接进内核(obj-y)的对象目标
所有其它的目标名称后缀。都会被
Kbuild
忽略,且对应文件不会被编译。
Note.
这些后缀可以方便使用
make menuconfig
命令或直接编辑.config
文件配置内核。.config
文件设置了一系列变量,用于确定在构建时将哪些功能添加到内核中。例如,当使用
make menuconfig
添加BTRFS
支持时,会将CONFIG BTRFS FS=y
行添加到.config
文件中。原本BTRFS kbuild
包含行obj-$(CONFIG BTRFS FS):=BTRFS.o
,现在该行会变为obj-y:=BTRFS.
o。这将编译BTRFS.0
对象并将其链接到内核。在设置变量之前,该行变为obj:=btrfs.o
,因此它被忽略,构建得到的内核也就不支持BTRFS
。
内核模块的加载和卸载
加载模块使用insmod
命令,接收内核模块路径作参数;卸载模块使用rmmod
命令,使用模块名称作为参数。
1 | insmod modul.ko |
加载内核模块时,会执行被指定为module_init
宏参数的例程。类似地,当卸载模块时,会执行被指定为module_exit
宏参数的例程。
一个内核模块完整的编译、加载、卸载的过程如下
1 | faust:~/lab-01/modul-lin# ls |
已加载模块的信息,可以通过lsmod
命令进行查看,也可以通过 /proc/modules
文件 和 /sys/module
目录进行查看。
内核模块调试
对内核模块进行故障排除比调试常规程序要复杂得多。首先,内核模块中的错误可能导致整个系统阻塞,因此故障排除也就慢很多。为了避免重启,推荐使用虚拟机(如qemu,virtualbox,vmware等)。
当一个包含bug
的内核模块被加载到内核中时,最终会生成一个内核oops
。内核oops
是内核检测到的无效操作,只能由内核产生。对于稳定的内核版本,oops
的产生几乎可以肯定地意味着内核模块中存在bug
。在oops
出现后,内核会继续工作。
保存oops
出现时内核发出的消息是很重要的,和上面提到的一样,内核产生的消息被保存到日志中,能够使用dmesg
命令进行展示。为了不丢失任何的内核消息,推荐直接从控制台终端插入/测试内核模块,或者定期查看内核消息。值得注意的是,oops
的产生既可能是因为一个编程错误,也可能是因为一个错误。
如果出现一个致命的错误,导致系统无法返回到一个稳定态,会产生一个内核panic
。
下面是一个包含bug,会产生oops
的内核模块源码示例:
1 | /* |
将这个模块插入到内核中时,会产生一个oops
:
1 | faust:~/lab-01/modul-oops |
尽管相对神秘,内核给出的消息提供了出现oops
错误的重要信息。第一行:
1 | BUG: unable to handle kernel paging request at 00001234 |
告诉我们产生错误的原因,和造成错误的指令的地址。本例中,这是一个无效内存地址获取。
下一行
1 | Oops: 0002 [# 1] PREEMPT DEBUG_PAGEALLOC |
告诉我们这是第一个oops
(#1),在一个oops
可能导致其它oops
时,这一点是很重要的。通常,我们要关注的是第一个oops
。此外,oops code
(0002)标明了错误类型(见arch/x86/include/asm/trap_pf.h
):
- Bit 0 == 0 表示找不到页,1 表示页保护错误
- Bit 1 == 0 表示读,1 表示写
- Bit 2 == 0 表示内核模式,1 表示用户模式
在本例中,产生oops
(Bit 1 == 1)的原因是,尝试在内核模式
向一个找不到的内存页
执行写操作
。
下面使用dmesg
产看日志,可以看到寄存器的转储信息,给出了EIP
寄存器的值,同时可以注意到bug
出现在my_oops_init
函数,偏移为5字节(EIP: [<c89d4005>] my_oops_init+0x5
)(译者注:?),同时消息还展示了堆栈内容和在oops
出现前的调用回溯。
1 | faust:~/lab-01/modul-oops# dmesg | tail -33 |
如果生成一个无效的读调用(#define OP_OOPS OP_READ
),消息基本会是相同的,但是oops code
会变成 0000
。
objdump
使用objdump
工具,可以获得导致oops
的指令的详细信息。常用指令有两个,-d
用于反汇编,-S
用于交织显示C
代码和汇编代码,一般组合使用-dS
。为了提高解码效率,我们需要用到内核模块的加载地址,它可以在/proc/modules
中找到。
下面是一个示例,对上面的内核模块使用objdump
命令,识别生成oops
的指令:
1 | faust:~/lab-01/modul-oops# cat /proc/modules |
可以看到,上面得到的造成oops
的指令的地址(c89d4005)处的内容是:
1 | C89d4005: c7 05 34 12 00 00 03 movl $ 0x3,0x1234 |
这正是我们期望的——在0x0001234
处存储3
。
/proc/modules
中包含内核模块的加载地址,--adjust-vma
选项允许我们展示和0xc89d4000
相关的指令。
-l
选项展示插入到汇编代码中的C
源码的行号。
addr2line
一个更简单地找到造成oops
的指令的方式,是使用addr2line
工具:
1 | faust:~/lab-01/modul-oops# addr2line -e oops.o 0x5 |
其中0x5
是生成oops
的指令的程序计数(EIP=c89d4005
)减去模块加载基址(0xc89d4000,可在/proc/modules
中查看)后的值。
minicom
minicom
(或其他等效程序,如 picocom
, screen
),是一个能够用于连接串行端口并与之交互的工具。使用串行端口实在开发阶段分析内核消息或与嵌入式系统进行交互的基本方法。有两种常见的连接方式:
- 我们将使用的设备的串行端口是
/dev/ttyS0
- 我们将使用的设备的
USB
端口(FTDI)是/dev/ttyUSB
如果使用虚拟机,虚拟机启动时会显示我们使用的设备。
1 | char device redirected to /dev/pts/20 (label virtiocon0) |
minicom
使用示例:
1 | 使用COM1连接,115,200比特率 |
netconsole
netconsole
是一个可以使用网络打印内核日志消息的工具,当磁盘日志系统无法工作、串行端口无法使用或终端没有回显时,使用netconsole
很合适。netconsole
本身以内核模块的形式存在。
工作时需要以下参数:
- 端口@IP 地址/调试站的源接口名称
- 端口@调试消息被发送到的机器的 IP 地址/MAC 地址
这些参数可以在模块被插入内核时进行配置,也可以在模块插入后进行配置(要求编译时开启了 CONFIG_NETCONSOLE_DYNAMIC
选项)。
将netconsole
插入内核时的一个配置示例如下:
1 | alice:~# modprobe netconsole netconsole=6666@192.168.191.130/eth0,6000@192.168.191.1/00:50:56:c0:00:08 |
IP 地址为192.168.191.130
的源机器上的调试信息,会经过6666
端口上的eth0
接口,发送到 IP 地址为192.168.191.1
MAC 地址为00:50:56:c0:00:08
的目标机器的6000
端口。
在目标机器上可以使用netcat
接收消息:
1 | bob:~ # nc -l -p 6000 -u |
或者,目标机器上可以配置syslogd
来拦截这些消息。更多信息可在Documentation/networking/netconsole.txt
中找到。
Printk 调试
两个最经典、最有用的调试工具是你的大脑和 Printf。
对于调试,大家经常使用一种原始但非常高效的方式:printk
调试。尽管可以使用调试器,但它通常不是很有用:简单的bug
(比如未初始化的变量,内存管理问题等)可以通过控制消息打印或观察解码后的内核oops
信息快速定位。
对于更复杂的bug
,即便是调试器也没办法给予我们太多帮助,除非操作系统的结构非常好理解。当调试内核模块时,存在很多位置的因素:多个上下文(同一时刻系统里运行着多个进程和线程),中断,虚拟内存等等。
你可以使用printk
把内核消息展示到用户空间。它和printf
的功能相似,唯一的区别是,传输的消息可以以字符串”<n>
“为前缀,其中n
表示错误级别(日志级别),值的范围是0-7
。如果不使用”<n>
“,也可以使用一些符号常量表示日志级别,对应关系如下:
1 | n = 0 KERN_EMERG |
关于所有日志级别的定义,可以在linux/kern_levels.h
文件中找到。基本上,这些级别主要用于告诉系统要把消息发送到哪里:终端,日志文件,或者 /var/log
等等。
Note.
为了在用户空间展示
printk
消息,消息的日志级别必须比console_loglevel
的级别要高(数值要小)。默认的终端日志级别可以在/proc/sys/kernel/printk
进行配置。比如,
1 echo 8 > /proc/sys/kernel/printk以上命令将使得所有内核日志消息都能够在终端中展示。也就是说,日志记录级别必须严格小于
console_loglevel
变量。例如,如果console_loglevel
的值为 5(KERN_NOTICE
),则只显示loglevel<=5
的消息(即KERN_EMERG、KERN_ALERT、KERN_CRIT、KERN_ERR、KERN_WARNING)。
控制台重定向消息对于快速查看执行内核代码的效果非常有用,但如果内核遇到无法修复的错误并且系统冻结,它们就不再那么有用了。
在这种情况下,必须查阅系统的日志,因为它们在系统重新启动之间保留信息。这些文件位于/var/log
中,是文本文件,在内核运行期间由syslogd
和klogd
填充。syslogd
和klogd
从装载的/proc
虚拟文件系统中获取信息。原则上,打开syslogd
和klogd
后,所有来自内核的消息都将转到/var/log/kern.log
。
一个更简单的调试方法是使用/var/log/debug
文件。它只由来自内核的具有KERN_DEBUG
日志级别的printk消息填充。
考虑到生产内核(类似于我们可能正在运行的内核)只包含发布代码,我们的模块是少数几个发送以KERN DEBUG
为前缀的消息的模块之一。通过这种方式,我们可以通过查找与模块的调试会话相对应的消息,轻松地浏览/var/log/debug
信息。
示例如下:
1 | Clear the debug file of previous information (or possibly a backup) |
为了检测错误,打印出的消息应当尽量包含所有感兴趣的信息,但在代码中插入printk
可能与编写解决问题的代码一样耗时。因此通常需要是调试消息完整性和将这些消息插入代码所需时间之间进行权衡。
可以使用预定义的常量__FILE__
, __LINE__
and __func__
来提高插入printk
语句的效率:
__FILE__
被编译器替换为源文件的名称__LINE__
被编译器替换为当前指令对应的源文件中代码的行号__func__/__FUNCTION__
被编译器替换为当前指令所在函数的名称
Note.
__FILE__
和__LINE__
是ANSI C
规范的一部分:__func_
是C99
规范的一部分;__FUNCTION __
是一个GNU C
扩展,不可移植;不过,由于我们为Linux
内核编写代码,因此可以毫无问题地使用它们。
下面的宏定义可以在这样的情况下使用:
1 | define PRINT_DEBUG \ |
之后,在每个我们想要观察是否执行到的位置,插入PRINT_DEBUG
即可。这是一个简单快速的方式,且可以用于仔细的分系。
dmesg
命令被用来观察使用printk
打印,但未在终端输出的消息。
运行以下命令,可以删除日志文件中之前的消息:
1 | cat /dev/null > /var/log/debug |
运行以下命令,可以删除当前能被dmesg
输出的消息:
1 | dmesg -c |
dyndbg 动态调试
动态调试能够显著地减少要输出的消息的数量。为了使用动态调试函数,编译内核时要开启CONFIG_DYNAMIC_DEBUG
选项,之后就可以使用pr_debug()
, dev_dbg()
, print_hex_dump_debug()
, print_hex_dump_bytes()
等函数。
当debugfs
被挂载到/sys/kernel/debug
时,/sys/kernel/debug/dynamic_debug/control
文件用于过滤消息,也可以通过它查看已经存在的过滤器。
1 | mount -t debugfs none /debug |
Debugfs
是一个简单的文件系统,用作内核空间接口和用户空间接口来配置不同的调试选项。任何调试工具都可以在debugfs
中创建和使用自己的文件/文件夹。
比如,为了展示动态调试(dyndbg)中已经存在的过滤器,可以使用:
1 | cat /debug/dynamic_debug/control |
如郭想要接收svsock.c
文件的第1603行输出的调试消息,可以使用以下命令进行设置:
1 | echo 'file svcsock.c line 1603 +p' > /debug/dynamic_debug/control |
动态调试选项
func - 根据所在函数的函数名过滤消息
1
echo 'func svc_tcp_accept +p' > /debug/dynamic_debug/control
file - 根据源文件名过滤消息,可以使用绝对路径和相对路径,以及内核树路径
1
2
3file svcsock.c
file kernel/freezer.c
file /usr/src/packages/BUILD/sgi-enhancednfs-1.4/default/net/sunrpc/svcsock.cmodule - 根据模块名过滤消息
1
module sunrpc
format - 只显示包含以下字符串的消息
1
format "nfsd: SETATTR"
line - 根据行号启用调试函数
1
2
3
4Triggers debug messages between lines 1603 and 1605 in the svcsock.c file
echo 'file svcsock.c line 1603-1605 +p' > /sys/kernel/debug/dynamic_debug/control
Enables debug messages from the beginning of the file to line 1605
echo 'file svcsock.c line -1605 +p' > /sys/kernel/debug/dynamic_debug/control
除了以上选项,还可以使用操作符(+
-
=
)添加、删除或设置一系列flags
- p 激活
pr_debug()
- f 在输出消息中包含函数名
- l 在输出消息中包含行号
- m 在输出消息中包含模块名
- t 在输出消息中包含线程 id ,如果不是从中断上下文中调用的话
- _ 不设置任何标志
KDB内核调试器
内核调试器已被证明对促进开发和调试过程非常有用。它的主要优点之一是可以执行实时调试。这使我们能够实时监控对内存的访问,甚至在调试时修改内存。从2.6.26-rci
版本开始,调试器已经集成在主流内核中。KDB不是源调试器,但要进行完整的分析,它可以并行使用gdb
和符号文件——请参阅gdb调试部分
要使用KDB
,有以下选项:
非
usb
键盘+VGA
文本控制台串行端口控制台
USB EHCI
调试端口
对于本实验,我们将使用连接到主机的串行接口。以下命令将通过串行端口激活GDB:
1 | echo hvc0 > /sys/module/kgdboc/parameters/kgdboc |
KDB
是一个stop
模式调试器,这意味着当它处于活动状态时,所有其他进程都会停止。在执行过程中,可以使用以下Sys Rq
命令强制内核进入KDB
1 | echo g > /proc/sysrq-trigger |
或者通过在连接到串行端口(例如使用minicom)的终端中使用组合键Ctrl+O g
。
KDB有各种命令来控制和定义被调试系统的上下文:
lsmod、ps、kill、dmesg、env、bt(backtrace,回溯)
转储跟踪日志
硬件断点
修改内存
为了更好地描述可用的命令,可以使用KDBshell
中的help
命令。在下一个示例中,您可以注意到一个简单的KDB
使用示例,它设置了一个硬件断点来监视mVar
变量的更改
1 | trigger KDB |
Note.
如果你想学习如何轻松浏览Linux源代码以及如何调试内核代码,请阅读“Good to know ”部分。
练习
基本准备
下载内核源码
1 | cd ~ |
配置编译选项,
1 | make menuconfig |
根据配置准备必要文件
1 | make prepare |
创建内核模块实验目录
1 | mkdir linuxkm |
设置vscode 头文件目录,添加以下三个路径,如果头文件仍然显示错误,建议禁用错误波形曲线
1 | ~/linux-5.4.98/include/** |
Makefile
1 | KDIR = ~/linux-5.4.98/ |
Kbuild
1 | EXTRA_CFLAGS = -Wall -g |
modul.c
1 |
|
执行make命令,输出以下内容则编译成功
1 | make |
启动虚拟机
1 |
执行 make boot
1 | make boot |
使用 minicom 登录qemu,注意 -D 后的设备号与上面输出的最后一行保持一致,进入时输入root,即获得shell界面
1 | minicom -D /dev/pts/9 |