开启Win10RS4ARM64远程内核调试之旅

author : wup and suezi of IceSword Lab , Qihoo 360


  今年6月,微软联合一线笔记本厂商正式发布了搭载高通骁龙处理器的Windows 10笔记本产品。作为主角的Win10 ARM64,自然亮点无数,对PC设备厂商也是各种利好。实际上,为了与厂商同步发布安全防护产品,IceswordLab的小伙伴早已将底层驱动程序集移植到了Win10 ARM64平台上,笔者也因此积累了一些有趣的内核调试方法。在x86平台使用vmware等虚拟机软件搭建远程内核调试环境是非常方便有效的办法,但目前Win10 ARM64平台没有这样的虚拟机软件,于是笔者利用qemu模拟器DIY一个。

0x0 准备试验环境

物理机系统环境 :Windows10 RS4 x64
虚拟化软件qemu : qemu-w64-setup-20180519.exe
虚拟机系统环境 :Windows10 RS4 ARM64
UEFI 模块 : Linaro 17.08 QEMU_EFI.fd
WINDBG :WDK10 (amd64fre-rs3-16299)附带的WinDBG

0x1 qemu远程内核调试开启失败

  在qemu环境下,我们使用Linaro.org网站提供的针对QEMU(AARCH64)的1708版的UEFI文件QEMU_EFI.fd启动Win10ARM64的系统,并使用bcdedit修改qemu模拟器里的Win10ARM64的启动配置以实现远程内核调试。配置如下图,

我们遇到了两个问题:
(1) 以“-serial pipe:com_1”参数启动qemu模拟器,qemu会被卡住,导致虚拟机系统无法启动;
(2)无论是否开启了基于串口的远程内核调试,系统内核加载的都是kd.dll而非预期的kdcom.dll;

对于问题(1),我们利用qemu串口转发功能,开发一个代理程序:建立一个namedpipe等待windbg的连接,并建立与qemu串口socket服务器的连接,从而实现将pipe上读取(ReadFile)的数据写入(send)到socket、将socket上读取(recv)的数据写入(WriteFile)到pipe。如此我们解决了问题(1)。
至于问题(2),对比VMWare里用UEFI方式部署的Win10RS4x64,不开启内核调试时系统加载的是kd.dll,开启内核调试时系统加载的是kdcom.dll,下面对其进一步分析。

0x2 系统提供的kdcom.dll存在问题

  在Win10RS4ARM64安装镜像的预置驱动里,无法找到serial.sys这个经典的串口驱动;而Win10ARM64笔记本的串口设备是存在的,且串口驱动是高通官方提供的。实际上通过串口远程调试windows,系统正常的启动过程中,调试子系统的初始化是早先于串口驱动程序,调试子系统调用kdcom.dll提供的功能,并不需要串口驱动程序的支持。因此微软没有为Win10RS4ARM64提供串口驱动serial.sys,对我们最终的目标没有影响。

那么问题究竟出在哪里呢?是因为Loader所使用的Qemu中的UEFI有问题吗?

对照qemu的源码可知,qemu为aarch64模拟器环境提供了串口设备PL011。我们研究了Linaro UEFI的源码EDK2并编译了对应的UEFI文件,确保使用的UEFI文件确实提供了串口功能。再用与Win10ARM64模拟器同样的配置安装了Ubuntu for ARM,在这个模拟器里PL011串口通信正常,串口采用MMIO,其映射的基址为0x09000000。但安装Win10后问题依旧:以基于串口的远程内核调试的启动配置来启动Win10RS4ARM64,系统加载的是kd.dll而非期望的kdcom.dll,故而推测是winload 没有识别PL011串口设备、没能去加载kdcom.dll。由此,我们决定直接将kdcom.dll替换kd.dll来使用。不过使用kdcom.dll替换kd.dll后出现了新的问题——系统引导异常,下面进一步分析其原因。

