翻译:牧野之鹰
预估稿费:200RMB
投稿方式:发送邮件至linwei#360.cn,或登陆网页版在线投稿
前言
传统上,操作系统的内核是位于攻击者和对目标系统的完全控制之间的最后一个安全边界。 因此,必须额外注意以确保内核的完整性。 首先,当系统引导时,必须验证其关键组件(包括操作系统内核的关键组件)的完整性。 这是通过验证的启动链在Android上实现的。 然而,简单地引导已验证的内核是不够的 – 在系统执行时如何维护内核的完整性?
想象一下,攻击者能够在操作系统的内核中找到并利用漏洞的情况。 使用这种漏洞,攻击者可能通过修改其代码的内容,或通过引入新的,受攻击者控制的代码,并在操作系统的上下文中运行,来试图破坏内核本身的完整性。 甚至更巧妙的是,攻击者可以选择修改操作系统使用的数据结构,以便改变其行为(例如,通过授予过多的权限来选择进程)。 由于内核负责管理所有内存转换,包括其自身,因此没有机制阻止同一上下文中的攻击者这样做。
然而,为了符合“深度防御”的概念,可以添加附加层以便保护内核不受这种将要攻击者的攻击。 如果正确堆叠,这些层可以以严重限制或者简单地防止攻击者破坏内核完整性的方式来设计。
在Android生态系统中,三星提供了一个安全管理程序,旨在解决在运行时确保内核完整性的问题。 管理程序被称为“实时内核保护”(RKP),作为三星KNOX的一部分。 在这篇博文中,我们将深入研究RKP的内部工作,并提出多个漏洞,允许攻击者颠覆RKP的每个安全机制。 我们还将看到如何加强RKP的设计,以防止未来的这种性质的攻击,使利用RKP更困难。
和往常一样,本文中的所有漏洞已被披露给三星,修复已在1月SMR提供。
我想指出,除了解决报告的问题,三星KNOX团队十分积极并开放讨论。 这种对话有助于确保问题得到正确诊断和确定根本原因。 此外,KNOX团队已经提前审查了本文,并提供了关于RKP的未来改进计划基于这项研究的关键见解。
我要特别感谢三星KNOX团队的Tomislav Suchan帮助解决我所有的每一个查询,并提供深刻的见解。 Tomislav的辛勤工作确保所有的问题都得到正确和完整的解决,不留下任何东西。
HYP 101
在我们开始探索RKP的架构之前,我们首先需要了解ARMv8上的虚拟化扩展。 在ARMv8架构中,引入了一个新的异常级别概念。 通常,离散组件在不同的异常级别运行 – 组件的特权级别越高,其异常级别越高。
在这篇博文中,我们将只关注“正常世界”中的异常级别。 在此上下文中,EL0表示在Android上运行的用户模式进程,EL1表示Android的Linux内核,EL2(也称为“HYP”模式)表示RKP管理程序。
然后,当用户模式进程(EL0)希望与操作系统的内核(EL1)交互时,它们必须通过发出“管理程序调用”(SVC)来触发,然后触发由内核处理的异常。 以相同的方式,通过发出“管理程序调用”(HVC)来执行与管理程序(EL2)的交互。
另外,管理程序可以通过使用“管理程序配置寄存器”(HCR)来控制在内核内执行的关键操作。 此寄存器管理虚拟化功能,使EL2能够与EL1中运行的代码交互。 例如,设置HCR中的某些位将导致管理程序捕获通常由EL1处理的特定操作,使得管理程序能够选择是允许还是不允许所请求的操作。
最后,管理程序能够实现额外的存储器转换层,称为“阶段2转换”。 代替使用操作系统的转换表在虚拟地址(VA)和物理地址(PA)之间映射的常规模型,翻译过程被分成两部分。
首先,使用EL1转换表以便将给定的VA映射到中间物理地址(IPA) – 这被称为“第1阶段转换”。 在该过程中,还应用在翻译中存在的访问控制,包括访问许可(AP)位,从不执行(XN)和从不执行特权执行(PXN)。
然后,通过执行“阶段2转换”将所得到的IPA转换为PA。 该映射通过使用EL2可访问的转换表来执行,并且对于在EL1中运行的代码是不可访问的。 通过使用这种两级转换机制,管理程序能够防止对物理存储器的某些关键区域的访问,其可能包含应该对EL1保密的敏感数据。
创建研究平台
正如我们在我们的“HYP 101”课程中看到的,与EL2的沟通明确地通过发出HVC来完成。 与可以由在EL0中运行的代码自由地发出的SVC不同,HVC只能由在EL1中运行的代码触发。 由于RKP在EL2中运行,并通过可以从HVC触发的命令暴露其绝大部分功能,我们首先需要一个平台,我们可以从中发送任意的HVC。
幸运的是,在最近的一篇博客中,我们已经涵盖了一个漏洞,允许我们将权限提升到system_server的上下文中。 这意味着在我们开始调查RKP和与EL2交互之前剩下的所有工作都是找到一个额外的漏洞,允许从已经特权的上下文(如system_server)升级到内核的上下文。
幸运的是,简单地测量暴露于这种特权上下文的攻击面暴露了大量相对简单的漏洞,其中任何一个可以用于在EL1中获得一些立足点。 为了这个研究的目的,我决定利用这些中最方便的:在sysfs条目中的简单堆栈溢出,可以用于获得对内核线程的堆栈内容的任意控制。 一旦我们控制了栈的内容,我们可以构造一个ROP有效载荷,为内核中的函数调用准备参数,调用该函数,并将结果返回给用户空间。
为了便于使用,我们可以将创建一个ROP堆栈的整个过程包装到一个函数中,这个函数调用一个内核函数并将结果返回给用户空间,我们称之为“execute_in_kernel”。 结合我们的shellcode包装器,它将正常的C代码转换为可以注入到system_server中的shellcode,我们现在可以自由构建和运行能够根据需要调用内核函数的代码。
综上所述,我们可以使用这个强大的研究平台开始调查和与RKP交互。 本博客中详细介绍的其余研究在完全更新的Galaxy S7 Edge(SM-G935F,XXS1APG3,Exynos芯片组)上进行,使用这个确切的框架,以便使用第一个exploit将代码注入到system_server中,然后运行代码 在内核中使用第二个exploit。
最后,现在我们已经搞定了所有需要的基础,让我们开始吧!
Mitigation #1 – KASLR
随着KNOX v2.6的推出,三星设备实现内核地址空间布局随机化(KASLR)。 这个安全特性引入了每次设备引导时产生的随机“偏移”,通过该偏移,内核的基地址被移位。 通常,内核被加载到固定的物理地址,其对应于内核的VAS中的固定虚拟地址。 通过引入KASLR,所有内核的内存,包括其代码,被这个随机偏移量(也称为“幻灯片”)移动。
虽然KASLR可能是针对旨在利用内核的远程攻击者的有效缓解,但是对于本地攻击者以鲁棒的方式实现是非常困难的。 事实上,已经有一些非常有趣的最近对该主题的研究,其设法击败KASLR,而不需要任何软件缺陷(例如,通过观察定时差异)。
虽然这些攻击本身是非常有趣的,但应该注意,绕过KASLR通常可以更容易地实现。 回想一下,整个内核被一个“滑动”值移动 – 这意味着泄露内核中与内核基址地址有一个已知偏移量的任何指针将允许我们很容易地计算出滑块的值。
Linux内核确实包括旨在防止这种指针泄漏到用户空间的机制。 一种这样的缓解是通过确保每次指针的值由内核写入时,使用特殊格式说明符“%pK”来打印。 然后,根据kptr_restrict的值,内核可以匿名打印的指针。 在我遇到的所有Android设备中,kptr_restrict配置正确,确保“%pK”指针是匿名的。
是的,因为它可能,我们所需要的是找到一个内核开发人员忽略匿名的单一指针。 在三星的情况下,这变得很有趣… pm_qos debugfs条目,它是可读的system_server,包括以下代码片段负责输出条目的内容:
不幸的是,匿名化格式说明符区分大小写…使用小写“k”,如上面的代码,导致上面的代码输出指针,而不应用“%pK”提供的匿名化(也许这是一个很好的例子 脆弱的KASLR)。 无论如何,这允许我们简单地读取pm_qos的内容,并从指针的值与内核基地址的已知偏移量相减,从而给出KASLR幻灯片的值。
Mitigation #2 – 加载任意内核代码
防止新内核代码的分配是RKP强制执行的主要缓解之一。 此外,RKP旨在保护所有现有的内核代码不被修改。 这些缓解是通过强制执行以下规则集来实现的:
1.除了内核代码之外,所有页面都被标记为“Privileged Execute Never” (PXN)
2.内核数据页从不标记为可执行
3.内核代码页从不标记为可写
4.所有内核代码页在阶段2转换表中被标记为只读
5.所有存储器翻译条目(PGD,PMD和PTE)对于EL1被标记为只读
虽然这些规则看起来相当健壮,但我们如何确保它们被正确执行? 不可否认,规则在RKP文档中很好地布局,但这不是一个强大的保证…
让我们开始挑战第一个断言; 即,除了内核的代码,所有其他页面被标记为PXN。 我们可以通过查看EL1中的阶段1翻译表来检查这个断言。 ARMv8支持在EL1,TTBR0_EL1和TTBR1_EL1中使用两个转换表。 TTBR0_EL1用于保存用户空间VAS的映射,而TTBR1_EL1保存内核的全局映射。
为了分析内核使用的EL1 stage 1翻译表的内容,我们需要首先找到翻译表本身的物理地址。 一旦我们找到翻译表,我们可以使用我们的execute_in_kernel原语,以便在内核中迭代地执行“读取小工具”,从而允许我们读出翻译表的内容。
有一个微小的障碍,我们如何能够检索翻译表的位置? 为此,我们需要找到一个小工具,允许我们读取TTBR1_EL1,而不会对内核造成任何不良影响。
不幸的是,梳理内核的代码揭示了一个令人沮丧的事实 – 似乎这样的小工具是相当罕见的。 虽然有一些功能读取TTBR1_EL1,但它们还执行其他操作,导致不必要的副作用。 相比之下,RKP的代码段似乎充斥着这样的小工具 – 事实上,RKP包含小的小工具几乎读取和写入属于EL1的每个控制寄存器。
在深入了解内核代码(init / main.c)后,有一些地方令人费解,在Exynos设备(与基于Qualcomm的设备相反)上,RKP由EL1内核引导。 这意味着,不是直接从EL3引导EL2,似乎EL1首先被引导,然后仅执行一些操作以引导EL2。
这种引导是通过在EL1内核的代码段中嵌入包含RKP的代码的整个二进制来实现的。 然后,一旦内核启动,它将RKP二进制复制到预定义的物理范围,并转换到TrustZone,以便引导和初始化RKP。
通过在内核的文本段中嵌入RKP二进制,它成为EL1可执行的内存范围的一部分。 这使我们可以利用嵌入式RKP二进制文件中的所有小工具 – 使生活更容易。
有了这个新的知识,我们现在可以创建一个小程序,读取阶段1翻译表的位置,使用RKP二进制直接在EL1中的小工具,然后转储和解析表的内容。 由于我们有兴趣绕过由RKP实施的代码加载缓解,我们将关注包含Linux内核的物理内存范围。 编写和运行此程序后,我们面临着以下输出:
...
[256] L1 table [PXNTable: 0, APTable: 0]
[ 0] 0x080000000-0x080200000 [PXN: 0, UXN: 1, AP: 0]
[ 1] 0x080200000-0x080400000 [PXN: 0, UXN: 1, AP: 0]
[ 2] 0x080400000-0x080600000 [PXN: 0, UXN: 1, AP: 0]
[ 3] 0x080600000-0x080800000 [PXN: 0, UXN: 1, AP: 0]
[ 4] 0x080800000-0x080a00000 [PXN: 0, UXN: 1, AP: 0]
[ 5] 0x080a00000-0x080c00000 [PXN: 0, UXN: 1, AP: 0]
[ 6] 0x080c00000-0x080e00000 [PXN: 0, UXN: 1, AP: 0]
[ 7] 0x080e00000-0x081000000 [PXN: 0, UXN: 1, AP: 0]
[ 8] 0x081000000-0x081200000 [PXN: 0, UXN: 1, AP: 0]
[ 9] 0x081200000-0x081400000 [PXN: 0, UXN: 1, AP: 0]
[ 10] 0x081400000-0x081600000 [PXN: 1, UXN: 1, AP: 0]
...
如上所述,整个物理内存范围[0x80000000,0x81400000]在第一级转换表中使用第一级“段”描述符映射,每个描述符负责转换1MB范围的内存。 我们还可以看到,如所期望的,该范围被标记为UXN和非PXN – 因此允许EL1在这些范围中执行存储器,而禁止EL0这样做。 然而,更令人惊讶的是,整个范围用访问许可(AP)位值“00”标记。 让我们参考ARM VMSA,看看这些值指示:
Aha – 所以实际上这意味着这些内存范围也是可读写的从EL1! 结合所有这些,我们得出结论,[0x80000000,0x81400000]的整个物理范围在阶段1转换表中被映射为RWX。
这并不意味着我们可以修改内核的代码。 记住,RKP也执行阶段2的内存转换。 这些存储器范围可以在阶段2翻译中受到限制,以防止攻击者获得对它们的写入访问。
在一些逆转之后,我们发现RKP的初始阶段2翻译表实际上嵌入在RKP二进制本身中。 这允许我们提取其内容并详细分析它,类似于我们以前在阶段1翻译表上的工作。
我写了一个python脚本,它根据ARM VMSA中指定的阶段2翻译表格式分析给定的二进制Blob。 接下来,我们可以使用这个脚本来发现RKP在内核的物理地址范围上实施的内存保护:
...
0x80000000-0x80200000: S2AP=11, XN=0
0x80200000-0x80400000: S2AP=11, XN=0
0x80400000-0x80600000: S2AP=11, XN=0
0x80600000-0x80800000: S2AP=11, XN=0
0x80800000-0x80a00000: S2AP=11, XN=0
0x80a00000-0x80c00000: S2AP=11, XN=0
0x80c00000-0x80e00000: S2AP=11, XN=0
0x80e00000-0x81000000: S2AP=11, XN=0
0x81000000-0x81200000: S2AP=11, XN=0
0x81200000-0x81400000: S2AP=11, XN=0
0x81400000-0x81600000: S2AP=11, XN=0
...
首先,我们可以看到RKP使用的阶段2翻译表将每个IPA映射到相同的PA。 因此,在博客文章的剩余部分,我们可以安全地忽略IPA的存在。
然而,更重要的是,我们可以看到,我们的感兴趣的记忆范围没有标记为XN,如预期的。 毕竟,内核应该是EL1可执行的。 但令人困惑的是,整个范围标记有阶段2访问许可(S2AP)位设置为“11”。 再次,让我们参考ARM VMSA:
所以这似乎有点奇怪…这是否意味着整个内核的代码范围在阶段1和阶段2翻译表中被标记为RWX? 这似乎没有加起来。 事实上,尝试写入包含EL1内核代码的内存地址会导致翻译错误,因此我们肯定在这里缺少一些东西。
啊,但等等! 我们上面分析的第2阶段翻译表只是RKP启动时使用的初始翻译表。 也许在EL1内核完成初始化之后,它会以某种方式请求RKP修改这些映射,以保护其自己的内存范围。
实际上,再次查看内核的初始化例程,我们可以看到,在启动后不久,EL1内核调用RKP:
static void rkp_init(void)
{
rkp_init_t init;
init.magic = RKP_INIT_MAGIC;
init.vmalloc_start = VMALLOC_START;
init.vmalloc_end = (u64)high_memory;
init.init_mm_pgd = (u64)__pa(swapper_pg_dir);
init.id_map_pgd = (u64)__pa(idmap_pg_dir);
init.rkp_pgt_bitmap = (u64)__pa(rkp_pgt_bitmap);
init.rkp_map_bitmap = (u64)__pa(rkp_map_bitmap);
init.rkp_pgt_bitmap_size = RKP_PGT_BITMAP_LEN;
init.zero_pg_addr = page_to_phys(empty_zero_page);
init._text = (u64) _text;
init._etext = (u64) _etext;
if (!vmm_extra_mem) {
printk(KERN_ERR"Disable RKP: Failed to allocate extra memn");
return;
}
init.extra_memory_addr = __pa(vmm_extra_mem);
init.extra_memory_size = 0x600000;
init._srodata = (u64) __start_rodata;
init._erodata =(u64) __end_rodata;
init.large_memory = rkp_support_large_memory;
rkp_call(RKP_INIT, (u64)&init, 0, 0, 0, 0);
rkp_started = 1;
return;
}
在内核方面,我们可以看到,此命令为RKP提供了属于内核的许多内存范围。 为了弄清楚这个命令的实现,让我们把焦点转移回RKP。 通过在RKP中逆向工程此命令的实现,我们得到以下近似的高级逻辑:
void handle_rkp_init(...) {
...
void* kern_text_phys_start = rkp_get_pa(text);
void* kern_text_phys_end = rkp_get_pa(etext);
rkp_debug_log("DEFERRED INIT START", 0, 0, 0);
if (etext & 0x1FFFFF)
rkp_debug_log("Kernel range is not aligned", 0, 0, 0);
if (!rkp_s2_range_change_permission(kern_text_phys_start, kern_text_phys_end, 128, 1, 1))
rkp_debug_log("Failed to make Kernel range RO", 0, 0, 0);
...
}
上面突出显示的函数调用用于修改给定PA内存范围的阶段2访问权限。使用这些参数调用函数将导致给定的内存范围在阶段2转换中被标记为只读。这意味着在引导EL1内核后不久,RKP确实锁定了对内核代码范围的写访问。
…但是,还是有些东西还在这里。记住,RKP不仅应该防止内核的代码被修改,而且还旨在防止攻击者在EL1中创建新的可执行代码。好吧,虽然内核的代码确实被标记为只读在阶段2翻译表,这是否必然阻止我们创建新的可执行代码?
回想一下,我们以前遇到过KASLR的存在,其中内核的基地址(在内核的VAS和相应的物理地址中)被一个随机化的“滑动”值移动。此外,由于Linux内核假定内核地址的虚拟到物理偏移是恒定的,这意味着相同的滑动值用于虚拟地址和物理地址。
然而,这里有一个小小的障碍 – 我们前面研究的地址范围,标记为RWX的同一个是第一阶段和第二阶段翻译表,比内核的文本段大得多。这部分地是为了在确定KASLR幻灯片之后允许将内核放置在该区域内的某处。然而,正如我们刚才看到的,在选择KASLR幻灯片之后,RKP只保护从“_text”到“_etext”的范围,也就是说,在应用KASLR幻灯片之后,只保留包含内核文本的区域。
这使我们有两个大的区域:[0x80000000,“_text”],[“_etext”,0x81400000],在阶段1和阶段2翻译表中留下标记为RWX! 因此,我们可以简单地向这些区域写入新代码,并在EL1的上下文中自由执行,因此绕过代码加载缓解。 我包括一个小PoC,演示这个问题,在这里。
Mitigation #3 – 绕过EL1内存控制
正如我们刚刚在上一节中看到的,RKP的某些目标需要的内存控制不仅在阶段2翻译中实施,而且还直接在EL1使用的阶段1翻译中实施。例如,RKP旨在确保除内核代码之外的所有页面都标记为PXN。这些目标要求RKP对阶段1翻译表的内容具有某种形式的控制。
那么RKP究竟如何保证这些类型的保证?这是通过使用组合的方法;首先,阶段1翻译表被放置在阶段2翻译表中被标记为只读的区域中。这实际上不允许EL1代码直接修改转换表本身。其次,内核被检测(一种半虚拟化的形式),以使其意识到RKP的存在。执行该仪器,使得对在阶段1翻译过程(PGD,PMD或PTE)中使用的数据结构的每个写入操作将改为调用RKP命令,通知它请求的改变。
将这两种防御结合在一起,我们得出的结论是,对阶段1翻译表的所有修改必须通过RKP,这反过来可以确保它们不违反其任何安全目标。
虽然这些规则确实防止了阶段1翻译表的当前内容的修改,但是它们不防止攻击者使用存储器管理控制寄存器来规避这些保护。 例如,攻击者可以尝试直接修改TTBR1_EL1的值,将其指向任意(且不受保护)的内存地址。
显然,RKP不允许这样的操作。 为了允许管理程序处理这种情况,可以利用“管理程序配置寄存器”(HCR)。 回想一下,HCR允许管理程序不允许在EL1下执行某些操作。 可以捕获的一种这样的操作是修改EL1存储器管理控制寄存器。
在Exynos设备上的RKP的情况下,虽然它没有设置HCR_EL2.TRVM(即它允许对存储器控制寄存器的所有读访问),但它确实设置了HCR_EL2.TVM,允许它捕获对这些寄存器的写访问。
因此,虽然我们已经确定RKP正确地捕获对控制寄存器的写访问,但这仍然不能保证它们保持受保护。 这实际上是一个微妙的情况 – Linux内核需要一些访问许多这些寄存器,以执行常规操作。 这意味着虽然某些访问可以被RKP拒绝,但是在允许其继续之前,需要仔细检查其他操作,以确保它们不违反RKP的安全保证。 再次,我们需要反向工程RKP的代码来评估情况。
正如我们可以看到的,尝试修改翻译表本身的位置,导致RKP正确地验证整个翻译表,确保它遵循允许的第1阶段翻译策略。 相比之下,有几个关键的内存控制寄存器,当时,它们不被RKP截获 – TCR_EL1和SCTLR_EL1!
在ARM参考手册中检查这些寄存器揭示了它们都可以对阶段1翻译过程具有深远的影响。
首先,EL1的系统控制寄存器(SCTLR_EL1)对EL1中的系统(包括存储器系统)提供顶级控制。 在我们的场景中一个至关重要的位是SCTLR_EL1.M位。
该位表示用于EL0和EL1中的级1转换的MMU的状态。 因此,只要取消设置此位,攻击者就可以禁用MMU进行阶段1转换。 一旦未置位,EL1中的所有存储器转换都直接映射到IPA,但更重要的是 – 这些存储器转换没有启用任何访问权限检查,有效地使所有存储器范围在阶段1转换中被视为RWX。 这反过来绕过了几个RKP的保证,例如确保只有内核的文本没有标记为PXN。
至于EL1的翻译控制寄存器(TCR_EL1),它的效果略微更微妙。 不是完全禁用阶段1翻译的MMU,该寄存器控制执行翻译的方式。
事实上,更仔细地观察这个寄存器,发现攻击者可以利用它以规避RKP第1阶段保护的某些关键方法。 例如,取决于系统在其下操作的转换颗粒,AARCH64存储器转换表可以采取不同的格式。 通常,AARCH64 Linux内核使用4KB的转换粒度。
这个事实在RKP中被承认。 例如,当EL1中的代码改变转换表的值(例如,TTBR1_EL1)时,RKP必须在阶段2转换中保护该PGD,以便确保EL1不能获得对它的访问。 确实,颠倒RKP中的相应代码揭示了它只是:
然而,如我们在上面的图片中可以看到的,阶段2保护仅在4KB区域(单页)上执行。 这是因为当使用4KB转换颗粒时,转换机制具有4KB的转换表大小。 然而,这是我们作为攻击者进入的地方。如果我们通过修改TCR_EL1.TG0和TCR_EL1.TG1来将翻译颗粒的值改为64KB呢?
在这种情况下,翻译机制的翻译表现在也将是64KB,而不是在以前的制度下的4KB。 由于RKP在保护转换表时使用硬编码值4KB,底部60KB保持不受RKP保护,允许EL1中的攻击者自由修改它以便指向任何IPA,更重要的是具有任何访问权限, UXN / PXN值。
最后,应该再次注意到,虽然访问这些寄存器的小工具在内核的映像中并不丰富,但是它们存在于Exynos设备上的嵌入式RKP二进制文件中。 因此,我们可以简单地在EL1中执行这些小工具,以修改上面的寄存器。 我写了一个小PoC,通过禁用阶段1 MMU在EL1中演示此问题。
Mitigation #4 – 访问阶段2未映射内存
除了操作系统的存储器之外,存在可能包含不应当由在EL0和EL1中运行的代码访问的潜在敏感信息的若干其它存储器区域。例如,SoC上的外设可以将其固件存储在“正常世界”中,在物理存储器范围中,Android应该从不能访问它。
为了实施这样的保护,RKP显式地从阶段2转换表中取消映射几个存储器范围。通过这样做,任何尝试访问EL0或EL1中的这些PA范围将导致翻译错误,从而崩溃内核并重新启动设备。
此外,RKP自己的内存范围也应该使得较小特权代码不可访问。这是至关重要的,以便保护RKP免受EL0和EL1的修改,但也用于保护在RKP中处理的敏感信息(例如“cfprop”键)。实际上,启动后,RKP显式地取消映射它自己的内存范围,以防止这种访问:
不可否认,阶段2翻译表本身被放置在从阶段2翻译表未映射的非常区域内,因此确保EL1中的代码不能修改它。 然而,也许我们可以找到另一种方法来控制阶段2的映射,但利用RKP本身。
例如,如我们之前所见,某些操作(如设置TTBR1_EL1)会导致对阶段2转换表的更改。 组合RKP二进制,我们遇到一个这样的操作,如下:
__int64 rkp_set_init_page_ro(unsigned args* args_buffer)
{
unsigned long page_pa = rkp_get_pa(args_buffer->arg0);
if ( page_pa < rkp_get_pa(text) || page_pa >= rkp_get_pa(etext) )
{
if ( !rkp_s2_page_change_permission(page_pa, 128, 0, 0) )
return rkp_debug_log("Cred: Unable to set permission for init cred", 0LL, 0LL, 0LL);
}
else
{
rkp_debug_log("Good init CRED is within RO range", 0LL, 0LL, 0LL);
}
rkp_debug_log("init cred page", 0LL, 0LL, 0LL);
return rkp_set_pgt_bitmap(page_pa, 0);
}
正如我们所看到的,这个命令从EL1接收一个指针,验证它不在内核的文本段内,如果是这样,继续调用rkp_s2_page_change_permission,以便在阶段2翻译表中修改这个范围的访问权限。 深入了解函数揭示了这组参数用于将区域表示为只读和XN。
但是,如果我们要提供一个驻留在当前没有映射到stage 2翻译的地方的页面,例如RKP自己的内存范围呢? 好吧,在这种情况下,rkp_s2_page_change_permission将很乐意为给定页面创建一个翻译条目,有效地映射到以前未映射的区域!
这允许我们从EL1重新映射任何阶段2未映射区域(尽管为只读和XN)。 我写了一个小PoC,通过阶段2重新映射RKP的物理地址范围并从EL1读取它来演示该问题。
设计改进RKP
在看到这篇博文中的一些具体问题后,强调了RKP的不同防御机制如何被攻击者颠覆,让我们思考一下,考虑一些设计选择,以加强RKP的安全态势,防止未来的攻击。
首先,Exynos设备上的RKP当前正由EL1代码引导。这与Qualcomm设备上使用的模型形成了对比,EL2代码由引导加载程序进行验证,随后由EL3引导。理想情况下,我认为Exynos也应该采用在高通设备上使用的相同型号。
以此顺序执行引导会自动修复其他相关的安全问题,例如在内核文本段中存在RKP的二进制文件。正如我们所看到的,这个看似无害的事实在我们在本文中强调的几种情况下对于攻击者是非常有用的。此外,它消除了其他风险,例如攻击者在引导过程中早期利用EL1内核,并利用该访问来颠覆EL2的初始化。
在临时改进中,RKP决定在初始化期间清零在EL1代码中驻留的RKP二进制。这种改进将在三星设备的下一个Nougat里程碑版本中推出,并解决攻击者利用二进制小工具的问题。然而,它没有解决关于潜在的早期利用EL1内核颠覆EL2的初始化的问题,这需要更广泛的修改。
第二,RKP的代码段目前在TTBR0_EL2中标记为可写和可执行。这与SCTLR_EL2.WXN未设置的事实相结合,允许攻击者使用EL2中的任何内存破坏原语,以便直接覆盖EL2代码段,从而更容易利用虚拟机管理程序。
虽然我没有选择在博客文章中包括这些问题,但我发现几个内存损坏,其中任何可以用于修改RKP上下文中的内存。将这两个事实结合在一起,我们可以得出结论,任何这些内存损坏都可以被攻击者用来直接修改RKP的代码本身,从而获得代码执行。
简单地设置SCTLR_EL2.WXN并将RKP的代码标记为只读不会阻止攻击者访问RKP,但是它可以利用这样的内存损坏更难以利用并且更加耗时。
第三,RKP应锁定所有存储器控制寄存器,除非它们绝对必须由Linux内核使用。这将防止滥用这些寄存器,这些寄存器可能会对系统的行为产生影响,并且这样做会违反RKP关于内核的假设。当这些寄存器必须由EL1修改时,RKP应该验证只有适当的位被访问。
RKP已经锁定了访问这个博客文章中提到的两个寄存器。这是朝着正确方向迈出的一个很好的一步,不幸的是,必须保留对这些寄存器中的一些寄存器的访问权,所以简单地撤销对它们的访问不是一个可行的解决方案。因此,防止对其它存储器控制寄存器的访问仍然是长期目标。
最后,应该有一些区别,第二阶段未映射区域,从来没有映射,和那些明确映射出来。这可以通过存储与明确未映射区域相对应的存储器范围并且不允许将导致在RKP内重新映射它们的任何修改来实现。虽然我强调的问题现在是固定的,实施这个额外的步骤将防止未来出现类似的问题。