前言

记录20250423及之前关于linux内核学习的内容

参考博客:

https://arttnba3.cn/2021/02/21/OS-0X00-LINUX-KERNEL-PART-I/

https://arttnba3.cn/2021/02/21/OS-0X01-LINUX-KERNEL-PART-II/

https://arttnba3.cn/2021/03/03/PWN-0X00-LINUX-KERNEL-PWN-PART-I/

由题目开始的内核学习

示例题目为 强网杯2018 - core

开始

解压命令

本题目中core.cpio使用了gzip压缩,解压命令:

mkdir file_folder
cd file_folder
zcat ../core.cpio | cpio -idmv

解压后有一堆文件

其中几个文件夹很明显就是linux根目录的文件夹,另外在当前目录下还有core.ko init gen_cpio.sh等文件

core.ko就是有漏洞的内核模块了

gen_cpio.sh

我们先看gen_cpio.sh,这里面的内容是打包命令

find . -print0 \
| cpio --null -ov --format=newc \
| gzip -9 > $1

可以看到使用了cpio和gzip

我们在当前文件夹修改之后如何打包呢?

./gen_cpio.sh ../core.cpio

即可

init

以前只模模糊糊知道init是linux启动后的第一个程序,那init到底是什么?

在 Linux 系统中,init 文件是系统启动过程中的第一个用户空间程序。它的作用非常关键,是操作系统启动时执行的第一个用户进程,它负责初始化系统的其他部分,并最终启动系统的主要服务。

init 文件的作用:

  1. 启动系统服务: init 是在内核初始化后第一个运行的程序,它通常会启动其他必要的系统服务(如网络、文件系统、用户空间进程等)。这些服务可能包括登录进程、系统守护进程(如 syslog)、硬件初始化等。
  2. 进程树的根: init 进程的进程号(PID)是 1,它是所有其他进程的祖先。所有其他进程最终都是由 init 启动的,因此它也是进程树的根节点。任何其他用户进程都是由它派生出来的。
  3. 处理运行级别(Runlevels): 在传统的 Unix 系统中,init 还负责管理不同的运行级别(runlevels)。它通过读取 /etc/inittab 或其他配置文件来确定系统当前应该处于哪个运行级别,并根据运行级别启动不同的服务。例如,在多用户模式下,init 会启动网络服务和登录进程,而在单用户模式下,它可能仅启动最基础的服务。
  4. 执行用户指定的启动脚本: init 通常会根据配置文件的设置执行一些初始化脚本,例如 /etc/rc.d/rc,这些脚本用于启动各种服务。现代的 Linux 系统通常使用 systemdupstart 来取代传统的 init 进程。

在 QEMU 环境中:

当你在 QEMU 中搭建 Linux 环境时,init 文件通常是你自己创建的一个文件,它用于替代或模拟系统启动时的第一个用户进程。你可以自定义这个 init 文件来完成特定的初始化工作,例如:

  • 挂载文件系统
  • 初始化网络接口
  • 启动虚拟机中的关键服务
  • 启动一个 shell 或其他应用程序

例如,在一个简化的 Linux 系统中,你的 init 文件可能只是一个简单的 shell 脚本,它执行一些初始化任务,并最终启动一个 shell 让你可以与虚拟机进行交互。

ok,以上是gpt生成的答案,大概还是只需要知道init就是第一个程序就可以了?

init文件内容

下面是init文件内容

#!/bin/sh
mount -t proc proc /proc
mount -t sysfs sysfs /sys
mount -t devtmpfs none /dev
/sbin/mdev -s
mkdir -p /dev/pts
mount -vt devpts -o gid=4,mode=620 none /dev/pts
chmod 666 /dev/ptmx
cat /proc/kallsyms > /tmp/kallsyms
echo 1 > /proc/sys/kernel/kptr_restrict
echo 1 > /proc/sys/kernel/dmesg_restrict
ifconfig eth0 up
udhcpc -i eth0
ifconfig eth0 10.0.2.15 netmask 255.255.255.0
route add default gw 10.0.2.2
insmod /core.ko

#poweroff -d 120 -f &
setsid /bin/cttyhack /bin/sh
echo 'sh end!\n'
umount /proc
umount /sys

poweroff -d 0 -f

其中poweroff -d 120 -f &意思是定时2分钟关机,因此我把这句话注释掉了。

insmod /core.ko就是加载core.ko模块。

setsid /bin/cttyhack /bin/sh 原来的指令是setsid /bin/cttyhack setuidgid 1000 /bin/sh

其中setuidgid 1000就导致用户转变为普通用户。我们在调试的时候可以吧这句话删掉,这样我们就可以在进入linux之后查看core.ko模块的基址了。