kdcom!KdCompInitialize是串口初始化的关键函数,分析它是如何初始化并使用串口设备的。系统第一次调用kdcom!KdInitialize初始化串口时,传递给KdCompInitialize的第二个参数LoaderBlock是nt!KeLoaderBlock,非NULL,此时kdcom!KdCompInitialize里的关键流程如下:
(1) HalPrivateDispatchTable->KdEnumerateDebuggingDevices已被赋值为hal!HalpKdEnumerateDebuggingDevices,调用返回0xC0000001;
(2) 串口处理器UartHardwareDriver为NULL,没有被赋值;
(3) HalPrivateDispatchTable->KdGetAcpiTablePhase0已被赋值为hal!HalAcpiGetTable,
调用HalAcpiGetTable(loaderBlock, ‘2GBD’)返回NULL,
调用HalAcpiGetTable(loaderBlock, ‘PGBD’)返回NULL,
因此gDebugPortTable为NULL;
(4) 参数LoaderBlocker非NULL且gDebugPortTable为NULL,调用GetDebugAddressFromComPort来配置串口地址;
GetDebugAddressFromComPort调用nt!KeFindConfigurationEntry失败,按照既定策略,基于DebugPortId的值指派串口地址(DebugPort.Address)为0x3F8/0x2F8/0x3E8/0x2E8/0x00五者之一;
(5) 由于gDebugPortTable为NULL,串口处理器UartHardwareDriver赋值为Uart16550HardwareDriver;
由于串口地址(DebugPort.Address)非NULL,调用串口初始化函数UartHardwareDriver->InitializePort初始化串口;
模拟器提供的串口设备为PL011, 串口处理器应被赋值为是PL011HardwareDriver 而非Uart16550HardwareDriver;

至此,我们发现导致异常的原因: 模拟器提供的是PL011串口设备, kdcom.dll虽提供了支持PL011的代码,但未能正确识别适配,依然把它当成了PC的isa-serial串口设备。这应属于kdcom.dll的bug。

0x3 开启qemu远程内核调试

  现在看来,我们需要解决的问题有两个:系统Loader仅加载不支持远程内核调试的kd.dll,系统模块kdcom.dll没能完全支持PL011串口设备。

对于第一个问题,我们简单采取文件替换的办法绕过它。
对于第二个问题,预期可以使用这样的办法解决:开发一个boot类型的驱动,让它能够加载kdcom.dll并主动修正kdcom.dll中所有相关数据,对内核映像Ntoskrnl.exe执行IATHook——把导入地址表中的kd.dll函数地址全部替换成kdcom.dll对应函数地址,最后执行nt!KdInitSystem来初始化调试子系统。这种方案篡改内核数据后,会很快触发PatchGuard蓝屏,因此我们需要设计出一个更可用的方案。

我们可以开发一个能够实现远程内核调试所需的串口通信功能的dll(即没有BUG的kdcom.dll)来替换系统目录下kd.dll,在“禁用驱动程序强制签名”的场景下实现对操作系统初始化流程的劫持。

微软给WINDBG的安装包捆入了一个名为KdSerial的示例项目。这个项目缺少了一些代码,但是关键的部分都在。通过笔者的改造,成功编译得到一个kdserial.dll,它拥有远程内核调试所需的串口通信功能和正确的PL011串口配置,能够替代Win10ARM64RS4系统里的kdcom.dll。将这个kdserial.dll替换系统里的kd.dll,开机时选择“启动设置”菜单里的“禁止驱动程序强制签名”,达成远程内核调试Win10RS4ARM64的目标。

参考文献

[1] Windows Internals 6th
[2] https://docs.microsoft.com/en-us/windows-hardware/drivers/devtest/bcdedit--dbgsettings
[3] https://docs.microsoft.com/en-us/windows-hardware/drivers/devtest/bcd-boot-options-reference
[4] https://wiki.linaro.org/LEG/UEFIforQEMU
[5] https://blog.csdn.net/iiprogram/article/details/2298550

利用一个竞态漏洞root三星s8的方法

author : zjq(@spinlock2014) of IceSword Lab , Qihoo 360


  在安卓阵营中,三星手机可以说是最重视安全的了,各种mitigation技术都是早于官方系统应用到自己手机上,并且加入了KNOX技术,在内核层设置了重重校验,提高了手机root难度。17年下半年,研究过一段时间三星手机s8的内核安全问题,发现了一些比较有意思的漏洞。本文中,将介绍一个race condition漏洞,利用此漏洞绕过KALSR,PXN,CFI,KNOX2.8等拿到了s8内核root权限。目前这些漏洞都已经被修复。

