从零开始的 kernel pwn 入门 - I:Linux kernel 简易食用指南

 

0x00.一切开始之前

前言

当前阶段在 CTF 比赛中 pwn 类型的题目主要以 Linux 下的漏洞利用为基础,而Linux 下的漏洞利用又可以分为用户态与内核态两个方面的漏洞利用——我们在各种比赛中最常接触到的是前者,很少有接触到后者的机会(笔者最近打的几场比赛中只有 TCTF/0CTF FINAL 出了两道)

但若是我们将目光放得更长远些,对操作系统内核的利用的研究,毫无疑问是安全界十分热门的一个方向,因此本系列文章并非 CTF 导向的教程,笔者更愿意向大家介绍现实中的 Linux kernel exploit,即真实世界中的pwn

不过不可否认的是,CTF 仍然是一个十分适合入门的方法,因此本系列教程当中仍然会以讲解近些年 CTF 中的 kernel pwn 题目为主,kernel cve 为辅,来帮助大家更好地踏入kernel pwn 的世界

文章计划

笔者粗略计划,在接下来的一系列文章当中,你将接触到以下的内核攻击手法:

  • 内核栈利用
    • 返回导向编程(ROP)
      • ret2usr
        • SMEP-BYPASS
      • ret2dir
    • KPTI bypass
  • 内核“堆”利用
    • Use After Free
    • 堆溢出
      • Off-by-one
  • 条件竞争
    • double fetch
    • userfaultfd
  • 堆喷(heap spraying)
  • qemu逃逸
  • ……

同时你还将接触到一些 kernel cve 的简单解析,以及使用 syzkaller、AFL-fuzz 等自动化的漏洞挖掘工具的基本用法

目标人群:

重要的事情说三遍:

该系列文章不适用于刚刚入门pwn的萌新!该系列文章不适用于刚刚入门pwn的萌新!该系列文章不适用于刚刚入门pwn的萌新!

笔者个人建议,在入门 kernel pwn 之前,你应当对用户态下的基本利用手法了如指掌,且至少完成对操作系统这一门课程系统性的学习

本篇内容

作为本系列的第一篇文章,在正式进入到 kernel pwn 的世界之前,笔者还是想先向大家介绍一些操作系统的基础知识,包括:

  • 操作系统基础
  • 内核保护机制
  • 内核编译、运行、替换
  • 内核调试基本方法(qemu + gdb)
  • 基本 LKM 的编写入门
  • CTF 中 kernel 类题目的部署方式

 

0x01.操作系统基本知识补充

在入门之前,我们首先得需要了解,操作系统是什么?她和我们普通的应用程序有何不同?

本篇文章中笔者将带着大家简单地操作系统这一门课程过一遍,主要讲解 Linux kernel

一、内核

操作系统(Operation System)本质上也是一种软件,可以看作是普通应用程式与硬件之间的一层中间层,其主要作用便是调度系统资源、控制IO设备、操作网络与文件系统等,并为上层应用提供便捷、抽象的应用接口

而运行在内核态的内核kernel)则是一个操作系统最为核心的部分,提供着一个操作系统最为基础的功能

这张十分经典的图片说明了Kernel在计算机体系结构中的位置:

kernel的主要功能可以归为以下三点:

  • 控制并与硬件进行交互
  • 提供应用程式运行环境
  • 调度系统资源

包括 I/O,权限控制,系统调用,进程管理,内存管理等多项功能都可以归结到以上三点中

与一般的应用程式不同,kernel的crash通常会引起重启

内核架构:微内核 & 宏内核(单内核)

通常来说我们可以把内核架构分为两种:宏内核微内核,大致架构如下图所示:

宏内核(Monolithic Kernel,又叫单内核)

宏内核(英语:Monolithic kernel),也译为集成式内核单体式内核,一种操作系统内核架构,此架构的特性是整个内核程序是一个单一二进制可执行文件,在内核态以监管者模式(Supervisor Mode)来运行。相对于其他类型的操作系统架构,如微内核架构或混合内核架构等,这些内核会定义出一个高端的虚拟接口,由该接口来涵盖描述整个电脑硬件,这些描述会集合成一组硬件描述用词,有时还会附加一些系统调用,如此可以用一个或多个模块来实现各种操作系统服务,如进程管理、并发(Concurrency)控制、存储器管理等。

Wikipedia: 整塊性核心

通俗地说,宏内核几乎将一切都集成到了内核当中,并向上层应用程式提供抽象API(通常是以系统调用的形式)

Unix与类Unix便是宏内核

微内核(Micro Kernel)

对于微内核而言,大部分的系统服务(如文件管理等)都被剥离于内核之外,内核仅仅提供最为基本的一些功能:底层的寻址空间管理、线程管理、进程间通信等

Windows NT与Mach都宣称采用了微内核架构,不过本质上他们更贴近于混合内核(Hybrid Kernel)——在内核中集成了部分需要具备特权的服务组件

本文中我们主要讨论Linux内核

二、分级保护域

分级保护域hierarchical protection domains)又被称作保护环,简称 Rings ,是一种将计算机不同的资源划分至不同权限的模型

在一些硬件或者微代码级别上提供不同特权态模式的 CPU 架构上,保护环通常都是硬件强制的。Rings是从最高特权级(通常被叫作0级)到最低特权级(通常对应最大的数字)排列的

在大多数操作系统中,Ring0 拥有最高特权,并且可以和最多的硬件直接交互(比如CPU,内存)

内层ring可以任意调用外层ring的资源

Intel Ring Model

Intel的CPU将权限分为四个等级:Ring0、Ring1、Ring2、Ring3,权限等级依次降低

大部分现代操作系统只用到了ring0 和 ring3,其中 kernel 运行在 ring0,用户态程序运行在 ring3

使用 Ring Model 是为了提升系统安全性,例如某个间谍软件作为一个在 Ring 3 运行的用户程序,在不通知用户的时候打开摄像头会被阻止,因为访问硬件需要使用 being 驱动程序保留的 Ring 1 的方法

现代操作系统启动过程概述

操作系统本身便是一个运行在内核态的程序,当计算机通电之后处于实模式下,从磁盘上载入第一个扇区(MBR)执行,之后载入第二引导程序(Linux通常用 GNU Grub),由第二引导程序来将操作内核载入到内存当中并跳转到内核入口点,将控制权移交内核

内核在完成一系列的初始化过程之后,会启动一些低权限(ring3)的进程以向我们提供用户界面

用户空间 & 内核空间

在现代操作系统中,计算机的虚拟内存空间通常被分为两块空间——供用户进程使用的用户空间(user space)与供操作系统内核使用的内核空间(kernel space)

32位下的虚拟内存空间布局如下:

64位下的虚拟内存空间布局如下:

用户态 & 内核态

通俗地说,当进程运行在内核空间时就处于内核态(kernelland),而进程运行在用户空间时则处于用户态(userland),这是现代操作系统的设计者人为区分出来的设计

当我们要运行一个程序时,本质上是通过操作系统预先运行的用户态进程(如 shell 等)向操作系统发出请求,此时控制权移交内核,内核完成进程内存空间的初始化后再将控制权移交回用户进程

我们可以将操作系统理解为一个与我们的程序并行运行着的高权限程序

进程运行态切换

应用程式运行时总会经历无数次的用户态与内核态之间的转换,这是因为用户进程往往需要使用内核所提供的各种功能(如IO等),此时就需要陷入(trap)内核,待完成之后再“着陆”回用户态

这里我们讲到一个概念叫进入内核态,本质上其实是将进程的控制权限转交给操作系统内核,当内核完成其工作后控制权又重新回到用户进程

中断

中断即硬件/软件向 CPU 发送的特殊信号,CPU 接收到中断后会停下当前工作转而执行中断处理程序,完成后恢复原工作流程

中断向量表(interrupt vector table)类似一个虚表,该表通常位于物理地址 0~1k处,其中存放着不同中断号对应的中断处理程序的地址

自保护模式起引入中断描述符表(Interrupt Descriptor Table)用以存放「门描述符」(gate descriptor),中断描述符表地址存放在 IDTR 寄存器中,CPU 通过中断描述符表访问对应门

「门」(gate)可以理解为中断的前置检查物件,当中断发生时会先通过这些「门」,主要有如下三种门:

  • 中断门(Interrupt gate):用以进行中断处理,其类型码为 110;中断门的 DPL(Descriptor Priviledge Level)为 0,故只能在内核态下访问,即中断处理程序应当由内核激活;进入中断门会清除 IF 标志位以关闭中断,防止中断嵌套的发生
  • 陷阱门(Trap gate):类型码为 111,类似于中断门,主要用以处理 CPU 异常,但不会清除 IF 标志位
  • 系统门(System gate):Linux 特有门,类型码为 3、4、5、128;其 DPL 为 3,用以供用户进程访问,主要用以进行系统调用(int 0x80)

信号机制

