京东安全
- 关注
0
1
2
3
4
5
6
7
8
9
0
1
2
3
4
5
6
7
8
9
0
1
2
3
4
5
6
7
8
9
背景
GPU是终端设备负责图形化渲染的硬件,和CPU相比,它能更高效地并行计算。传统的PC端GPU可以通过PCIe接口在主板上热插拔,而在移动端,GPU往往和CPU集成在一块芯片上,再搭配负责网络通信的基带等配件,统一称为SoC。目前移动端市场占有率比较高的SoC有高通的骁龙系列处理器、联发科天玑系列处理器、华为海思处理器等。这些SoC的CPU部分都有统一规范的ARM指令集,这保证了一套程序只需编译一次,在不同厂商的CPU上都能正确运行。但是这些厂商的GPU指令集却非常封闭,甚至有些没有公开的文档,每家厂商在自己的标准上发展了自己的生态,试图构建商业护城河。作为开发者如果需要根据每个硬件厂商定制不同的GPU操作逻辑,想必是一件非常复杂的事情,而事实上安卓开发者大多数情况下并不需要直接和GPU进行交互,我们在绘制一个窗口、展示一张图片时,是通过调用安卓系统封装的统一接口实现。那安卓又是如何保证这么多硬件兼容性的呢?
上面的兼容性问题其实不止出现在移动端,在PC端也普遍存在。为了保证各种GPU硬件的兼容性,业界制定了统一的OpenGL、Vulkan、DirectX等标准,这些标准约定了名称规范统一的API调用约定。各家SoC厂商如果想推广自己的硬件,就必须自己负责开发基于这些标准实现的系统驱动、软件链接库。以OpenGL标准为例,如果要绘制一个窗口,开发者需要编写下面这样的代码。至于glfwInit、glfwCreateWindow、glfwMakeContextCurrent、glewInit这些函数,是由硬件厂商负责编写成链接库,在系统里供我们动态链接。这些链接库函数会和GPU内核驱动交互,而GPU驱动控制硬件,处理用户的逻辑。
int main() { // 初始化 GLFW if (!glfwInit()) { std::cerr << "Failed to initialize GLFW" << std::endl; return -1; } GLFWwindow* window = glfwCreateWindow(800, 600, "OpenGL Rectangle", nullptr, nullptr); if (!window) { std::cerr << "Failed to create GLFW window" << std::endl; glfwTerminate(); return -1; } glfwMakeContextCurrent(window); // 初始化 GLEW if (glewInit() != GLEW_OK) { std::cerr << "Failed to initialize GLEW" << std::endl; return -1; } ... }
安卓操作系统约定,硬件厂商的SoC在出厂时同时需要提供软件支持,必须满足至少OpenGL以及Vulkan(安卓7以后支持)两种调用约定。厂商负责编写的代码有两个部分,内核层的GPU驱动以及用户态的GPU 链接库。这种代码模块分离的思想在其他硬件如音频驱动、摄像头上也有类似的使用,被安卓统一定义为硬件抽象层(Hardware Abstraction Layer),又称HAL。
安卓只需要在HAL层声明需要哪些接口,明确定义好动态链接库导出的函数名称,传参方式,具体实现都交给了厂商。由于硬件厂商众多,他们编写的代码安全性必定不能和主线代码相比。与之矛盾的是,为了和硬件交互,厂商编写的代码往往都直接运行在内核态,一旦驱动代码出现安全问题,整个系统的安全建设将付之一炬,可谓安卓安全生态中最短的一块木板。
当然安卓研发人员也意识到这种问题的严重性,使用SELinux约束了这些硬件接口的调用权限。比如普通app没有权限直接使用摄像头、麦克风等硬件,必须通过系统的service进行数据中转,极大减少了驱动暴露出的攻击面。在service中转前,又使用AOSP的权限管控约束了APP的行为。
然而,GPU硬件出于性能的考虑,没办法使用service进行中转,任意一个APP默认就有权限和GPU驱动进行交互,在SELinux上也没有相应的进行权限管控。这也导致了GPU成为安卓安全生态中最为脆弱的一环,在过去一两年的安卓在野漏洞利用中,攻击者无一例外地瞄准了GPU驱动,借助GPU的驱动漏洞实现从普通APP到root的权限提升。
认识GPU
在利用GPU漏洞进行系统提权之前,我们有必要理清楚GPU的正常交互逻辑,开发者是如何操作GPU进行图形绘制、并行计算呢?
Shader编程
在CPU计算领域,我们通常使用一些高级编程语言进行程序编写,交给编译器将我们的程序编译成CPU能理解的机器码运行。和CPU流程类似,GPU领域也有专用的编程语言,称为shader,它是控制图形硬件进行图像渲染或单元计算的程序。如下所示,是一个简单的并行计算数组绝对值的shader代码。
#version 430 layout(std430, binding = 0) buffer InputBuffer { float inputData[]; }; layout(std430, binding = 1) buffer OutputBuffer { float outputData[]; }; void main() { uint index = gl_GlobalInvocationID.x; outputData[index] = abs(inputData[index]); }
当然GPU硬件肯定没办法理解上述语言是什么意义,从上面的语言到GPU硬件指令还需要额外的中间语言编译、硬件语言翻译两个阶段。
我们使用glslang编译工具链将shader代码编译为SPIR-V格式的字节码。
glslangValidator -V shader_abs.comp -o shader_abs.spv
SPIR-V字节码是由业界提出的一种计算机图形学统一的中间语言。OpenGL、VulKan等标准提供了接口来加载并运行SPIR-V字节码,在运行时,OpenGL这些框架会动态地将字节码翻译为GPU能直接理解的指令码进行执行。
至此我们简单理解了GPU的交互过程,这对GPU驱动漏洞挖掘还不够,我们需要从更底层的视角去发现问题。像上面的shader代码,程序的输入输出是float数组,但对于硬件来说这些都是内存里的比特,程序的运行必定涉及到GPU、CPU的内存数据交换、共享,这些又是怎么处理的呢?
GPU内存模型
传统的CPU在使用内存时,提出了虚拟内存的思想。基于硬件控制,不同的进程内存空间相互独立,称为虚拟内存,每个进程的虚拟内存和实际的物理内存之间的映射关系由页表保存。GPU在内存管理方面和CPU有着非常相似的地方。每个GPU程序上下文运行在相互独立的虚拟内存空间,GPU内核驱动负责维护每个GPU上下文的页表,管理程序内存申请、释放、和CPU的共享内存逻辑。
以高通GPU驱动为例,用户态的程序可以通过ioctl和GPU驱动交互,和GPU共享内存。
// 打开GPU驱动,创建一个gpu程序上下文 int gslfd = open("/dev/kgsl-3d0",0); // 申请一块内存 void *buffer = (void *)mmap((void *)0x40000000L, 0x1000L, PROT_READ | PROT_WRITE, MAP_PRIVATE | MAP_ANONYMOUS|MAP_FIXED, -1, 0); if (buffer == MAP_FAILED) { printf("mmap error:%d\n",errno); return 0; } printf("mmap buffer return %p\n", buffer); // 配置GPU共享内存参数 struct kgsl_map_user_mem args = { .flags = KGSL_MEMFLAGS_USE_CPU_MAP | KGSL_MEMFLAGS_USERMEM_ADDR | (KGSL_CACHEMODE_UNCACHED << KGSL_CACHEMODE_SHIFT) , .memtype = KGSL_USER_MEM_TYPE_ADDR, .hostptr = (uint64_t)buffer, .offset = 0, .len = 0x1000L, }; ret = ioctl(gslfd, IOCTL_KGSL_MAP_USER_MEM, &args); if (ret) { printf("ioctl IOCTL_KGSL_MAP_USER_MEM cmd_data error: %d %s\n",errno,strerror(errno)); return 0; }
继续查看高通GPU内核驱动的代码,可以看到驱动里大量复杂的ioctl交互逻辑,处理用户态的请求。
正常的GPU交互逻辑,是app通过加载OpenGL的链接库,在链接库里构造好参数,来和驱动进行ioctl交互。但是一个恶意的攻击者可以直接不加载OpenGL库,直接调用内核态的驱动代码。如果驱动代码中未正确处理用户请求,就有可能导致UAF、溢出等内存漏洞。
GPU漏洞回顾
2024年8月,Google的Android Red Team团队披露了一个高通GPU驱动的UAF漏洞CVE-2024-23380,借助这个漏洞,攻击者可以从普通APP的权限提升到系统root。接下来本文对该漏洞的成因、利用过程进行详细分析。
高通GPU驱动提供了一系列接口用于操作GPU的虚拟内存。如IOCTL_KGSL_GPUOBJ_ALLOC用于申请GPU对象(内存),API返回一个id标志,用于区分不同的对象。可以通过IOCTL_KGSL_GPUOBJ_INFO查询到id对应的GPU对象所占居的虚拟内存地址、内存大小等信息。
struct kgsl_gpuobj_alloc param = { .size = 0x1000, .flags = KGSL_MEMFLAGS_FORCE_32BIT, }; ret = ioctl(gslfd, IOCTL_KGSL_GPUOBJ_ALLOC, ¶m); if(ret < 0) { printf("alloc failed %d %s\n",errno,strerror(errno)); return -1; } printf("vbo obj size: 0x%llx, flags: 0x%llx mmapsize: 0x%llx id 0x%x\n", param.size, param.flags, param.mmapsize, param.id); struct kgsl_gpuobj_info info = { .id = param.id, }; ret = ioctl(gslfd, IOCTL_KGSL_GPUOBJ_INFO, &info); if(ret < 0) { printf("get info failed %d %s\n",errno,strerror(errno)); return -1; } uint64_t obj_addr = info.gpuaddr; printf("obj_addr = 0x%lx\n",obj_addr);
类似地,IOCTL_KGSL_GPUOBJ_FREE用于释放,IOCTL_KGSL_MAP_USER_MEM用于将CPU的虚拟内存映射到GPU虚拟地址,实现内存共享。上述这些内存申请释放操作,实际上驱动的处理逻辑都是在读写GPU的页表,建立或释放从GPU到物理内存页的映射。
除了正常的GPU内存申请操作,IOCTL_KGSL_GPUOBJ_ALLOC还支持一个特殊的flag KGSL_MEMFLAGS_VBO。通过查阅驱动代码发现,带有这个特殊flag的GPU对象在申请时并没有申请对应的物理内存,而是以zero page进行占位填充。
而后又可以通过IOCTL_KGSL_GPUMEM_BIND_RANGES操作将其他GPU对象的内存页映射到自己对应的虚拟地址空间。相反,也有与之对应的KGSL_GPUMEM_RANGE_OP_UNBIND取消映射操作。而这也是本次漏洞的关键所在。
struct kgsl_gpumem_bind_range ranges = { .child_offset = 0, .target_offset = 0, .length = 0x1000, .child_id = victim_param.id, .op = KGSL_GPUMEM_RANGE_OP_BIND, }; struct kgsl_gpumem_bind_ranges ranges_args = { .ranges = (uint64_t)&ranges, .ranges_nents = 1, .ranges_size = sizeof(struct kgsl_gpumem_bind_range), .id = vbo_param.id, .flags = 0, .fence_id = 0, }; ret = ioctl(gslfd, IOCTL_KGSL_GPUMEM_BIND_RANGES, &ranges_args); if(ret < 0) { printf("IOCTL_KGSL_GPUMEM_BIND_RANGES failed %d %s\n",errno,strerror(errno)); return -1; }
CVE-2024-23380漏洞分析
内核驱动开发中,一个需要特别注意的点就是并发控制,当读写一些全局变量时,需要对加锁、释放锁的时机格外注意。如下时GPU VBO在进行BIND_RANGES时的处理逻辑,用于将GPU的虚拟内存页VA1映射到另外一块虚拟内存页VA2。操作完成后,VA1和VA2将指向同一块物理内存。
不难看出,上面的操作中 3 和4的顺序被搞反了,正确逻辑应该是先建立好映射后,再释放锁。
BIND_UNRANGES与之相反,加锁、解除VA2和物理页的映射、添加VA2和zero page的映射、释放锁。
漏洞利用
上述的漏洞是由于锁释放的实际不对导致的race,那触发漏洞必定通过多线程并发实现,那竞争成功后又能达到什么样的效果呢?
在GPU中申请分别一个正常的对象、一个带有VBO标记的对象,内存页映射关系如下所示。
当正常VBO对象正常执行BIND_RANGE后,GPU的VA1和VA2会同时指向一块物理内存。如果此时执行UNBIND_RANGE操作,它们又会回到上面的初始状态。
但当race的过程时,如果出现以下的执行过程
在第三步释放VA1后,GPU对应的物理页会被还给系统的内存分配器,然而此时VA2仍保留着到物理内存的映射。导致出现PAGE UAF,我们可以通过控制GPU指令,来实现对该物理内存块的读写。
在实际的代码利用过程中,我们可以频繁触发PAGE UAF漏洞,使GPU保留更多的物理地址页映射,方便后续的占位操作。此时GPU映射的物理页虽然处于空闲状态,但是仍保留在kgsl_page_pool这个页管理器中,并没有完全被系统回收,需要Linux内核触发memory shrink后,才会回调_kgsl_pool_shrink,进而将物理页完全交给系统。通过查阅文档得知,正常情况下高权限用户可以通过命令 echo 3> /proc/sys/vm/drop_caches 来触发内存碎片回收,对于一个普通app来说,没有权限修改procfs,可以通过频繁申请内存来触发。
在系统完全回收这些内存后,我们可以再次调用mmap申请内存,这一步是为了让CPU的PTE table占据这些空闲页。接下来,我们通过编写shader程序,去读写这些PTE table对应的GPU VA,进而间接通过改变CPU的虚拟内存映射的物理地址,实现全局物理内存读写。在实验过程中发现,高通处理器系列的kernel虽然开启了KASLR,内核虚拟地址是完全随机化的,但是物理地址却是固定值0xa8000000L。从这个位置开始,我们就可以读写整个内核空间的代码、数据段,进而实现代码提权。
最后放出一个视频演示
总结
本文简述了移动端GPU安全研究方向、GPU的攻击面梳理、漏洞分析等内容。从攻击者角度来看,纵观过去几年漏洞安全研究历史,传统的内存破坏、整数溢出等漏洞开始淡出历史舞台,这些都可以通过编译期检查、各种sanitizer机制加以缓解,但像mmu misconfiguration等逻辑型漏洞很难通过fuzzer来触达,驱动在运行时更需要硬件支持,这也为自动化漏洞挖掘带来了新的挑战。从防御者角度来看,内核驱动代码的漏洞不可避免,这也不失为当时安卓顶层安全设计的一个失误,既然无法收敛权限,那也许将驱动程序从内核中剥离出来是一种更安全的解决方案,但这更需要SoC厂商更多的技术支持,其中的技术路线选型、架构设计和原始的驱动代码开发不尽相同,涉及到的软件稳定性、兼容性、性能验证等工作量又是一个未知数,仅从安全收益的角度来说似乎很难说服SoC厂商做出这样的改变。如果安卓官方重新定义一种更为安全的HAL层的接口,强制约束厂商的接入方式,理论上是一种更合理的动机,不过这都需要Android官方和SoC厂商迈出艰难的第一步,究竟GPU安全未来的发展如何,我们拭目以待。
参考资料
【2】https://yanglingxi1993.github.io/dirty_pagetable/dirty_pagetable.html
如需授权、对文章有疑问或需删除稿件,请联系 FreeBuf 客服小蜜蜂(微信:freebee1024)