Quantcast
Channel: CodeSection,代码区,网络安全 - CodeSec
Viewing all articles
Browse latest Browse all 12749

四两拨千斤 ―― Ubuntu kernel eBPF 0day分析

$
0
0

中国武术博大精深,其中太极作为不以拙力胜人的功夫备受推崇。同样如果从攻击的角度窥视漏洞领域,也不难看出攻防之间的博弈不乏“太极”的身影,轻巧稳定易利用的漏洞与工具往往更吸引黑客,今天笔者要着墨分析的就是这样一个擅长“四两拨千斤”的0day漏洞。

0day漏洞的攻击威力想必大家都听说过,内核0day更因为其影响范围广,修复周期长而备受攻击者的青睐。近期,国外安全研究者Vitaly Nikolenko在twitter[1]上公布了一个Ubuntu 16.04的内核0day利用代码[2],攻击者可以无门槛的直接利用该代码拿到Ubuntu的最高权限(root);虽然只影响特定版本,但鉴于Ubuntu在全球拥有大量用户,尤其是公有云用户,所以该漏洞对企业和个人用户还是有不小的风险。

笔者对该漏洞进行了技术分析,不管从漏洞原因还是利用技术看,都相当有代表性,是Data-Oriented Attacks在linux内核上的一个典型应用。仅利用传入的精心构造的数据即可控制程序流程,达到攻击目的,完全绕过现有的一些内存防护措施,有着“四两拨千斤”的效果 。

0x02 漏洞原因

这个漏洞存在于Linux内核的eBPF模块,我们先来简单了解下eBPF。

eBPF(extended Berkeley Packet Filter)是内核源自于BPF的一套包过滤机制,严格来说,eBPF的功能已经不仅仅局限于网络包过滤,利用它可以实现kernel tracing,tracfic control,应用性能监控等强大功能。为了实现如此强大的功能,eBPF提供了一套类RISC指令集,并实现了该指令集的虚拟机,使用者通过内核API向eBPF提交指令代码来完成特定的功能。

看到这里,有经验的安全研究者可能会想到,能向内核提交可控的指令代码去执行,很可能会带来安全问题。事实也确实如此,历史上BPF存在大量漏洞[3]。关于eBPF的更多细节,可以参考这里[4][5]。

eBPF在设计时当然也考虑了安全问题,它在内核中实现了一套verifier机制,过滤不合规的eBPF代码。然而这次的漏洞就出在eBPF的verifier机制。

从最初Vitaly Nikolenko公布的补丁截图,我们初步判断该漏洞很有可能和CVE-2017-16995是同一个漏洞洞[6],但随后有2个疑问:

1.CVE-2017-16995在去年12月份,内核4.9和4.14及后续版本已经修复,为何Ubuntu使用的4.4版本没有修复?

2.CVE-2017-16995是Google Project Zero团队的Jann Horn发现的eBPF漏洞,存在于内核4.9和4.14版本[7],作者在漏洞报告中对漏洞原因只有简短的描述,跟本次的漏洞是否完全相同? 注:笔者所有的代码分析及调试均基于Ubuntu 14.04,内核版本为4.4.0-31-generic #50~14.04.1-Ubuntu[8]。

先来回答第二个问题,中间的调试分析过程在此不表。

参考以下代码,eBPF的verifer代码(kernel/bpf/verifier.c)中会对ALU指令进行检查(check_alu_op),该段代码最后一个else分支检查的指令是:

1.BPF_ALU64|BPF_MOV|BPF_K,把64位立即数赋值给目的寄存器;

2.BPF_ALU|BPF_MOV|BPF_K,把32位立即数赋值给目的寄存器;

但这里并没有对2条指令进行区分,直接把用户指令中的立即数insn->imm赋值给了目的寄存器,insn->imm和目的寄存器的类型是integer,这个操作会有什么影响呢?


四两拨千斤 ―― Ubuntu kernel eBPF 0day分析

我们再来看下,eBPF运行时代码(kernel/bpf/core.c),对这2条指令的解释是怎样的(__bpf_prog_run)。

参考以下代码,上面2条ALU指令分别对应ALU_MOV_K和ALU64_MOV_K,可以看出verifier和eBPF运行时代码对于2条指令的语义解释并不一样,DST是64bit寄存器,因此ALU_MOV_K得到的是一个32bit unsigned integer,而ALU64_MOV_K会对imm进行sign extension,得到一个signed 64bit integer。


四两拨千斤 ―― Ubuntu kernel eBPF 0day分析

至此,我们大概知道漏洞的原因,这个逻辑与CVE-2017-16995基本一致,虽然代码细节上有些不同(内核4.9和4.14对verifier进行了较大调整)。但这里的语义不一致又会造成什么影响?