Signals机制(又称之为软中断信号)是UNIX及类UNIX系统中的一种异步的进程间通信方式,用以通知一个进程发生了某个事件,通常情况下常见的流程如下图所示:

  • Pre. 内核代替进程接受信号,将信号放入对应进程的信号队列中,同时将对应进程挂起,让进程陷入内核态
  • ① 进程陷入内核态后,在返回用户态前会检测信号队列,若存在新信号则开始进入信号处理流程:内核会将用户态进程的寄存器逐一压入【用户态进程的栈上】,形成一个sigcontext结构体,接下来压入 SIGNALINFO 以及指向系统调用 sigreturn 的代码,用以在后续返回时恢复用户态进程上下文;压入栈上的这一大块内容称之为一个 SigreturnFrame,同时也是一个ucontext_t结构体;接下来就是内核内部的工作了
  • ② 控制权回到用户态进程,用户态进程跳转到相应的 signal handler 函数以处理不同的信号,完成之后将会执行位于其栈上的第一条指令——sigreturn系统调用
  • ③ 进程重新陷入内核,通过 sigreturn 系统调用恢复用户态上下文信息
  • ④ 控制权重新返还给用户态进程,恢复进程原上下文

用户态 —-> 内核态

由用户态陷入到内核态主要有以下几种途径:

  • 系统调用(int 0x80/sysenter)
  • 异常
  • 外设产生中断

I.切换GS段寄存器

通过 swapgs 切换 GS 段寄存器,将 GS 寄存器值和一个特定位置的值进行交换,目的是保存 GS 值,同时将该位置的值作为内核执行时的 GS 值使用

II.保存用户态栈帧信息

将当前栈顶(用户空间栈顶)记录在 CPU 独占变量区域里,将 CPU 独占区域里记录的内核栈顶放入 rsp/esp

III.保存用户态寄存器信息

通过 push 保存各寄存器值到栈上,以便后续“着陆”回用户态

IV.通过汇编指令判断是否为32位

V.控制权转交内核,执行系统调用

在这里用到一个全局函数表sys_call_table,其中保存着系统调用的函数指针

内核态 —-> 用户态

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

  • swapgs指令恢复用户态GS寄存器
  • sysretq或者iretq恢复到用户空间

三、系统调用

系统调用system call)是由操作系统内核向上层应用程式提供的应用接口,操作系统负责调度一切的资源,当用户进程想要请求更高权限的服务时,便需要通过由系统提供的应用接口,使用系统调用以陷入内核态,再由操作系统完成请求

系统调用本质上与一般的C库函数没有区别,不同的是系统调用位于内核空间,以内核态运行

Windows系统下将系统调用封装在win32 API中,不过本篇博文主要讨论Linux

系统调用表

所有的系统调用被声明于内核源码arch/x86/entry/syscalls/syscall_64.tbl中,在该表中声明了系统调用的标号、类型、名称、内核态函数名称

在内核中使用系统调用表(System Call Table)对系统调用进行索引,该表中储存了不同标号的系统调用函数的地址

进入系统调用

Linux 下进入系统调用有两种主要的方式:

  • 32位:执行 int 0x80汇编指令(80号中断)
  • 64位:执行 syscall 汇编指令 / 执行 sysenter 汇编指令(only intel)

接下来就是由用户态进入到内核态的流程

Linux下的系统调用以eax/rax寄存器作为系统调用号,参数传递约束如下:

  • 32 位:ebx、ecx、edx、esi、edi、ebp作为第一个参数、第二个参数…进行参数传递
  • 64 位:rdi、rsi、rdx、rcx、r8、r9作为第一个参数、第二个参数…进行参数传递

退出系统调用

同样地,内核执行完系统调用后退出系统调用也有对应的两种方式:

  • 执行iret汇编指令
  • 执行 sysret 汇编指令 / 执行sysexit汇编指令(only Intel)

接下来就是由内核态回退至用户态的流程

四、进程权限管理

前面我们讲到,kernel 调度着一切的系统资源,并为用户应用程式提供运行环境,相应地,应用程式的权限也都是由 kernel 进行管理的

进程描述符(process descriptor)

在内核中使用结构体 task_struct 表示一个进程,该结构体定义于内核源码include/linux/sched.h中,代码比较长就不在这里贴出了

一个进程描述符的结构应当如下图所示:

本篇我们主要关心其对于进程权限的管理

注意到task_struct的源码中有如下代码:

    /* Process credentials: */

    /* Tracer's credentials at attach: */
    const struct cred __rcu        *ptracer_cred;

    /* Objective and real subjective task credentials (COW): */
    const struct cred __rcu        *real_cred;

    /* Effective (overridable) subjective task credentials (COW): */
    const struct cred __rcu        *cred;

Process credentials 是 kernel 用以判断一个进程权限的凭证,在 kernel 中使用 cred 结构体进行标识,对于一个进程而言应当有三个 cred:

  • ptracer_cred:使用ptrace系统调用跟踪该进程的上级进程的cred(gdb调试便是使用了这个系统调用,常见的反调试机制的原理便是提前占用了这个位置)
  • real_cred:客体凭证objective cred),通常是一个进程最初启动时所具有的权限
  • cred:主体凭证subjective cred),该进程的有效cred,kernel以此作为进程权限的凭证

一般情况下,主体凭证与客体凭证的值是相同的

例:当进程 A 向进程 B 发送消息时,A为主体,B为客体

进程权限凭证:cred结构体

对于一个进程,在内核当中使用一个结构体cred管理其权限,该结构体定义于内核源码include/linux/cred.h中,如下:

struct cred {
    atomic_t    usage;
#ifdef CONFIG_DEBUG_CREDENTIALS
    atomic_t    subscribers;    /* number of processes subscribed */
    void        *put_addr;
    unsigned    magic;
#define CRED_MAGIC    0x43736564
#define CRED_MAGIC_DEAD    0x44656144
#endif
    kuid_t        uid;        /* real UID of the task */
    kgid_t        gid;        /* real GID of the task */
    kuid_t        suid;        /* saved UID of the task */
    kgid_t        sgid;        /* saved GID of the task */
    kuid_t        euid;        /* effective UID of the task */
    kgid_t        egid;        /* effective GID of the task */
    kuid_t        fsuid;        /* UID for VFS ops */
    kgid_t        fsgid;        /* GID for VFS ops */
    unsigned    securebits;    /* SUID-less security management */
    kernel_cap_t    cap_inheritable; /* caps our children can inherit */
    kernel_cap_t    cap_permitted;    /* caps we're permitted */
    kernel_cap_t    cap_effective;    /* caps we can actually use */
    kernel_cap_t    cap_bset;    /* capability bounding set */
    kernel_cap_t    cap_ambient;    /* Ambient capability set */
#ifdef CONFIG_KEYS
    unsigned char    jit_keyring;    /* default keyring to attach requested
                     * keys to */
    struct key    *session_keyring; /* keyring inherited over fork */
    struct key    *process_keyring; /* keyring private to this process */
    struct key    *thread_keyring; /* keyring private to this thread */
    struct key    *request_key_auth; /* assumed request_key authority */
#endif
#ifdef CONFIG_SECURITY
    void        *security;    /* subjective LSM security */
#endif
    struct user_struct *user;    /* real user ID subscription */
    struct user_namespace *user_ns; /* user_ns the caps and keyrings are relative to. */
    struct group_info *group_info;    /* supplementary groups for euid/fsgid */
    /* RCU deletion */
    union {
        int non_rcu;            /* Can we skip RCU deletion? */
        struct rcu_head    rcu;        /* RCU deletion hook */
    };
} __randomize_layout;

我们主要关注cred结构体中管理权限的变量

用户ID & 组ID

一个cred结构体中记载了一个进程四种不同的用户ID

  • 真实用户ID(real UID):标识一个进程启动时的用户ID
  • 保存用户ID(saved UID):标识一个进程最初的有效用户ID
  • 有效用户ID(effective UID):标识一个进程正在运行时所属的用户ID,一个进程在运行途中是可以改变自己所属用户的,因而权限机制也是通过有效用户ID进行认证的,内核通过 euid 来进行特权判断;为了防止用户一直使用高权限,当任务完成之后,euid 会与 suid 进行交换,恢复进程的有效权限
  • 文件系统用户ID(UID for VFS ops):标识一个进程创建文件时进行标识的用户ID

在通常情况下这几个ID应当都是相同的

用户组ID同样分为四个:真实组ID保存组ID有效组ID文件系统组ID,与用户ID是类似的,这里便不再赘叙

进程权限改变

前面我们讲到,一个进程的权限是由位于内核空间的cred结构体进行管理的,那么我们不难想到:只要改变一个进程的cred结构体,就能改变其执行权限

在内核空间有如下两个函数,都位于kernel/cred.c中:

  • struct cred* prepare_kernel_cred(struct task_struct* daemon):该函数用以拷贝一个进程的cred结构体,并返回一个新的cred结构体,需要注意的是daemon参数应为有效的进程描述符地址或NULL
  • int commit_creds(struct cred *new):该函数用以将一个新的cred结构体应用到进程

提权

查看prepare_kernel_cred()函数源码,观察到如下逻辑:

struct cred *prepare_kernel_cred(struct task_struct *daemon)
{
    const struct cred *old;
    struct cred *new;

    new = kmem_cache_alloc(cred_jar, GFP_KERNEL);
    if (!new)
        return NULL;

    kdebug("prepare_kernel_cred() alloc %p", new);

