题目内容
题目该给的都给了:一个内核,一个config,一个文件系统,一个启动脚本,还有一个告诉我们如何自己手动配置环境(估计是自己build可以调试)的readme。
1 | ## How to build your own kernel |
这道题并没有什么内核模块,题目的本质就是一个patch.这个patch对linux内核的一些核心功能做了一点修改。启动参数:
1 | qemu-system-x86_64 \ |
可以看到,开了kptr,当时咨询了内核大手子,根据config来看基本绕过这个bpf直接提权是不现实了。另外关闭了kaslr,我们的利用路径变得简单了一点,在没有泄露原语的情况下直接rop应该就可以。
aliyunctf.patch
patch的内容放在这里:
1 | diff --color -ruN origin/include/linux/bpf.h aliyunctf/include/linux/bpf.h |
能看到它注册了一些新的结构体和函数,并且似乎提供了一次内核空间写入的机会。那我们怎么用呢?下面就得看下ebpf是什么了。
ebpf是什么?
ebpf,全称extended Berkeley Packet Filter,是从bpf进化而来的,一个在linux内核中运行的“虚拟机”,它能访问内核功能和内存的子集,在所有的发行版中都默认开启。
原理?
这个东西比较独特,有些类似java(对没有学过java的本人来说第一次见理解确实有难度)。整个bpf虚拟机有自己的指令集,用户态用编译器将自己编写的C程序编译成.bpf
字节码程序,然后这个东西会用bpf调用加载入内核,经过一次安全check之后(kernel/bpf/verifier.c),用jit编译成真正的可执行机器码,并且hook到一些内核的运行路径上。这些代码的数据会写入ringbuffers或者ebpf单独管理的kv maps,用户空间从中读取结果。
辅助函数-漏洞的本质
ebpf提供了一套可以扩展功能的模式,通过struct bpf_func_proto
描述定义。
这些辅助函数的逻辑是由编写者自定义的,这些辅助函数的C代码可以在ebpf中去调用————不出意外的话就要出意外了。首先,所有的检查都是针对bpf字节码的,对于内存的权限控制是由虚拟机管理而非操作系统,那么就产生了这道题。通过辅助函数的恶意操作写入一些ebpf中原本认为只读的区域(C代码写,不会被check),在完成后ebpf就不会对原先只读的区域再check,造成UAF或更大的危险。换言之,bpf的检查器跟踪不到辅助函数的内部逻辑,只会根据我们告诉它的proto在bpf程序真正执行前做check。这道题用一个evil patch欺骗了bpf的verifier,就达到了攻击的目的。
利用过程
在patch中定义了一个辅助函数aliyunctf_xor
,接收三个参数,一个buf,buf_len还有一个64位的指针res。函数内会将传入的buffer内的8字节和2025做异或,然后写入第三参数的位置。乍一看似乎是一个8字节的任意地址写,但是在proto中,arg3被声明为了只读。
漏洞利用:ebpf指令编写
ebpf的汇编字节码是基于risc指令集的。linux的example里有一个用宏打包好的,可读性强一点的能让我们在C中编写ebpf汇编的头文件.
r0返回值,r1-5传参,r6-9通用,r10只读堆栈寄存器。8bit的操作码(5-3 末尾3bit表示指令类型,访存/运算/跳转,高5位表示一个类型下面的不同的具体指令,划分方法也不同),关于指令格式和操作方法的内容,可以参考这里
这些内容被送到内核后会被编译成可以实际运行的本机架构指令放在一个内核地址空间中。
官方wp做了什么?
我们倒着看,最后是一个简单的ROP。先看最后的ROP做了什么:
1 | // Prepare data for ROP chain |
最后只是用了syscall(bpf),加载了一段内容,标记为了PROG_TEST_RUN,这个标记是允许不hook任何执行流来执行bpf代码,这里将attr.test.prog_fd配置成了上文中自己写的一段bpf代码,将attr.test.data_in配置成了在30偏移处含有rop链的一个buffer。
1 | pwndbg> vmmap-explore 0xffffffffc00006af |
在bpf_skb_load_bytes断点跟进,在第二个ret,也就是从bpf_skb_load_Bytes出来之后,触发了rop:
1 | 00:0000│ rsp 0xffffc90000213cd8 —▸ 0xffffffff8130d3de (fifo_open+206) ◂— 0xffffffff8130d3de |
那么现在的问题是,这个ret的执行流来自哪里?为什么这里会被写入?
我们做一个对比程序,把rop链注释掉来观察。首先是ebpf risc汇编代码究竟编译成了什么。
1 | // Setup evil bpf prog |
下面是所有的risc代码转成的机器码内容,它显然是存在一些编译优化和代码复用的机制在。但是我们能够看到,转x86_64之后变动不是太大,函数调用基本上也是call地址,这个test程序片段的退出最终也是靠的671处的ret指令。
1 | => 0xffffffffc0000670: endbr64 |
从bpf_test_run+351开始进入我们的程序。我们从bpf_test_run打断点开始跟踪,首先第一个点是这里:
1 | ► 0xffffffff81c513df <bpf_test_run+351> call 0xffffffff81f49480 <__x86_indirect_thunk_array> |
这里
然后他会用一个很巧妙的跳转来到JIT编译的代码处:
1 | 0xffffffff81f49480 <__x86_indirect_thunk_array> call 0xffffffff81f49486 <__x86_indirect_thunk_array+6> |
用一个mov [rsp] rax;ret
完成了一次更加合法的call rax
。而rax此时就是我们输入的代码位置了,从0xffffffffc0000670开始。到这里我们大概能明白这套东西的运作流程。
那它的栈帧又为什么会是刚刚指定的内容呢?
核心的bpf字节码程序
bpf字节码程序一共调用了三个函数,依次是bpf_map_lookup_elem
,aliyunctf_xor
和skb_load_bytes
。
整个exp是这样的:先生成了一个只读的map{0:1}。