我们再来看下vefier中以下代码(check_cond_jmp_op),这段代码是对BPF_JMP|BPF_JNE|BPF_IMM指令进行检查,这条指令的语义是:如果目的寄存器立即数==指令的立即数(insn->imm),程序继续执行,否则执行pc+off处的指令;注意判断立即数相等的条件,因为前面ALU指令对32bit和64bit integer不加区分,不论imm是否有符号,在这里都是相等的。


四两拨千斤 ―― Ubuntu kernel eBPF 0day分析

再看下eBPF运行时对BPF_JMP|BPF_JNE|BPF_IMM指令的解释(__bpf_prog_run),显然当imm为有符合和无符号时,因为sign extension,DST!=IMM结果是不一样的。


四两拨千斤 ―― Ubuntu kernel eBPF 0day分析

注意这是条跳转指令,这里的语义不一致后果就比较直观了,相当于我们可以通过ALU指令的立即数,控制跳转指令的逻辑。这个想象空间就比较大了,也是后面漏洞利用的基础,比如可以控制eBPF程序完全绕过verifier机制的检查,直接在运行时执行恶意代码。

值得一提的是,虽然这个漏洞的原因和CVE-2017-16995基本一样,但但控制跳转指令的思路和CVE-2017-16995中Jann Horn给的POC思路并不一样。感兴趣的读者可以分析下,CVE-2017-16995中POC,因为ALU sign extension的缺陷,导致eBPF中对指针的操作会计算不正确,从而绕过verifier的指针检查,最终读写任意kernel内存。但这种利用方法,在4.4的内核中是行不通的,因为4.4内核的eBPF不允许对指针类型进行ALU运算。

到这里,我们回过头来看下第一个问题,既然漏洞原因一致,为什么Ubuntu 4.4的内核没有修复该漏洞呢?

和Linux kernel的开发模式有关。

Linux kernel分mainline,stable,longterm 3种版本[9],一般安全问题都会在mainline中修复,但对于longterm,仅会选择重要的安全补丁进行backport,因此可能会出现,对某个漏洞不重视或判断有误,导致该漏洞仍然存在于longterm版本中,比如本次的4.4 longterm,最初Jann Horn并没有在报告中提到影响4.9以下的版本。 关于Linux kernel对longterm版本的维护,争论由来已久[10],社区主流意见是建议用户使用最新版本。但各个发行版(比如Ubuntu)出于稳定性及开发成本考虑,一般选择longterm版本作为base,自行维护一套kernel。

对于嵌入式系统,这个问题更严重,大量厂商代码导致内核升级的风险及成本都远高于backport安全补丁,因此大部分嵌入式系统至今也都在使用比较老的longterm版本。比如Google Android在去年Pixel /Pixel XL 2发布时,内核版本才从3.18升级到4.4,原因也许是3.18已经进入EOL了(End of Life),也就是社区要宣布3.18进入死亡期了,后续不会在backport安全补丁到3.18,而最新的mainline版本已经到了4.16。笔者去年也在Android kernel中发现了一个未修复的历史漏洞(已报告给google并修复),但upstream在2年前就修复了。

而Vitaly Nikolenko可能是基于CVE-2017-16995的报告,在4.4版本中发现存在类似漏洞,并找到了一个种更通用的利用方法(控制跳转指令)。

0x03 漏洞利用

根据上一节对漏洞原因的分析,我们利用漏洞绕过eBPF verifier机制后,就可以执行任意eBPF支持的指令,当然最直接的就是读写任意内存。漏洞利用步骤如下:

1.构造eBPF指令,利用ALU指令缺陷,绕过eBPF verifier机制;

2.构造eBPF指令,读取内核栈基址;

3.根据泄漏的SP地址,继续构造eBPF指令,读取task_struct地址,进而得到task_struct->cred地址;

4.构造eBPF指令,覆写cred->uid, cred->gid为0,完成提权。

漏洞利用的核心,在于精心构造的恶意eBPF指令,这段指令在Vitaly Nikolenko的exp中是16机制字符串(char *__prog),并不直观,笔者为了方便,写了个小工具,把这些指令还原成比较友好的形式,当然也可以利用eBPF的调试机制,在内核log中打印出eBPF指令的可读形式。

我们来看下这段eBPF程序,共41条指令(笔者写的小工具的输出):

