【NOTES.0x02】Linux Kernel Pwn学习笔记 I:一切开始之前

本文最后更新于:2021年8月3日 凌晨

Big Linus is watching you!

0x00.Linux Kernel Basic Knowledge

一、内核

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

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

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

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

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

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

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

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

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

image.png

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

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

Wikipedia: 整塊性核心

台湾这什么鬼译名

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

Unix与类Unix便是宏内核

image.png

微内核(Micro Kernel)

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

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

image.png

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

二、分级保护域

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

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

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

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

Intel Ring Model

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

image.png

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

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

用户空间 & 内核空间

用户空间为我们的应用程式一般所运行的空间,运行在 ring3 权限的用户态

内核空间则是kernel所运行的空间,运行在 ring0 权限的内核态,所有进程共享一份内核空间

用户态 & 内核态

可以理解为操作系统对 ring0 与 ring3 的封装,通俗地说,当进程运行在内核空间时就处于内核态,而进程运行在用户空间时则处于用户态
在内核态下,进程运行在内核地址空间中,此时 CPU 可以执行任何指令
在用户态下,进程运行在用户地址空间中,此时CPU所执行的指令是受限的

进程运行态切换

应用程式运行时总会经历无数次的用户态与内核态之间的转换,这是因为用户进程往往需要使用内核所提供的各种功能(如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系统中的一种异步的进程间通信方式,用以通知一个进程发生了某个事件,通常情况下常见的流程如下图所示:

image.png

  • 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库函数没有区别,不同的是系统调用位于内核空间,以内核态运行

image.png

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中,代码比较长就不在这里贴出了

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

image.png

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

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

1
2
3
4
5
6
7
8
9
10
/* 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以此作为进程权限的凭证

进程权限凭证:cred结构体

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
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进行认证的
  • 文件系统用户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()函数源码,观察到如下逻辑:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
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命令令扬声器产生随机噪声

image.png

进程文件系统

进程文件系统(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是一个专用于设备输入输出操作的一个系统调用,其调用方式如下:

1
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保护机制时,内核的基址为0xffffffff80000000, 内核会占用0xffffffff80000000~0xffffffffC0000000这1G虚拟地址空间

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),内核空间与用户空间分别使用两组不同的页表集,这对于内核的内存管理产生了根本性的变化

0x01.Linux Kernel 简易食用指南

Pre.安装依赖

环境是Ubuntu20.04

1
2
$ 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这个版本的内核镜像

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

II.配置编译选项

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

1
$ tar -xvf linux-5.11.tar.xz

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

1
$ make menuconfig

进入如下配置界面

image.png

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

  • 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.开始编译

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

1
$ make bzImage

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

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

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

可能出现的错误

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

1
cc1: error: code model kernel does not support PIC mode
1
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 文件中找到这一项,等于号后面的值改为 ""

image.png

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

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

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

1
Kernel: arch/x86/boot/bzImage is ready  (#1)
vmlinux:原始内核文件

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

1
2
$ 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,为压缩后的内核文件,适用于大内核

1
2
$ 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

1
114514	64	arttnba3_test		sys_arttnba3_test
II.声明系统调用

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

1
2
/* for arttnba3's personal syscall test */
asmlinkage long sys_arttnba3_test(void);
III.添加系统调用函数定义

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

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

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

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

IV.重新编译内核

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

V.测试系统调用

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

1
2
3
4
5
6
#include <unistd.h>
int main(void)
{
syscall(114514);
return 0;
}

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

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

image.png

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

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

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

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

1
$ sudo apt search linux-image- 

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

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

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

1
2
3
4
5
6
7
8
9
10
$ 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这个版本

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

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

解压

1
$ tar -jxvf busybox-1.33.0.tar.bz2

II.编译busybox源码

进入配置界面

1
$ make menuconfig

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

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

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

1
$ make install

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

三、构建磁盘镜像

建立文件系统

I.初始化文件系统

一些简单的初始化操作…

1
2
3
4
5
6
$ 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,写入如下内容:

1
2
3
4
5
6
::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,写入如下内容:

1
2
3
4
5
6
#!/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文件,写入如下内容:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
#!/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

别忘了添加可执行权限:

1
$ chmod +x ./init

III.配置用户组

1
2
3
4
5
$ 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库

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

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

打包文件系统为镜像文件

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

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

也可以这么写

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

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

向文件系统中添加文件

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

I.解压磁盘镜像

1
$ cpio -idv < ./rootfs.cpio

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

II.重打包磁盘镜像

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

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

四、使用qemu运行内核

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

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

配置启动脚本

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

接下来编写启动脚本

1
$ touch boot.sh

写入如下内容:

1
2
3
4
5
6
7
8
9
10
11
12
#!/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,成功启动~撒花~🌸🌸🌸

image.png

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

1
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()在内核缓冲区进行输出:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
/*
* 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来构建一个内核模块

1
2
3
4
5
6
7
8
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的文件后,在终端执行如下指令,我们的内核模块就会自动进行构建

1
$ make

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

1
2
3
4
5
6
7
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'

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

image.png

提供应用程式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进制

最终的代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
/*
* 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目录下,撒花~🌸

image.png

接下来我们只要为内核模块编写相应的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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
/*
* 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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
/*
* 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;
}

编译成功

image.png

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

III.测试模块接口

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
#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;
}

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

image.png

EXTRA.procfs接口

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

procfs:进程文件系统

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

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

proc_ops结构体

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

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
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

1
2
3
4
5
6
7
8
9
10
11
12
#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,
};

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
#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;
}

成功运行~撒花~🌸

image.png

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

载入内核符号表

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

1
$ sudo gdb vmlinux

remote连接

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

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

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

image.png

源码 + 符号 + 堆栈 一目了然(截屏没法截全…

寻找gadget

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

1
$ ROPgadget --binary ./vmlinux > gadget.txt

一般出来大概有个几十MB

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
#!/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

用法如下:

1
$ ./extract-vmlinux ./bzImage > vmlinux

七、编写自己的shell

注:实际生产开发中推荐使用bash/dash等成熟的shell,这里仅仅是做了一个勉强能用的小玩具

我们的kernel虽然成功地跑了起来,但是不可置否的是,仅有一个白色的/#作为提示符的默认shell似乎过于丑陋了(),于是我们接下来试着开发一个属于自己的可爱的shell吧!

通常而言,一个shell可以简化为如下形式:

参考自《现代操作系统》P31 图 1-19

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
while(1)
{
typePrompt();
readCommand();

int pid = fork();

if(pid < 0)
{
puts("Unable to fork the child, inner error.");
}
else if(pid == 0) // the child thread
{
execve(command); //execve the command
}
else // the parent thread
{
wait(NULL); //waiting for the child to exit
}
}

当我们在shell中进行输入时,fork()出子进程来执行我们的输入,父进程则等待我们的子进程执行完成

打印提示符

一个“比较好看”的shell应当形如如下形式:

bash,大多数Linux发行版上默认的shell

image.png

即我们在输入命令之前应当有如下结构的提示符:

1
user@hostname:current_path$
  • 获取用户相关信息可以使用getpwuid(getuid())获取一个passwd结构体

  • 获取主机名则可以使用gethostname()函数

  • 获取当前路径可以使用getcwd()函数,按照bash的风格若是包含当前用户的home路径则我们应当将其缩写为~

  • 改变字体颜色则可以用相应的转义序列控制字符,便不在此赘叙

最终我们得到的打印提示符的函数如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
uid_t uid;
int user_path_len;
char local_host_name[0x100];
char user_path[0x100];
char current_path[0x200];
struct passwd * user_info = NULL;

void typePrompt(void)
{
uid = getuid();
user_info = getpwuid(uid);
user_path_len = strlen(user_info->pw_dir);

if(gethostname(local_host_name, 0x100))
{
printf("\033[31m\033[1m[x] Unable to get the hostname, inner error.\033[0m\n");
exit(-1);
}

if(!getcwd(current_path, 0x200))
{
printf("\033[31m\033[1m[x] Unable to get the current path, inner error.\033[0m\n");
exit(-1);
}

if(uid == 0) // for root, no color
{
printf(user_info->pw_name);
printf("@");
printf(local_host_name);
printf(":");
if(strlen(current_path) > user_path_len)
{
memcpy(user_path, current_path, user_path_len);
user_path[user_path_len] = '\0';
if(!strcmp(user_path, user_info->pw_dir))
{
printf("~");
printf(current_path + user_path_len);
}
else
printf(current_path);
}
else
{
printf(current_path);
}
printf("# ");
}
else
{
printf("\033[32m\033[1m");
printf(user_info->pw_name);
printf("@");
printf(local_host_name);
printf("\033[0m\033[1m");
printf(":");
printf("\033[34m");
if(strlen(current_path) > user_path_len)
{
memcpy(user_path, current_path, user_path_len);
user_path[user_path_len] = '\0';
if(!strcmp(user_path, user_info->pw_dir))
{
printf("~");
printf(current_path + user_path_len);
}
else
printf(current_path);
}
else
{
printf(current_path);
}
printf("\033[0m");
printf("$ ");
}
}

简单测试一下,以假乱真还是没什么问题的()

image.png

输入读取

对于用户的一次输入,毫无疑问我们不应当也不可能无限进行读取,因此我们应当对输入的读取的字符的上限做一个限制,超出这个限制长度往后的字符尽数丢弃

同样地,为了避免一开始就分配过大的内存空间,笔者选择使用malloc进行动态内存分配,一开始时先分配一个适当大小的缓冲区,后续若输入超出这个大小则重新分配一个两倍大小的缓冲区

对于输入历史是否记录,我们还需要进行判断,若是用户仅仅是在不断敲击ENTER,那么就没必要记录了

后台执行

有的时候我们想要让应用被放到后台去执行,那么我们的父进程(shell)就不应当等待子进程,笔者选择仿照bash的方式——若最后一个字符是'&'则不等待子进程执行,这里我们选择在读取命令时使用一个返回值进行标识

历史命令

为了模拟bash的功能,我们还应当实现!!执行上一条命令、!数字执行历史记录中的某条命令,简单判断即可

完整代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
int readCommand(void)
{
unsigned long long count = 0;
char ch;
while((ch = getchar()) != '\n')
{
if(count == command_buf_size)
{
if(2 * command_buf_size > BUF_MAX) //overflow
{
while((ch = getchar()) != '\n')
continue;
break;
}
char * new_buf = (char*)malloc(2 * command_buf_size);
if(!new_buf) //malloc error
{
while((ch = getchar()) != '\n')
continue;
break;
}
memcpy(new_buf, command_buf, command_buf_size);
command_buf_size *= 2;
free(command_buf);
command_buf = new_buf;
}
command_buf[count++] = ch;
}
command_buf[count] = '\0';
if(count == 0)
return FLAG_NULL_INPUT;

if(count > 1)
{
if (command_buf[0] == '!')
{
if (command_buf[1] == '!')
{
if(!his_full && his_count == 0)
{
puts("\033[31m\033[1m[x] No available command, history is empty.\033[0m");
return FLAG_NULL_INPUT;
}

char * temp = malloc(command_buf_size);
int flag = FLAG_EXECVE_WAIT;

if(command_buf[count - 1] == '&')
{
command_buf[count - 1] = '\0';
flag = FLAG_EXECVE_BACKGROUND;
}

strcpy(temp, history[((his_count + HIS_MAX - 1) % HIS_MAX)]);
strncat(temp, command_buf + 2, command_buf_size - strlen(temp));
strcpy(command_buf, temp);
free(temp);
historyRecord();
printf("\n%s\n", command_buf);
return flag;
}
else if (command_buf[1] >= '0' && command_buf[1] <= '9')
{
int num_end = 1;
while(command_buf[num_end] >= '0' && command_buf[num_end] <= '9')
num_end++;
char ch = command_buf[num_end];
command_buf[num_end] = '\0';
int his = atoi(command_buf + 1);
command_buf[num_end] = ch;

if (his < 0 || his >= HIS_MAX || !history[his])
{
puts("\033[31m\033[1m[x] No available command, invalid history index.\033[0m");
return FLAG_NULL_INPUT;
}

char * temp = malloc(command_buf_size);
int flag = FLAG_EXECVE_WAIT;

if(command_buf[count - 1] == '&')
{
command_buf[count - 1] = '\0';
flag = FLAG_EXECVE_BACKGROUND;
}

strcpy(temp, history[his]);
strncat(temp, command_buf + num_end, command_buf_size - strlen(temp));
strcpy(command_buf, temp);
free(temp);
historyRecord();
printf("\n%s\n", command_buf);
return flag;
}
}
}

historyRecord();

if(command_buf[count - 1] == '&')
{
command_buf[count - 1] = '\0';
return FLAG_EXECVE_BACKGROUND;
}
return FLAG_EXECVE_WAIT;
}

命令行解析

最为简单的解析方式便是使用strtok()函数进行分割,这里我们选择以空格" "作为分隔符

同样地,一行命令中的参数数量不应当过多,我们应当限制仅读取一定数量的参数

代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
#define ARGS_MAX 0x100

char * args[0x100];
int args_count = 0;

void analyseCommand(void)
{
args_count = 0;
args[args_count] = strtok(command_buf, " ");
char * ptr;
while(ptr = strtok(NULL, " "))
{
args_count++;
args[args_count] = ptr;
if(args_count + 1 == ARGS_MAX)
break;
}
}

命令执行

相比起execve()execvp()函数更适合用以执行我们输入的命令,同时我们解析后的命令行格式可以直接传入,较为方便

1
2
3
4
5
6
7
8
9
10
11
12
void createChild(int flag)
{
int pid = fork();

if(pid < 0) // failed to fork a new thread
printf("\033[31m\033[1m[x] Unable to fork the child, inner error.\033[0m\n");
else if(pid == 0) // the child thread
execvp(args[0], args);
else // the parent thread
if(flag == FLAG_EXECVE_WAIT)
wait(NULL); //waiting for the child to exit
}

内建命令

部分命令如cd(改变当前工作目录)、history(查看历史)、exit(退出)等命令若是直接使用execvp()执行的话我们会发现毫无效果,因此这几个命令我们需要自行建立在我们的shell当中

  • cd命令可以直接使用chdir()函数改变当前工作目录,需要注意的是对于字符串"~"我们应当单独解析——将其替换为用户工作目录后再进行字符串拼接
  • history命令则需要我们预先有一个储存历史命令的缓冲区,同时当历史记录达到上限时我们应当进行清除,这里我们选择模拟一个循环链表以在历史命令满之后每次输入命令时都会去除现存的最早的命令
  • exit命令则只需要在主进程中识别到该字符串时直接调用exit()即可

同样地,在主进程若是检测到输入的命令为内建命令,则应当不调用execvp(),在这里笔者选择添加一个返回值进行判定

代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
static char * history[HIS_MAX];
static int his_count = 0;
static int his_start = 0;
static int his_full = 0;

int innerCommand(void)
{
if(!strcmp(args[0], "exit"))
{
puts("Exit the a3shell now, see you again!");
exit(-1);
}
else if(!strcmp(args[0], "cd"))
{
if(args_count > 1)
puts("cd: too many arguments");
else
{
if(args[1][0] == '~' && args[1][1] == '/')
{
char * dir = malloc(strlen(args[1]) + strlen(user_info->pw_dir));
strcpy(dir, user_info->pw_dir);
strncat(dir, args[1][1], strlen(args[1]) - 1);
chdir(dir);
free(dir);
dir = NULL;
}
else
chdir(args[1]);
}
return 1;
}
else if(!strcmp(args[0], "history"))
{
if(args[1] && !strcmp(args[1], "-c"))
{
his_count = 0;
his_full = 0;
his_start = 0;
return 1;
}
int count = 0;
if(his_full)
{
for(int i = his_start; i < HIS_MAX; i++)
{
printf(" %d ", count++);
puts(history[i]);
}
for(int i = 0; i < his_start;i++)
{
printf(" %d ", count++);
puts(history[i]);
}
}
else
{
for(int i = 0; i < his_count; i++)
{
printf(" %d ", count++);
puts(history[i]);
}
}
return 1;
}
return 0;
}

void historyRecord(void)//to record the history
{
history[his_count] = malloc(strlen(command_buf));
strcpy(history[his_count], command_buf);
his_count++;
if(his_full)
{
his_start++;
his_start %= HIS_MAX;
}
if(his_count == HIS_MAX)
{
his_count = 0;
his_full = 1;
}
}

代码测试

最终我们的主程序如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
#include <unistd.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <fcntl.h>
#include <sys/ioctl.h>
#include <pwd.h>
#include <sys/types.h>
#include "func.c"

int main(void)
{
init();

while(1)
{
memset(args, 0, sizeof(char*) * 0x100);
typePrompt();
int flag = readCommand();
if(flag == FLAG_NULL_INPUT)
continue;
analyseCommand();
if(innerCommand())
continue;

createChild(flag);
}
return 0;
}

完整代码见https://github.com/arttnba3/a3shell

编译运行,示意图如下,左边为a3shell,右边为bash

image.png

勉强能用(),后面还会继续进行进一步的优化(如代码补全等

EXTRA.MORE POWERFUL SHELL

毫无疑问的是,一个成熟易用的shell应当还要具备如代码补全上下切换历史记录等功能,因此我们决定为我们的shell添加这样的功能,让她成为一个更加强大的shell

在这里我们将会用到一个库The GNU Readline Library

安装readline库

1
2
$ sudo apt-get install libreadline-gplv2-dev
$ sudo apt-get install libreadline6-dev

也可以在这里下载源码

使用readline读取输入

需要#include <readline/readline.h>

只需要将我们原来的readCommand()函数换为readline()函数即可,返回值即为读取到的输入

需要注意的是我们需要手动进行释放,否则会造成内存泄漏

readline()函数接收一个参数作为输入前的提示符,我们只需要稍微原有函数将拼接好的提示符传入即可

✳需要注意的是我们传入的提示符字符串应当以'\001'开头'\002'结尾

对于空行而言,readline()将会返回一个空字符串(buf[0] == ‘\0’)而不是NULL

记录历史输入

readline库提供了强大的历史输入记录功能,在使用readline()函数读取输入后我们可以使用add_history()记录输入,传入的参数则是readline()返回的字符串

添加历史后我们便可以像普通的shell那样使用↑↓来显示历史输入记录

打印的功能依然需要我们自定义,在readline lib中使用一个HIST_ENTRY结构体数组来记录我们传入的历史输入,而使用history_list()便可以获得指向该结构体数组的指针

使用clear_history()则可以清除所有历史记录

代码如下:

1
2
3
4
5
6
7
8
9
10
#include <readline/history.h>

...

HIST_ENTRY ** his = history_list();
for(int i = 0; his[i]; i++)
{
printf(" %d\t\t", i);
puts(his[i]->line);
}

编译运行

需要注意的是我们编译时应当添加上-lreadline参数

示例:

1
$ gcc shell.c -o a3sh -lreadline

程序完整代码见https://github.com/arttnba3/a3shell

大概效果图如下(自动补全的效果没法表现出来,感受一下…)

image.png

说实话按照笔者个人的体验而言这个库并不算特别完善…

八、替换内核

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

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

image.png

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

1
2
3
4
5
6
$ 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 应为你自己的新内核版本号

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

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

之后输入 reboot 命令重启即可

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

image.png

九、CTF中kernel类题目的部署

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

首先下载ctf_xinted

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

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

1
2
3
4
5
6
7
8
9
10
11
12
$ tree
.
├── ctf.xinetd
├── Dockerfile
├── files
│ ├── boot.sh
│ ├── bzImage
│ └── rootfs.cpio
├── README.md
└── start.sh

1 directory, 7 files

ctf.xinted

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
service ctf
{
disable = no
socket_type = stream
protocol = tcp
wait = no
user = ctf
type = UNLISTED
port = 25000
bind = 0.0.0.0
server = /usr/sbin/chroot
# replace helloworld to your program
server_args = --userspec=1000:1000 /home/ctf ./boot.sh
banner_fail = /etc/banner_fail
# safety options
per_source = 10 # the maximum instances of this service per source IP address
rlimit_cpu = 1024 # 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
}

Dockerfile

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
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 && \
apt-get install -y lib32z1 xinetd

RUN useradd -m ctf

WORKDIR /home/ctf

RUN mkdir /home/ctf/usr && \
cp -rf /lib* /home/ctf && \
cp -rf /usr/lib* /home/ctf/usr

RUN mkdir /home/ctf/dev && \
mknod /home/ctf/dev/null c 1 3 && \
mknod /home/ctf/dev/zero c 1 5 && \
mknod /home/ctf/dev/random c 1 8 && \
mknod /home/ctf/dev/urandom c 1 9 && \
chmod 666 /home/ctf/dev/*

RUN mkdir /home/ctf/bin && \
cp /bin/sh /home/ctf/bin && \
cp /bin/ls /home/ctf/bin && \
cp /bin/cat /home/ctf/bin

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/ /home/ctf/
COPY ./flag /home/ctf/flag
RUN chown -R root:ctf /home/ctf && \
chmod -R 750 /home/ctf && \
chmod 600 /home/ctf/flag

CMD ["/start.sh"]

EXPOSE 25000

之后使用如下命令启动xinted

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

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—设备号(dev_t、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