cat /proc/kallsyms > /tmp/kallsyms
  • 开始时内核符号表被复制了一份到/tmp/kalsyms中,利用这个我们可以获得内核中所有函数的地址(原博客内容)

start.sh

我们接下来再看一下start.sh内容。

qemu-system-x86_64 \
-m 128M \
-kernel ./bzImage \
-initrd ./core.cpio \
-append "root=/dev/ram rw console=ttyS0 oops=panic panic=1 quiet nokaslr" \
-s \
-netdev user,id=t0, -device e1000,netdev=t0,id=nic0 \
-nographic \

其中 -m 128M 是我改的,原先是64M,导致内存太少一直进不去。

报错信息:

[    0.038667] Spectre V2 : Spectre mitigation: LFENCE not serializing, switching to generic retpoline
[ 1.409156] Kernel panic - not syncing: Out of memory and no killable processes...
[ 1.409156]
[ 1.409452] Rebooting in 1 seconds..

然后append里面有一个nokaslr,这就是关闭内核基址随机化,原来是没有no的,我们加上no就关闭随机化方便调试。

  • -s:相当于-gdb tcp::1234的简写(也可以直接这么写),后续我们可以通过gdb连接本地端口进行调试(原博客内容)

qemu+gdb调试

ok,我们更改完init(重新打包)和start.sh之后,就可以运行了。

直接./start.sh即可,接下来就会运行linux内核了。

获取基址

我们需要知道core模块的基址,刚好我们之前在init里面直接以root用户登录,因此我们现在可以很轻松地得到。

cat /sys/module/core/sections/.bss
0xffffffffc0002400
cat /sys/module/core/sections/.text
0xffffffffc0000000
cat /sys/module/core/sections/.data
0xffffffffc0002000

其中cat /sys/module/core/sections/.text是最重要的。

gdb调试

接下来我们新开一个终端

gdb vmlinux

这是加载内核符号表

然后

set architecture i386:x86-64
target remote localhost:1234

其中第二句话因为我们qemu的设置中增加了-s,因此可以这样。

之后我们还需要加载core模块符号表

add-symbol-file ./file_folder/core.ko 0xffffffffc0000000

ok,现在我们就成功调试上linux内核了。

我们可以下断点

b core_ioctl 
b core_read
b core_write

其中这三个函数都是在core.ko里面的函数,这代表我们成功加载上符号表了。

接下来就可以愉快地进行内核调试了。

用户态与内核态的切换

首先贴一下大佬的博客

主要的一个过程如下:

  • 切换 GS 段寄存器:通过 swapgs 切换 GS 段寄存器,将 GS 寄存器值和一个特定位置的值进行交换,目的是保存 GS 值,同时将该位置的值作为内核执行时的 GS 值使用
  • 保存用户态栈帧信息:将当前栈顶(用户空间栈顶)记录在 CPU 独占变量区域里(由 GS 寄存器所指定的percpu 段),将 CPU 独占区域里记录的内核栈顶放入 rsp/esp
  • 保存用户态寄存器信息: 通过 push 保存各寄存器值到栈上,以便后续“着陆”回用户态
  • 通过汇编指令判断是否为32位
  • 控制权转交内核,执行相应的操作

由内核态重新“着陆”回用户态只需要恢复用户空间信息即可:

  • swapgs 指令恢复用户态GS寄存器
  • sysretq 或者iretq 系列指令让 CPU 运行模式回到 ring 3,恢复用户空间程序的继续运行

这里着重讲一下我的理解,尤其是从用户态转为内核态的。

  1. 内核空间中有一段空间是percpu空间,这段空间可以通过gs段寄存器来访问。

  2. 当用户态切换为内核态时,首先swapgs,将gs_base和gs_kernel_base交换,然后将用户态栈帧信息保存在percpu空间中,并把之前保存在percpu空间中的内核态rsp切换到现在的rsp里。

  3. 那现在的栈就是内核空间中的栈了, 接下来把用户态的几个寄存器保存在内核空间的栈中,接下来就是内核态代码了。

简单来说,内核空间中有一段空间叫做percpu,可以通过gs寄存器来访问。把用户态rsp信息保存在这里面,把其余寄存器信息设置为pt_regs结构体并保存在内核态栈上(内核态栈本来也保存在percpu空间里)(内核态栈在内核态空间里,用户态栈在用户态空间里)

其中还有几个段寄存器,比如cs ss,这几个段寄存器的值表明了当前正在运行在哪一个状态下(ring0还是ring3)

当然目前我所涉猎的知识还非常有限,未来会对这块内容进行再补充的。