• 还是得接触一下Linux内核驱动开发,这样才更了解Linux内核pwn

  • 首先我们给一个C语言编写的内核驱动版本的Hello world,逐行理解这个代码,并配置环境。

  • 网站:The Linux Kernel Module Programming Guide

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
/*  
* hello-1.c - The simplest kernel module.
*/
#include <linux/module.h> /* Needed by all modules */
#include <linux/kernel.h> /* Needed for KERN_INFO */

int init_module(void)
{
printk(KERN_INFO "Hello world 1.\n");

/*
* A non 0 return means init_module failed; module can't be loaded.
*/
return 0;
}

void cleanup_module(void)
{
printk(KERN_INFO "Goodbye world 1.\n");
}

MODULE_LICENSE("GPL");

内核模块基础

内核模块相关概念

  • 内核模块,英文名称为Kernel Module,内核模块的可执行文件后缀通常是.ko,如hello.ko
    • 内核模块是一段可以根据需要加载和卸载内核中的代码。
    • 内核模块可以在不重启系统的情况下扩展内核的功能。
    • 最常见的内核模块其实就是设备驱动程序,它使得内核能够访问连接到系统上的硬件。例如,鼠标其实就是一个硬件,鼠标连接到电脑上,就是由某个内核模块使得内核能与鼠标相互交互,进而操作电脑。
    • 如果没有内核模块,在添加一个新的内核功能,我们就必须修改内核文件,并重新编译内核,这不仅会使得内核变得很大,而且编译内核之后还需要重启电脑,非常不方便。

注意:在具体了解内核模块之前,我将设备驱动等同于内核模块了,这是不对的,设备驱动是内核模块的其中一种。

拓展:内核模块除了有设备驱动程序,还包括了文件系统模块网络协议模块非硬件驱动类型的模块内核功能拓展模块安全模块虚拟化或容器相关模块伪文件系统、接口模块,其实任何可以动态插入内核的功能单元都可以是模块。

内核模块操作

  • Linux中我们将介绍几个对内核模块的操作,注意在执行这些操作一般都需要root权限:

    • 查看已经载入到内核正在运行的内核模块
    • 将未被载入的内核模块载入到内核中。
    • 将被载入的内核模块从内核中移除。
  • 要查看已经载入内核的内核模块,在Linux中可以使用lsmod命令来查看。

    • 运行lsmod就会显示出已经载入内核的内核模块

    image-20260521232109954

    • 运行lsmod命令本质上是读取/proc/modules这个文件,并输出到终端上。

    image-20260521232216580

  • 要将内核模块载入到内核中(其实就是运行内核模块,与运行用户程序是不一样的。)insmodmodprobe这两个命令都可以使用。而这两个命令稍微会有区别:

    • modprobe:最后是调用insmod命令将内核模块载入内核,该命令会自动寻找模块的默认位置,懂得如何找出依赖关系并按正确的顺序加载模块。相对来说更灵活。
    • insmod:这个就是直接将内核模块载入内核,在载入内核模块的时候需要输入内核模块完整的路径,相对来说比较死板。
    • 注意:对于初学来说,就使用insmod进行内核模块的载入即可,modprobe等之后再说。
    • 假设我们这边有一个内核模块,使用insmod载入内核模块就需要输入如下命令,如下图所示,当然图中还是没有hello.ko模块的:

    image-20260521233139705

1
2
insmod /home/myheart/program/my_module/hello.ko
modprobe hello
  • 要将已经载入到内核的内核模块移除,可以使用rmmod命令,也可以使用modprobe -r命令。
1
2
3
4
rmmod 模块名
rmmod /home/myheart/program/my_module/hello.ko
modprobe -r 模块名
modprobe -r hello

环境准备

