Intro to Kernel Pwn
要开始折磨自己了吗?
权限模型
文件权限
众所周知, 文件具有三种权限, user, group, world 的可读可写可执行, 同时还记录了所有者 (user) 和所有组 (group). ls -l
就能够查看文件对应的这些权限.
- 当使用文件所有者这个用户去访问文件, 会查看文件的 user 可读可写可执行权限;
- 当使用文件所有组中的用户去访问文件, 会查看文件的 group 对应权限;
- 当用户不是所有者也不在所有组内, 则会查看文件的 world 对应权限.
一个问题, 操作系统怎么知道访问这个文件的是谁.
以前我天真的认为, 我登录了这个用户, 我用任何程序访问文件都是具有这个用户对应的权限; sudo 是暂时切换到 root 用户执行后面的命令.
当然, 这是错的.
操作系统不是从某一个地方看当前登录的是哪个用户, 而是看访问文件的这个进程的 id.
进程凭证
每一个进程都有一些凭证, 由操作系统在 PCB 中维护.
linux 的 PCB 具体是 task_struct
这个结构体:
|
|
其中的 cred
就是保存了这些 id 的结构体:
|
|
- euid/egid: effective UID/GID, 有效 UID/GID, 用于访问其他对象时的权限检查
- suid/sgid: 切换 eUID/eGID 时保存原先值的地方, 用于暂时切换权限而已, 和一会介绍的 SUID 不同.
这里还有一个 real UID/GID, 以及 PCB 中还存在一个 real_cred
. 这里的 real 表示的是这个进程的本身的凭证, 在进程被访问时, 具有的凭证.
一般来说, id = 0 表示 root, 具有最高的权限. id = 1000 开始是用户, 权限较低. 1000 以下是特殊用途的用户, 比如 www-data, nginx 等. fork 时子进程的 cred (包括 UID 等) 将继承父进程的.
举个例子, 进程 A 给进程 B 发送一个 kill signal (SIGKILL), 那么操作系统会检查 A 的 cred
中的 eUID, 检查 B 的 real_cred
中的 UID (real UID), 如果 A 的 eUID = 0, 那么 A 就可以 kill B; 反过来如果 B 的 UID = 0, A 的 eUID = 1000, 那么 A 就不能够 kill B.
操作文件的时候也是一样, 假设一个文件的权限是 rw-r--r--
, 进程 A 的 eUID = 0 时可以写这个文件, 否则只能读这个文件, 不具有写的权限.
综上, cred
中的 eUID 才是操作系统检查当前进程主动操作的凭证.
SUID
其实文件还有另外三个权限标志位, 其中一个是 SUID (Set UID) bit. 如果某个可执行文件的这一位为 1, 那么执行后进程的 eUID 将与 文件的所有者 一致. sudo 就是这个原理. 查看 /bin/sudo
可以看到, 这个文件的所有者是 root, 它的 SUID bit 是 1, 在 user 原来的 x 位置被标记了 s, 且文件被标红了.

Loadable Kernel Modules
Linux 采用宏内核, 如果要添加新的功能如外设驱动, 那么就必须重新编译整个内核.
LKMs 就是针对上述问题的解决方案. 操作系统支持向内核中安装可拓展的模块, 那么添加新的功能就不用重新编译了.
Kernel Module 是 ELF 文件, 一般以 .ko
作为后缀. 它无法独立运行, 只能运行在 kernel space 中. 也就是说, 它也是 kernel 的一部分.
insmod
命令: 安装模块rmmod
命令: 卸载模块lsmod
命令: 查看已安装的模块
Kernel Module 会一般可以实现设备驱动, 文件系统, 网络功能等.
交互方式
Kernel Module 与用户交互主要有如下方式:
- 添加系统调用
- 修改中断响应
- 注册文件
其中, 添加系统调用的方法直接修改 kernel 的系统调用表, 存在极大的安全隐患, 已经不支持了. 添加中断响应往往意味着要使用中断命令 (如 int3), 高级语言不太适用. Unix 哲学一切皆文件, 和文件打交道就用 orw 那一套, 所以注册文件的方法较为通用.
文件一般注册在 /dev
或者 /proc
下, /dev
多用于设备, /proc
下是正在运行的程序的抽象, 被拓展还可以存放正在运行的 LKMs 的接口文件抽象.
接着就可以使用 open 打开这个接口文件, read / write 接收 / 发送数据. 除此之外, 还有一个系统调用 ioctl 提供了更通用的交互方式.
ioctl 的定义为 int ioctl(int fd, unsigned long request, ...)
, 第一个参数是文件描述符, 第二个参数是请求码, 之后跟的是参数. 这个系统调用最终会走向该 Module 的代码 (前提是写了), LKMs 判断请求码和额外数据, 更具请求码的不同实现 / 提供相应的功能.
Kernel Pwn in CTF
一般来说, CTF 赛事中的 Kernel Pwn 会加载自己写的一个 Kernel Module, 这个 Module 中存在漏洞, 我们需要在 user space 中通过与其交互的方式利用漏洞, 在 kernel 中将当前进程的权限提升到 root.
前面说过, 提升权限实际上是改变 euid. 除了最直接的在 kernel 内存中找到 task_struct
并改写 euid 外, 更简便的方法是使用 kernel 自己提供的功能.
commit_creds
程序在运行的过程中是支持改变权限 (或者说凭证) 的, kernel 中的 commit_creds(struct cred *)
函数就是用来干这个操作的. 它接收一个 cred
结构体, 然后将 task_struct
中的 cred
改为传入的.
prepare_kernel_cred
这个 cred 也不需要自己伪造, kernel 中的 prepare_kernel_cred
函数可以为我们创造一个. 完整定义为 struct cred * prepare_kernel_cred(struct task_struct *reference_task_struct)
有趣的是, 如果传入的参数是 NULL, 那么这个函数将会返回一个 root 的 cred!
那么我们只需要想方设法在 kernel space 中实现 commit_creds(prepare_kernel_cred(NULL))
, 即可将进程的权限提升至 root.
返回用户态
最后还需要返回用户态, 此时进程的 eUID = 0, 具有最高的 root 权限. 此时创建子进程也具有 root 权限.
如何返回用户态, 也是 kernel pwn 需要学习的知识之一.
How to Pwn
笼统的方法和 user space 的 Pwn 一样, 都是控制执行流罢了. 在 user space, 我们与用户进程进行交互; 而在 kernel space, 我们与 LKMs 进行交互. LKMs 也可能会出现有各种各样可以利用的 bug, 如栈溢出, 堆溢出, UAF 等等.
有攻就有防, kernel 也有很多保护工作, 如 kernel 的 cannay, kernel 的地址随机化等等. 如何绕过这些保护, 也是 kernel pwn 需要学习的知识之一.