    if (daemon)
        old = get_task_cred(daemon);
    else
        old = get_cred(&init_cred);
...

prepare_kernel_cred()函数中,若传入的参数为NULL,则会缺省使用init进程的cred作为模板进行拷贝,即可以直接获得一个标识着root权限的cred结构体

那么我们不难想到,只要我们能够在内核空间执行commit_creds(prepare_kernel_cred(NULL)),那么就能够将当前进程的权限提升到root

五、I/O

*NIX/Linux追求高层次抽象上的统一,其设计哲学之一便是万物皆文件

“万物皆文件”

NIX/Linux设计的哲学之一——万物皆文件,在Linux系统的视角下,无论是文件、设备、管道,还是目录、进程,甚至是磁盘、套接字等等,*一切都可以被抽象为文件,一切都可以使用访问文件的方式进行操作

通过这样一种哲学,Linux予开发者以高层次抽象的统一性,提供了操作的一致性

  • 所有的读取操作都可以通过read进行
  • 所有的更改操作都可以通过write进行

对于开发者而言,将一切的操作都统一于一个高层次抽象的应用接口,无疑是十分美妙的一件事情——我们不需要去理解实现的细节,只需要对”文件”完成简单的读写操作

例如,在较老版本的Linux中,可以使用cat /dev/urandom > /dev/dsp命令令扬声器产生随机噪声

进程文件系统

进程文件系统(process file system, 简写为procfs)用以描述一个进程,其中包括该进程所打开的文件描述符、堆栈内存布局、环境变量等等

进程文件系统本身是一个伪文件系统,通常被挂载到/proc目录下,并不真正占用储存空间,而是占用一定的内存

当一个进程被建立起来时,其进程文件系统便会被挂载到/proc/[PID]下,我们可以在该目录下查看其相关信息

文件描述符

进程通过文件描述符file descriptor)来完成对文件的访问,其在形式上是一个非负整数,本质上是对文件的索引值,进程所有执行 I/O 操作的系统调用都会通过文件描述符

每个进程都独立有着一个文件描述符表,存放着该进程所打开的文件索引,每当进程成功打开一个现有文件/创建一个新文件时(通过系统调用open进行操作),内核会向进程返回一个文件描述符

在kernel中有着一个文件表,由所有的进程共享

stdin、stdout、stderr

每个*NIX进程都应当有着三个标准的POSIX文件描述符,对应着三个标准文件流:

  • stdin:标准输入 - 0
  • stdout:标准输出 - 1
  • stderr:标准错误 - 2

此后打开的文件描述符应当从标号3起始

系统调用:ioctl

在*NIX中一切都可以被视为文件,因而一切都可以以访问文件的方式进行操作,为了方便,Linux定义了系统调用ioctl供进程与设备之间进行通信

系统调用ioctl是一个专用于设备输入输出操作的一个系统调用,其调用方式如下:

int ioctl(int fd, unsigned long request, ...)
  • fd:设备的文件描述符
  • request:请求码
  • 其他参数

对于一个提供了ioctl通信方式的设备而言,我们可以通过其文件描述符、使用不同的请求码及其他请求参数通过ioctl系统调用完成不同的对设备的I/O操作

例如CD-ROM驱动程序弹出光驱的这一操作就对应着对“光驱设备”这一文件通过ioctl传递特定的请求码与请求参数完成

六、Loadable Kernel Modules(LKMs)

前面我们讲到,Linux Kernle采用的是宏内核架构,一切的系统服务都需要由内核来提供,虽然效率较高,但是缺乏可扩展性与可维护性,同时内核需要装载很多可能用到的服务,但这些服务最终可能未必会用到,还会占据大量内存空间,同时新服务的提供往往意味着要重新编译整个内核

综合以上考虑,可装载内核模块Loadable Kernel Modules,简称LKMs)出现了,位于内核空间的LKMs可以提供新的系统调用或其他服务,同时LKMs可以像积木一样被装载入内核/从内核中卸载,大大提高了kernel的可拓展性与可维护性

常见的外设驱动便是LKM的一种

LKMs与用户态可执行文件一样都采用ELF格式,但是LKMs运行在内核空间,且无法脱离内核运行

通常与LKM相关的命令有以下三个:

  • lsmod:列出现有的LKMs
  • insmod:装载新的LKM(需要root)
  • rmmod:从内核中移除LKM(需要root)

CTF 比赛中的 kernel pwn 的漏洞往往出现在第三方 LKM 中,一般来说不会真的让你去直接日内核组件

七、保护机制

与一般的程序相同,Linux Kernel同样有着各种各样的保护机制:

KASLR

KASLR即内核空间地址随机化(kernel address space layout randomize),与用户态程序的ASLR相类似——在内核镜像映射到实际的地址空间时加上一个偏移值,但是内核内部的相对偏移其实还是不变的

在未开启KASLR保护机制时,内核的基址为0xffffffff81000000

内核内存布局可以参考这↑里↓

FGKASLR

KASLR 虽然在一定程度上能够缓解攻击,但是若是攻击者通过一些信息泄露漏洞获取到内核中的某个地址,仍能够直接得知内核加载地址偏移从而得知整个内核地址布局,因此有研究者基于 KASLR 实现了 FGKASLR,以函数粒度重新排布内核代码

STACK PROTECTOR

类似于用户态程序的canary,通常又被称作是stack cookie,用以检测是否发生内核堆栈溢出,若是发生内核堆栈溢出则会产生kernel panic

内核中的canary的值通常取自gs段寄存器某个固定偏移处的值

SMAP/SMEP

SMAP即管理模式访问保护(Supervisor Mode Access Prevention),SMEP即管理模式执行保护(Supervisor Mode Execution Prevention),这两种保护通常是同时开启的,用以阻止内核空间直接访问/执行用户空间的数据,完全地将内核空间与用户空间相分隔开,用以防范ret2usr(return-to-user,将内核空间的指令指针重定向至用户空间上构造好的提权代码)攻击

SMEP保护的绕过有以下两种方式:

  • 在设计中,为了使隔离的数据进行交换时具有更高的性能,隐性地址共享始终存在(VDSO & VSYSCALL),用户态进程与内核共享同一块物理内存,因此通过隐性内存共享可以完整的绕过软件和硬件的隔离保护,这种攻击方式被称之为ret2dir(return-to-direct-mapped memory )
  • Intel下系统根据CR4控制寄存器的第20位标识是否开启SMEP保护(1为开启,0为关闭),若是能够通过kernel ROP改变CR4寄存器的值便能够关闭SMEP保护,完成SMEP-bypass,接下来就能够重新进行ret2usr

KPTI

KPTI即内核页表隔离(Kernel page-table isolation),内核空间与用户空间分别使用两组不同的页表集,这对于内核的内存管理产生了根本性的变化

KPTI 机制的出现使得 ret2usr 彻底成为过去式

 

0x01.Linux Kernel 简易食用指南

Pre.安装依赖

环境是Ubuntu20.04

$ sudo apt-get update
$ sudo apt-get install git fakeroot build-essential ncurses-dev xz-utils qemu flex libncurses5-dev fakeroot build-essential ncurses-dev xz-utils libssl-dev bc bison libglib2.0-dev libfdt-dev libpixman-1-dev zlib1g-dev libelf-dev

一、获取内核镜像(bzImage)

大概有如下三种方式:

  • 下载内核源码后编译
  • 直接下载现成的的内核镜像,不过这样我们就不能自己魔改内核了2333
  • 直接使用自己系统的镜像

方法一:自行编译内核源码

I.获取内核源码

前往Linux Kernel Archive下载对应版本的内核源码

笔者这里选用5.11这个版本的内核镜像

$ wget https://cdn.kernel.org/pub/linux/kernel/v5.x/linux-5.11.tar.xz

II.配置编译选项

解压我们下载来的内核源码

$ tar -xvf linux-5.11.tar.xz

完成后进入文件夹内,执行如下命令开始配置编译选项

$ make menuconfig

进入如下配置界面

保证勾选如下配置(默认都是勾选了的):

  • Kernel hacking —-> Kernel debugging
  • Kernel hacking —-> Compile-time checks and compiler options —-> Compile the kernel with debug info
  • Kernel hacking —-> Generic Kernel Debugging Instruments —> KGDB: kernel debugger
  • kernel hacking —-> Compile the kernel with frame pointers

一般来说不需要有什么改动,直接保存退出即可

III.开始编译

运行如下命令开始编译,生成内核镜像

$ make bzImage

可以使用make bzImage -j4加速编译

笔者机器比较烂,大概要等一顿饭的时间…

以及编译内核会比较需要空间,一定要保证磁盘剩余空间充足

可能出现的错误

笔者在编译 4.4 版本的内核时出现了如下错误:

cc1: error: code model kernel does not support PIC mode
make[1]: *** No rule to make target 'debian/canonical-certs.pem', needed by 'certs/x509_certificate_list'.  Stop

这个时候只需要在Makefile文件中:

  • KBUILD_CFLAGS 的尾部添加选项 -fno-pie
  • CC_USING_FENTRY 项添加 -fno-pic

以及在 .config 文件中找到这一项,等于号后面的值改为 ""

最后又出现了一个错误…笔者实在忍不了了,换到Ubuntu 16进行编译…一遍过…

出现这种情况的原因主要是高版本的 gcc 更改了内部的一些相关机制,只需要切换回老版本gcc即可正常编译

