Exploiting “BadIRET” vulnerability (CVE-2014-9322, Linux kernel privilege escalation)

from:http://labs.bromium.com/2015/02/02/exploiting-badiret-vulnerability-cve-2014-9322-linux-kernel-privilege-escalation/

POC( 感谢Mickey提供的链接):

https://rdot.org/forum/showthread.php?t=3341

Shawn:对于这个漏洞,本文的结论是SMEP虽然被绕过了,但SMAP是依然奏效的,这里只想提一下类似PaX/Grsecurity的UDEREF特性和SMAP类似,只是属于纯软件 实现,大概2006年左右这个特性就已经有了而且被一些anarchy广泛使用。

0x00 Intro


CVE-2014-9322的描述如下:

linux内核代码文件arch/x86/kernel/entry_64.S在3.17.5之前的版本都没有正确的处理跟SS(堆栈区)段寄存器相关的错误,这可以让本地用户通过触发一个IRET指令从错误的地址空间去访问GS基地址来提权。

这个漏洞于2014年11月23日被社区修复2,至今我并没有见到公开的利用代码和详细的讨论。这篇文章我会尝试去解释这个漏洞的本质以及利用的过程。不幸的 是,我无法完全引用Intel白皮书3的所有内容,如果有读者不熟悉一些术语可以直接查Intel白皮书。所有的实验都是在Fedora 20 64-bit发行版上完成的,内核是3.11.10-301,所有的讨论基于64位进行。

简单结论概要:

1. 通过测试,这个漏洞可以完全稳定的被利用。
2. SMEP[4]不能阻止任意代码执行;SMAP[5]可以阻止任意代码执行。

0x01 Digression: kernel, usermode, iret


enter image description here

0x02 漏洞


在一些情况下,linux内核通过iret指令返回用户空间时会产生一个异常。异常处理程序把执行路径返回到了bad_iret函数,她做了:

#!bash
 /* So pretend we completed the iret and took the #GPF in user mode.*/
 pushq $0
 SWAPGS
 jmp general_protection