0x0 MobiCore驱动的提权漏洞 回页首

  在MobiCore驱动中,ioct的MC_IO_GP_REGISTER_SHARED_MEM接口会从slab中分配一块cwsm buffer,MC_IO_GP_RELEASE_SHARED_MEM接口用来释放cwsm buffer和相关资源。但是在释放过程中,由于没有加锁,存在race condition进而导致double free的可能:



  看此函数的实现,首先从链表中查找获取该内存块,并将引用计数加1以持有该cwsm buffer。然后通过连续两个cwsm_put函数减去引用计数并释放cwsm buffer。cwsm_put的实现是引用计数减1,然后检查引用计数是否为0,如果为0,则执行cwsm_release函数释放cwsm,如下所示:




  正常情况下,创建该buffer时引用计数被设为1,cwsm_find查找该buffer时引用计数加1,第一个cwsm_put调用减去cwsm_find持有的引用计数,然后第二个cwsm_put将引用计数减为0,并调用cwsm_release释放资源。
但在client_gp_release_shared_mem函数中,由于cwsm_find和两个cwsm_put之间并未加锁保护,使获取cwsm和释放cwsm不是原子操作,当race condition发生时,多个线程在cwsm被释放前调用cwsm_find获取该buffer后,接下来的多次cwsm_put调用则可以触发对cwsm的double free。

  我们再看cwsm_release这个函数,还是比较复杂的:



其中,cwsm的结构为:


  仔细分析cwsm_release函数,我们会发现,这个函数中当race condition发生时, tee_mmu_delete(cwsm->mmu) 会造成cwsm->mmu 的double free, client_put(client) 会造成cwsm->client的double free,最后kfree(cwsm) 也会造成cwsm的double free。三个大小不一的slab内存块同时double free,极易引起内核崩溃,除非我们在cwsm第一次被释放后占住该内存,从而控制内存中内容,改变第二次执行此函数中的流程。而list_del_init(&cwsm->list)这一句:







如果我们可以控制cwsm的内容,也就是list->next 和list->prev指针的值,则可以做成一个任意地址写。

## 0x1 利用方案 回页首
  从client_gp_release_shared_mem函数中可以看到,调用cwsm_find获得buffer和调用cwsm_put释放buffer时间间隙极小,如何能提高race condition的成功率,有效控制指针,并能尽可能的降低崩溃率呢?通过对slab中内存分配释放机制的分析,主要采用了几下几个方法:

1. 如何增加race condition成功率呢?kmalloc在slab中分配内存块会记录下本线程所在核,kfree释放内存时,如果判断当前线程所在核与分配内存时的所在核一致,则将内存释放到快速缓存链表freelist中,这样当其他线程分配相同大小的内存块时能快速取到,这样可以增加释放后马上占位的成功率;如果释放时判断当前线程所在核与分配内存时的所在核不一致,则将内存释放到page->freelist中,当其他线程分配内存时,缓存链表中内存耗尽后,才会从此链表中取用,因为时间间隙很小,这会降低占位成功率。所以分配slab内存,释放内存,占位内存的线程最好在同一个核上。假设有0,1,2三个核,线程A在0核上分配了buffer,线程B在0核上释放buffer,同时为了制造race condition需要线程C在1核上释放buffer,同时线程D在0核上,可以调用add_key系统调用来占用线程B释放掉的内存块,并填上我们需要的内容。当然这实际调试中,因为race condition间隙很小,可能需要几个甚至几十几百个线程同时操作来增加成功率。同时,因为race condition间隙很小,可以在0核上增加大量打酱油线程,使其在race condition间隙中获得调用机会,以增大时间间隙,提高占位的成功率;
2. 我们在cwsm double free的第一次释放后将其占住,那么就可以控制其中的内容,填上我们需要的值,因此我们可以将cwsm->list.next设为一个内核地址,利用list_del_init(&cwsm->list)再调用__list_del,可以实现内核地址写,比如将ptmx->check_flags 设置为我们需要的函数指针;
3. 当race condition发生时,多个线程调用cwsm_release时,大小不同的slab块cwsm->mmu,cwsm->client和cwsm都会被重复释放,在此情况下,内核大概率会崩。因此,当cwsm第一次释放,我们占住后,需要将cwsm->client和cwsm->mmu填上合适的值,防止内核崩溃。我们先看client_put(client) 函数:


  这个函数首先引用计数client->kref减1,如果为0,则调用client_release释放资源。因此我们可以将client->kref设为大于1的值,防止cwsm->client被二次释放。
