前言
本文根据英文原文“Exploit the Last Straw That Breaks Android Systems”整理撰写。原文发表在IEEE Symposium on Security and Privacy. 2022。作者在完成英文原文工作时,为复旦大学在读博士生。本文较原文有所删减,详细内容可参考原文 。
01
介绍
安卓系统服务在系统执行很多重要任务时都扮演着关键角色,特别是用于存储用户和系统数据。例如,系统服务“AccountManagerService”可以帮助应用程序保存用户帐户信息,方便用户在打开应用程序时避免重复登录。
近年来,安卓系统服务的安全性受到越来越多的关注。然而,直到现在,它们的数据存储过程很少被理解和分析。在本文中,我们首次对安卓系统服务的数据存储过程进行了系统性的安全分析。并发现了几种不同类型的关键数据存储操作和指令,它们经常被使用,但在系统服务内部却没有得到保护。具体来说,许多关键数据存储指令通过公开的系统服务接口暴露给不可信的第三方应用程序。恶意的第三方应用程序可以向目标系统服务发送精心构造的消息,访问其数据存储指令,并将垃圾数据注入相应的内存对象。随着攻击的不断累积(重复多次访问),所有内存资源(例如,堆内存)将会被耗尽。最终,这种攻击将导致受害的系统服务崩溃,并进一步导致安卓系统崩溃(即重新启动)。
此外,我们发现某些DoS(拒绝服务)攻击会导致永久性的影响。在某些情况下,设备受到攻击后将无法通过重新启动恢复正常。在图1的例子中,“AccountManagerService”系统服务允许应用程序使用其公开接口“addAccountExplicit()”保存帐户信息。然而,在接口内部实现中,数据存储操作“db.insert()”被暴露,它将输入的账户信息添加到数据库中并永久性保存。虽然系统服务会验证所存储的帐户是否属于发起请求的应用程序,但它并不限制应用程序可以存储的帐户数量。因此,恶意应用程序可以通过多次请求该服务来存储大量的帐户信息,最终导致帐户数据库的大小(通常会被加载到内存中)超过系统服务的内存上限(例如,在Pixel 3XL设备中系统服务进程的堆内存限制为512MB),系统服务进程崩溃,导致整个系统重新启动。更糟糕的是,每次重新启动安卓系统时,都需要启动该关键系统服务,并将帐户数据库中的内容加载到内存中。由于数据库过大,该服务很快就会占用过多的内存并被关闭。因此,安卓系统将会陷入无休止的重启和崩溃循环,导致持久性DoS攻击。
同时,我们还发现许多攻击目标(即暴露的数据存储指令)并不仅仅与保存用户或应用程序信息(如帐户信息)相关。例如,窗口系统服务“WindowsManagerService”会创建一个窗口会话以捕获当前屏幕上的用户操作,这个窗口会话存储在容器中,也会受到攻击。
图 1:AccountManagerService中添加帐户信息的简化版调用链
如上所述,这类安全缺陷的一个(主要)原因是对系统服务中的数据存储操作缺少保护。安卓系统服务中容器(如数组、集合、数据库等)的生命周期设计是另一个重要原因。为了提供流畅的用户体验,Android系统服务始终随时准备为应用程序服务。具体来说,无论应用程序是否在使用,系统服务始终准备着各种容器来存储应用程序的必要数据。值得注意的是,这是安卓系统的基本功能。例如,安卓系统允许应用程序注册许多事件监听器,当特定的系统事件发生时,这些监听器将被触发。相应的系统服务在应用程序启动之前创建一个容器来存储这些事件监听器,并在应用程序关闭后释放它们。因此,在应用程序事件监听器被存储到容器中的时间(time-to-store)和被释放的时间(time-to-release)之间有一个很长的时间窗口,这给了攻击者发动攻击的机会。
在本文中,我们将这种拒绝服务攻击称为稻草攻击(这种攻击依赖于不断向Android系统服务注入数据,就像是在骆驼背上不断添加稻草,最终将其破坏)。从本质上讲,稻草攻击属于一种空间DoS攻击,它依赖于对数据存储操作的累积影响——每次数据存储对系统服务内存的影响很小,但攻击者可以通过在数据存储到释放的时间窗口内连续调用易受攻击的接口来累积影响。
在了解了稻草攻击之后,我们致力于设计针对这类漏洞的自动化检测和验证工具。然而,这不是一项容易的任务,它需要解决若干挑战并实现以下目标:
■ 覆盖尽可能多的数据存储指令。Android系统会在应用程序请求之前准备大量的资源,然而,没有详细的文档可以告诉我们相应的机制是如何设计和实现的。此外,子系统(服务)的定义不明确,实现上分散在庞大的Android代码库中。例如,“audio”系统服务的文档只说明了其负责与音频硬件的交互。然而,当应用初始化音频播放器时,它的音频配置信息会存储在系统服务中,而此配置仅在注册的音频播放器触发特定事件时使用。因此,该工具应该能够理解数据存储过程,并尽可能多地找到数据存储指令的类型。
■ 支持测试大量不同类型的服务接口。在Android中存在大量不同的系统服务接口,这些接口具有不同的功能和复杂的代码逻辑,理解它们需要强大的领域知识。更糟糕的是,不同的接口通常需要不同类型的输入。因此,该工具应该是通用的、可扩展的,并且独立于领域知识。此外,最好能够向系统服务接口提供适当的输入在测试期间引导服务触发尽可能多的漏洞。
■ 以轻量而有效的方式监控攻击效果。发起稻草攻击通常需要累积效应。触发一次数据存储指令所造成的影响,例如内存变化,通常是很微小的,难以监控。因此,该工具应该能够对内存变化比较敏感。然而,仅靠内存变化对于漏洞检测来说是比较低效的,因为它无法判断数据驻留的时间窗口是否足以耗尽系统服务内存。因此,为了确认漏洞,我们应该要触发攻击效果,例如,系统服务崩溃。此外,为了触发这样的结果,每个数据存储操作可能需要执行成千上万次。因此,该工具应该以轻量级的方式进行分析。
为了实现这些目标,我们提出一种基于定向灰盒模糊测试(DGF)的方法,称为StrawFuzzer。我们的基本思路是不断向系统服务进程发送输入数据(通过Android API调用),并监视其内存大小的变化,以查看其是否能够及时释放注入的数据。StrawFuzzer结合了静态分析和动态分析技术,可以有效地检测和验证Android系统服务中的straw漏洞。首先,通过静态分析可以了解系统服务的全貌,并且能够定位所有潜在的数据存储指令。但是,静态分析很难得到运行时内存的状态。因此,我们引入动态模糊作为补充,它不仅可以监控运行时内存使用情况,还可以生成PoC来验证漏洞。
遵循上述方法,StrawFuzzer被设计为一个两阶段的分析工具。在第一阶段(即静态分阶段析),StrawFuzzer首先在系统服务代码上应用静态程序分析技术来生成调用图、控制流图和数据流图。接下来StrawFuzzer设计了一些启发式规则,以尽可能多地定位易受攻击的数据存储指令,并将其用作下一阶段模糊测试的目标。另外,StrawFuzzer可以提取相关的路径约束条件来生成高质量的种子输入。在第二个分析阶段(即动态模糊测试阶段),StrawFuzzer验证暴露的数据存储指令是否可以被注入并超过内存限制。首先,StrawFuzzer使用一个轻量级的插装环境来仔细监控内存大小的变化,并计算输入到目标测试指令的距离。其次,StrawFuzzer使用自适应策略对种子进行优先级排序,以实现在路径探索(旨在执行到目标指令)和漏洞利用(旨在耗尽内存)之间进行良好的平衡。最后,当一个漏洞被确认时,StrawFuzzer将收集所有结果以生成漏洞利用。
我们在3个流行的Android系统(Pixel3上的Android 10.0, Pixel3 XL上的Android 11.0,Oneplus7上的Android 10.0)上测试了StrawFuzzer,成功发现了35个straw漏洞和474个易受攻击的系统服务接口,这些漏洞影响了约35%的Android系统服务,即使是高权限访问控制的接口也可能受到攻击。我们进一步分析了华为、三星和Vivo的3个定制化Android系统,发现它们继承了原生Android系统中的大部分漏洞。此外,攻击者可以控制攻击速度,我们发现至少42%的攻击可以在1秒内完成,90%的攻击可以在77.6秒内完成。除了系统服务,我们确认至少存在3个漏洞可以被利用以攻击Android应用程序提供的服务,因为应用程序提供的服务与系统服务设计和实现原理相同,同样受这类漏洞的影响。具体来说,我们收集了Google Play上排名前100的免费应用,并确认其中76个应用能够遭受稻草攻击。
02
理解稻草攻击
在本节中,我们将解释稻草攻击是如何执行的,以及为什么这些漏洞在Android中普遍存在。为了保持一致性,本文使用服务端进程表示接收数据的进程,客户端进程指代在进程间通信中发送数据的进程,公开接口指代服务端进程提供的Android API调用。此外,客户端进程通过发送包含输入参数的请求向服务端进程注入数据。由于Android系统和应用程序都可以提供接收数据的服务,所以它们都可以作为服务端进程,受到稻草攻击的影响。
1. 安卓服务中的稻草攻击
稻草攻击利用服务端代码中的逻辑缺陷来耗尽其内存资源。也就是说,服务端进程不限制其存储的从客户端进程发送的数据总量。在这类攻击发起的过程中,恶意应用程序不需要特殊的配置,看起来就像一个普通的应用程序。具体而言,在每次迭代中,恶意应用程序调用Android服务中的一个公开接口,随后Android Binder提供所需的进程间通信(IPC)来连接客户端和服务端进程。接下来,目标接口在服务端进程中被执行,使用来自客户端进程的输入数据。从接口执行过程中触发易受攻击的数据存储指令。执行完成后,数据被存储并保留在服务器内存中。值得注意的是,恶意应用程序调用公开接口的行为和所有其他无害应用程序是相同的,因此很难将其与其他应用程序区分开来,唯一的区别是恶意应用程序会多次调用该接口。
Android包含大量的系统服务(例如,Android 11.0中有200个服务)和应用程序(GooglePlay中有超过280万个应用程序)。尽管它们基于Binder 进程间通信实现了不同的功能,但底层机制是相同的——客户端数据首先序列化存储在parcel中,通过Binder.transaction()发送到服务端,然后服务端进程在Binder.onTransaction()中对输入数据进行反序列化,并传递到对应的本地方法。如果传入的数据存储在服务端内存中,则会消耗服务端进程的内存资源。因此,对它们而言,稻草攻击是相似的。此外,Android Binder使用固定大小的缓冲区来限制进程间通信的数据不能超过1MB,并且这个缓冲区是系统中所有进程间通信所共享的。
2. 根因分析
不一致的生命周期。通过基于Binder的进程间通信,客户端进程可以向服务端进程发送数据。但是,由于服务端进程数据存储(time-to-store)和数据释放(time-to-release)操作之间存在时间窗口,导致客户端进程和服务端进程中这部分数据的生命周期存在了不一致。更具体地说,当进程间通信结束时,客户端进程可以释放数据并回收它们占用的内存,但是服务端进程将长时间保存这些数据,以备将来使用。例如,在图2中,客户端应用程序创建了一个用于处理用户操作的窗口会话,对于每个会话,服务端和客户端进程都维护着一些数据结构来存储此会话的数据。然而,如果客户端应用程序释放了这些数据占用的内存,服务端进程仍然会保持这个会话。事实上,通过linkToDeath()将会话与客户端应用程序的生命周期绑定后,服务端进程将不会回收此会话,除非客户端应用程序终止。因此,进程间通信传递的数据对象的生命周期不一致,会导致服务端进程比客户进程消耗更多的内存资源。
图2:在WindowManagerService中注册Windows会话的调用链简化。
Android系统开发人员也可能对存储在系统服务端进程中的数据的生命周期感到困惑。图2所示的Android源代码中的注释就是一个例子,他们认为一旦调用者进程结束,这个会话对象应该被释放,他们想知道是否应该主动使用killSessionLocked()来回收它。但是,根据我们的实验发现,当调用者进程结束时,服务端进程不会立即杀死会话对象。实际上,在我们结束调用方应用程序后,平均需要大约385秒后服务端才会释放这些数据。如果我们直接卸载调用方应用程序,则平均需要大约450秒。这个时间窗口可能会被攻击者进一滥用,误导移动用户。例如,攻击者应用程序可以事先消耗大部分系统服务器内存资源,这些资源只比上限小一点。在用户关闭攻击者应用程序并启动另一个应用程序后,一旦该应用程序消耗服务器内存,系统服务器就会发生内存溢出而崩溃,这从移动用户的角度来看,攻击者的角色被嫁祸给了另一个应用程序。
有限的内存使用。Android对每个进程的内存资源使用(包括系统服务进程)实施了限制,这代表服务端进程内存中存储的数据总量有一个上限,例如,在Pixel 3XL (Android 11)中每个进程的堆内存上限为512MB。如果系统服务进程的堆内存被耗尽,将会抛出'java.lang.OutOfMemoryError '并崩溃。此外,Android还设置了大量的定制化内存资源限制。例如,如图3所示,在“传感器”服务中注册的传感器监听器的数量不能超过128个。否则它将触发'java.lang.IllegalStateException'。
图3:传感器服务中限制注册的传感器监听器数量不能超过128个。
当这些有限的内存资源被耗尽,将引起Android恢复机制的关注,如Watchdog和ANR。这些机制原本被设计用于在系统运行至错误状态时重新启动系统,然而攻击者可以通过耗尽系统服务进程内存资源,控制系统服务进程何时以及多久会进入一个错误状态,这远远超出了Android恢复机制原本的设计目的。此外,虽然Android实现了大量的系统服务,但它们大多运行在同一个进程中,即系统服务进程。因此,一个系统服务的崩溃会破坏整个系统服务进程,影响到其他系统服务。
内存大小检查不足。保护服务端进程避免内存资源耗尽的一个直观方法是在存储来自客户端的数据之前检查当前可用内存的大小。但是,我们发现一些内存检查是不完整的。在图4中的例子,Android的“window”系统服务期望每个客户端应用程序只注册一个Session对象来与窗口管理器交互,然而恶意应用程序可以注册任意多的Session对象。由于缺乏必要的检查,这最终会耗尽服务器内存资源。
此外,Android进程间通信存在设计缺陷,这给服务端进程执行内存检查带来了挑战。例如,图5展示了Android 进程间通信机制提供的两个反序列化客户端数据的接口。与createFloatArray()相比,createStringArray()不会检查服务器的可用内存是否足够存储客户端数据。为了执行这样的内存大小检查,服务端进程需要知道客户端数据将要消耗的内存的确切大小。对于createStringArray(),服务端进程需要知道数组长度(即第2行中的N)和每个java.lang.String对象所占用的内存大小。不幸的是,每个java.lang.String对象的内存大小取决于其中的字符数,这是未定义的,可以是任意大小。因此,服务端进程无法计算字符串数组所需的确切内存大小,从而无法与可用内存进行比较。这个缺陷可以被攻击者滥用来攻击大量的公共接口,甚至是特权接口。
最后,强制的内存大小检查对所有应用程序来说都是一把双刃剑,也可能被攻击者滥用来发起DoS攻击。例如,虽然在createFloatArray()中检查服务端进程当前可用内存(dataAvail())可以防止服务器内存耗尽,但如果服务端进程没有足够的可用内存,它将拒绝所有应用程序向其发送的数据。因此,如果恶意应用程序首先在服务端进程中存储了大量数据,仅比内存上限小一些,那么其他应用程序将无法正常使用系统服务。
图4:Android系统框架开发人员希望每个客户端进程有一个会话。
图5:createStringArray() vs. createFloatArray()
(本文只选取原文中部分章节,更多精彩内容敬请期待后续出版的《网络安全研究进展》)
作者简介
廉轲轲,复旦大学计算机科学技术学院,系统软件与安全实验室在读博士生,研究方向为漏洞挖掘与利用技术研究,目前主要关注移动安全,云服务安全。已在IEEE S&P上发表多篇成果,并获得众多厂商和组织的认可与致谢,包括Google,HUAWEI,VIVO,XiaoMi,Tencent等。
相关阅读
【S&P 2022论文分享】以Protocol为中心的UEFI固件SMM提权漏洞静态检测
【Usenix Security 2022论文分享】StateFuzz: 状态敏感的Linux内核驱动模糊测试