在编写一个内核程序之前,还需要解决几个问题,也就是环境问题:

  • 内核版本问题:选择内核版本,有些魔改后的内核,或者打了补丁这就会导致一些问题,并且有些Linux发行版/usr/src/是缺少头文件的,就导致无法编译,所以我们尽量下载、编译并启动一个全新的、纯净的Linux内核。
  • 模块版本管理问题:当一个内核模块编译是给linux kernel 5.8.1使用的,那如果在linux kernel 5.8.2载入该模块基本上会报错,因为大部分Linux发行版的stock 内核都默认开启了modversioning选项。最好是编译一个关闭modversioning的内核。
  • 编译问题:注意到前面的内核模块的hello world程序,引入了与用户态的C语言不同的头文件<linux/module.h><linux/kernel.h>。而gcc默认引用在/usr/src/这个文件夹中的这两个头文件。如果/usr/src/文件夹中没有这两个头文件,gcc编译就会报错。
  • 调试问题:内核模块不能像printf()那样打印到屏幕上,它会记录一些信息和警告,这些信息最终只会打印到控制台中这个控制台指的是tty终端,而不是xterm等图形终端,如果使用xterm载入内核模块,只会有日志、信息和警告会被记录,而不会被输出到控制台中。我们可以输入tty命令,如果输出的是/dev/pts/tty0那么就是伪终端信息不会打印,如果是/dev/tty1那么就是真实的控制台。

我的环境

  1. 使用重新编译内核后的WSL2这样支持使用qemu,再下载一个干净的内核源码和与之对应的头文件。
  2. 编译干净的内核源码,并关闭modversioning,并使用busybox文件系统,配合qemu模拟出一个简单的系统,而qemu模拟出来的系统终端是/dev/tty
  3. WSL2中修改gcc编译的一些设置,使得编译时能正确引用头文件,这样实现在WSL2使用gcc编译好的内核模块,可以与内核打包成cpio文件格式,使用qemu模拟的时候放入所模拟的操作系统中,从而对内核进行调试与操作。

WSL2内核重新编译

  • 重新编译WSL2内核是为了能够使用qemu,这一步在我博客上已经写过了,这里不多说了。

编译纯净内核源码

  • 由于WSL2版本对应的是5.15.153.1-microsoft-standard-WSL2,为了兼容,同时也算是省事吧,我就选择与5.15.153.1-microsoft-standard-WSL2相近的Linux kernel 5.15版本。
  • 使用wget命令从linux kernel网站上下载对应版本的内核源码,这里选择下载的是linux kernel 5.15.1版本的:
1
wget https://www.kernel.org/pub/linux/kernel/v5.x/linux-5.15.1.tar.gz

image-20260522025139966

  • 下载完整后使用命令将源码的压缩包进行解压操作:
1
tar -zxvf linux-5.15.1.tar.gz
  • 接着下载编译linux kernel所需要的工具
1
2
3
sudo apt update
sudo apt upgrade
sudo apt install -y build-essential libncurses-dev bison flex libssl-dev libelf-dev bc git
  • 这里为了不多事,就直接将WSL2的内核配置文件作为该内核编译的配置文件(不太建议这样,因为不能保证WSL2的编译配置有没很大改动)
1
2
cd linux-5.15.1/
zcat /proc/config.gz > .config
  • 接着关闭内核模块版本管理:
1
scripts/config --disable MODVERSIONS
  • 接着使用make menuconfig命令,进入配置界面,把虚拟化部分给关了,可以减少一点编译时间,然后保存配置。

image-20260522032334678

  • 接着给编译所要运行的脚本文件添加可执行权限:
1
2
chmod +x scripts/*.sh
chmod +x scripts/remove-stale-files
  • 接着直接使用全部cpu核心开始编译内核
1
make -j$(nproc)

image-20260522032754919

  • 在编译的时候还是遇到报错了,处理方案在这里

image-20260522033243885

1
2
3
scripts/config --disable DEBUG_INFO_BTF --disable DEBUG_INFO_BTF_MODULES
scripts/config --disable MODVERSIONS
make -j$(nproc)
  • 编译完成

image-20260522034201387

  • 将内核复制一份过来,这样就有编译好的内核了。
1
cp ./arch/x86/boot/bzImage ../

image-20260522034256460

使用qemu启动内核

  • 接着使用busybox作为文件系统,配合qemu进行模拟从而启动内核。编译busybox这里也不多说了。这里先使用命令创建文件
1
mkdir -p initramfs/{bin,sbin,proc,sys,dev,etc}
  • 接下来复制busybox并创建符号链接,先将busybox复制到该文件夹的/bin目录下,并进入initramfs/bin目录中
1
2
cp /home/myheart/busybox-1.36.1/busybox initramfs/bin/busybox
cd initramfs/bin
  • 接着将进行创建符号链接操作,并回到initramfs的父目录中
1
2
3
for cmd in $(./busybox --list); do ln -sf busybox $cmd; done
cd ..
cd ..
  • 在内核启动之后,会执行一个init文件,这个了解一下即可,具体是在linux启动流程中会具体介绍,在initramfs这个目录下创建init文件
1
2
3
4
5
6
7
8
cat > initramfs/init << 'EOF'
#!/bin/busybox sh
mount -t proc proc /proc
mount -t sysfs sysfs /sys
mount -t devtmpfs devtmpfs /dev
echo "Welcome to BusyBox Linux!"
exec setsid cttyhack sh
EOF
  • 接着给这个文件赋予可执行权限:
1
chmod +x initramfs/init
  • 接着就是将文件打包为cpio文件
1
2
3
cd initramfs
find . -print0 | cpio --null -ov --format=newc | gzip -9 > ../initramfs.cpio.gz
cd ..
  • 然后使用qemu命令直接启动即可,这里我直接创建一个sh文件,就免得总是要输入这么一大串命令:
1
2
3
4
5
6
7
8
# vim qemu.sh
qemu-system-x86_64 \
-kernel /home/myheart/program/my_module/bzImage \
-initrd /home/myheart/program/my_module/initramfs.cpio.gz \
-append "console=ttyS0" \
-nographic
# chmod +x ./qemu.sh
# ./qemu.sh
  • 运行之后查看tty,发现是ttyS0,而不是/dev/pts/tty0,所以控制台的环境也解决了。

image-20260523025346163

image-20260523025502549

编译hello_world.ko文件

  • 对于编译内核的时候,gcc编译在引用内核头文件的时候会稍微有点问题,所以我们直接采用写Makefile来构建。
  • 我们直接将上面hello world的代码复制到hello_world.c文件中。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
/*  
* hello-1.c - The simplest kernel module.
*/
#include <linux/module.h> /* Needed by all modules */
#include <linux/kernel.h> /* Needed for KERN_INFO */

int init_module(void)
{
printk(KERN_INFO "Hello world 1.\n");

/*
* A non 0 return means init_module failed; module can't be loaded.
*/
return 0;
}

void cleanup_module(void)
{
printk(KERN_INFO "Goodbye world 1.\n");
}

MODULE_LICENSE("GPL");
  • 接下来我们要编译该文件,编译内核的时候通常不用gcc命令,而是使用make文件,所以我们要写一个简单的make文件,先确保有make这个程序。

image-20260523030755701

  • 接着创建Makefile文件,并写入这些编译信息:
1
2
3
4
5
6
7
obj-m += hello_world.o

all:
make -C /home/myheart/program/my_module/linux-5.15.1 M=$(PWD) modules

clean:
make -C /home/myheart/program/my_module/linux-5.15.1 M=$(PWD) clean
  • 接着输入make命令即可进行编译操作,运行之后就会看到编译好的内核模块文件.ko
1
make

image-20260523031504337

  • 此时我们就可以将编译好的内核模块文件复制一份到initramfs中,然后再重新打包cpio文件。
1
cp ./hello_world.ko ./initramfs/hello_world.ko
  • 重新打包cpio
1
find . -print0 | cpio --null -ov --format=newc | gzip -9 > ../initramfs.cpio.gz
  • 接着再运行qemu.sh脚本,尝试挂载hello_world.ko这个内核模块,可以成功挂载。

image-20260523032415085

image-20260523032512485

  • 在内核模块中printk,并不会将字符串输出到控制台终端,而是写入内核日志缓冲区。我们可以使用dmesg | tail查看内核日志缓冲区。

image-20260523033032634

  • 至此,内核模块的环境就已经搭建好了。如果我们想要在控制台中显示Hello world 1.我们需要将printk(KERN_INFO "Hello world 1.\n");替换成printk(KERN_EMERG "Hello world 1.\n");,并重新编译内核文件打包cpio文件,挂载即可。

image-20260523034141518