再看tee_mmu_delete(cwsm->mmu),这一句比较麻烦,它将调用mmu_release函数,看内部实现(片段):






可以看到,mmu_release 不仅要释放mmu,并且要引用mmu中指针。如果我们能控制cwsm->mmu,那么我们必须将cwsm->mmu设为一个合法的slab地址,并且能够控制这个slab中的内容,否则系统将崩溃。幸运的是,我们找到了一个信息泄露漏洞:




/sys/kernel/debug/ion/event文件将泄露ion中分配的ion_buffer的地址。我们可以利用ion接口分配大量ion_buffer,然后在泄露的地址中查找到连续8k大小(cwsm->mmu的大小)的ion_buffer内存。然后在ion中占住这一块内存不释放,将其地址填到cwsm->mmu中,使mmu_release释放此内存块,但因为我们在ion中此内存占住不释放不使用,所以即使被别人重新获得,也可避免内核崩溃。

0x2 Bypass KALSR 回页首

Android 8.0之后安卓手机普遍启用了内核地址随机化,而三星手机启用的要更早一些。此漏洞本身泄露内核地址比较困难,所以还需要一个信息泄露漏洞。debugfs 文件系统一直是比较容易出问题的,我们尝试着用简单指令测试了一下:find /sys/kernel/debug | xargs cat,片刻之后,屏幕上打印出了如下信息:




经过分析,这是/sys/kernel/debug/tracing/printk_formats文件所泄露出来的地址,有些函数地址,比如dpm_suspend,此地址加上一个固定的偏移量即可得到内核启动后的真实函数地址。经过fuzz发现,类似的信息泄露不止一处。

0x3 Bypass PXN && CFI 回页首

我们曾在16年mosec会议上介绍过几种过PXN方法。其中一个方法是,将函数指针kernel_setsockopt覆盖到ptmx_fops->check_flags,然后通过控制第一个参数跳转,绕过set_fs(oldfs)语句,当函数执行完,本进程addr_limit被设为0xffffffffffffffff,此时我们可以在用户态通过一些系统调用直接读写内核数据。




然而在s8上使用此方法时确出现了系统崩溃,仔细检查s8的kernel_sock_ioctl汇编代码时,发现跳转指令改变了,跳转到寄存器的指令改成的直接跳转到固定地址0xffffffc000c56f6c的指令:




下面看看跳转到0xffffffc000c56f6c这个地址干了些什么:




如上代码,实际上是对跳转地址做了检查,如果跳转到的地址的上一条语句是0x00be7bad,则认为是合法地址,执行跳转,如果不是则认为是非法地址,执行一条非法语句导致内核崩溃。为什么必须要上一条语句是0x00be7bad呢?原来s8在编译时每一个函数结尾都加上了一句0x00be7bad作为标记,如果上一条语句是0x00be7bad,则表明这个地址是函数的起始地址,否则不是。也就是说,在每一个跳转到寄存器地址之前都要检查地址是否为函数的起始地址,否则非法。
虽然此路不通,但是另外一个办法还是可以的。我们找到了一个比较好用的bug,在s2mm005_flash函数中有一个代码片段:




文件CCIC_DEFAULT_UMS_FW定义为:”/sdcard/Firmware/usbpd/s2mm005.bin”,由于此文件并不存在,当调用到此代码时,filp_open将返回错误,跳到done返回。可以看到错误处理中并没有恢复addr_limit。也就是当调用此函数失败时,本进程将得到读写内核的权限。
当然上面这个办法有赖于这个简单的bug,在错误处理中漏掉了set_fs(old_fs)的操作。如果没有这种bug怎么办呢?还是有办法的,我们在内核中找到了这样的函数:




将此函数地址,利用漏洞覆盖掉ptms_fops-> check_flags指针,当我们调用check_flags时,可以控制第一个入参,那么合理设置参数内容,可以达到读写内核的目的。