完成之后会出现如下信息:

Kernel: arch/x86/boot/bzImage is ready  (#1)

vmlinux:原始内核文件

在当前目录下提取到vmlinux,为编译出来的原始内核文件

$ file vmlinux
vmlinux: ELF 64-bit LSB executable, x86-64, version 1 (SYSV), statically linked, BuildID[sha1]=f1fc85f87a5e6f3b5714dad93a8ac55fa7450e06, with debug_info, not stripped

bzImage:压缩内核镜像

在当前目录下的arch/x86/boot/目录下提取到bzImage,为压缩后的内核文件,适用于大内核

$ file arch/x86/boot/bzImage
arch/x86/boot/bzImage: Linux kernel x86 boot executable bzImage, version 5.11.0 (root@iZf3ye3at4zthpZ) #1 SMP Sun Feb 21 21:44:35 CST 2021, RO-rootFS, swap_dev 0xB, Normal VGA

zImage && bzImage

zImage—是vmlinux经过gzip压缩后的文件。
bzImage—bz表示“big zImage”,不是用bzip2压缩的,而是要偏移到一个位置,使用gzip压缩的。两者的不同之处在于,zImage解压缩内核到低端内存(第一个 640K),bzImage解压缩内核到高端内存(1M以上)。如果内核比较小,那么采用zImage或bzImage都行,如果比较大应该用bzImage。

https://blog.csdn.net/xiaotengyi2012/article/details/8582886

EXTRA.添加系统调用

据说大二下的操作系统实验里就有这个…不过笔者的寒假还没放完呢233333

以及请先阅读完「0x01.四 」之后再回来看本节内容~

I.分配系统调用号

arch/x86/entry/syscalls/syscall_64.tbl中添加我们自己的系统调用号,这里用笔者个人比较喜欢的数字114514

114514    64    arttnba3_test        sys_arttnba3_test

II.声明系统调用

include/linux/syscalls.h中添加如下函数声明:

/* for arttnba3's personal syscall test */
asmlinkage long sys_arttnba3_test(void);

III.添加系统调用函数定义

kernel/sys.c中添加如下代码(放置于最后一行的#endif /* CONFIG_COMPAT */之前):

SYSCALL_DEFINE0(arttnba3_test)
{
    printk("arttnba3\'s personal syscall has been called!\n");
    return 114514;
}

这里的SYSCALL_DEFINE0()本质上是一个宏,意为接收0个参数的系统调用,其第一个参数为系统调用名

笔者定义了一个简单的输出一句话的系统调用,在这里使用了内核态的printk()函数,输出的信息可以使用dmesg进行查看

IV.重新编译内核

这一步参照之前的步骤即可,通过这一步我们要将我们自己的系统调用编译到内核当中

V.测试系统调用

我们使用如下的例程测试我们的新系统调用

#include <unistd.h>
int main(void)
{
    syscall(114514);
    return 0;
}

编译,放入磁盘镜像中后重新打包,qemu起内核后尝试运行我们的例程,结果如下:

因为dmesg输出的东西太多,这里还附加用了grep命令过滤

可以看到,我们的系统调用arttnba3_test被成功地嵌入了内核当中,并成功地被测试例程所调用,撒花~?

方法二:下载现有内核镜像

我们也可以自己下载现有的内核镜像,而不需要自行编译一整套Linux内核

使用如下命令列出可下载内核镜像

$ sudo apt search linux-image-

选一个自己喜欢的下载就行,笔者所用的阿里云源似乎没有最新的5.11的镜像,这里用5.8的做个示范:

$ sudo apt download linux-image-5.8.0-43-generic

下载下来是一个deb文件,解压

$ dpkg -X ./linux-image-5.8.0-43-generic_5.8.0-43.49~20.04.1_amd64.deb extract
./
./boot/
./boot/vmlinuz-5.8.0-43-generic
./usr/
./usr/share/
./usr/share/doc/
./usr/share/doc/linux-image-5.8.0-43-generic/
./usr/share/doc/linux-image-5.8.0-43-generic/changelog.Debian.gz
./usr/share/doc/linux-image-5.8.0-43-generic/copyright

其中的./boot/vmlinuz-5.8.0-43-generic便是bzImage内核镜像文件

方法三:使用系统内核镜像

一般位于/boot/目录下,也可以直接拿出来用

二、获取busybox

BusyBox 是一个集成了三百多个最常用Linux命令和工具的软件,包含了例如ls、cat和echo等一些简单的工具

后续构建磁盘镜像我们需要用到busybox

编译busybox

I.获取busybox源码

busybox.net下载自己想要的版本,笔者这里选用busybox-1.33.0.tar.bz2这个版本

$ wget https://busybox.net/downloads/busybox-1.33.0.tar.bz2

外网下载的速度可能会比较慢,可以在前面下载Linux源码的时候一起下载,也可以选择去国内的镜像站下载

解压

$ tar -jxvf busybox-1.33.0.tar.bz2

II.编译busybox源码

进入配置界面

$ make menuconfig

勾选Settings —-> Build static binary file (no shared lib)

若是不勾选则需要单独配置lib,比较麻烦

接下来就是编译了,速度会比编译内核快很多

$ make install

编译完成后会生成一个_install目录,接下来我们将会用它来构建我们的磁盘镜像

三、构建磁盘镜像

建立文件系统

I.初始化文件系统

一些简单的初始化操作…

$ cd _install
$ mkdir -pv {bin,sbin,etc,proc,sys,home,lib64,lib/x86_64-linux-gnu,usr/{bin,sbin}}
$ touch etc/inittab
$ mkdir etc/init.d
$ touch etc/init.d/rcS
$ chmod +x ./etc/init.d/rcS

II.配置初始化脚本

首先配置etc/inttab,写入如下内容:

::sysinit:/etc/init.d/rcS
::askfirst:/bin/ash
::ctrlaltdel:/sbin/reboot
::shutdown:/sbin/swapoff -a
::shutdown:/bin/umount -a -r
::restart:/sbin/init

在上面的文件中指定了系统初始化脚本,因此接下来配置etc/init.d/rcS,写入如下内容:

#!/bin/sh
mount -t proc none /proc
mount -t sys none /sys
/bin/mount -n -t sysfs none /sys
/bin/mount -t ramfs none /dev
/sbin/mdev -s

主要是配置各种目录的挂载

也可以在根目录下创建init文件,写入如下内容:

#!/bin/sh

mount -t proc none /proc
mount -t sysfs none /sys
mount -t devtmpfs devtmpfs /dev

exec 0</dev/console
exec 1>/dev/console
exec 2>/dev/console

echo -e "\nBoot took $(cut -d' ' -f1 /proc/uptime) seconds\n"
setsid cttyhack setuidgid 1000 sh

umount /proc
umount /sys
poweroff -d 0  -f

别忘了添加可执行权限:

$ chmod +x ./init

III.配置用户组

$ echo "root:x:0:0:root:/root:/bin/sh" > etc/passwd
$ echo "ctf:x:1000:1000:ctf:/home/ctf:/bin/sh" >> etc/passwd
$ echo "root:x:0:" > etc/group
$ echo "ctf:x:1000:" >> etc/group
$ echo "none /dev/pts devpts gid=5,mode=620 0 0" > etc/fstab

在这里建立了两个用户组rootctf,以及两个用户rootctf

IV.配置glibc库

将需要的动态链接库拷到相应位置即可

为了方便笔者这里就先不弄了,直接快进到下一步,以后有时间再补充(咕咕咕

打包文件系统为镜像文件

使用如下命令打包文件系统

$ find . | cpio -o --format=newc > ../../rootfs.cpio

也可以这么写

$ find . | cpio -o -H newc > ../core.cpio

这里的位置是笔者随便选的,也可以将之放到自己喜欢的位置

向文件系统中添加文件

若是我们后续需要向文件系统中补充一些其他的文件,可以选择在原先的_install文件夹中添加(不过这样的话若是配置多个文件系统则会变得很混乱),也可以解压文件系统镜像后添加文件再重新进行打包

I.解压磁盘镜像

$ cpio -idv < ./rootfs.cpio

该命令会将磁盘镜像中的所有文件解压到当前目录下

II.重打包磁盘镜像

和打包磁盘镜像的命令一样

$ find . | cpio -o --format=newc > ../new_rootfs.cpio

四、使用qemu运行内核

终于到了最激动人心的时候了:我们即将要将这个Linux内核跑起来——用我们自己配置的文件系统与内核

安全起见,我们并不直接在真机上运行这个内核,而是使用qemu在虚拟机里运行

配置启动脚本

首先将先前的bzImagerootfs.cpio放到同一个目录下

接下来编写启动脚本

$ touch boot.sh

写入如下内容:

#!/bin/sh
qemu-system-x86_64 \
    -m 128M \
    -kernel ./bzImage \
    -initrd  ./rootfs.cpio \
    -monitor /dev/null \
    -append "root=/dev/ram rdinit=/sbin/init console=ttyS0 oops=panic panic=1 loglevel=3 quiet nokaslr" \
    -cpu kvm64,+smep \
    -smp cores=2,threads=1 \
    -netdev user,id=t0, -device e1000,netdev=t0,id=nic0 \
    -nographic \
    -s

部分参数说明如下:

  • -m:虚拟机内存大小
  • -kernel:内存镜像路径
  • -initrd:磁盘镜像路径
  • -append:附加参数选项
    • nokalsr:关闭内核地址随机化,方便我们进行调试
    • rdinit:指定初始启动进程,/sbin/init进程会默认以/etc/init.d/rcS作为启动脚本
    • loglevel=3 & quiet:不输出log
    • console=ttyS0:指定终端为/dev/ttyS0,这样一启动就能进入终端界面
  • -monitor:将监视器重定向到主机设备/dev/null,这里重定向至null主要是防止CTF中被人给偷了qemu拿flag
  • -cpu:设置CPU安全选项,在这里开启了smep保护
  • -s:相当于-gdb tcp::1234的简写(也可以直接这么写),后续我们可以通过gdb连接本地端口进行调试

运行boot.sh,成功启动~撒花~???

在这里遇到了一条报错信息:

mount: mounting none on /sys failed: No such device

暂且没查到原因…

五、编写可装载内核模块(LKMs)

写这一部分把我的虚拟机搞崩了好几次…Or2

我们的Linux kernel虽然成功启动了,但是其本身的功能似乎有些单调,那么我们不如自己编写可装载内核模块(Loadable Kernel Modules)来扩充内核的功能吧!

预备知识

前面我们讲到,LKM同样是ELF格式文件,但是其不能够独立运行,而只能作为内核的一部分存在

同样的,对于LKM而言,其所处在的内核空间与用户空间是分开的,对于通常有着SMAP/SMEP保护的Linux而言,这意味着LKM并不能够使用libc中的函数,也不能够直接与用户进行交互

虽然我们同样能够使用C语言编写LKM,但是作为内核的一部分,LKM编程在一定意义上便是内核编程, 内核版本的每次变化意味着某些函数名也会相应地发生变化,因此LKM编程与内核版本密切相关

简单的测试模块

我们来编写这样一个简单的内核模块,在载入/卸载时会通过printk()在内核缓冲区进行输出:

/*
* hellokernel.c
* developed by arttnba3
*/
#include <linux/module.h>
#include <linux/kernel.h>
#include <linux/init.h>

static int __init kernel_module_init(void)
{
    printk("<1>Hello the Linux kernel world!\n");
    return 0;
}

static void __exit kernel_module_exit(void)
{
    printk("<1>Good bye the Linux kernel world! See you again!\n");
}

module_init(kernel_module_init);
module_exit(kernel_module_exit);
MODULE_LICENSE("GPL");
MODULE_AUTHOR("arttnba3");

头文件

  • linux/module.h:对于LKM而言这是必须包含的一个头文件
  • linux/kernel.h:载入内核相关信息
  • linux/init.h:包含着一些有用的宏

通常情况下,这三个头文件对于内核模块编程都是不可或缺的

入口点/出口点

一个内核模块的入口点应当为module_init(),出口函数应当为module_exit(),在内核载入/卸载内核模块时会缺省调用这两个函数

在这里我们将自定义的两个函数的指针作为参数传入LKM入口函数/出口函数中,以作为其入口/出口函数

其他…

  • __init & __exit:这两个宏用以在函数结束后释放相应的内存
  • MODULE_AUTHOR() & MODULE_LICENSE():声明内核作者与发行所用许可证
  • printk():内核态函数,用以在内核缓冲区写入信息,其中<1>标识着信息的紧急级别(一共有8个优先级,0为最高,相关宏定义于linux/kernel.h中)

编译内核模块:makefile

与一般的可执行文件所不同的是,我们应当使用makefile来构建一个内核模块

obj-m += hellokernel.o
CURRENT_PATH := $(shell pwd)
LINUX_KERNEL := $(shell uname -r)
LINUX_KERNEL_PATH := /usr/src/linux-headers-$(LINUX_KERNEL)
all:
    make -C $(LINUX_KERNEL_PATH) M=$(CURRENT_PATH) modules
clean:
    make -C $(LINUX_KERNEL_PATH) M=$(CURRENT_PATH) clean

关于makefile的编写方式在网上有着很多教程,笔者在这里便不再赘叙,只简单说明一下这个makefile:

  • obj-m: 指定了编译的结果应当为.ko文件,即可装载内核模块,类似命令有: obj-y 编译进内核 ,obj-n 不编译
  • CURRENT_PATH & LINUX_KERNEL & LINUX_KERNEL_PATH:三个自定义变量,分别意味着通过shell命令获得当前路径、内核版本、内核源码路径
  • all:编译指令
  • clean:清理指令

将以上内容写入当前目录下一个名为makefile的文件后,在终端执行如下指令,我们的内核模块就会自动进行构建

$ make

随后会输出如下信息,标志着内核模块的编译顺利完成

make -C /usr/src/linux-headers-5.8.0-43-generic M=/home/arttnba3/Music modules
make[1]: Entering directory '/usr/src/linux-headers-5.8.0-43-generic'
  CC [M]  /home/arttnba3/Music/hellokernel.o
  MODPOST /home/arttnba3/Music/Module.symvers
  CC [M]  /home/arttnba3/Music/hellokernel.mod.o
  LD [M]  /home/arttnba3/Music/hellokernel.ko
make[1]: Leaving directory '/usr/src/linux-headers-5.8.0-43-generic'

测试一下,内核模块成功运行,撒花~?

提供应用程式I/O接口

虽然说我们的新模块成功跑起来了,但是除了在内核缓冲区进行输入输出以外好像就做不了什么了,我们希望我们写的内核模块能够向我们提供更多的功能并能够让用户与其进行交互,以发挥更多的作用

I.设备注册

前面讲到,*NIX/Linux的哲学之一便是万物皆文件一切都可以被抽象为文件,一切都可以使用访问文件的方式进行操作,这提供了高层次上的操作一致性

同样地,我们若是想要能够与我们的内核模块进行交互,则同样可以通过文件进行——注册一个“虚拟设备节点”,随后我们的用户态程序便可以使用系统调用read、write、ioctl来完成与内核模块间的通信

设备分类

在Linux中I/O设备分为如下两类:

  • 字符设备:在I/O传输过程中以字符为单位进行传输的设备,例如键盘、串口等。字符设备按照字符流的方式被有序访问,不能够进行随机读取
  • 块设备:在块设备中,信息被存储在固定大小的块中,每个块有着自己的地址,例如硬盘、SD卡等。用户可以对块设备进行随机访问——从任意位置读取一定长度的数据

file_operations结构体

在注册设备之前,我们需要用到一个结构体——file_operations来完成对设备的一些相关定义,该结构体定义于include/linux/fs.h中,相关源码比较长不在此贴出,在其中定义了大量的函数指针

笔者感觉作用类似于OOP中的接口类,或者是虚函数表

一个文件应当拥有一个file_operations实例,并指定相关系统调用函数指针所指向的自定义函数,在后续进行设备的注册时会使用该结构体

主设备号 & 次设备号

在Linux内核中,使用类型dev_t(unsigned long)来标识一个设备的设备号

一个字符的设备号由主设备号次设备号组成,高字节存储主设备号,低字节存储次设备号:

  • 主设备号:标识设备类型,使用宏MAJOR(dev_t dev)可以获取主设备号
  • 次设备号:用以区分同类型设备,使用宏MINOR(dev_t dev)可以获取次设备号

Linux还提供了一个宏MKDEV(int major, int minor);,用以通过主次设备号生成对应的设备号

设备节点(struct device_node & struct device)

基于“万物皆文件”的设计思想,Linux中所有的设备都以文件的形式进行访问,这些文件存放在/dev目录下,一个文件就是一个设备节点

在Linux kernel中使用结构体device描述一个设备,该结构体定义于include/linux/device.h(内核源码路径)中,每个设备在内核中都有着其对应的device实例,其中记录着设备的相关信息

在DTS(Device Tree Source,设备树)中则使用device_node结构体表示一个设备

设备类(struct class)

在Linux kernel中使用结构体class用以表示高层次抽象的设备,该结构体定义于include/linux/device/class.h

每个设备节点实例中都应当包含着一个指向相应设备类实例的指针

设备的注册与注销

方便起见,我们接下来将会注册一个字符型设备,大致的一个步骤如下:

  • 使用由内核提供的函数register_chrdev(unsigned int major, const char *name, const struct file_operations *fops)进行字符型设备注册,该函数定义于include/linux/fs.h,会将注册成功后的主设备号返回,若失败则会返回一个负值,参数说明如下:
    • major:主设备号,若为0则由内核分配主设备号
    • name:设备名,由用户指定
    • fops:该设备的文件操作系统(file_operations结构体)指针
  • 使用宏class_create(owner, name)创建设备类,该宏定义于include/linux/device.h中,其核心调用函数是__class_create(struct module *owner, const char *name, struct lock_class_key *key)
  • 使用函数device_create(struct class *cls, struct device *parent, dev_t devt, void *drvdata, const char *fmt, ...)创建设备节点,若成功则最终会在/dev目录下生成我们的设备节点文件,各参数说明如下:
    • cls:该设备的设备类
    • parent:该设备的父设备节点,通常情况下应当为某种总线或主机控制器,若该设备为顶级设备则设为NULL
    • devt:该设备的设备号
    • drvdata:该驱动的相关信息,若无则填NULL
    • fmt:设备名称

设备的注销则是逆着上面的进程进行,同样有着相对应的三个函数:device_destroy(struct class *cls, dev_t devt)class_destroy(struct class *cls)unregister_chrdev(unsigned int major, const char *name),用法相似,这里就不一一赘叙了

✳ 需要注意的是若是注册设备的进程中的某一步出错了,我们在退出内核态函数之前应当手动调用注销函数清理原先的相关资源

设备权限

内核模块运行在内核空间,所创建的设备节点只有root用户才有权限进行读写,对于其他用户而言便毫无意义,这并不是我们想要的,因此我们需要通过进一步的设置使得所有用户都有权限通过设备节点文件与我们的内核模块进行交互

在内核中使用inode结构体表示一个文件,该结构体定义于include/linux/fs.h中,其中用以标识权限的是成员i_mode

而在内核中对于使用flip_open()打开的文件,Linux内核中使用 file 结构体进行描述,该结构体定义于include/linux/fs.h中,其中有着指向内核中该文件的 inode 实例的指针,使用file_inode()函数可以获得一个 file 结构体中的 inode 结构体指针

那么我们不难想到,若是在内核模块中使用file_open()函数打开我们的设备节点文件,随后修改 file 结构体中的 inode 指针指向的 inode 实例的 i_mode 成员,便能够修改该文件的权限

需要注意的是rwx三个权限位仅占3位,因而应当使用八进制进行操作:__inode->i_mode |= 0666;,而不是16进制

最终的代码如下:

/*
* arttnba3_module.ko
* developed by arttnba3
*/
#include <linux/module.h>
#include <linux/kernel.h>
#include <linux/init.h>
#include <linux/fs.h>
#include <linux/device.h>

#define DEVICE_NAME "a3device"
#define DEVICE_PATH "/dev/a3device"
#define CLASS_NAME "a3module"

static int major_num;
static struct class * module_class = NULL;
static struct device * module_device = NULL;
static struct file * __file = NULL;
struct inode * __inode = NULL;

static struct file_operations a3_module_fo = 
{
    .owner = THIS_MODULE
};

static int __init kernel_module_init(void)
{
    printk(KERN_INFO "[arttnba3_TestModule:] Module loaded. Start to register device...\n");
    major_num = register_chrdev(0, DEVICE_NAME, &a3_module_fo);
    if (major_num < 0)
    {
        printk(KERN_INFO "[arttnba3_TestModule:] Failed to register a major number.\n");
        return major_num;
    }    
    printk(KERN_INFO "[arttnba3_TestModule:] Register complete, major number: %d\n", major_num);

    module_class = class_create(THIS_MODULE, CLASS_NAME);
    if (IS_ERR(module_class))
    {
        unregister_chrdev(major_num, DEVICE_NAME);
        printk(KERN_INFO "[arttnba3_TestModule:] Failed to register class device!\n");
        return PTR_ERR(module_class);
    }
    printk(KERN_INFO "[arttnba3_TestModule:] Class device register complete.\n");

    module_device = device_create(module_class, NULL, MKDEV(major_num, 0), NULL, DEVICE_NAME);
    if (IS_ERR(module_device))
    {
        class_destroy(module_class);
        unregister_chrdev(major_num, DEVICE_NAME);
        printk(KERN_INFO "[arttnba3_TestModule:] Failed to create the device!\n");
        return PTR_ERR(module_device);
    }
    printk(KERN_INFO "[arttnba3_TestModule:] Module register complete.\n");

    __file = filp_open(DEVICE_PATH, O_RDONLY, 0);
    if (IS_ERR(__file))
    {
        device_destroy(module_class, MKDEV(major_num, 0));
        class_destroy(module_class);
        unregister_chrdev(major_num, DEVICE_NAME);
        printk(KERN_INFO "[arttnba3_TestModule:] Unable to change module privilege!\n");
        return PTR_ERR(__file);
    }
    __inode = file_inode(__file);
    __inode->i_mode |= 0666;
    filp_close(__file, NULL);
    printk(KERN_INFO "[arttnba3_TestModule:] Module privilege change complete.\n");

    return 0;
}

static void __exit kernel_module_exit(void)
{
    printk(KERN_INFO "[arttnba3_TestModule:] Start to clean up the module.\n");
    device_destroy(module_class, MKDEV(major_num, 0));
    class_destroy(module_class);
    unregister_chrdev(major_num, DEVICE_NAME);
    printk(KERN_INFO "[arttnba3_TestModule:] Module clean up complete. See you next time.\n");
}

module_init(kernel_module_init);
module_exit(kernel_module_exit);
MODULE_LICENSE("GPL");
MODULE_AUTHOR("arttnba3");

在这里我们用到了一个函数IS_ERR(),定义于include/linux/err.h中,其核心为宏IS_ERR_VALUE(),即对于内核中的一些操作(如创建设备节点等)若是失败,则通常会返回一个小于-4095的负值,该函数用以进行判定

编译,测试,我们的内核模块成功地注册了一个名叫a3device的设备并成功注册到了/dev目录下,撒花~?

接下来我们只要为内核模块编写相应的API,便可以在用户态应用程式上通过虚拟设备/dev/a3device完成与内核模块间的通信

II.编写系统调用接口函数

我们编写如下的三个简单的函数使得用户应用程式可以通过open、close、read、write、ioctl与其进行交互

在这里我们引入了自旋锁spinlock_t类型变量以增加对多线程的支持

需要注意的是file_operations结构体中ioctl的函数指针应当为unlocked_ioctl,close对应的函数指针应当为release

✳ 以及内核空间与用户空间之间传递数据应当使用copy_from_user(void *to, const void *from, unsigned long n)、copy_to_user(void *to, const void *from, unsigned long n)函数

最终的代码如下:

我们的模块也逐渐大了起来,出于软件设计的考虑笔者选择将部分内容单独封装在一个头文件中

a3module.h

/*
* arttnba3_module.ko
* developed by arttnba3
*/
#include <linux/module.h>
#include <linux/kernel.h>
#include <linux/init.h>
#include <linux/fs.h>
#include <linux/device.h>

#define DEVICE_NAME "a3device"
#define CLASS_NAME "a3module"
#define NOT_INIT 0xffffffff
#define READ_ONLY 0x1000
#define ALLOW_WRITE 0x1001
#define BUFFER_RESET 0x1002

static int major_num;
static int a3_module_mode = READ_ONLY;
static struct class * module_class = NULL;
static struct device * module_device = NULL;
static void * buffer = NULL;
static spinlock_t spin;

static int __init kernel_module_init(void);
static void __exit kernel_module_exit(void);
static int a3_module_open(struct inode *, struct file *);
static ssize_t a3_module_read(struct file *, char __user *, size_t, loff_t *);
static ssize_t a3_module_write(struct file *, const char __user *, size_t, loff_t *);
static int a3_module_release(struct inode *, struct file *);
static long a3_module_ioctl(struct file *, unsigned int, unsigned long);
static long __internal_a3_module_ioctl(struct file * __file, unsigned int cmd, unsigned long param);

static struct file_operations a3_module_fo = 
{
    .owner = THIS_MODULE,
    .unlocked_ioctl = a3_module_ioctl,
    .open = a3_module_open,
    .read = a3_module_read,
    .write = a3_module_write,
    .release = a3_module_release,
};

arttnba3_module.c

/*
* arttnba3_module.ko
* developed by arttnba3
*/
#include <linux/module.h>
#include <linux/kernel.h>
#include <linux/init.h>
#include <linux/fs.h>
#include <linux/device.h>
#include <linux/slab.h>
#include "a3module.h"

module_init(kernel_module_init);
module_exit(kernel_module_exit);
MODULE_LICENSE("GPL");
MODULE_AUTHOR("arttnba3");

static int __init kernel_module_init(void)
{
    spin_lock_init(&spin);
    printk(KERN_INFO "[arttnba3_TestModule:] Module loaded. Start to register device...\n");
    major_num = register_chrdev(0, DEVICE_NAME, &a3_module_fo);
    if(major_num < 0)
    {
        printk(KERN_INFO "[arttnba3_TestModule:] Failed to register a major number.\n");
        return major_num;
    }    
    printk(KERN_INFO "[arttnba3_TestModule:] Register complete, major number: %d\n", major_num);

    module_class = class_create(THIS_MODULE, CLASS_NAME);
    if(IS_ERR(module_class))
    {
        unregister_chrdev(major_num, DEVICE_NAME);
        printk(KERN_INFO "[arttnba3_TestModule:] Failed to register class device!\n");
        return PTR_ERR(module_class);
    }
    printk(KERN_INFO "[arttnba3_TestModule:] Class device register complete.\n");

    module_device = device_create(module_class, NULL, MKDEV(major_num, 0), NULL, DEVICE_NAME);
    if(IS_ERR(module_device))
    {
        class_destroy(module_class);
        unregister_chrdev(major_num, DEVICE_NAME);
        printk(KERN_INFO "[arttnba3_TestModule:] Failed to create the device!\n");
        return PTR_ERR(module_device);
    }
    printk(KERN_INFO "[arttnba3_TestModule:] Module register complete.\n");
    return 0;
}

static void __exit kernel_module_exit(void)
{
    printk(KERN_INFO "[arttnba3_TestModule:] Start to clean up the module.\n");
    device_destroy(module_class, MKDEV(major_num, 0));
    class_destroy(module_class);
    unregister_chrdev(major_num, DEVICE_NAME);
    printk(KERN_INFO "[arttnba3_TestModule:] Module clean up complete. See you next time.\n");
}

static long a3_module_ioctl(struct file * __file, unsigned int cmd, unsigned long param)
{
    long ret;

    spin_lock(&spin);

    ret = __internal_a3_module_ioctl(__file, cmd, param);

    spin_unlock(&spin);

    return ret;
}

static long __internal_a3_module_ioctl(struct file * __file, unsigned int cmd, unsigned long param)
{
    printk(KERN_INFO "[arttnba3_TestModule:] Received operation code: %d\n", cmd);
    switch(cmd)
    {
        case READ_ONLY:
            if (!buffer)
            {
                printk(KERN_INFO "[arttnba3_TestModule:] Please reset the buffer at first!\n");
                return -1;
            }
            printk(KERN_INFO "[arttnba3_TestModule:] Module operation mode reset to READ_ONLY.\n");
            a3_module_mode = READ_ONLY;
            break;
        case ALLOW_WRITE:
            if (!buffer)
            {
                printk(KERN_INFO "[arttnba3_TestModule:] Please reset the buffer at first!\n");
                return -1;
            }
            printk(KERN_INFO "[arttnba3_TestModule:] Module operation mode reset to ALLOW_WRITE.\n");
            a3_module_mode = ALLOW_WRITE;
            break;
        case BUFFER_RESET:
            if (!buffer)
            {
                buffer = kmalloc(0x500, GFP_ATOMIC);
                if (buffer == NULL)
                {
                    printk(KERN_INFO "[arttnba3_TestModule:] Unable to initialize the buffer. Kernel malloc error.\n");
                    a3_module_mode = NOT_INIT;
                    return -1;
                }
            }
            printk(KERN_INFO "[arttnba3_TestModule:] Buffer reset. Module operation mode reset to READ_ONLY.\n");
            memset(buffer, 0, 0x500);
            a3_module_mode = READ_ONLY;
            break;
        case NOT_INIT:
            printk(KERN_INFO "[arttnba3_TestModule:] Module operation mode reset to NOT_INIT.\n");
            a3_module_mode = NOT_INIT;
            kfree(buffer);
            buffer = NULL;
            return 0;
        default:
            printk(KERN_INFO "[arttnba3_TestModule:] Invalid operation code.\n");
            return -1;
    }

    return 0;
}

static int a3_module_open(struct inode * __inode, struct file * __file)
{
    spin_lock(&spin);

    if (buffer == NULL)
    {    
        buffer = kmalloc(0x500, GFP_ATOMIC);
        if (buffer == NULL)
        {
            printk(KERN_INFO "[arttnba3_TestModule:] Unable to initialize the buffer. Kernel malloc error.\n");
            a3_module_mode = NOT_INIT;
            return -1;
        }
        memset(buffer, 0, 0x500);
        a3_module_mode = READ_ONLY;
        printk(KERN_INFO "[arttnba3_TestModule:] Device open, buffer initialized successfully.\n");
    }
    else
    {
        printk(KERN_INFO "[arttnba3_TestModule:]Warning: reopen the device may cause unexpected error in kernel.\n");
    }

    spin_unlock(&spin);

    return 0;
}

static int a3_module_release(struct inode * __inode, struct file * __file)
{
    spin_lock(&spin);

    if (buffer)
    {
        kfree(buffer);
        buffer = NULL;
    }
    printk(KERN_INFO "[arttnba3_TestModule:] Device closed.\n");

    spin_unlock(&spin);

    return 0;
}

static ssize_t a3_module_read(struct file * __file, char __user * user_buf, size_t size, loff_t * __loff)
{
    const char * const buf = (char*)buffer;
    int count;

    spin_lock(&spin);

    if (a3_module_mode == NOT_INIT)
    {
        printk(KERN_INFO "[arttnba3_TestModule:] Buffer not initialized yet.\n");
        return -1;
    }

    count = copy_to_user(user_buf, buf, size > 0x500 ? 0x500 : size);

    spin_unlock(&spin);

    return count;
}

static ssize_t a3_module_write(struct file * __file, const char __user * user_buf, size_t size, loff_t * __loff)
{
    char * const buf = (char*)buffer;
    int count;

    spin_lock(&spin);

    if (a3_module_mode == NOT_INIT)
    {
        printk(KERN_INFO "[arttnba3_TestModule:] Buffer not initialized yet.\n");
        count = -1;
    }
    else if(a3_module_mode == READ_ONLY)
    {
        printk(KERN_INFO "[arttnba3_TestModule:] Unable to write under mode READ_ONLY.\n");
        count = -1;
    }
    else
        count = copy_from_user(buf, user_buf, size > 0x500 ? 0x500 : size);

    spin_unlock(&spin);

    return count;
}

编译成功

接下来我们开始编写用户态程序以测试我们的模块功能是否正常

III.测试模块接口

我们编写如下程序来测试我们的接口是否正常:

#include <unistd.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <fcntl.h>
#include <sys/ioctl.h>

char * buf = "test for read and write.\n";

int main(void)
{
    char ch[0x100];
    int fd = open("/dev/a3device", 2);
    int len = strlen(buf);
    ioctl(fd, 0x1000, NULL);

    write(fd, buf, len);

    ioctl(fd, 0x1001, NULL);
    write(fd, buf, len);
    read(fd, ch, len);
    write(0, ch, len);

    ioctl(fd, 0x1002, NULL);
    read(fd, ch, len);
    write(0, ch, len);

    close(fd);
    return 0;
}

编译运行,一切正常,撒花~?

EXTRA.procfs接口

除了创建虚拟设备节点供通信以外,我们也可以选择创建虚拟文件节点的方式与用户进程间进行通信

procfs:进程文件系统

procfs即进程文件系统( Process file system ),其中包含一个伪文件系统,在系统启动时动态生成文件,不会占用真正的储存空间,而是占用一定的内存

procfs用以通过内核访问进程信息,通常被挂载到/proc目录下

proc_ops结构体

类似于file_operations结构体,不同的是该结构体被用于procfs

定义于include/linux/proc_fs.h中,仅定义了少量函数指针成员,如下:

struct proc_ops {
    unsigned int proc_flags;
    int    (*proc_open)(struct inode *, struct file *);
    ssize_t    (*proc_read)(struct file *, char __user *, size_t, loff_t *);
    ssize_t (*proc_read_iter)(struct kiocb *, struct iov_iter *);
    ssize_t    (*proc_write)(struct file *, const char __user *, size_t, loff_t *);
    loff_t    (*proc_lseek)(struct file *, loff_t, int);
    int    (*proc_release)(struct inode *, struct file *);
    __poll_t (*proc_poll)(struct file *, struct poll_table_struct *);
    long    (*proc_ioctl)(struct file *, unsigned int, unsigned long);
#ifdef CONFIG_COMPAT
    long    (*proc_compat_ioctl)(struct file *, unsigned int, unsigned long);
#endif
    int    (*proc_mmap)(struct file *, struct vm_area_struct *);
    unsigned long (*proc_get_unmapped_area)(struct file *, unsigned long, unsigned long, unsigned long, unsigned long);
} __randomize_layout;

创建虚拟文件夹

使用proc_mkdir(const char *, struct proc_dir_entry *)函数可以快速创建虚拟文件夹,第一个参数用以指定文件夹名,第二个参数用以指定该?挂载于哪个procfs节点下,若为NULL则自动挂载到/proc目录下

该函数的返回值为proc_dir_entry类型的指针,该结构体定义于fs/proc/internal.h中,我们可以通过这个结构体指针管理我们的虚拟文件

创建虚拟文件节点

使用proc_create(const char *name, umode_t mode, struct proc_dir_entry *parent, const struct proc_ops *proc_ops)函数可以快速创建一个文件节点

  • name:文件名
  • mode:文件读写执行权限
  • parent:该文件挂载的procfs节点,若为NULL则自动挂载到/proc目录下
  • proc_ops:该文件的proc_ops结构体

该函数的返回值同样为proc_dir_entry类型的指针

注销虚拟文件节点

函数remove_proc_entry(const char *, struct proc_dir_entry *)用以注销此前创建的文件,其中第一个参数为文件名,第二个参数为其挂载的节点,若为NULL则默认为/proc目录

测试模块接口

我们此前的模块代码只需稍加修改即可适用于procfs,这里只贴出修改后的部分:

a3module.c

static int __init kernel_module_init(void)
{
    spin_lock_init(&spin);

    a3_module_proc = proc_create(PROC_NAME, 0666, NULL, &a3_module_fo);

    return 0;
}

static void __exit kernel_module_exit(void)
{
    printk(KERN_INFO "[arttnba3_TestModule:] Start to clean up the module.\n");
    remove_proc_entry(PROC_NAME, NULL);
    printk(KERN_INFO "[arttnba3_TestModule:] Module clean up complete. See you next time.\n");
}

a3module.h

#define PROC_NAME "a3proc"

static struct proc_dir_entry * a3_module_proc = NULL;

static struct proc_ops a3_module_fo = 
{
    .proc_ioctl = a3_module_ioctl,
    .proc_open = a3_module_open,
    .proc_read = a3_module_read,
    .proc_write = a3_module_write,
    .proc_release = a3_module_release,
};

我们使用如下程序进行测试:

#include <unistd.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <fcntl.h>
#include <sys/ioctl.h>

char * buf = "test for read and write.\n";

int main(void)
{
    char ch[0x100];
    int fd = open("/proc/a3proc", 2);
    int len = strlen(buf);
    ioctl(fd, 0x1000, NULL);

    write(fd, buf, len);

    ioctl(fd, 0x1001, NULL);
    write(fd, buf, len);
    read(fd, ch, len);
    write(0, ch, len);

    ioctl(fd, 0x1002, NULL);
    read(fd, ch, len);
    write(0, ch, len);

    close(fd);
    return 0;
}

成功运行~撒花~?

六、使用 qemu + gdb 调试Linux内核

载入内核符号表

直接使用 gdb 载入之前在源码根目录下编译出来的未压缩内核镜像 vmlinux 即可

$ sudo gdb vmlinux

remote连接

我们启动时已经将内核映射到了本地的1234端口,只需要gdb连接上就行

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

笔者的gdb使用了pwndbg这个插件

需要注意的是若要源码调试则需要用我们手动编译的内核

寻找gadget

用ROPgadget或者ropper都行,笔者比较喜欢使用ROPgadget

$ ROPgadget --binary ./vmlinux > gadget.txt
$ ropper --file ./vmlinuz --nocolor > gadget2.txt

一般出来大概有个几十MB

在CTF中有的kernel pwn题目仅给出压缩后镜像bzImage,此时我们可以使用如下脚本进行解压(来自github):

#!/bin/sh
# SPDX-License-Identifier: GPL-2.0-only
# ----------------------------------------------------------------------
# extract-vmlinux - Extract uncompressed vmlinux from a kernel image
#
# Inspired from extract-ikconfig
# (c) 2009,2010 Dick Streefland <dick@streefland.net>
#
# (c) 2011      Corentin Chary <corentin.chary@gmail.com>
#
# ----------------------------------------------------------------------

check_vmlinux()
{
    # Use readelf to check if it's a valid ELF
    # TODO: find a better to way to check that it's really vmlinux
    #       and not just an elf
    readelf -h $1 > /dev/null 2>&1 || return 1

    cat $1
    exit 0
}

try_decompress()
{
    # The obscure use of the "tr" filter is to work around older versions of
    # "grep" that report the byte offset of the line instead of the pattern.

    # Try to find the header ($1) and decompress from here
    for    pos in `tr "$1\n$2" "\n$2=" < "$img" | grep -abo "^$2"`
    do
        pos=${pos%%:*}
        tail -c+$pos "$img" | $3 > $tmp 2> /dev/null
        check_vmlinux $tmp
    done
}

# Check invocation:
me=${0##*/}
img=$1
if    [ $# -ne 1 -o ! -s "$img" ]
then
    echo "Usage: $me <kernel-image>" >&2
    exit 2
fi

# Prepare temp files:
tmp=$(mktemp /tmp/vmlinux-XXX)
trap "rm -f $tmp" 0

# That didn't work, so retry after decompression.
try_decompress '\037\213\010' xy    gunzip
try_decompress '\3757zXZ\000' abcde unxz
try_decompress 'BZh'          xy    bunzip2
try_decompress '\135\0\0\0'   xxx   unlzma
try_decompress '\211\114\132' xy    'lzop -d'
try_decompress '\002!L\030'   xxx   'lz4 -d'
try_decompress '(\265/\375'   xxx   unzstd

# Finally check for uncompressed images or objects:
check_vmlinux $img

# Bail out:
echo "$me: Cannot find vmlinux." >&2

用法如下:

$ ./extract-vmlinux ./bzImage > vmlinux

七、替换原有内核

好像一切都没有问题了,我们来把我们的新内核换到我们的主机上吧!

我们原有的机子的内核版本为 5.8.0

在编译好内核后,我们在之前的源码目录下继续执行如下指令:

$ sudo make modules
$ sudo make modules_install
$ sudo make install
$ sudo update-initramfs -c -k 5.11.0
$ sudo update-grub
$ sudo apt-get install linux-source

这里的 5.11.0 应为你自己的新内核版本号

需要注意的是在执行命令之前我们应当预留足够的空间

会比你预想中的可能还要再大一些

之后输入 reboot 命令重启即可

重新进入系统,我们可以看到我们的内核版本已经被替换为 5.11.0

八、CTF中kernel类题目的部署

和常规的CTF题目的布置方法是相类似的,最常见的办法便是使用ctf_xinted + docker布置,我们只需要配置用 ctf_xinetd 启动 boot.sh 即可

首先下载ctf_xinted

$ git clone https://github.com/Eadom/ctf_xinetd.git

之后将内核复制进来,大概的架构如下:

$ tree
.
├── ctf.xinetd
├── Dockerfile
├── bin
│   ├── boot.sh
│   ├── bzImage
│   └── rootfs.cpio
├── README.md
└── start.sh

1 directory, 7 files

Dockerfile

因为不需要太多东西所以写的比较简洁,一开始安装 qemu 可能会多耗一点点时间,DEBIAN_FRONTEND=noninteractive 主要用于省去配置中的一些交互

FROM ubuntu:20.04

RUN sed -i "s/http:\/\/archive.ubuntu.com/http:\/\/mirrors.tuna.tsinghua.edu.cn/g" /etc/apt/sources.list && \
    apt-get update && apt-get -y dist-upgrade && \
    DEBIAN_FRONTEND=noninteractive \
    apt-get install -y lib32z1 xinetd git libglib2.0-dev libfdt-dev libpixman-1-dev zlib1g-dev qemu qemu-system-x86

RUN useradd -m ctf

WORKDIR /

COPY ./ctf.xinetd /etc/xinetd.d/ctf
COPY ./start.sh /start.sh
RUN echo "Blocked by ctf_xinetd" > /etc/banner_fail

RUN chmod +x /start.sh

COPY ./bin/ /

CMD ["/start.sh"]

EXPOSE 25000

ctf.xinted

这里直接把 boot.sh 给起了就行

service ctf
{
    disable = no
    socket_type = stream
    protocol    = tcp
    wait        = no
    user        = ctf
    type        = UNLISTED
    port        = 25000
    bind        = 0.0.0.0
    server      = /boot.sh
    # replace helloworld to your program
    banner_fail = /etc/banner_fail
    # safety options
    per_source    = 10 # the maximum instances of this service per source IP address
    rlimit_cpu    = 20 # the maximum number of CPU seconds that the service may use
    #rlimit_as  = 1024M # the Address Space resource limit for the service
    #access_times = 2:00-9:00 12:00-24:00
}

之后使用如下命令启动xinted

$ sudo docker build -t "kernel" .
$ sudo docker run -d -p "0.0.0.0:25000:25000" -h "kernel" --name="kernel" kernel

init 和 boot.sh 也应当进行适当修改:

init

#!/bin/sh

mount -t proc none /proc
mount -t sysfs none /sys
mount -t devtmpfs devtmpfs /dev

exec 0</dev/console
exec 1>/dev/console
exec 2>/dev/console

chown root:root /flag
chmod 600 /flag

insmod a3dev.ko

echo -e "\nBoot took $(cut -d' ' -f1 /proc/uptime) seconds\n"
setsid cttyhack setuidgid 1000 sh

umount /proc
umount /sys
poweroff -d 0  -f

boot.sh

#!/bin/sh
qemu-system-x86_64 \
    -m 128M \
    -kernel ./bzImage \
    -initrd  ./rootfs.cpio \
    -append "root=/dev/ram console=ttyS0 oops=panic panic=1 loglevel=3 quiet nokaslr" \
    -cpu kvm64,+smep,+smap \
    -smp cores=2,threads=2 \
    -netdev user,id=t0, -device e1000,netdev=t0,id=nic0 \
    -nographic \
    -monitor /dev/null

 

0xFF.reference

eqqie – Linux下kernel调试环境搭建

TaQini – Linux Kernel Pwn 入门笔记

Mask – Linux Kernel Pwn I: Basic Knowledge

CTF Wiki – Linux Pwn – kernel – 基础知识

Wikipedia: 整塊性核心

进程描述符

Lab1:Linux内核编译及添加系统调用(详细版) – 睿晞 – 博客园

m4x – Play with file descriptor(II)

文件描述符表、文件表、索引结点表_luotuo44的专栏-CSDN博客_文件描述符表

Linux内核之旅

linux设备驱动程序之简单字符设备驱动 – LoveFM – 博客园

Linux驱动(字符设备):02—-设备号(devt、MAJOR、MINOR、MKDEV、register_chrdev_region、alloc_chrdev_region)江南、董少-CSDN博客

Linux内核模块编程指南(三)_yeshen.org-CSDN博客

手把手教你编写一个具有基本功能的shell(已开源) – 五岳 – 博客园

《Understanding the Linux Kernel(Third Edition)》 —— Daniel P. Bovet & Marco Cesati

《Modern Operating System(Fourth Edition)》 —— Andrew S. Tanenbaum & Herbert Bos

(完)