正如这行评论所解释,接下来的代码流应该和一般保护异常(General Protection)在用户空间发生时(转跳到#GP处理程序)完全相同。这种异常处理情况大多是由iret指令引发的,e.g. #GP。

问题在于#SS异常。如果有漏洞的内核(比如3.17.5)也有"espfix"功能(从3.16引入的特性),之后bad_iret函数会在只读的栈上执行"push"指令,这会导致页错误(page fault)而会直接引起两个错误。我不考虑这种场景;从现在开始,我们只关注在3.16以前的没有"espfix"的内核。

这个漏洞根源于#SS的异常处理程序没有符合“pretend-it-was-#GP-in-userspace”[6]的规划,与#GP处理程序相比,#SS异常处理会多做一次swapgs指令。如果你对swapgs不了解,请不要跳过下面的章节。

0x03 偏题:swapgs指令


当内存通过gs段进行访问时,像这样:

#!bash
mov %gs:LOGICAL_ADDRESS, %eax

实际会发生以下几步:

1. BASE_ADDRESS值从段寄存器的隐藏部分取出
2. 内存中的线性地址LOGICAL_ADDRESS+BASE_ADDRESS被dereferenced(Shawn:char *p; *p就是deref)。

基地址是从GDT(或者LDT)继承过来的。无论如何,有一些情况是GS段基地址被修改的动作不需要GDT的参与。

引用自Intel白皮书:

“SWAPGS把当前GS基寄存器值和在MSR地址C0000102H(IA32_KERNEL_GS_BASE)所包含的值进行交换。SWAPGS指令是一个为系统软件设计的特权指令。(....)内核可以使用GS前缀在正常的内存引用去访问[per-cpu]内核数据结构。”

Linux内核为每个CPU在启动时分配一个固定大小的结构体来存放关键数据。之后为每个CPU加载IA32_KERNEL_GS_BASE到相应的结构地址上,因此,通常的情况,比如系统调用的处理程序是:

1. swapgs(现在是GS指向内核空间)
2. 通过内存指令和gs前缀访问per-cpu内核数据结构
3. swapgs(撤销之前的swapgs,GS指向用户空间)
4. 返回用户空间

0x04 触发漏洞


现在很明显可以看到这个漏洞简直就是坟墓,因为多了一个swapgs指令在有漏洞代码路径里,内核会尝试从可能被用户操控的错误GS基地址访问重要的数据结构。

当iret指令产生了一个#SS异常?有趣的是,Intel白皮书在这方面介绍不完全(Shawn:是阴谋论的话又会想到BIG BROTHER?);描述iret指令时,Intel白皮书这 么讲:

64位模式的异常:
#SS(0)
如果一个尝试从栈上pop一个值违反了SS限制。
如果一个尝试从栈上pop一个值引起了non-canonical地址(Shawn: 64-bit下只允许访问canonical地址)的引用。

没有一个条件能被强制在内核空间里发生。无论如何,Intel白皮书里的iret伪代码展示了另外一种情况:when the segment defined by the return frame is not present:

IF stack segment is not present
THEN #SS(SS selector); FI;

所以在用户空间,我们需要设置ss寄存器为某个值来表示不存在。这不是很直接:

我们不能仅仅使用:

mov $nonpresent_segment_selector, %eax
mov %ax, %ss

第二条指令会引发#GP。通过调试器(任何ptrace)设置ss寄存器是不允许的;类似的,sys_sigreturn系统调用不会在64位系统上设置这个寄存器(可能32位能工作)。解决方案是:

1. 线程A:通过sys_modify_ldt系统调用在LDT里创建一个定制段X
2. 线程B:s:=X_selector
3. 线程A:通过sys_modify_ldt使X无效
4. 线程B:等待硬件中断

为什么需要在一个进程里使用两个线程的原因是从系统调用(包括sys_modify_ldt)返回是通过硬编码了#ss值的sysret指令。如果我们使X在相同的线程中无效就等同于"ss:=X 指令“,ss寄存器会处于未完成设置的状态。运行以上代码会导致内核panic。按照更有意义的做法,我们将需要控制用户空间的gs基地址;她可以通过系统调用arch_prctl(ARCH_SET_GS)被设置。

0x05 Achieving write primitive


如果运行以上代码,#SS处理程序会正常的返回bad_iret(意思是没有触及到内存的GS基地址),之后转跳到#GP异常处理程序,执行一段时间后就调用到了这个函数:

#!cpp
289 dotraplinkage void
290 do_general_protection(struct pt_regs *regs, long error_code)
291 {
292         struct task_struct *tsk;
...
306         tsk = current;
307         if (!user_mode(regs)) {
                ... it is not reached
317         }
318 
319         tsk->thread.error_code = error_code;
320         tsk->thread.trap_nr = X86_TRAP_GP;
321 
322         if (show_unhandled_signals && unhandled_signal(tsk, SIGSEGV) &&
323                         printk_ratelimit()) {
324                 pr_info("%s[%d] general protection ip:%lx sp:%lx
error:%lx",
325                         tsk->comm, task_pid_nr(tsk),
326                         regs->ip, regs->sp, error_code);
327                 print_vma_addr(" in ", regs->ip);
328                 pr_cont("\n");
329         }
330 
331         force_sig_info(SIGSEGV, SEND_SIG_PRIV, tsk);
332 exit:
333         exception_exit(prev_state);
334 }

C代码不太明显,但从gs前缀读取到现有宏的值赋给了tsk。第306行是:

#!bash
0xffffffff8164b79d :    mov    %gs:0xc780,%rbx

这很变得有意思起来了。我们控制了current指针,她指向用于描述整个Linux进程的数据结构。

319         tsk->thread.error_code = error_code;
320         tsk->thread.trap_nr = X86_TRAP_GP;

写入(从task_struct开始的固定偏移)我们控制的地址。注意值本身不能被控制(分别是0和0xd常量),但这不应该成为一个问题。游戏结束?

不会,我们想覆盖一些在X上的重要数据结构。如果我们按照以下的步骤:

1. 准备在FAKE_PERCPU的用户空间内存,设置gs基地址给她
2. 让地址FAKE_PERCPU+0xc780存着指针FAKE_CURRENT_WITH_OFFSET,以满足FAKE_CURRENT_WITH_OFFSET= X – offsetof(struct task_struct,thread.error_code)
3. 触发漏洞

之后do_general_protection会写入X。但很快就会尝试再次访问current task_current的其他成员,e.g.unhandled_signal()函数从task_struct指针解引用。我们没有依赖X来控制,最终会在内核产生一个页错误。我们怎么避免这个问题?选项有:

  1. 什么都不做。Linux内核不像Windows,Linux内核是完全允许当一个不是预期的页错误在内核出现,如果可能的话,内核会杀死当前进程之后尝试继续运行(Windows会蓝屏)。这种机制对于大量内核数据污染就无能为力了。我的猜测是在当前进程被杀死后,swapgs不平衡的保持下来,这会导致其他进程上下文的更多页错误。

  2. 使用“tsk->thread.error_code = error_code”覆盖为页错误处理程序的IDT入口。之后页错误发生(被unhandled_signal()触发)。这个技术曾经在一些偶然的环境中成功过。但在这里不会成功,因为有2个原因:

    • Linux让IDT只读
    • 就算IDT可写,我们也不能控制覆盖的值 -- 0或者0xd。SMEP/SMAP也会是问题。
  3. 我们可以尝试产生一个竞争。“tsk->thread.error_code = error_code”会促进代码执行,比如允许通过系统调用控制的代码指针P。之后我们可以在CPU 0上触发漏洞,在同一时间段CPU 1可以循环执行一些系统调用。这个思路可以在CPU 0被破坏前让通过CPU 1获得代码执行,比如hook页错误处理程序,这样CPU 0不会影响更多的地方,我尝试了这种方法多次,但都失败了。可能不同的漏洞在时间线上的不同所致。

  4. Throw a towel on “tsk->thread.error_code = error_code” write.

虽然有些恶心,我们会尝试最后一个选项。我们会让current指向用户空间,设置这个指针可以通过读的deref到我们能控制的内存。自然的,我们观察接下来的代码,找找更多的写deref。

0x06. Achieving write primitive continued, aka life after do_general_protection

下一个机会是do_general_protection()所调用的函数:

#!cpp
int
force_sig_info(int sig, struct siginfo *info, struct task_struct *t)
{
        unsigned long int flags;
        int ret, blocked, ignored;
        struct k_sigaction *action;

        spin_lock_irqsave(&t->sighand->siglock, flags);
        action = &t->sighand->action[sig-1];
        ignored = action->sa.sa_handler == SIG_IGN;
        blocked = sigismember(&t->blocked, sig);   
        if (blocked || ignored) {
                action->sa.sa_handler = SIG_DFL;
                if (blocked) {
                        sigdelset(&t->blocked, sig);
                        recalc_sigpending_and_wake(t);
                }
        }
        if (action->sa.sa_handler == SIG_DFL)
                t->signal->flags &= ~SIGNAL_UNKILLABLE;
        ret = specific_send_sig_info(sig, info, t);
        spin_unlock_irqrestore(&t->sighand->siglock, flags);

        return ret;
}

task_struct的成员sighand是一个指针,我们可以设置任意值。

action = &t->sighand->action[sig-1];
action->sa.sa_handler = SIG_DFL;

我们无法控制写的值,SIG_DFL是常量的0。这里最终能工作了,虽然有些扭曲。假设我们想覆盖内核地址X。为此我们准备伪造的task_struct,所以X等于t->sighand->action[sig-1].sa.sa_handler的地址。上面还有一行要注意:

#!cpp
spin_lock_irqsave(&t->sighand->siglock, flags);

t->sighand->siglock在t->sighand->action[sig-1].sa.sa_handler的常量偏移上,内核会调用spin_local_irqsave在某些地址上,X+SPINLOCK的内容无法控制。这会发生什么呢?两种可能性:

  1. X+SPINLOCK所在的内存地址看起来像没有锁的spinlock。spin_lock_irqsave会立即完成。最后,spin_unlock_irqrestore会撤销spin_lock_irqsave的写操作。

2.X+SPINLOCK所在的内存地址看起来像上锁的spinlock。如果我们不介入的话,spin_lock_irqsave会无线循环等待spinlock。有些担心,要绕过这个障碍我们得需要其他假设 ---|| X+SPINLOCK所在内存地址的内容。这是可接受的,我们可以在后面看到在内核.data区域里设置X。

* 首先,准备FAKE_CURRENT,让t->sighand->siglock指向用户空间上锁的区域,SPINLOCK_USERMODE
* force_sig_info()会挂在spin_lock_irqsave里
* 这时,另外一个用户空间的线程在另外一个CPU上运行,并且改变了t->sighand,所以t->sighand->action[sig-1.sa.sa_hander成了我们的覆盖目标,之后解锁SPINLOCK_USERMODE
* spin_lock_irqsave会返回
* force_sig_info()会重新载入t->sighand,执行期望的写操作

鼓励细心的读者追问为什么不能使用第2种方案,即X+SPINLOCK在初始时是没有锁的。这并不是全部 ---|| 我们需要准备一些FAKE_CURRENT的字段来让尽量少的代码执行。我不会再透露更多细节 ---|| 这篇BLOG已经够长了....下一步会发生什么?force_sig_info()和do_general_protection()返回。接下来iret指令会再次产生#SS异常处理(因为仍然是用户空间ss的值在栈上引用了一个nonpresent段),但这一次,#SS处理程序里的额外swapgs指令会返回并取消之前不正确的swapgs。 do_general_protection()会调用和操作真正的task_struct,而不是伪造的FAKE_CURRENT。最终,current会发出SIGSEGV信号,其他进程会被调度来执行。这个系统仍然是稳定的。

enter image description here

0x07 插曲:SMEP


SMEP是Intel处理器从第3代Core(Shawn:酷睿)时加入的硬件特性。如果控制寄存器CR4里的SMEP位被设置的话,当RING0(Shawn:标准Linux内核是RING0,在XEN下是例外,RING0是Hypervisor)尝试执行的代码来自标记为用户空间的内存页,CPU就会生成一个错误(Shawn:就是拒绝)。如果可能的话,Linux内核会默认开启SMEP。

0x08 实现代码执行


之前的章节讲述了一种如何以0在内核内存中覆盖8个连续字节的方法。如果SMEP开启的情况下如何实现代码执行呢?

直接覆盖一个内核代码的指针是不行的。我们可以清零top bytes( Shawn: MSB)- 但之后的地址会在用户空间,所以SMEP会阻止这个指针的deref。

换一种方式,我们可以清零几个low bytes( Shawn: LSB),但是之后能利用这个指针的概率也很低。

我们需要一个内核指针P指向结构X包含了代码指针。我们可以覆盖P的top bytes让她成为一个用户空间的地址,这样P->code_pointer_in_x()调用会跳转到一个我们能选择的地址。我不确定最好选择哪个攻击对象。从我的经验来看,我选择内核proc_root变量,这是一个结构体:

#!cpp
struct proc_dir_entry {
            ...
        const struct inode_operations *proc_iops;
        const struct file_operations *proc_fops;
        struct proc_dir_entry *next, *parent, *subdir;
        ...
        u8 namelen;
        char name[];
};

这个结构体是一个proc文件系统的入口(proc_root是/proc作为proc文件系统的根目录)。当一个文件名路径开始在/proc里查询时,subdir指针(从proc_root.subdir开始)会跟进,直到名字被找到。之后proc_iops的指针会被调用:

#!cpp
struct inode_operations {
        struct dentry * (*lookup) (struct inode *,struct dentry *, unsigned int);
        void * (*follow_link) (struct dentry *, struct nameidata *);
        ...many more...
        int (*update_time)(struct inode *, struct timespec *, int);
        ...
} ____cacheline_aligned;

proc_root驻扎在内核代码段里,这意味着漏洞利用需要知道她的地址。这个信息可以从/proc/kallsyms符号表得到;当然,很多加固过的内核不允许普通用户读取这个文件。但如果内核是一个已知的build(标准的GNU/Linux发行版),这个地址可以轻松获得;和一堆偏移一样需要构建FAKE_CURRENT。

我们会覆盖proc_root.subdir,让她成为一个指向一个在用户空间能被控制的结构体proc_dir_entry。有点困难在于我们不能覆盖整个指针。别忘了我们的写操作是“覆盖8个0”。如果我们让proc_root.subdir变成0,我们不会去映射她,因为Linux内核不允许用户空间映射到地址0上(更确切的说发是,任何低于/proc/sys/vm/mmap_min_addr的地址,默认值一般是4k)。(Shawn:想想哪些0ld good hacking days,每天都有一堆NULL pointer deref是多么幸福活着无挑战的时光啊;-))。这意味着我们需要:

1. 映射16MB的内存到地址4096
2. 使用类似proc_dir_entry的方式来填充,把inode_operations字段指向用户空
间的地址FAKE_IOPS,name字段为字符串"A"。
3. 配置漏洞利用去覆盖proc_root.subdir的top 5 bytes。

之后,除非proc_root.subdir最低的3 bytes是0,我们可以确定在触发force_sig_info()覆盖后,proc_root.subdir会指向被控制的用户空间内存。当我们的进程调用open("/proc/A",...)时,FAKE_IOPS的指针会被调用。她们应该指向哪里呢?如果你认为答案是“指向我们的shellcode“,请再读一遍上面的分析。

我们需要让FAKE_IOPS指针指向一个stack pivot1序列。这再次假设了具体内核运行的版本情况。通常的"xchg %esp, %eax; ret"代码序列(2个字节,94 c3是在测试内核的地址0xffffffff8119f1ed)很好的可以用于64位内核的ROP。就算没能控制%rax,这个xchg指令操作32位的寄存器也能清掉%rsp的高32位而让%rsp着陆在用户空间的内存里。在最糟糕的情况下,我们可以分配低4GB的虚拟内存然后填充ROP链条。

在当前测试的内核(Fedora 20)有两种方法去deref在FAKE_IOPS的指针:

1. %rax:=FAKE_IOPS; call *SOME_OFFSET(%rax)
2. %rax:=FAKE_IOPS; %rax:=SOME_OFFSET(%rax); call *%rax 

第1种情况里,在%rsp和%rax交换值后,她会等于FAKE_IOPS。我们需要ROP链条驻扎在FAKE_IOPS的起始位置,这需要类似“add $A_LOT, %rsp; ret”的指令,然后在继续。

第2种情况里,%rsp会分配低32位的调用目标,即0x8119f1ed。我们需要准备在这个地址上的ROP链条。

计算一下%rax值有两者之一的已知值在特定的时间指向stack pivot序列,我们不需要ROP链条填充整个4GB内存,只需要上面的两个地址即可。第2种情况的ROP链条自身很简洁:

#!bash
unsigned long *stack=0x8119f1ed;
*stack++=0xffffffff81307bcdULL;  // pop rdi, ret
*stack++=0x407e0;                //cr4 with smep bit cleared
*stack++=0xffffffff8104c394ULL;  // mov rdi, cr4; pop %rbp; ret
*stack++=0xaabbccdd;             // placeholder for rbp
*stack++=actual_shellcode_in_usermode_pages;

0x09 插曲:SMAP


SMAP是Intel从第5代Core处理器推出的一个硬件特性。如果CR4控制寄存器的SMAP位被设置的话,CPU会拒绝用户空间的页被RING0访问(Shawn:个人理解,SMAP和SMEP最大的不同主要是SMEP针对代码段,而SMAP针对数据段)。Linux内核通常会默认开启SMAP。一个测试的内核模块(Core-M 5Y10a CPU)尝试访问用户空间然后crash了:

#!bash
[  314.099024] running with cr4=0x3407e0
[  389.885318] BUG: unable to handle kernel paging request at 00007f9d87670000
[  389.885455] IP: [ffffffffa0832029] test_write_proc+0x29/0x50 [smaptest]
[  389.885577] PGD 427cf067 PUD 42b22067 PMD 41ef3067 PTE 80000000408f9867
[  389.887253] Code: 48 8b 33 48 c7 c7 3f 30 83 a0 31 c0 e8 21 c1 f0 e0 44 89 e0 48 8b 

正如我们看到的,用户空间的页是正常的,但访问也报了页错误。Windows系统不太支持SMAP;Windows 10技术预览版build 9926的cr4=0x1506f8(SMEP启动,SMAP关闭);对比Linux内核(同样的测试硬件)你可以看到cr4的bit 21是没有设置的。这不奇怪,在Linux中,访问用户空间是通过调用copy_from_user(),copy_to_user()和类似函数显式执行的,所以执行这些操作时临时关闭SMAP是可行的。在Windows上,内核代码直接访问用户空间代码,只是包装了一层访问异常处理程序,所以要让SMAP工作正常需要调整所有的驱动,这是一项困难的工作。

0x0A SMAP to the rescue!


上面的漏洞利用方法依赖于在用户空间里准备特定的数据结构,然后强制内核认为她们是可信的内核数据。这种方法对于开启SMAP特性的内核不奏效 ---|| CPU会拒绝从用户空间读取恶意数据。我们能做的是构造所有需要用的数据结构,然后拷贝她们到内核。比如:

#!cpp
write(pipe_filedescriptor, evil_data, ...

之后evil_data会被拷贝到一个内核管道缓冲区里。我们可能需要猜测她的地址; some sort of heap spraying, combined with the fact that there is no spoon^W effective kernel ASLR[9], could work, although it is likely to be less reliable than exploitation without SMAP.

总之,还有最后一个障碍 ---|| 不要忘了我们需要设置用户空间的gs base去指向我们的漏洞利用的数据结构。在上面的场景(没有SMAP),我们使用arch_prctl(ARCH_SET_GS)系统调用,她是这样在内核里实现的:

#!bash
long do_arch_prctl(struct task_struct *task, int code, unsigned long addr)
{ 
         int ret = 0; 
         int doit = task == current;
         int cpu;

         switch (code) { 
         case ARCH_SET_GS:
                 if (addr >= TASK_SIZE_OF(task))
                         return -EPERM; 
                 ... honour the request otherwise

休斯顿,我们有一个麻烦 ---|| 我们不能使用这个API去设置gs base用户空间以上的内存!

最近的CPU有wrgsbase指令可以直接设置gs base,这是一个非特权级指令,但需要通过内核设置CR4控制寄存器中的FSGSBASE bit( no 16)来开启。Linux并没有设置这个位,因此用户空间不能使用这条指令。

在64位系统上,非系统级的GDT和LDT条目依然是8个字节长,base field是最大4GB-1,所以根本没有机会设置一个基地址的段在内核空间里。所以,除非我漏掉了能在内核里设置用户态gs base的其他方法,不然SMAP能保护CVE-2014-9322针对64位Linux内核任意代码执行的漏洞利用。

1 CVE-2014-9322 http://cve.mitre.org/cgi-bin/cvename.cgi?name=CVE-2014-9322

2 Upstream fix http://git.kernel.org/cgit/linux/kernel/git/torvalds/linux.git/commit/?id=6f442be2fb22be02cafa606f1769fa1e6f894441

3 Intel Software Developer’s Manuals, http://www.intel.com/content/www/us/en/processors/architectures-software-developer-manuals.html

[4] SMEP http://vulnfactory.org/blog/2011/06/05/smep-what-is-it-and-how-to-beat-it-on-linux/

[5] SMAP http://lwn.net/Articles/517475

[6] "pretend-it-was-#GP-in-userspace" https://lists.debian.org/debian-kernel/2014/12/msg00083.html

[7] Stack Pivoting https://trailofbits.files.wordpress.com/2010/04/practical-rop.pdf

[8] TSX improves timing attacks against KASLR http://labs.bromium.com/2014/10/27/tsx-improves-timing-attacks-against-kaslr/

©乌云知识库版权所有 未经许可 禁止转载


感谢知乎授权页面模版