事件背景
3月初,国外安全研究人员披露了一个新的Android/Linux内核的高危漏洞,漏洞编号为CVE-2022-0847。由于类似大名鼎鼎的DirtyCOW(脏牛)漏洞,又被命名为DirtyPipe(脏管道)。漏洞从上游linux内核5.8版本的一个补丁引入,影响OPPO目前所有Android内核版本,包括云桌面、编译服务器、云服务器等linux内核操作系统,在旗舰机型Android12+kernel5.10上安全危害最大。经过我们内部研究发现,使用DirtyPipe结合利用技术甚至能够发挥出万花筒写轮眼般的实战效果。
漏洞分析
漏洞原理概述
Dirtypipe漏洞允许向任意可读文件中写数据,可造成非特权进程向root进程注入代码。该漏洞发生linux内核空间通过splice方式实现数据拷贝时,以"零拷贝"的形式(将文件缓存页作为pipe的buf页使用)将文件发送到pipe,并且没有初始化pipe缓存页管理数据结构的flag成员。若提前操作管道,将flag成员设置为PIPE_BUF_FLAG_CAN_MERGE,就会导致文件缓存页会在后续pipe通道中被当成普通pipe缓存页,进而被续写和篡改。在这种情况下内核并不会将这个缓存页判定为"脏页",不会刷新到磁盘。在原缓存页的有效期内所有访问该文件的场景都将使用被篡改的文件缓存页,而不会重新打开磁盘中的正确文件读取内容,因此达成一个"对任意可读文件任意写"的操作,即可完成本地提权。
漏洞补丁分析
splice函数通过调用copy_page_to_iter_pipe函数操作pipe管道实现内核空间数据拷贝功能。通过分析linux内核补丁,发现补丁增加了对copy_page_to_iter_pipe函数中buf->flags的初始化操作,因此推定Dirtypipe漏洞本质上为变量未初始化漏洞。
漏洞根因分析
1.管道(pipe)实现机制:
管道(pipe)是内核提供的一种通信机制,通过pipe/pipe2函数创建,返回两个文件描述符,一个用于发送数据,另一个用于接受数据,类似管道的两端。
在linux内核实现中,通常管道会缓存总长度65536字节,且用页的形式进行管理,总共16页(一页4096字节),页面之间并不连续,而是通过数组进行管理,形成一个环形结构。管道会维护两个指针,一个用来写管道头(pipe->head),一个用来读管道尾(pipe->tail),此处重点分析pipe_write函数。
2.pipe_write函数代码关键功能说明:
[1]如果当前管道(pipe)中不为空(head==tail判定为空管道),则说明现在管道中有未被读取的数据,则获取head指针,也就是指向最新的用来写的页,查看该页的len、offset(为了找到数据结尾)。接下来尝试在当前页面续写。
[2]判断当前页面是否带有PIPE_BUF_FLAG_CAN_MERGEflag标记,如果不存在则不允许在当前页面续写。或当前写入的数据拼接在之前的数据后面长度超过一页(即写入操作跨页),如果跨页,则无法续写。
[3]如果无法在上一页续写,则另起一页。
[4]alloc_page申请一个新的页。
[5]将新的页放在数组最前面(可能会替换掉原有页面),初始化页管理结构的相关成员。
[6]buf->flag默认初始化为PIPE_BUF_FLAG_CAN_MERGE,因为默认状态是允许pipe缓存页续写的。
漏洞利用的关键就是在splice中未初始化buf->flag标记,导致splice传送的文件缓存页在buf->flag为PIPE_BUF_FLAG_CAN_MERGE时被当成了普通pipe缓存页。
3.splice函数关键功能分析
在上文提到的pipe通过管理16个页来作为缓存,splice的零拷贝方法是直接用文件缓存页来替换pipe中的缓存页(更改pipe缓存页指针指向文件缓存页)。
基于对splice函数代码和调用栈关系分析发现splice函数通过调用copy_page_to_iter_pipe函数将pipe缓存页结构指向要传输的文件的文件缓存页。调用栈如下图:
4.copy_page_to_iter_pipe函数关键功能:
[1]首先根据pipe页数组环形结构,找到当前写指针(pipe->head)位置。
[2]将当前需要写入的页指向准备好的文件缓存页,并设置其他信息,比如buf->len是由splice系统调用的传入参数决定的,此处唯独没有初始化buf->flag
根据前面管道实现机制章节中对pipe_write的分析可知,如果重新调用pipe_write向pipe中写数据,写指针(pipe->head)指向刚传送的文件缓存页,且flag为PIPE_BUF_FLAG_CAN_MERGE时,则pipe_write在写入长度不跨页的前提下,会认为可以继续在该页写,这样本次写操作就写在了本不该写的文件缓存页,如下图代码所示。
kernel将打开的文件放到缓存页之中,在这个缓存页的生命周期内内访问同一个文件,都会操作相同的文件缓存页,而不是反复打开。通过上文写缓存页的方法篡改了目标文件缓存页(即便目标文件没有写权限),导致在接下来的一段时间内所有使用这个文件的进程都会访问被篡改的缓存页,从而完成短时间内对目标文件的写操作。
利用分析
联想到DirtyCOW漏洞的利用方式,在linux发行版中的常规思路是覆写su二进制可执行文件或者/etc/passwd而直接获取到root权限,可是在Android系统的release版本中没有su,/etc/passwd也形同虚设,还有selinux的严格访问限制,此路明显是不通的。
背景中介绍到,使用DirtyPipe结合利用技术甚至能够发挥出万花筒写轮眼般的实战效果。
通过漏洞原理,我们了解到DirtyPipe的利用先决条件也如火影中的写轮眼般,得先看到对方,所以我们的目标是当前权限域可读文件。当我们在linux偌大的文件系统中进行不断尝试利用思路时,也发现了一些血继限界般的存在,即使看到也无法进行利用,比如selinux的policy文件,不过这也不影响,通过系统内各种so文件足以让我们实现任意利用效果了!
利用基点
so文件特性
so文件是可执行文件,使用so的导出符号时是直接在so文件所在的代码段执行代码的,也就是说如果我们直接修改了so代码段所在的内存,所有修改都会在下一次使用这个so的这一段代码时即时生效。
so文件是只读的,而且读取权限很低,文件本身支持splice函数。
我们可以通过修改so的方式影响任意使用so的进程的流程,而系统中任意一个进程都需要使用我们能够访问的so,并且都支持splice函数,那么只要我们在代码段进行修改,并且不写每页的第一个字节,不增大文件,我们就可以随意控制so的代码,从而控制使用so的进程做出任何我们想要的行为。
写轮眼--复制Nday利用
N-day是指已经发现的历史漏洞,常见的有CVE-20xx-xxxx,历史漏洞如果没有得到修复,安全危害往往巨大。
1.xxxservice命令执行历史漏洞
xxxservice是我们自研的多媒体守护系统中底层的支持服务,属于root域,如果控制了xxxservice来执行命令,相当于是root用户执行命令。
根据实验室小伙伴提供的历史xxxservice漏洞,第三方可以调用系统服务接口对xxxservice进行命令注入,从而在root域执行命令。这个补丁的初步patch是对调用接口的用户进行鉴权,禁止低权限用户使用这个接口。这个补丁是加在so里面的。
2.利用流程
由于这个漏洞的效果就是直接修改内存中的只读区域,所以后续利用过程主要描述修改位置,修改内容和修改内存的触发方式。
这里我们只需要进行一次修改,将so文件改掉。我们将打了补丁的so使用ida进行逆向分析,找到目标函数android::xxxService::onTransact(),这个函数的初步修改是在switch之前添加鉴权,UID必须为音视频或者SYSTEM,或者有xxxService的访问权限,对应到汇编之中就是这样的代码。
我们看到在调用鉴权的函数之后,有一个TBNZ指令,这个指令的意思是检查W0`寄存器的#0位,如果不为0就说明鉴权成功,跳转到loc_74C8,否则失败就继续执行。
那么现在只要让TBNZ指令跳转之后再跳转回来,这个鉴权就相当于毫无用处了,结合到实际代码,就是在75C4这一行和上一行一样跳转回loc_74C8,为了操作简便,目标的汇编指令设计为TBZ W0, #0, loc_74C8,也就是为0是跳转,这样无论鉴权的结果如何,最终都会直接执行。
3.指令计算
TBZ指令介绍
这里的Rt表示目标寄存器,b5表示Rt寄存器为低32位还是64位,[b5:b40]一共六位表示检查寄存器哪一位,imm14是14位立即数,用来标识跳转多少条指令。
根据上面的汇编指令,我们使用低32位寄存器W0,b5为0,Rt为0,TBZ的op为0,检查#0所以b40位0,imm14是跳转指令数,由于指令都是4个字节,也就是跳转的地址偏移/4,计算方法是(目标地址-源地址)>>2,74C8-75C4得-FC,补码为FF04,右移2位是3FC1,组合之后指令为3607F820,小端序的实际存储形式为\x20\xF8\x07\x36。
4.利用结果
将这四个字节放到0x75C4,通过读取hook之后的so,确认这个鉴权已经无效。成功绕过了patch检查代码。
原xxxservice汇编代码:
修改后xxxservice汇编代码:
下面展示复现过程。
能够看到下图在打了鉴权的补丁之后第一次运行POC是被拦截的,我们可以看到正常使用这个命令是没有权限的。
下图在运行利用脚本之后,修改了libxxxservice.so,然后在运行POC,
这里能够看到命令注入的结果,成功以root权限执行了命令,在/data目录下创建和读取到了文件。
万花筒写轮眼--创造漏洞
1.通过修改代码段即时改变所有分支逻辑
在arm64架构中,我们的代码最终都会编译成汇编代码,常见的漏洞补丁、安全检测代码都是通过if条件语句进行判断,检测安全继续执行,否则的话则return错误。
而在汇编语言中,所有分支逻辑都是依靠跳转指令实现的,如bl、blx、blz、beq、bnq等。如果能够对跳转指令修改,我们则可以任意控制函数的执行流,比如bypass关键的if检测。刚才的回退补丁TBZ就是一个很好的例子。
通过DirtyPipe修改高权限系统服务so中的汇编指令,我们可以bypass其鉴权的逻辑,绕过关键的if检测,创造出该系统服务中的新漏洞。
如下图中跳过检测,直接到达目标代码。
2.实现方案
找到要修改的目标so,反编译出结果,之后找到对应的逻辑设计汇编指令就可以,所有的限制就只有不能写每页第一个字节和不能在代码段范围之外写,但是对于程序而言,patch代码的位置本身可以是多变的,基本可以说没有什么限制。
这里最大的限制是在于hook的只有用户态的程序,包括so也都是用户态的甚至是低权限可读的,因此利用的效果其实取决于被hook的程序有多大的权限,通过直接对so的修改可以说能够拿到用户态的最高权限。
完全体ROOT
使用上面的利用技术,各个系统服务都可能成为我们的跳板,能够造成的安全风险已经很多。但是在我们面前还剩最后一道屏障,也是Android系统安全的关键--Selinux。当关闭selinux之后,我们才算完全控制了这台手机。甚至可以在内存中自己编写一个能够做任何事的微型操作系统。
1. Init进程注入
通过直接修改so我们可以获得用户态的任何权限,但是各种操作都需要比较复杂的流程,因此这个漏洞最终利用的思路一定是要关闭selinux然后完成root。而想要做到这里点,直接修改用户态程序几乎是不可能的,我们需要控制内核才能关闭selinux。
内核的代码用户态是不可能读到的,内核也不会使用so,不过内核的ko动态装载机制为利用提供了可能性。ko是内核模块,在加载进内核之后就直接以内核态和内核权限运行,而ko是可以由modprobe进程加载的,modprobe进程是一个用户态程序。不过这个进程我们正常无法触发,而能够运行这个程序的是init进程,这个进程是一直运行的,这样我们就可以通过init调用modprobe加载ko控制内核关闭selinux。
2. 控制init进程
init进程本身的代码我们是不可能修改的,但是init进程作为现代操作系统的一部分,是使用了c++库的,因此通过hook libc++.so可以对init进程进行注入。
不过c++库太过于重要,我们不能直接覆盖原先的功能,因此最终选择将一个导出函数的第一个指令hook来跳转到我们额外附加的一段payload上,运行之后再跳转回去。那么最终选择_ZNSt3__115basic_streambufIcNS_11char_traitsIcEEEC2Ev(std::__1::basic_streambuf<char, std::__1::char_traits<char> >::basic_streambuf())作为hook的目标,而payload则写在libc++代码段最后一页的空余位置,这个位置具有可执行权限而且不会影响正常功能。
为了兼容性考虑,我们可以按照elf文件的正常解析流程,先读取section table找到字符串表,之后找到目标函数的入口地址,劫持控制流到我们的附加代码,之后再跳转回下一条指令位置,在附加代码中,最重要的是调用一个不常用的so,因为libc++最后一页的位置太过于有限,同时要让这个函数在之后被调用时不要一直执行被hook的逻辑之后选择一个不常用的so,比如为了兼容性考虑的32位so。这种so几乎不会被使用,因此可以随意进行修改,不需要顾忌原本功能。这里的payload需要实现的功能就是我们控制init进程做的事情,包括hook modprobe和触发modprobe。
触发方式是使用一些init进程支持的系统操作,让init进程使用so。
3. 控制modprobe
modprobe可以加载vendor_file类型上下文的文件作为ko到内核,但是这种文件都需要高权限才能访问,我们通过init进程将另一个不常使用的so完全hook为我们需要的ko,之后hook modprobe让其能够不检查直接将这个so作为ko加载到内核之中,这里由于modprobe本身没有在运行,可以直接通过dirty pipe对这个可执行文件进行编辑,所以直接将程序开始执行的地方劫持到我们的payload再跳转回去。payload需要做的事情一个是将我们指定的so作为ko加载进内核,同时由于这个进程是最终执行内核payload的进程,内核的提权会体现在这个进程中,因此让这个进程fork一个子进程进行最后的操作。
另一个需要hook的文件就是so,ko的第一个字节也是可执行头,所以是一致的,根据ko的每页第一个字节,可以选择合适的so,避免页第一个字节无法覆盖。这个ko需要做的事情就是通过符号表找到selinux_state这个selinux状态结构体,并且直接修改policy所在的内存,将当前进程,也就是modprobe的上下文设为permissive,让selinux不检查这个这个上下文的权限,ko如果遇到加载校验问题可能需要在编译时解决。
4. 关闭selinux
由于内核将modprobe进程的权限设为permissive,这个进程刚好也是root域,对于DAC和selinux都能通过,因此之前fork的子进程就可以执行一个用户脚本,将selinux关闭。
5. Root
最后通过之前的子进程通过网络调用反弹一个root权限的shell,本身selinux也是关闭的,从而完成对android系统的root。
小结
1. 通过dirty pipe可以写只读文件的特性,我们hook so的代码从而注入系统进程。包含复制Nday,绕过if创造漏洞,以及注入init进程控制内核至少三个方向的利用。由于这是一个逻辑漏洞,在利用时使用基本完全区别于传统内核漏洞的利用方法,传统内核对于内存的缓解措施对于这个漏洞的利用也不会生效,最终可以完成对android系统的root。
2. 这里能够看到android系统的安全性其实已经达到了一个很高的程度,尤其是selinux系统,正常设置的selinux对系统是非常强有力的保护,如何关闭和防止关闭selinux是亘古不变的话题。
3.传统防御思路中默认只读文件是安全的,但是目前已经出现几例能够覆写只读文件内容的漏洞利用,运行时系统包括重要只读文件的完整性保护也是值得思考的。
4.生态链安全仍需持续关注,Linux上游发现的安全漏洞,影响了所有Linux乃至Android厂商和设备。
5.CVE漏洞的安全影响依旧巨大,一方面是信息公开会吸引大量安全人员关注研究--包括黑灰产;另一方面如果修复发版不及时,会遭到外界大量的质疑和安全舆论风险。
参考链接:
- dirtypipe漏洞发现者公布的漏洞细节 https://dirtypipe.cm4all.com/
- 在linux系统中提出的利用思路 https://github.com/Arinerron/CVE-2022-0847-DirtyPipe-Exploit
- 在pixel中尝试的Android利用思路 https://github.com/polygraphene/DirtyPipe-Android