漏洞描述
当执行执行指令序列“mov , ss/pop ss; int $n/syscall/sysenter”时,如果事先对“mov , ss/pop ss”访问的内存地址下了read write类型的硬件断点的话,那么#DB异常会被推迟到系统调用或int $n对应的中断向量的入口点的时候才发生。系统软件的开发人员可能会因为这个意外的执行序列错误地引用了非特权进程的上下文信息,进而导致潜在的安全漏洞。
具体安全漏洞的危害程度取决于不同的OS/PV hypervisor的实现,不过可以几乎确定的是,目前没有任何一个OS/PV hypervisor能够完全正确处理这种情况。
本地运行的非特权恶意代码可以利用本漏洞导致Linux内核crash,进而发动DoS攻击。
本地运行的非特权恶意代码可以利用本漏洞进行提权,导致Windows内核和XEN PV hypervisor执行任意代码。
另外,物理机内核和Guest内核均受到该漏洞的影响(且Guest环境比物理机环境的问题更加严重);除此以外,AMD处理器的syscall指令不会丢弃推迟的#DB,进而能够引起更严重的安全问题。
背景知识
从最初的8086开始,x86处理器的编程模型就引入了所谓的段的概念。为了保持兼容性,这个段的概念在今天最新的x86处理器上也依旧存在。
具体来说,在使用一个段之前,需要先加载段寄存器,然后再基于这个段所在的基地址+段内偏移的方式来引用内存。对于栈段这种特殊的段,需要系统软件事先设置好ss段寄存器和sp寄存器,才能确保应用软件能够使用栈来保存函数内的临时变量和函数返回地址等数据。
x86处理器加载ss段寄存器的方式有两种:
mov , ss将寄存器或指定内存地址中的值加载到ss段寄存器中。
pop ss将从栈顶弹出的值加载到ss段寄存器中。
然后系统软件可以用类似的方法在再加载sp寄存器,这样就完成整个栈的初始化。
假设存在这么一个场景:在执行加载ss段寄存器的指令期间发生了中断(即在执行加载sp寄存器的指令之前发生了中断),处理器会在ss:sp指定的位置上保存上下文。然而此时ss和sp是不匹配的,这种情况会导致处理器可能会向非法地址保存上下文数据,进而触发异常甚至系统崩溃;即使不是非法地址,也可能会破坏合法地址上的内容,导致系统运行异常。究其原因,还是因为建立栈的过程不是一个原子操作,因为需要两条指令才能完成。虽然后来也设计了专门的指令lss可以一次性同时加载ss和sp寄存器,但是出于兼容性的考量,还是不能保证所有软件都不再使用“mov/pop ss + mov/pop sp”的指令序列来建立栈;另外就算系统软件主动在执行这个指令序列前关闭中断,但这个指令序列仍旧可能会被NMI、machine check和异常打断。
为此,Intel和AMD针对这个代码序列提供了一种特殊的保障机制:在执行move/pop ss指令时,会禁止中断、部分异常和NMI,并在执行完下一条指令后,恢复中断和NMI。也就是说,在move/pop ss指令与下一条指令之间,处理器会忽略所有的中断和NMI,以及部分异常。本质上,中断和NMI的处理会被推迟一个指令,但异常的处理却变得非常复杂:
施加在下一条指令上的指令断点会被忽略,无法触发#DB。
对下一条指令的单步调试会被忽略,无法触发#DB。
施加在mov/pop ss指令上的数据断点(从内存中加载ss的情况)所引发的#DB会被推迟到下一条指令运行结束后。
对系统软件来说,这个机制确保了加载ss和sp的指令序列形成了一个原子操作。
漏洞原理
在mov , ss/pop ss指令之后执行的int $n,syscall,sysenter等引起处理器进入CPL 0的指令执行时,被推迟的#DB异常会在处理器进入CPL 0但在执行任何指令前触发。操作系统开发人员可能没有考虑过这种特殊的执行序列,进而可能会引起一些非预期的结果。
注意:对于int $n的情况,能够利用的n值必须是能够通过DPL检查的,因此并不是任何软中断都能成功利用本漏洞。
mov/pop ss + int $n
假设攻击者构造了如下指令序列:
mov (%rax), %ss//指令1
int3//指令2
再假设攻击者已经事先在指令1上下了一个数据断点。假设指令1不是“mov/pop ss”指令的话,当执行到指令1时,#DB异常会生在指令1的边界上(即指令1与指令2的间隙处)。但如果指令1是“mov/pop ss”指令的话,指令1上的数据断点所触发的#DB异常会被推迟到int3指令之后。由于int3指令又会触发#BP异常进而跳转到#BP异常处理函数的入口点处,因此#DB异常实际发生的时机是在执行#BP异常处理函数的第一条指令之前的指令边界处。
Linux内核漏洞原理
下面以3.10内核的#BP异常处理函数入口点的反汇编代码为例(参考对应的源码arch/x86/kernel/entry_64.S):
pushq$-1
sub$0x78, %rsp
callq save_paranoid
mov%rsp,%rd
xor%esi,%esi
subq$0x1000,%gs:0x11d34
callqdo_int3
addq$0x1000,%gs:0x11d34
jmpqparanoid_exit
在触发#BP时,由于内核事先在初始化阶段将#BP设置为使用IST(即DEBUG_STACK,使用IST 3作为内核栈):
/* int3 can be called from all */
set_system_intr_gate_ist(X86_TRAP_BP, &int3, DEBUG_STACK);
因此被#BP打断的用户态上下文会被处理器自动保存在IST指定的专用内核栈上。
由于延迟的#DB异常发生在#BP异常的入口点处,所以#BP异常入口点的代码还没有来得及执行,处理器立即就跳转到了#DB异常的入口点代码:
pushq$-1
sub $0x78, %rsp
callq save_paranoid
mov%rsp,%rd
xor%esi,%esi
subq$0x1000,%gs:0x11d34
callqdo_debug
addq$0x1000,%gs:0x11d34
jmpqparanoid_exit
这个时候其实已经出现了问题:由于内核事先在初始化阶段将#DB也设置为使用IST,而且使用的IST栈与#BP是相同的:
set_intr_gate_ist(X86_TRAP_DB, &debug, DEBUG_STACK);
这就意味着当#DB发生时,处理器会再次切到IST 3内核栈上,因此被#DB打断的#BP入口点的上下文会被处理器在进入#DB时自动保存的上下文给覆盖掉。当从#DB执行iret返回时,会返回到被打断的#BP的入口点。不过当从#BP返回时,由于被#BP打断的、返回到用户态的上下文被进入#DB时自动保存的上下文所覆盖,因此无法返回到用户态。分析一下IST 3栈的内容可知:
||
+--------+ @ IST 3栈底
|ss|
+--------+
|rsp|指向IST 3中rip的位置
+--------+
| rflags | IF为被清,中断被屏蔽
+--------+
|cs| RPL为
+--------+
|rip|指向#BP入口点
+--------+
||
从#BP返回时栈的情况,与从#DB返回到#BP时的情况完全一致,相当于进入#DB时的上下文完全没有被弹出,因此#BP返回等价于从#DB返回,即再次进入#BP。这个过程会一直执行下去,相当于当前处理器陷入了无限进入退出#BP的节奏。由于Linux内核在初始化的过程中将#BP和#DB设置为中断门,因此当进入到这些异常入口点的时候,中断已经被自动屏蔽了,相当于这个处于无限循环的过程还一直处于中断关闭的状态。
Windows内核漏洞原理
下面是版本为Windows 10.0.15063.608的ntoskrnl.exe的#BP向量入口点的反汇编代码:
// KiBreakpointTrapproc
subrsp ,8
pushrbp
subrsp ,158h
learbp , [ rsp +80h ]
mov[ rbp + TrapInfo.ExceptionActive ],1
mov[ rbp + TrapInfo._Rax],rax
mov[ rbp + TrapInfo._Rcx],rcx
mov[ rbp + TrapInfo._Rdx],rdx
mov[ rbp + TrapInfo._R8 ],r8
mov[ rbp + TrapInfo._R9 ],r9
mov[ rbp + TrapInfo._R10],r10
mov[ rbp + TrapInfo._R11],r11
testbyte ptr [ rbp +TrapInfo.SegCs ],1
jzshortExecutingInKernelModeContext
swapgs
movr10 ,gs : _KPCR.Prcb.CurrentThread
test[ r10 + _KTHREAD.Header.DebugActive ],80h
jzshortDebugIsActive
movecx ,0C0000102h
rdmsr
...
这里的代码会检查硬件自动保存的CS寄存器中的RPL。如果RPL为,说明该中断打断的是内核态代码,因此直接执行内核代码ExecutingInKernelModeContext;否则,说明打断是用户态代码,因此在访问任何内核态的数据前,先执行swapgs将gs寄存器切换为内核态的值,才能安全地访问内核态的全局变量_KPCR.Prcb.CurrentThread。
尽管这种安全检查是正确的,但是很容易会被该漏洞无情地利用。正如前面提到的,延迟的#DB会发生在执行“sub rsp , 8”之前,因此内核会立即进入到#DB入口点开始执行。可想而知,#DB入口点的代码会用上面类似的方法去判断被打断的上下文的RPL。但这一次内核误判了,因为被打断的上下文是#BP入口点,必然是内核态,因此#DB内的异常处理程序不会再执行swapgs指令,而是会使用用户态的gs寄存器来引用内核数据结构。如果攻击者事先在用户态巧妙构建gs.base指向的内容(比如TLS区域),在没有开启SMEP的情况下甚至能执行用户态的代码。
Windows和Linux内核的区别
Linux内核之所以遭受到的侵害要比Windows低,重点是在save_paranoid函数的实现上。以#DB入口点代码为例:
pushq$-1// #DB异常没有error code,用-1来填充orig_rax
sub $0x78, %rsp//为pt_regs中由内核保存的上下文分配栈空间
callq save_paranoid//保存pt_regs中由内核需要保存的的上下文,并通过ebx返回被打断的状态是是用户态还是内核态
mov%rsp,%rdi//构造do_debug函数的第一个参数
xor%esi,%esi//构造do_debug函数的第二个参数,表示没有错误码
subq$0x1000,%gs:0x11d34 // init_tss中记录的#DB异常对应的ist大小是8K,这里只用中间4K
callqdo_debug //调用#DB异常的真正的处理函数
addq$0x1000,%gs:0x11d34
jmpqparanoid_exit
:
cld
mov%rdi,0x78(%rsp)//保存pt_regs中由内核需要保存的的所有寄存器的值
mov%rsi,0x70(%rsp)
mov%rdx,0x68(%rsp)
mov%rcx,0x60(%rsp)
mov%rax,0x58(%rsp)
mov%r8,0x50(%rsp)
mov%r9,0x48(%rsp)
mov%r10,0x40(%rsp)
mov%r11,0x38(%rsp)
mov%rbx,0x30(%rsp)
mov%rbp,0x28(%rsp)
mov%r12,0x20(%rsp)
mov%r13,0x18(%rsp)
mov%r14,0x10(%rsp)
mov%r15,0x8(%rsp)
mov$0x1,%ebx//假定当前gs.base的值是内核态
mov$0xc0000101,%ecx//读取MSR IA32_GS_BASE的值
rdmsr
test%edx,%edx//判断当前的gs.base是内核态还是用户态
js1f
swapgs//将MSR IA32_KERNEL_GS_BASE设置为当前的gs.base
xor%ebx,%ebx//表示当前gs.base的值是用户态
1:
retq
save_paranoid除了保存上下文以外,另一个很重要的工作是检查被打断的上下文是来自于内核态还是用户态。与Windows内核不同,Linux内核并没有直接使用处理器自动保存的RPL来判断。相反,用的是MSR IA32_GS_BASE的值。这里涉及到一个背景知识:内核态的gs默认值位于irq_stack_union.gs_base,即per cpu的irq_stack的栈底位置,因此一定是一个符号位为1的值;而用户态代码没有办法直接将gs.base的值修改为符号位为1的值。而且用户态使用gs寄存器作为TLS指针,指向的总是用户态的地址,因此符号位为。这就是之所以能够用这个方法判断是否为内核态的依据。
之所以Linux内核会这样设计,是为了能够随时处理NMI。因此,Linux内核不会像Windows内核那样易受到任意代码执行威胁的影响。但是由于#DB和#BP共用了相同的IST,导致了死循环。
mov/pop ss + syscall
根据作者的文章描述,延迟的#DB异常还能够发生在AMD处理器的syscall快速系统调用入口点处,而Intel处理器则不受影响,这说明Intel处理器在快速系统调用入口点处丢弃了延迟的#DB。
再假设内核没有为#DB使用IST机制的话,则进入#DB时栈的情况如下:
+--------+
||进入系统调用入口点时的栈顶
+--------+
|ss|
+--------+
|rsp|指向“进入系统调用入口点时的栈顶”
+--------+
| rflags | IF为被清,中断被屏蔽
+--------+
|cs| RPL为
+--------+
|rip|指向系统调用入口点
+--------+
||
注意#DB此时使用的是用户栈,因为快速系统调用syscall指令不会切换栈,而紧随其来的#DB因为CPL没有发生变化的缘故进而也不会切栈,这就导致#DB异常处理程序不仅使用的用户栈的,而且RSP也指向了用户栈。目前这种情况不会发生在Linux系统,因为Linux内核总是为#DB使用IST。
PoC
我自己写了个PoC,放在了我的个人github(https://github.com/jiazhang0/pop-mov-ss-exploit)上。在没有打内核补丁的系统上,该PoC会因触发#DF而导致系统直接重启。
缓解、修复以及澄清
开启处理器的SMEP功能
开启SMEP后,能够在一定程度上阻止利用该漏洞执行用户态任意代码,尤其是Windows系统。
大多数Linux内核默认都开启了该功能,不过正如前文分析的那样,Linux内核的固有设计导致攻击者无法利用该漏洞执行任意代码,因此在Linux系统上开启SMEP对缓解本漏洞不是必须的,不过SMEP对缓解其他危险的漏洞还是非常有用的。
关闭内核对ptrace的支持
用户态代码是无法直接操作DBx寄存器的。正如PoC演示的那样,正因为Linux内核通过ptrace系统调用允许用户态恶意代码间接设置DBx寄存器进而利用该漏洞。
不过在很多情况下,关闭对ptrace的支持对很多系统来说可能并不可行。
使用IST机制
因为延迟的#DB能够打断mov/pop ss之后的那条指令所引发的CPL转换后的入口点代码,为了确保#DB异常处理程序在任何情况下都不会访问到用户栈,#DB异常必须使用IST机制以防止攻击者利用快速系统调用不切栈的特性;另外,也不要让#DB使用的IST与其他能够引发的CPL转换的指令所使用的IST相同。使用任务门也可以起到与IST相似的结果,只不过这种做法并不流行罢了,不过对于32位系统别无选择。
Linux内核修复
这个fix的修复思路很巧妙,只要让#BP和#DB向量不复用相同的IST就好了。不过IST本来就只支持7个,再加上IST机制提供的专用内核栈还是非常有用的,因此解决方案就变成了#BP不再使用IST。
Intel官方文档的澄清
Intel的官方编程手册在一开始确实没有明确地把这个mov/pop ss的副作用写得很清楚,而且有明显的疏漏。现在Intel终于把这个这个副作用写得很清楚了:
只不过这是在发生了如此严重的安全漏洞之后的事情了。
参考
OP SS/MOV SS Vulnerability (https://everdox.net/popss.pdf)
CVE-2018-8897 (https://cve.mitre.org/cgi-bin/cvename.cgi?name=CVE-2018-8897)
CVE-2018-8897: #DB exceptions that are deferred by MOV SS or POP SS may cause unexpected behavior (http://www.openwall.com/lists/oss-security/2018/05/08/4)
RedHat的安全公告 (https://access.redhat.com/security/cve/cve-2018-8897)
Ubuntu发布的安全公告 (https://usn.ubuntu.com/3641-2/)
Windows安全公告 (https://portal.msrc.microsoft.com/en-US/security-guidance/advisory/CVE-2018-8897)
XEN发布的安全公告 (https://xenbits.xen.org/xsa/advisory-260.html)
selftests/x86: Add mov_to_ss (https://patchwork.kernel.org/patch/10386677/)
领取专属 10元无门槛券
私享最新 技术干货