parsing eBPF prog, size 328, len 41
ins 0: code(b4) alu | = | imm, dst_reg 9, src_reg 0, off 0, imm ffffffff
ins 1: code(55) jmp | != | imm, dst_reg 9, src_reg 0, off 2, imm ffffffff
ins 2: code(b7) alu64 | = | imm, dst_reg 0, src_reg 0, off 0, imm 0
ins 3: code(95) jmp | exit | imm, dst_reg 0, src_reg 0, off 0, imm 0
ins 4: code(18) ld | BPF_IMM | u64, dst_reg 9, src_reg 1, off 0, imm 3
ins 5: code(00) ld | BPF_IMM | u32, dst_reg 0, src_reg 0, off 0, imm 0
ins 6: code(bf) alu64 | = | src_reg, dst_reg 1, src_reg 9, off 0, imm 0
ins 7: code(bf) alu64 | = | src_reg, dst_reg 2, src_reg a, off 0, imm 0
ins 8: code(07) alu64 | += | imm, dst_reg 2, src_reg 0, off 0, imm fffffffc
ins 9: code(62) st | BPF_MEM | u32, dst_reg a, src_reg 0, off fffffffc, imm 0
ins 10: code(85) jmp | call | imm, dst_reg 0, src_reg 0, off 0, imm 1
ins 11: code(55) jmp | != | imm, dst_reg 0, src_reg 0, off 1, imm 0
ins 12: code(95) jmp | exit | imm, dst_reg 0, src_reg 0, off 0, imm 0
ins 13: code(79) ldx | BPF_MEM | u64, dst_reg 6, src_reg 0, off 0, imm 0
ins 14: code(bf) alu64 | = | src_reg, dst_reg 1, src_reg 9, off 0, imm 0
ins 15: code(bf) alu64 | = | src_reg, dst_reg 2, src_reg a, off 0, imm 0
ins 16: code(07) alu64 | += | imm, dst_reg 2, src_reg 0, off 0, imm fffffffc
ins 17: code(62) st | BPF_MEM | u32, dst_reg a, src_reg 0, off fffffffc, imm 1
ins 18: code(85) jmp | call | imm, dst_reg 0, src_reg 0, off 0, imm 1
ins 19: code(55) jmp | != | imm, dst_reg 0, src_reg 0, off 1, imm 0
ins 20: code(95) jmp | exit | imm, dst_reg 0, src_reg 0, off 0, imm 0
ins 21: code(79) ldx | BPF_MEM | u64, dst_reg 7, src_reg 0, off 0, imm 0
ins 22: code(bf) alu64 | = | src_reg, dst_reg 1, src_reg 9, off 0, imm 0
ins 23: code(bf) alu64 | = | src_reg, dst_reg 2, src_reg a, off 0, imm 0
ins 24: code(07) alu64 | += | imm, dst_reg 2, src_reg 0, off 0, imm fffffffc
ins 25: code(62) st | BPF_MEM | u32, dst_reg a, src_reg 0, off fffffffc, imm 2
ins 26: code(85) jmp | call | imm, dst_reg 0, src_reg 0, off 0, imm 1
ins 27: code(55) jmp | != | imm, dst_reg 0, src_reg 0, off 1, imm 0
ins 28: code(95) jmp | exit | imm, dst_reg 0, src_reg 0, off 0, imm 0
ins 29: code(79) ldx | BPF_MEM | u64, dst_reg 8, src_reg 0, off 0, imm 0
ins 30: code(bf) alu64 | = | src_reg, dst_reg 2, src_reg 0, off 0, imm 0
ins 31: code(b7) alu64 | = | imm, dst_reg 0, src_reg 0, off 0, imm 0
ins 32: code(55) jmp | != | imm, dst_reg 6, src_reg 0, off 3, imm 0
ins 33: code(79) ldx | BPF_MEM | u64, dst_reg 3, src_reg 7, off 0, imm 0
ins 34: code(7b) stx | BPF_MEM | u64, dst_reg 2, src_reg 3, off 0, imm 0
ins 35: code(95) jmp | exit | imm, dst_reg 0, src_reg 0, off 0, imm 0
ins 36: code(55) jmp | != | imm, dst_reg 6, src_reg 0, off 2, imm 1
ins 37: code(7b) stx | BPF_MEM | u64, dst_reg 2, src_reg a, off 0, imm 0
ins 38: code(95) jmp | exit | imm, dst_reg 0, src_reg 0, off 0, imm 0
ins 39: code(7b) stx | BPF_MEM | u64, dst_reg 7, src_reg 8, off 0, imm 0
ins 40: code(95) jmp | exit | imm, dst_reg 0, src_reg 0, off 0, imm 0
parsed 41 ins, total 41 稍微解释下,ins 0 和 ins 1 一起完成了绕过eBPF verifier机制。ins 0指令后,regs[9] = 0xffffffff,但在verifier中,regs[9].imm = -1,当执行ins 1时,jmp指令判断regs[9] == 0xffffffff,注意regs[9]是64bit integer,因为sign extension,regs[9] == 0xffffffff结果为false,eBPF跳过2(off)条指令,继续往下执行;而在verifier中,jmp指令的regs[9].imm == insn->imm结果为true,程序走另一个分支,会执行ins 3 jmp|exit指令,导致verifier认为程序已结束,不会去检查其余的dead cod

Viewing all articles
Browse latest Browse all 12749

Trending Articles