0x4 KNOX2.8 && SELinux 回页首

三星手机为了提高手机安全性,加入了KNOX,使内核利用难度大大加强。这里简单介绍一下KNOX2.8在内核中主要实现的特性:

  1. 与root相关的关键数据,比如cred,页表项等需要在特定内存中分配,此内存中通用cpu端被设为只读,当需要修改时,则发送指令通过TrustZone进行修改;
  2. 在调用rkp_call让TrustZone执行命令时,TrustZone同样将对数据完整性进行校验,比如commit_creds函数在创建cred后,调用rkp_call时,TrustZone会检查本进程credential是否在只读内存区,检查本进程id是否大于1000,如果大于1000则不能将新创建的credential修改为小于1000的值,这也使得通过调用rkp_override_creds来修改credential用户id的办法不再有效;
  3. 在SELinux原有权限管理基础上,增加了额外的完整性校验,这几乎影响所有系统调用接口。以open系统调用为例,当打开CONFIG_RKP_KDP配置项时,增加了security_integrity_current的校验:







    可以看到,在security_integrity_current这个函数里,将校验:进程描述符中cred和security是否在只读内存区分配,bp_cred与cred是否一致(防止被修改),bp_task是否就是本进程,mm->pgd和cred->bp_pgd是否一致,current->nsproxy->mnt_ns->root和current->nsproxy->mnt_ns->root->mnt->bp_mount是否一致。如果其中某一项关键数据被修改而导致检验不通过,则导致系统产生panic,并打印出错误信息;

  4. 在load_elf_binary -> flush_old_exec函数中增加校验,如果进程为id小于1000,为内核进程,并且load的二进制文件及不再”/”目录又不在”/system”目录下则内核panic。




    这使得利用用户态调用__orderly_poweroff函数在内核中创建内核线程的方法将被阻止;KNOX还在内核其他地方加入了大量的检验。

KNOX的加入,使得以前常用的一些修改credential 用户id去root办法都比较难办了。随着KNOX版本的迭代,势必会对内核的保护越来越强化。但是就笔者当时研究的KNOX2.8而言,依然还有一些弱点可供利用,进而拿到root权限,读写高权限文件,起内核shell等。

前面提到,KNOX限制root的一个措施就是在大部分系统调用中,都会进行数据完整性校验,如果我们将进程credential修改非只读区,则会校验失败。这些校验函数都是挂接在全局变量security_hook_heads下面,比如open系统调用会调用security_hook_heads下挂的file_open钩子函数,最后调用到selinux_file_open进行权限和数据完整性校验。但是security_hook_heads这个全局变量却是可读写的,我们可以利用漏洞读写内核,将此变量下面挂的钩子函数有选择的设置为NULL,不仅可以绕过该校验,还可以绕过SELinux的检查。比如,我们可以把本进程credential设置为替换为一块可读写内存,将id修改为root用户,同时将和读写相关的校验函数设为NULL。这样可以用root用户稳定的读写系统中高权限文件。进行其他操作时,也可以通过禁用相关校验函数绕过校验,当然这种方法有些简单粗暴,需要小心使用,因为这些校验函数有些和系统耦合紧密,如果不小心很容易引起系统crash,操作完成后应该尽快恢复。在KNOX之前版本中,有研究员曾经通过调用__orderly_poweroff函数,可以利用内核起一个root进程,绕过了commit_creds中的校验,但是KNOX2.8中在load_elf_binary中增加了对用户id和binary路径的校验。然而我们发现,虽然load_elf_binary增加了此校验,但是load_script中却没有加上这个校验,这就意味着,虽然我们不能在内核中加载自己的binary,但是可以起一个root脚本进程,在脚本中进行我们需要的操作。

总结: 回页首

本文介绍了如何利用一个s8中race condition驱动漏洞,一步步绕过KALSR,PXN,CFI,KNOX2.8等mitigation机制,拿到root权限,读写高权限文件,并在内核中起一个shell进程。三星在内核加固方面下了很大功夫,KNOX的引入显著提高了root的难度,随着后面版本的不断迭代,对内核的加固会越来越强,值得持续的跟踪研究。