1
博客
-
基于eBPF实现一个简单的隐蔽脱壳工具-eBPFDexDumper
0x00 参考0x01 引言0x02设计与实现1. ART虚拟机解释器2. 设计与实现3. 现有不足0x03 测试与使用说明1. 环境说明2. 删除OAT优化文件3. (可选)提取偏移4. 开始Dump5. 编译方法如果在Android12+ 出现无法解析libart.so或自己也无法找到对应符号的情况可能需要进行art回滚,参考https://devblog.lac.co.jp/entry/20221021eBPF作为运行在内核的的一大追踪利器,其有着隐蔽性高,不需要重新编译内核或系统等优点。而在Android平台中,字节码主要在ART虚拟机中被执行,那么其实只需要追踪ART虚拟机执行过程中的一些关键函数即可被动的捕捉到DexFile
,从而实现脱壳。因此实现此小Demo,旨在抛砖引玉。该工具可作为FRIDA-DEXDump
等被动式脱壳工具的替代,并且有着更好的隐蔽性,完全不需要PTRACE附加调试目标程序或注入动态链接库。但是由于eBPF的局限性,其无法替代FART等基于主动调用的脱壳工具。除此之外,对于代码抽取等情况并未实现,各位大佬有需要可以自行实现。ART 虚拟机执行字节码有两种方式:一是解释执行,二是执行编译后得到的机器码。而在解释执行这部分又可以分为两种,以Android13为例,一种是走Nterp
高效解释器(其实严格意义不算解释模式了,叫快速模式比较好),另外一种走Switch
解释器,一般都是走前一种,后一种多用于指令追踪等情景。Nterp
是ART为优化字节码解释执行而引入的一种改进型解释器,在Android 11分支上就被引入了,只不过仅实现了x86_64架构,而在Android S上实现了arm和arm64架构。Nterp采用了和Native方法一样的栈帧结构,并且译码和翻译执行全程都由汇编代码实现,进一步拉进解释器和compiled code的性能差距。如果采用这种方法,执行路径直接到ninterpreter::ExecuteNterpImpl
- 如果不支持Nterp,则一个方法的执行路径从
art_quick_to_interpreter_bridge
到nartQuickToInterpreterBridge
再到EnterInterpreterFromEntryPoint
再进入interpreter::Execute
最终走到interpreter::ExecuteSwitch
利用eBPF对这两条路径进行追踪,除此之外,加入对art::verifier::ClassVerifier::VerifyClass
的追踪,防止漏掉一些动态加载的DexFile(经过测试其实还是走这两条路径,但是可能出于其他原因没办法追踪到),能Dump的更全。下面的结构为Android 13.0.0
的ArtMethod
的结构,其中entry_point_from_quick_compiled_code_
保存了这个方法的执行地址。当要执行某个方法时,运行时必须首先检查该方法所属的类是否已加载。如果未加载,则Runtime
将加载并链接该类。在这个过程中ART会调用UpdateClassAfterVerification
更新已经验证过的类中所有方法的入口点。如果能够使用nterp
,则将原先使用switch interpreter
的方法切换到nterp
。具体做法是遍历该类的所有方法,检查它们当前的入口点是否为QuickToInterpreterBridge
,如果是,则调用Instrumentation::InitializeMethodsCode
来更新入口点。这样,完成验证的类就可以切换到更高效的 nterp 解释器执行因此在设计上,通过eBPF
的Uprobe
去动态追踪libart.so
的三个关键函数,并从参数中提取ArtMethod
指针,进一步从中获取DexFile的指针并且获取DexFile的起始地址和size,即可实现被动式脱壳,即当方法被执行时即可获取到其所在DexFile。具体而言,在内核层通过eBPF追踪三个关键函数,提取到DexFile
的起始地址和size信息,然后将其传回用户态go程序。go程序读取到事件缓存区中的数据后,立即通过远程进程内存访问获取到dex文件并进行Dump。Linux
远程进程内存访问可通过process_vm_readv
和process_vm_writev
来进行。但是调用这两个 syscall 来实现远程进程访问会被目标检测到。原理是通过是否内存缺页(例如通过mincore
)来判断特定内存是否被访问过。但是在脱壳场景中是没有任何问题的。踩坑:前面在没有想到通过process_vm_readv
读取内存时,试图在eBPF程序内直接读取用户内存,并且将其分片带出到用户态(缓存区有大小限制),但是这个过程中会有大量的数据传输,极易丢失数据,从而使dump出的dex可用性较差。- 非主动调用脱壳
因为是基于eBPF的脱壳方式,只能实现被动的脱壳,无法实现类似于FART的主动调用脱壳,这是一个遗憾。需要哪部分的代码就必须使虚拟机执行到对应的部分才可以成功Dump,但是这对于一般的场景已经够用了。- 暂未实现抽取壳被抽取代码的部分
本项目只做一个小的demo,旨在抛砖引玉,对于代码抽取等情况并未实现,各位大佬有需要可以自行实现。需要有一台支持eBPF的设备,最好内核版本为5.10及以上,需要获取root权限,保证其Uprobe是可用的(一般是开启的)如果不删除优化文件 ,代码将会以其他方式执行从而无法在上述两个函数位置进行拦截,或获得的dump文件是cdex结构,不方便从中提取dex。因此,阻止代码的优化执行是一切的开端。注意可以选择(非必要)关闭dex文件的checksum校验以防止出现不可预期的校验错误,以JADX 1.5.1为例程序自动解析并获取所需要的三个函数的偏移,但是如果失败了则需要手动指定。这一步需要获取设备上libart.so
并进行反编译,从中提取三个函数的偏移。以测试设备Android13
为例,其他版本类似- 首先需要找到
ExecuteNterpImpl
函数的偏移0x200090
,这是最主要的解释器,大部分方法都会走这里。
- 其次还需要
art::interpreter::Execute
的偏移,防止漏掉走Switch
解释的方法。
- 还需要
art::verifier::ClassVerifier::VerifyClass
的偏移,防止漏掉其他动态加载的dex
。
拉取代码后简单修改Makefile然后make即可,在这之前需要有go的编译环境 -
Android 悬浮窗覆盖攻击
参考0x00 引言0x01 攻击实施1. 悬浮窗权限申请2. 悬浮窗服务3. 欺骗点击4. 劫持记录5. 测信道击键推断6. 攻击时机0x02 防护1. 系统应用屏蔽第三方悬浮窗2. 普通应用隐藏第三方悬浮窗- https://github.com/NoahS96/Cloak-And-Dagger 中包含了一些Android覆盖攻击的示例代码
- https://github.com/LLeavesG/Android-Overlay-Hijack 中是我实现的简单demo
下面的内容引用自参考1点击劫持Tapjacking
,是一种欺骗用户进行点击的攻击技术,可存在于任何操作系统和浏览器之中,尽管原理简单,对于普通用户危害却极大,是一种容易忽视的安全威胁。Android中的点击劫持原理如图1所示,当出现重要的、需要进行用户确认的安全对话框时,申请悬浮窗权限的恶意APP在受害APP之上进行部分覆盖,显示一个虚假的界面,隐藏了与安全相关的重要提示信息,但并未覆盖正常APP原有的按钮,诱骗用户进行错误地点击操作,点击事件结果最终传递到受害APP,造成严重的安全后果。在Android系统当中,要能够在其他APP的界面之上显示,恶意APP首先需要申请悬浮窗权限SYSTEM_ALERT_WINDOW
。该权限允许普通APP使用WindowManager.LayoutParams.TYPE_APPLICATION_OVERLAY
标志位,在其他APP之上显示需付出。按照Android的设计,该权限仅供很少的APP使用,当Target SDK 大于等于23时,还需要主动请求悬浮窗权限并经过用户手动同意(这也意味着APP Target SDK < 23时是默认开启的)曾有一段时间通过GooglePlay上安装的APP,若申请SYSTEM_ALERT_WINDOW
权限,在安装后均默认开启。该权限也经常被现实中的恶意APP滥用(参考4)。在具有悬浮窗权限之后,恶意APP还必须寻找一个劫持的时机,判断当前显示的Activity或者Fragment是否是重要的安全确认对话框,仅该时机实施点击劫持,否则达不到应有的攻击效果。当Build.VERSION.
SDK_INT
>= Build.VERSION_CODES.
M
时需要动态申请,即会弹出一个页面,需要用户手动选择第三方App并且授予对应的悬浮窗权限。悬浮窗以服务的方式进行实现,主要为了保活和监控劫持时机,这就需要组件有独立的生命周期,那就需要Service
来实现需要注意其中的几个参数:FLAG_NOT_TOUCH_MODAL
参数如果被设置,那么就意味着用户的触摸事件会被悬浮窗劫持,即可以在悬浮窗上接收到点击的坐标位置,那么也能进行对应的绘图,但是这样触摸事件就不会被传递到下层的其他App,仅能记录用户点击,也就是无法实现欺骗(但是如果完全伪造界面全覆盖就有可能欺骗),一般用于窃取用户输入数据等。
FLAG_NOT_TOUCHABLE
参数如果被设置,悬浮窗仅悬浮在应用界面,而无法触摸也。意味着用户的点击会被传递到下层,但是无法记录点击事件,即可以欺骗无法窃取。
FLAG_WATCH_OUTSIDE_TOUCH
参数可用于监听用户在对话框之外的点击,可用于虚假对话框的退出时机。
其中下面的代码用于调整悬浮窗大小,下面的是铺满全屏的意思。恶意APP能够主动控制劫持的时机,可以主动请求敏感操作,在显示用户确认对话框的同时,直接在上悬浮一个虚假的对话框。Android系统之前修复的一系列漏洞,均属于这种类型:- CVE-2020-0306:蓝牙发现请求确认框覆盖
- CVE-2020-0394:蓝牙配对对话框覆盖
- CVE-2020-0015:证书安装对话框覆盖
- CVE-2021-0314:卸载确认对话框覆盖
- CVE-2021-0487 : 日历调试对话框覆盖
在声明REQUEST_DELETE_PACKAGES
权限的情况下,通过Intent传递参数请求卸载App,在这个过程中系统会弹窗要求确认,在一些低版本Android(例如8)中该弹窗能够成功被覆盖显示出误导内容,欺骗用户点击OK确认卸载。需要注意覆盖的内容仅为标题和文字,确认和取消按钮不被覆盖,即下面的按钮还是属于PackageInstaller
应用的。在悬浮窗service
中添加MotionEvent.ACTION_OUTSIDE
事件的处理,即点击到悬浮窗外部事件,此时需要移除悬浮窗windowManager.removeView(button)
,以使得悬浮窗在用户进行点击操作后与弹窗一起消失,防止被发现。当用户点击OK时,其实已经卸载了恶意APP请求卸载的package,而此时恶意APP也已经监听到了ACTION_OUTSIDE
事件,于是主动退出。在整个欺骗过程中,用户难以察觉自己其实已经确认了一次卸载操作。除了”主动请求、主动劫持“这种恶意APP可以主动控制劫持时机的情况,恶意APP还可以监听用户确认对话框出现的其他时机,例如有特定的广播事件、特定的通知,在时机出现的时候进行劫持。例如,在重要对话框出现时,将出现一个通知,恶意APP可以监听通知该通知的出现,通过实现一个NotificationListenerService
,捕捉特定的通知,并启动服务,在原有对话框之上悬浮一个欺骗的对话框n漏洞案例:CVE-2020-0394nCVE-2020-0394 即为这种情况,当蓝牙配对发生时,蓝牙APP会发送一个通知,用户点击通知以后就会出现蓝牙配对对话框,供用户确认或取消下面的代码则是自定义的悬浮窗布局,可以设置为拦截后在悬浮窗绘图,实现信息窃取等攻击。效果可以见SekaiCTF2024 Hijacker赛题
,可以在设备装入恶意App,并且授予任何权限,模拟用户点击输入PIN,要求窃取PIN时就可以使用上面的代码,设置六个不同颜色的框来记录按键顺序,即可读取用户输入的PIN码。当然这里的场景是不出网截图,真实攻击场景是记录左边直接传回,不需要绘图以免被发现。还有一个问题就是点击事件不能传递到下层,这也会暴露。- Android为了安全引入了一个标志
FLAG_WINDOW_IS_OBSCURED
来保护用户点击事件的安全性。每次点击时,接收点击事件的控件会收到一个MotionEvent
对象,该对象存储了相关信息。在MotionEvent
对象中添加了该标志(简称为“遮挡标志”)。如果点击事件在到达最终目的地(例如“确定”按钮)之前经过了一个不同的覆盖层,则该标志会被设置为true
。该机制本用于确定在点击控件上层有无覆盖层,依此对用户点击事件进行安全处理。但该机制能够被用于一种测信道攻击,达到推断按键的目的。
FLAG_WATCH_OUTSIDE_TOUCH
标志允许覆盖层接收到任何点击事件的通知,即使点击发生在应用程序之外。为了安全起见,如果点击发生在覆盖层所属的应用程序之外,点击事件的精确坐标会被设置为(0,0)
。只有当点击发生在应用程序内部时,才会提供精确的坐标。这种机制防止攻击者通过坐标推断用户点击的位置,从而保护用户隐私和安全。因此通过FLAG_WATCH_OUTSIDE_TOUCH
记录外部App点击坐标的攻击是不成立的。
攻击者在底层按一定的顺序添加透明覆盖层,这些覆盖层不拦截点击事件直接将事件传递到底层。即通过添加TYPE_SYSTEM_ALERT
、FLAG_NOT_FOCUSABLE
、FLAG_NOT_TOUCHABLE
和FLAG_WATCH_OUTSIDE_TOUCH
标志。这些标志确保每个覆盖层不会拦截用户的点击(即,当用户点击键盘的键时,点击会到达键盘)。在四个按键的情况下,按1234的顺序创建覆盖层,使得视图堆栈中的覆盖层保持这个顺序(覆盖层#1在栈底,覆盖层#4在栈顶)。当点击不同按键时由于FLAG_WATCH_OUTSIDE_TOUCH
标志,每个覆盖层都会接收到一个MotionEvent
对象,但这些点击事件不包含用户实际点击位置的信息。对于每个覆盖层,obscured
标志的设置取决于用户是否点击了其上方的覆盖层。例如,如果用户点击覆盖层#4,所有接收到的事件的obscured
标志都设置为0,即传递给覆盖层#1,#2,#3的事件的obscured
标志将设置为1。从而推断出用户点击了第四个按键同理可以推断出其他按键的点击在实际攻击场景中,如下图所示的应用中,为每一个键添加一个覆盖层,覆盖层按照特定的顺序创建,并组织成堆栈:覆盖层#0(左上角)在堆栈底部,而覆盖层#42(右下角)在堆栈顶部。上文讲了很多,但是一直没讲到在哪个时间点实施攻击。- 在主动的情况下,例如主动发起卸载App的请求时的情况下,攻击实施时机是可控的,不需要做其他的感知行为。
- 在被动的情况下,由多种方法实现感知,例如:
- 通过获取顶层App的信息来判断是否可以进行攻击,但是这种感知手段在高版本Android系统中显然不可行。
- 还有上文提到的
NotificationListenerService
,在高版本Android中显然也不可能实现。 context-aware
感知:创建一个全屏的不透明覆盖层,捕捉所有用户的点击。在屏幕上创建一个特定位置的“洞”,攻击者希望用户点击这个位置。或通过创建多个覆盖层来形成一个“洞”,洞周围的覆盖层捕捉所有点击,而洞上的覆盖层允许点击通过。由于只有一个“洞”,用户的点击只有一种方式情况能穿透到最底层的App。或者在设置FLAG_WATCH_OUTSIDE_TOUCH
的情况下,如果事件的坐标被设置为(0,0),则表示用户点击了“洞”(否则,事件的坐标会被设置为实际值)。通过这些信息来判断用户是否点击了某些位置,进而确定是否实施下一步的攻击。
到这里其实还有一种很危险的机制导致的攻击,就是无障碍服务。在用户授权无障碍服务后即可实现任意键盘记录和任意点击。在恶意应用申请使用无障碍服务时会向用户声明危险性和其他信息,但是通过覆盖攻击能够覆盖其中的一些内容,神不知鬼不觉就使得用户进行了授权。如下图中的红框中的内容都能被覆盖实施攻击。在较新版本的 Android 上,如果覆盖处于活动状态,则无法配置辅助功能设置,或者 Android 在进入辅助功能设置页面时会自动禁用任何覆盖,因此不再赘述。Android 系统应用可以申请HIDE_NON_SYSTEM_OVERLAY_WINDOWS
权限(该权限保护等级为signature|installer
即第三方App无法申请),同时需要在App设置标志位SYSTEM_FLAG_HIDE_NON_SYSTEM_OVERLAY_WINDOWS
,设置后第三方App即使拥有SYSTEM_ALERT_WINDOW
权限,也无法进行覆盖。Android中的应用大多采用这种方法进行修复。可以尝试打开悬浮窗后再打开设置App就会发现,悬浮窗神奇不见了,其实就是因为设置App是系统App,为了安全申请了权限并且设置了对应标志位。为了让开发者能够更好地控制用户在与开发者的应用互动时会看到什么内容,Android 12 引入了隐藏由具有SYSTEM_ALERT_WINDOW
权限的应用绘制的叠加窗口的功能。声明HIDE_OVERLAY_WINDOWS
权限后,应用可以调用setHideOverlayWindows()
以指明当应用自己的窗口可见时所有TYPE_APPLICATION_OVERLAY
类型的窗口都应隐藏。在显示敏感屏幕(如交易确认流程)时,应用可能会选择这样做。显示TYPE_APPLICATION_OVERLAY
类型窗口的应用应考虑可能更适合其用例的替代方案,如画中画或气泡。Android 12将默认开启setFilterTouchesWhenObscured
为true
,在有其他APP覆盖的情况下,自动阻止APP接受输入事件。除了下面的特例:- 被自己覆盖:APP被自己的窗体覆盖
- 被受信任窗体覆盖:包括辅助服务窗体、输入法窗体和Assistant窗体
- 可见度不高的窗体:安卓认为这样的窗体难以展现欺骗内容。
也可以针对重要对话框的View,传入true
到如下方法public
void setFilterTouchesWhenObscured (boolean enabled)
或者设置View的布局文件属性android:filterTouchesWhenObscured
为true
,均可以防止点击劫持攻击。此时,如果防御对话框被其他恶意窗体(包括toast、dialog或window)覆盖时,所有的输入事件将会被过滤,防御对话框不再会对点击事件进行响应,也就防止了点击劫持。另外一种方法是重写View的onFilterTouchEventForSecurity
方法,在该方法中可以检测其他APP覆盖的情况 -
Uprobe及其对抗
Uprobes
(用户空间探针)是Linux内核的一个特性,它允许在任何用户空间程序的任何指令上设置钩子(hook)。当这些钩子被触发时,会创建一个事件,并将被探测程序的上下文(例如,CPU寄存器的值)提供给处理程序,这些处理程序可以是eBPF(扩展的Berkeley Packet Filter)程序。通过这种方式,你可以记录程序执行时的详细信息,或者执行特定的eBPF程序来分析或修改程序行为。例如,Quarkslab开发的peetch工具集就利用了uprobes和eBPF技术。它通过在OpenSSL库的SSL_read()
和SSL_write()
函数上设置uprobes钩子,来全系统地记录TLS(传输层安全协议)消息。当这些函数被调用时,uprobes机制会触发,并允许eBPF程序访问这些函数的上下文,包括可以读取或修改CPU寄存器的值。这样,peetch能够在TLS加密之前或解密之后捕获数据,实现对TLS消息的明文访问。这种技术的应用场景包括但不限于安全研究、性能分析、调试以及监控和审计系统行为。uprobes提供了一种强大的机制来深入理解和控制运行中的程序,而eBPF则扩展了这种能力,允许开发者编写高效且灵活的程序来处理uprobe生成的事件。可以通过向/sys/kernel/debug/tracing/uprobe_events
中添加内容进行挂钩例如假设要监听/bin/bash
的readline调用,执行下面的命令即可通过读取/sys/kernel/tracing/error_log
查看错误日志同样也可以通过bcc
等脚手架使用uprobe
挂钩各种调用在设置 uprobes 时,内核会调用 probes_write() 和 trace_uprobe_create(),它们又调用 __trace_uprobe_create()。(在5.10版本的源码中没有__trace_uprobe_create
的调用,而是直接调用trace_uprobe_create
,其实现是一致的)最后一个函数以uprobe_events
中的一行作为参数,并调用kern_path()
获取与所设置二进制文件的路径相对应的文件的inode
。随后,register_trace_uprobe()
、_add_event_to_tracers()
和其他函数创建了伪目录/sys/kernel/tracing/events/uprobes/<EVENT>/
,以及一些文件(enable
、id
等)。n当启用uprobes
时,会发生以下嵌套调用:trace_uprobe_register() => probe_event_enable() => trace_uprobe_enable() => uprobe_register()。n当探针被注册时,除了访问引用计数(access refcount)外,__uprobe_register
还会通过alloc_uprobe
增加一个创建引用计数(creation refcount),但这只在探针被插入红黑树(即该@inode:@offset
对的第一个消费者)时发生。创建引用计数防止在注册操作完成之前uprobe_unregister
函数释放@uprobe
alloc_uprobe()
:这个函数创建一个struct uprobe
实例。struct uprobe
是一个结构体,用于存储探针的相关信息,包括要放置探针的文件(inode)、在文件中的偏移量(offset),以及被替换的指令。创建的struct uprobe
实例包含了探针需要的所有信息,以便将来使用。insert_uprobe()
:这个函数将创建的struct uprobe
实例添加到一个红黑树(rb_tree)中。这个红黑树用于存储所有的探针实例,以便高效地查找和管理。register_for_each_vma()
:这个函数遍历所有现有的虚拟内存区域(VMA),寻找与某个探针的inode
对应的VMA
。对于每个找到的对应 VMA,valid_vma
函数会检查它是否是一个有效的 VMA。install_breakpoint()
:在确认 VMA 有效后,这个函数会在arch.insn
结构体中复制完整的被探测指令。arch.insn
是一个依赖于当前架构的结构体,用于存储指令信息。然后,它会将原指令替换为一个断点ret = set_swbp(&uprobe->arch, mm, vaddr);
(set_swbp - store breakpoint at a given address.
)。这样,当程序执行到这个位置时,执行会暂停。然后需要了解当一个新的ELF(Executable and Linkable Format)程序被执行时,其内存是如何通过mmap
系统调用被映射的过程,以及uprobes(用户空间探针)是如何被添加到新的程序实例中的。mmap
系统调用的入口点,用于请求内核将文件或设备的某个部分映射到内存中。__vma_adjust()
是一个辅助函数,用于在添加或修改VMA时调整VMA的属性。如果VMA是基于文件的(即代码段或数据段映射自一个文件),这个函数会调用uprobe_mmap()
,这个函数专门处理与uprobes相关的VMA。如果VMA有效,build_probe_list()
函数会查找与VMA关联的文件(inode)匹配的所有uprobes,并将它们组织成一个列表。对于列表中的每个uprobe,install_breakpoint
会在程序的代码中安装一个断点。当程序执行到这个断点时,会触发一个事件,允许调试器或其他工具介入程序的执行。在ARM 64中,断点会被设置为A0 00 20 D4 BRK #5
综上,其实uprobe实现挂钩的基本原理还是指令替换,以实现断点。当程序执行到设置了断点的地方时,会触发一个int3
异常。内核通过do_int3()
函数处理这个异常,并调用notify_die(DIE_INT3, …)
,随后调用atomic_notifier_call_chain(&die_chain, …)
。die_chain
包含了通过register_die_notifier()
函数注册的所有通知器。atomic_notifier_call_chain
会调用notifier_call_chain()
,通过它们的notifier_call
属性通知链中注册的通知器发生了一个事件。对于我们的uprobe,这个事件是arch_uprobe_exception_notify()
,这个函数在uprobe_init()
时设置。它调用uprobe_pre_sstep_notifier()
设置TIF_UPROBE
标志。当线程返回用户空间时,会注意到TIF_UPROBE
标志,并调用uprobe_notify_resume(struct pt_regs * regs)
,这个函数接着调用handle_swbp(regs)
。handle_swbp
这个函数主要做两件事:handler_chain(find_active_uprobe())
执行这个uprobe的处理程序。例如,被eBPF程序使用的perf_event
。
pre_ssout()
准备单步执行被探测的指令。因为原始指令已经被uprobe的断点操作码替换,所以这个指令不能在程序内存中执行。内核开发者最初尝试临时移除断点,但遇到了一些问题,因此他们选择在一个新的内存区域(也称为xol
,即out of line
)执行这个指令。首先调用xol_get_insn_slot
获取xol的虚拟地址,这个函数使用get_xol_area()
来设置[uprobes]特殊的虚拟内存区域,如果它还没有被创建的话,通过xol_add_vma() => install_special_mapping()
。这个vma是原始指令将要被out of line执行的地方。接着,pre_ssout()
使用arch_uprobe_pre_xol()
调用regs_set_return_ip(regs, current->utask->xol_vaddr)
和user_enable_single_step()
。此时current->utask->xol_vaddr
指向之前创建的XOL槽。因此,这个函数将程序计数器设置到原始指令的副本所在的位置,并激活单步模式。然后,这个指令将被执行,并且程序将再次被停止。
当单步执行结束后,从uprobe_notify_resume
调用arch_uprobe_post_xol
。这个函数准备在单步执行后恢复执行,并调用post_xol处理程序。在单步执行之后的RIP寄存器是相对于复制的指令地址的,指向的是在XOL区域中地址,而不是原本要指向的地址。接下来,内核需要调整RIP寄存器的值,使其指回原始指令之后的位置。如果原始指令使用了RIP寄存器(例如,用于计算相对地址的指令),内核可能需要使用另一个寄存器来替换RIP,以保持地址计算的正确性。以便程序可以正常继续执行。程序可以读取待检测函数的前几个字节,以检测某个函数是否被挂钩,但是这样显然是不现实的,因为uprobe
可以实现任意位置的挂钩,不仅局限于某个函数的开头。除此之外,检测函数需要不间断的检测,其开销十分巨大。同样的也可以去强制覆写函数的开头取消断点以实现uprobe监控逃逸。另一种方法是在触发uprobe
后检测它。我们知道在创建指令副本用于执行原本的指令时,创建了一个特殊的内存映射,称为[uprobes]
。因此可以读取/proc/self/maps
,并搜索这些内容。在进行uprobe注册时会寻找所有与inode
关联的vma,并且通过valid_vma
检查是否是有效的vma,其实现如下,可以发现,如果is_register
的标志为true ,则flags |= VM_WRITE
。在 uprobe 注册期间,is_register
值为true,就会执行flags |= VM_WRITE
,而如果代码段是可写的VM_WRITE
, 在之后的返回语句中(vma->vm_flags & flags) == VM_MAYEXEC
就会不成立,从而无法继续添加断点。所以可以通过将代码段的属性添加VM_WRITE
就可以使uprobe无法正常添加断点,从而实现监控逃逸。对于一般的进程可以设置自己的代码段为可写的,但是无法设置共享的动态链接库的属性,也就是说对共享的动态链接库(除非拥有root权限),比如libc
等无法实现监控逃逸。但是可以自实现ELF加载器,在这些库加载到程序内存前设置权限,为每个库添加写属性。当然在这么操作存在很多问题,包括破坏程序原本的安全性等等。这个部分就不再赘述,一个方法是条件竞争,创建一个线程发送虚假数据。另一个方法就是使用ptrace实现对自身的调试,相当于实现了一个调试器,在关键位置修改上下文以实现返回虚假数据。 -
ByteDance-AppShark静态分析工具
0x00 引言0x01 AppShark说明1. 概述2. 使用3. 规则撰写4. 四种mode的特殊性5. 结果解读6. 隐私合规问题0x02 经典案例1. content provider目录遍历漏洞2. Intent Redirection3. DirtyStream4. MAC 隐私获取5. ZIP 解压缩路径穿越6. JSInterface_analyzer0x03 总结0x04 参考在前期对App进行漏洞挖掘的过程中发现,以纯人工的方式去逆向某些App并且发现其中的漏洞已经不太现实,主要有以下几点原因:- Android系统体系十分庞大,App代码量巨大,存在超大型App(抖音目前已经有150万个函数),人工分析极度不现实
- 对Android静态分析精力耗费巨大,自动化静态分析完全可以简化大量重复的工作。
Appshark 是一个针对安卓的静态分析工具,它的设计目标是针对超大型App的分析(抖音目前已经有150万个函数). Appshark支持众多特性:- 基于json的自定义扫描规则,发现自己关心的安全漏洞以及隐私合规问题
- 灵活配置,可以在准确率以及扫描时间空间之间寻求平衡
- 支持自定义扩展规则,根据自己的业务需要,进行定制分析
apk文件预处理主要是提取app中的基本信息,比如导出组件,manifest解析,以及发现一些manifest中常见的漏洞. 这里面还有一个最重要的工作就是会使用jadx对apk进行反编译,其生成的java源码会在最后的漏洞详情中展示.代码预处理代码预处理最主要有三个功能:- 生成SSA
- 生成基本的call graph
- 根据配置进行各种指令的patch,比如callback注入.
用户自定义规则解析该部分主要的功能就是将模糊的用户自定义规则翻译为准确的source
以及sink
,然后根据用户的规则配置,查找相关的分析入口,生成TaintAnalyzer
. 所谓的TaintAnalyzer
就是source,sink,entry
的一个综合体.指针以及数据流分析该模块的输入主要是一个所谓的入口函数,当然也包含了一系列用户自定义的或者系统预置的分析规则. 通过较长时间的指针分析,生成AnalyzeContext
,AnalyzeContext
里面包含了从指定的入口分析以后,得到的指针指向关系以及数据流流向关系. 该模块的主要思想主要是参考了论文: P/Taint: unified points-to and taint analysis漏洞查找该模块的输入主要用三部分:- TaintAnalyzer,查找其中的source到sink的路径
- AnalyzeContext, 包含了数据流图
- 关联规则中的Sanitizer,用于过滤掉不符合要求的路径.
该模块会依据AnalyzeContext
提供的数据流图,查找从source到sink的路径,如果找到,并且该路径没有被Sanitizer过滤掉,那么就会在最终结果中添加一个漏洞.Sanitizer该模块的功能就是根据用户自定的sanitizer,过滤掉不符合要求的路径.报告生成模块每个漏洞会以用户可以阅读的方式进行展示. 同时会给一个result.json
,这里面包含了所有的漏洞信息.- 首先clone项目,并且下载发行的jar或者自己编译,建议自己编译,因为使用v0.1.2发行版和教程有些部分不符,比如文档中的规则写法是sanitizer,并且在源码中也为sanitizer,但是并没有进行编译和发行,所以只能使用旧的规则写法sanitize
- 修改config文件
- 将apkPath修改为你想要扫描的apk绝对路径.
- 指明你要使用的规则,以逗号分隔.并且这些规则应该都放在config/rules目录下. 因为appshark是通过这个路径来查找这些规则的.
- 指定输出结果保存的目录,默认是当前目录下的out文件,你可以指定一个其他目录.
- 启动appshark
java -jar AppShark-0.1.2-all.jar config/config.json5
#分析入口使用Appshark进行数据流分析,最重要的就是明确告诉Appshark你关心的分析入口,source,sink以及sanitizer. 根据source的特殊性,将分析入口模式分类为:- ConstStringMode 支持常量字符串作为source
- ConstNumberMode 支持常量整数作为source
- SliceMode和DirectMode 其他类型的source
#Appshark分析的对象是经过SSA处理的jimple指令,因此在指定source/sink的时候,引用的函数以及field签名必须符合jimple格式.jimple函数签名<android.content.Intent: android.content.Intent parseUri(java.lang.String,int)>
这是一个通用的Java函数签名,包含了类名,函数名,函数返回类型,参数类型列表. 在指定source,sink的过程中,每个部分都可以用*来模糊匹配. 比如<*: android.content.Intent parseUri(java.lang.String,int)>
匹配所有类中,函数名字为parseUri
,返回类型是android.content.Intent
以及参数列表为java.lang.String,int
的函数.jimple field签名<com.security.TestClass: android.content.Intent fieldName>
这是一个通用的Java 对象的field签名,这个field的类名是com.security.TestClass
,类型是android.content.Intent
,field的名字是fieldName
. field签名不支持模糊匹配指定,必须准确给出需要格外注意在签名中的类名后必须紧接着:
,否则将会无法处理函数签名导致分析失败,即不能是<android.content.Intent : android.content.Intent parseUri(java.lang.String,int)>
只能<android.content.Intent: android.content.Intent parseUri(java.lang.String,int)>
除此之外还需要注意:new一个实例对象的函数名为<init>
例如new一个File实例的函数签名应当为<java.io.File: * <init>(*)>
,其他类的实例创建也一样。一般规则包含四个部分,分别是:1. 分析的入口 2. source 3. sink 4. sanitizer.#分析入口的指定分析的入口一般是一个函数. 比如entry只有在DirectMode
下需要明确指定,其他三个模式下,都无需明确指明分析入口. 如果你不知道分析入口是什么,说明你不应该使用DirectMode
.#一般source的指定需要说明的是,appshark内部真正的source点会是具体的变量,因此无论哪种写法,都会转换成一个具体的变量. source可以有很多种类型,分别是:- 常量字符串,注意这与
ConstStringMode
是没关系的
- 函数返回值
- 函数的某个参数
- 对象的某个field
- 某个对象的创建
下面分别举例介绍这五种情况.常量字符串那么:s将成为source. 函数f的参数1将成为source.函数的返回值这种一种最常见的source形式,比如:也就是getName的返回值将会是source. 那么:name将成为source点.某对象的field比如:那么:uri将会成为source点. 注意不区分该field是静态field还是非静态field某个函数的参数函数参数作为source一般在重写系统类的情况, 比如:首先注意,p0是第一个参数,p1是第二个参数,这里类型为WebResourceRequest才是source.某个对象的创建这个规则非常特殊,一般不会用到. 比如:那么:这时候变量i将成为source点.#一般sink的指定目前sink点只能是函数的周边,可以是:- this指针 @this
- 函数的某个参数 p0,p1,p2
- 函数的所有参数 p*
- 函数的返回值 return
sink需要强调的是,所有的sink都会在内部转换成具体的变量. sink的指定相对于source的指定要简单许多,种类也比较单一. 例如:那么:这里面的f
,fileOutputStream
都会是sink点. appshark会检查能否找到从source到这些变量的一个污点传播路径.sink还有一个可配置的选项就是LibraryOnly
,默认值为false,如果设置为true,那么就要求匹配到的函数签名必须是EngineConfig.json5
中指定的Library. 以上面例子为例,如果在EngineConfig.json5
中指定com.security
为Library,那么path就是sink点. 否则如果没有在EngineConfig.json5
中指定com.security
为Library,那么path就不是sink点.n#sanitizer的指定sanitizer目的是消除误报. 虽然发现了一条从source到sink的完整传播路径,但是因为已经对source做了严格的校验,所以这并不是一条有效的路径. 下面以unzipSlip规则为例来介绍一下sanitizer的原理.zip slip漏洞的原理可以参考Directory traversal attack. 主要是在解压zip文件的时候,没有检查文件名中是否包含"../"
,导致如果zip文件外部可控的话,可能会导致任意文件覆盖问题.source和sink就不展开说了,上面刚刚介绍过.重点说一下sanitizer,因为它的设计不是那么容易理解.顶层规则是或的关系sanitizer分别包含了三个子key:- getCanonicalPath
- containsDotdot
- indexDotdot
这三个规则是或的关系. 根据规则,我们会找到N个 source,M个sink. 那么理论上就会存在N*M条路径.对于其中的任意一条路径,如果它满足了这三条规则中的任意一条,就会被sanitize掉.二层规则之间是与的关系由于这个例子中,二层规则都只有单独一条,所以这里造一个规则来演示.如果某条路径同时满足对<java.lang.String: boolean contains(java.lang.CharSequence)>
和<java.io.File: * init(java.lang.String)>
这两个函数的限制,那么这条路径就会被sanitize掉.具体规则的含义再次强调,appshark分析的是污点在变量之间的传递关系,所以无论是source,还是sink,还是sanitizer描述的具体粒度都是变量. 以containsDotdot为例:它的限制有两个:- TaintCheck 从source出发,传播到的所有变量中,是否污染到了
<java.lang.String: boolean contains(java.lang.CharSequence)>
这个函数的this指针. 比如:
那么这里的path就是contains的this指针,- 参数取值的限制
"p0":["..*"]
的含义是: 常量字符串..*
要能污染到contains的参数0.
NotTaint
这个和TaintCheck
格式一样,意思相反,要求函数的这些地方不能被source污染到.
这两个条件之间也是与的关系,因此:满足我们的sanitizer,从source(path)到sink(file)的这条传播路径就会被sanitize掉.反之: 下面的例子中,就不能被sanitize掉.这个例子中s.contains("../")
满足了对p0的检验,但是没有满足TaintCheck的检验. 而path.contains("root")
满足了对TaintCheck的检验,但是没有满足对p0的检验. 所以这条传播路径是有效的.sanitizer总结sanitizer针对的是,已经找到了一条从source到sink的路径,再根据source污染到的所有变量进行过滤,如果满足条件就删掉这条路径,否则保留.#DirectMode的特殊性它需要明确指明分析的入口,比如:那么这条规则的分析入口就是UnZipFolder
这个函数.这里还可以是一些虚拟入口,但是目的都是一样的,比如:这里的意思是,每个安卓的导出组件都是分析入口. 比如Activity的onCreate,onDestroy等等都是分析的入口. 这些入口有appshark根据manifest文件的解析得到,而不是写死,相对灵活一点. 当然你也可以针对具体的app,自行分析manifest文件,然后把每个导出的组件中的函数写到规则中,这样效果是一样的.这里还有一个关键特性就是traceDepth
,这里指的是从分析入口函数开始,分析多少层函数调用为止. 如果调用层级超过这个深度,会被忽略.#SliceMode的特殊性SliceMode和DirectMode的区别是它的分析入口不是固定的,而是根据具体的source,sink计算得到的. 这个mode的提出针对的是,在某些场景下,就没有固定的分析入口. 或者从指定的入口开始到我们想要分析的那部分代码之间距离太远,导致不能在有效的时间内取得分析结果.怎么根据source和sink计算分析入口,主要有两种情况:- source为某个函数的参数
- 其他形式的source
source为某个函数参数以下面的例子来说明:首先shouldInterceptRequest针对是WebViewClient的子类而言的,因为android.webkit.WebViewClient
是安卓的framework,我们并不会直接去分析framework的代码. 这里的source是shouldInterceptRequest的p1,也就是WebResourceRequest
这个参数. 如果我们override了shouldInterceptRequest
这个函数,那么将会从这个函数出发,找出它所有的显式或者隐式的被调函数,看看里面有没有包含sink点的函数. 如果有就将这个override的shouldInterceptRequest
函数作为分析入口.这里有一个规则的traceDepth
,指的是从shouldInterceptRequest出发,查找的函数层数.#其他形式的source比如:意思是从source
点往下搜索traceDepth
层,从sink
点往上搜索搜索traceDepth
层,找到它们最近交汇的函数作为分析入口.#ConstStringMode它之所以特殊,因为app中的常量字符串太多可能非常多,所以其分析的入口不受traceDepth
的约束,它分析的入口就是指定的常量字符串所在的函数. 比如:如果s是满足我们条件的常量字符串,那么f就是分析入口. 限制常量字符串的条件有:- constLen 长度必须是这个长度的倍数
- minLen 长度不能小于这个长度
- targetStringArr 形式上满足这个数组中的任意一个,比如
"targetStringArr": ["AES","DES","*ECB*"]
ConstNumberMode它与ConstStringMode类似,其分析入口也是这个常量数值所在的函数.对于常量数值的限制,只有targetNumberArr
,它表示只关心这个数组里面的数值. 比如:SecurityInfo这里的安全漏洞会根据你在规则中desc
指定的category
和name
进行分类. 方便程序处理,也方便人工阅读. 其中vulners
字段是这种类型漏洞的列表. 其中每个漏洞都有一个hash字段,该字段可以认为是漏洞的唯一标识. details字段包含了漏洞的大量信息:- Source 规则中source字段匹配到的变量.
- Sink 规则中sink字段匹配到的变量
- position source对应变量所在的函数
- entryMethod 分析的入口
- target 污点在变量之间传播的过程.
- url 以html格式展示的污点在变量之间传播的过程.
ComplianceInfoComplianceInfo专门针对隐私合规问题. 如果category是ComplianceInfo
,那么appshark将会到其特殊处理.比如:其分类将是:- 第一级是ComplianceInfo
- 第二级是ComplianceCategory指定的PersonalDeviceInformation_NetworkTransfer
- 第三级是name指定的GAID_NetworkTransfer_body.
比如:至于vulners中的字段和SecurityInfo中的含义是一样的.漏洞详情网页介绍漏洞详情网页设计的目的是,他可以脱离results.json独立展示信息给用户,方便分析漏洞的形成原因.vulnerability detail是app的基本信息以及漏洞的基本信息.data flow上面的target字段call stack污点传播经历了哪些函数.code detail详细展示了污点传播的过程. 如果config.json5
中指定了javaSource为true,那么还会展示反编译后的函数的java代码.隐私数据流分析就是数据流分析的其中一种,但绝大部分时候你不需要编写entry和sanitizer,你应该更关心规则中的source和sink。具体来说,你可以将source指定为某个获取隐私信息的API,例如:这个API的return是设备唯一标识IMEI。同时,将sink指定为你认为会存在隐私数据泄露的方法,例如写文件:当你所关注的隐私数据来源并非API,而是对象的某个field时,你依然可以按照通用规则编写中field类型source的格式编写规则,例如设备序列号作为source:最后,你需要使用SliceMode,可以减少分析的时间。完整的规则文件:我们的app有一个content provider
,用来共享sandbox目录下的文件.作者的意图是只共享sandbox目录,但是他直接把用户path作为参数传递给了File,这意味着,如果path中包含"../",那么就可以绕过sandbox目录限制. 可以轻松构造出一个poc:关键就是定义source,sink以及sanitizer. 明显openFile的参数0也就是uri是用户可控制的,一般把外部用户可直接或间接控制的变量视为source. 而sink点比较合适的一个地方是ParcelFileDescriptor.open
的参数0, 因为如果source能够控制ParcelFileDescriptor.open
参数0,那么基本上就可以读取任何文件了.然后给出运行配置假设修复方法如下,通过getLastPathSegment
截取最后一段路径。nreturn ParcelFileDescriptor.open(new File(root,
uri.getLastPathSegment()
), ParcelFileDescriptor.MODE_READ_ONLY);
添加sanitizer
检查getLastPathSegment
的@this
,如果调用了uri.getLastPathSegment()
,并且this
指针被source
污染了,那么可以认为漏洞修复了. 被污染了的准确含义是,可能被控制. 比如c=a+b,那么c就被a和b污染了.如果我们传输的不是content://slipme2/../../../../../../../../data/data/com.security.bypasspathtraversal/files/file2
, 而是content://slipme2/encoded/%2E%2E%2F%2E%2E%2F%2E%2E%2F%2E%2E%2F%2E%2E%2F%2E%2E%2F%2E%2E%2F%2E%2E%2Fdata%2Fdata%2Fcom%2Esecurity%2Ebypasspathtraversal%2Ffiles%2Ffile2
,VulProvider2也并不是一个有效的修复,仍然有漏洞存在.直接从这个特征入手,如果path中包含了..
那么就认为是非法路径即可.那么这个sanitizer的准确含义是什么呢? 针对一条从source到sink的路径上,如果:- String.contains的this指针被污染了,并且这个函数调用位置的p0参数能够被"..*"这个常量污染到
- 并且String.startWith的this指针也被污染了.
校验如果以app的内部路径开头,就抛出异常. 我们可以通过软链接,既不包含..,也不以app的内部路径开头. poc代码如下:可以存在两种有效的修复方式:修复方式1注意这里的startsWith 检查的是sandbox的path,所以我们就没法在自己的目录中创建一个软链接了.修复方式2这里通过getCanonicalFile
来解析软链接,这样获取到的就是真实的路径了. 所以这里的条件是:- 通过getCanonicalFile获取到真实的路径
- 通过startsWith校验真实路径是否以sandbox路径开头.
大家可能有疑问.containsDotDot这个sanitizer存在漏报问题啊,case3
中的修复方式明明是无效的,但是仍然会被引擎因为是修复了的,这实际上导致了漏报. 这里只能说一下sanitizer的局限性了,它只能根据source污染到的变量的范围来确定要不要去掉一条路径. 真实的修复方式:path.startsWith(root.getPath())
和有问题的修复方式path.startsWith(internalDir.getCanonicalPath())
从形式上看是没什么区别的. 让appshark去识别这种逻辑上的区别,是非常困难的, 这也是appshark的局限.nLaunchAnywhere是安卓最为经典的漏洞类型之一,现在被Google称为Intent Redirection:support.google.com/faqs/answer… 无恒实验室一直对该类型漏洞有研究,我们把这一类问题比作“安卓上的SSRF”,其中Intent就像一个HTTP请求,而未经验证完全转发了这个请求在安卓上会导致严重的安全问题。关于这类漏洞的逻辑与利用,推荐阅读retme.net/index.php/2… 这篇文章。基础的规则如下可以看到这个规则仅仅考虑从 getParcelable 到 startActivity 的数据流,且不考虑 sanitizer。这和我们实际使用的规则有一些差别,但足够说明问题。详见Android安全问题中的DirtyStream
一节。无sanitizer
加入sanitizer
无法扫描到漏洞,已修复。请看项目:静态分析能极大的减小人工分析的精力消耗,能够快速锁定调用链,但是依旧存在一些问题- 比如对于复杂调用环境的调用链可能会断,例如需要用户操作触发进一步的调用时,链子就会断掉,这种情况只能进行手动分析。
- 对于加固的App将可能无法进行静态分析,现有静态分析框架基本基于完整APK进行分析,脱壳后一般会得到dex文件,现有框架无法综合各个DEX进行静态分析,如果可以尝试将脱壳内容重新组包的情况下,说不定可行,请自行拓展尝试。
然还可以尝试一下其他的静态分析框架,这里仅对AppShark进行体验。 -
CVE-2021-0928漏洞分析
该漏洞在Android 12 Developer Preview 3版本是上被发现,并在Android 12 release中就已经完成了修复。安全公告:Android 安全公告 – 2021 年 11 月 | Android 开源项目BroadcastReceiver
(广播接收器) 是Android APP的四大组件之一,APP可以通过在AndroidManifest.xml
文件中添加一条<receiver>
的声明来完成组件的声明或者通过动态注册的方式进行广播接收器的注册。其他APP通过构造一个Intent
并通过sendBroadcast
方法即可发送广播,实际上进行了进程间的通信,广播接收器则可以接收到广播和数据。当应用程序进程最初启动时调用IActivityManager.attachApplication()
,以此来传递IApplicationThread
句柄,系统使用该句柄获得应用程序的控制权。当系统希望在应用程序进程中执行清单注册的BroadcastReceiver
时,它使用IApplicationThread
句柄调用scheduleReceiver
方法。进而通过Binder
的transact
实现跨进程调用。下跨进程通过onTransact
处理请求,需要跨进程传递的参数都会被放到同一个Parcel对象中,按顺序依次读取各种参数,包括Intent
ActivityInfo
等。其中ActivityInfo
包含有关要执行的组件的信息。此参数中传递的数据包括加载处理接收的广播的Java
类的文件路径。这时候你可能已经猜到这个新的漏洞路径是什么了:调用sendBroadcast
传递一个Intent
,当系统尝试调用scheduleReceiver
时,将导致在调用scheduleReceiver
的应用程序中看到被篡改的ActivityInfo
。需要注意的是,这条新的漏洞路径仅在Android 12中可行,因为先前没有办法将任意的Parcelable
放入Intent
中(Intent通过putExtras添加不算,因为这样会将数据放入Bundle中,Bundle序列化后被写入mParcelData
,在读取时不发生反序列化)而漏洞作者是用了一个在Android 12
中加入的Intent.ClipData
字段去实现的,该字段原本用于携带剪切板数据。sendMessage
后会有handleReceiver
进行处理。handleReceiver
调用getPackageInfoNoCheck
方法拿到packageInfo
,并在其中获取到需要拉起的receiver
对象并调用其onReceive
方法来完成BroadcastReceiver
组件的启动。继续深入getPackageInfoNoCheck
,调用了getPackageInfo
漏洞利用思路就是通过intent
参数的反序列化数据残留,间接地修改info
参数,因为应用 B 会使用ActivityInfo
中的数据去实例化代码,具体来说就是应用会通过传入的ActivityInfo
构造LoadedApk
,其中会使用内部的zipPaths
创建ClassLoader
,攻击者如果可以控制这个字段,就能实现针对应用 B 的任意代码执行。OutputConfiguration
类,其中有个序列化的字段sensorPixelModesUsed
是ArrayList<Integer>
类型,在反序列化该字段时,使用的是Parcel.readList
方法,其调用链路是:Parcel.readList
Parcel.readListInternal
Parcel.readValue
最终是使用readValue
读取List
中每个元素的值。因此实际上可以读出任何readValue
支持的类型,比如Parcelable
、IBinder
等,并不局限于Integer
。仅仅进行反序列化并不会出现任何问题,只不过在使用具体的元素时,如果我们实际读取的类型无法转换为整数,就会出现ClassCastException
异常。OutputConfiguration
在反序列化时用try-catch
包裹了createFromParcel
方法,这意味着如果在反序列化过程中抛出异常,对象的读取会提前结束,但之前写入的数据不会被读取,造成数据长度不匹配。在这个漏洞场景中,我们并不会使用这个数组的元素,因此我们可以指定任意的序列化类。又由于我们需要使目标类在序列化/反序列化过程产生不匹配,那么就需要找到一个类,使得该类可以在system_server
中成功反序列化,但是在应用 B 中出现ClassNotFoundException
异常。漏洞利用思路就是通过intent
参数的反序列化数据残留,间接地修改info
参数,因为应用 B 会使用ActivityInfo
中的数据去实例化代码,具体来说就是应用会通过传入的ActivityInfo
构造LoadedApk
,其中会使用内部的zipPaths
创建ClassLoader
,攻击者如果可以控制这个字段,就能实现针对应用 B 的任意代码执行。接着上文Parcel MisMatch Point
继续:- 上文提到为了让系统服务能序列化但应用不能反序列化,可以使用只在系统服务中存在的类,如
PackageManagerException
。但需要额外通过不指定ClassLoader
的readList
来绕过类加载约束,因为如果指定了ClassLoader
,那么应用B就可以在类加载器中找到对于的类,从而不会抛出异常,而如果没有指定ClassLoader,会默认只在BOOTCLASSPATH
中搜索类,而不会去system_server
类路径中查找,从而使system_server
也反序列化失败。原作者的利用是使用了PackageManagerException
,这是一个Serializable
类而不是Parcelable
。反序列化Serializable
对象时,会使用ObjectInputStream
,它在反序列化类时,会自动从堆栈轨迹中选择最近的非空非BootClassLoader的ClassLoader
。这里由于在系统服务的onTransact
等方法在堆栈中,ObjectInputStream
会优先使用包含system_server
类的ClassLoader
,所以可以成功加载到系统服务中定义的可序列化类。但在应用中反序列化该对象时,由于堆栈中没有系统服务的类,会导致ClassNotFoundException
。
- 实际利用中由于读取使用了带
classLoader
的readList
,因此到readSerializable
时loader
变量不为null
,从而不会调用resolveClass
在堆栈轨迹中寻找最近的loader,而是直接使用forName
加载类,从而导致直接抛出异常,因为当Class.forName
无法找到类时,它会抛出异常而不是返回null
。解决方案是将Serializable
再套一层Parcelable
,使用不带ClassLoader
的readList
去进行反序列化。这里选用的是WindowContainerTransaction
类。因此有了下面的构造对象方法。
这个类加载器在堆栈trace中存在,是因为在ActivityManagerService
中重写的onTransact
方法存在,而不是Binder#execTransact()
、IActivityManager$Stub#onTransact()
(由AIDL生成),以及所有使用的Parcelable
类中的方法存在。- 目标是在
Intent
对象中触发反序列化长度不匹配,因为Intent会在系统服务的onTransact
方法中作为第一个参数传递,这样就可以修改第二个参数ActivityInfo
。但是上文提到这条攻击链路仅在Android12
中可行,因为先前没有办法将任意的Parcelable
放入Intent
中。在Android12-bete
中的在ClipData$Item
中新增了一个字段mActivityInfo
,在ClipData
构造方法中通过in.readTypedObject(ActivityInfo.CREATOR)
读取。
ActivityInfo
继承自ComponentInfo
,并具有applicationInfo
字段。在ActivityInfo(Parcel source)
构造函数中,无法直接放置自定义Parcelable
对象,但是ApplicationInfo
中有一个字段splitDependencies
,它是SparseArray<int[]>
类型。在读取splitDependencies
时,使用了readSparseArray
方法,该方法又使用readValue
方法来读取SparseArray
。可以将OutputConfiguration
放置在splitDependencies
中。
- 但后面还有一些
readString
调用,为了完全控制不匹配后的未读数据,可以OutputConfiguration
中放一个Bundle
类型的原始数据,然后用ZenPolicy
类将OutputConfiguration
包裹起来,以在反序列化时跳过Bundle
的前三个int
字段VAL_BUNDLE
、长度和魔术字)。这样当触发PackageManagerException
时会停止继续正常反序列化,而留下了Bundle
数据,再由ZenPolicy
反序列化过程中后三个readInt
读取Bundle
字段的三个int
字段,并且将后续内容暴露出来,_arg114 = (ActivityInfo) ActivityInfo.CREATOR.createFromParcel(data);
继续读取剩下的Bundle
数据作为ActivityInfo
数据
handleReceiver
调用了getPackageInfoNoCheck
,并传入了可以被攻击者控制的ApplicationInfo
。getPackageInfoNoCheck
会根据ApplicationInfo
生成一个LoadedApk
实例。这里攻击者传入一个新的packageName
,以确保会创建新实例。然后调用ContextImpl.getClassLoader
,首次调用会代理到LoadedApk
的getClassLoader
,然后会调用createOrUpdateClassLoaderLocked
。createOrUpdateClassLoaderLocked
通过makePaths
生成类加载的路径zipPaths
,其中主要使用了ApplicationInfo
中的sourceDir
信息。攻击者控制的ApplicationInfo
将sourceDir
设置为攻击者的apk路径。n因此makePaths
将生成包含攻击者apk
的zipPaths
,交给createClassLoader
加载。n最终BroadcastReceiver
会从攻击者的apk
中加载类并执行,实现了代码执行。
这个漏洞本身对终端用户的影响不是很大,毕竟只在Android 12 Preview
版本中就修复了。但通过这个漏洞,Google 引入了许多修复和缓释方案,直接影响了后续的漏洞挖掘和利用思路。首先针对漏洞本身,修复方案为:- 对上述类去除隐式的异常处理,修复读写不一致的问题;
- 使用
readIntArray
而不是readList/readValue
去读取数据,消灭类型擦除的副作用;
- 防止
ClipData.mActivityInfo
写入Parcel
,除非显式指定。这消除了向 Intent 写入任意 Parcelable 的一个攻击链路;
另外,在 Andorid 13 中,引入了更强的反序列化缓释方案:- 新增了一个
readListInternal
方法的重载,增加额外的Class
参数,显式指定读取列表的元素的类型,并且将原来的方法标记为@Deprecated
;
- 新增了
Parcel.enforceNoDataAvail
方法,用于确保反序列化结束后,Parcel 中不再存在多余的数据;回想上一节中 Bundle 风水的利用,实际上第三个元素在第二次反序列化中是多出来的,因此这个修改会导致上述 Bundle 风水的失败;当然也有一些绕过的手法,比如通过更复杂的风水使得第二次反序列化能够到 Parcel 的末尾即可;
LazyBundle patch
,在 LazyBundle 实现中,Parcelable
、List
等类型会在序列化数据的元素开头单独存储长度信息。不过,这个 patch 并不会影响非 Bundle 造成的反序列化漏洞,比如这个漏洞。
-
Zygisk源码分析
接上文:Magisk源码分析
每个应用进程都从一个名为 Zygote 的现有进程进行fork
得到的。系统启动并加载通用框架代码和资源时,Zygote 进程随之启动。为启动新的应用进程,系统会fork
Zygote进程
,然后在新进程中加载并运行应用代码。Zygote 是 Android 所有其他应用进程的父进程。Zygisk 的得名非常直白 —— Magisk注入Zygote在Magisk启动过程中,通过挂载实现了/system/bin/app_process(32/64)
到magisk(32/64)
的替换,因此当启动app_process(32/64)
时实际是执行了magisk(32/64)
,而magisk本身入口位于native/src/core/applets.cpp
的main
,内部会校验当前是app_process(32/64)
被执行还是其他,如果是app_process(32/64)
,则进入app_process_main
。通过zygisk_request(ZygiskRequest::SETUP)
发送SETUP请求,由守护进程处理,处理完成后设置LD_PRELOAD
和MAGISKTMP_ENV
环境变量,LD_PRELOAD
在Linux系统中用于指定要在程序运行前加载的共享库。当应用程序启动时,动态链接器会优先加载指定的共享库,然后再加载其他库。这通常用于劫持库的加载,即用自己的库替换原来系统的库,或者在运行时注入代码,实际上这里添加的是后面提到的loader32.so
和loader64.so
。而MAGISKTMP_ENV
实际就是/debug_ramdisk/.magisk
。设置环境变量后fexecve(app_proc_fd, argv, environ);
,这里的文件描述符实际是原来的app_process_##bit
因此会执行原本的app_process
守护进程接收到请求后调用setup_files
进行处理 ,setup_files
获取到两个loader的二进制内容后输出到/.magisk/zygisk/
目录下,然后挂载到/system/bin/
对应位置(32:/system/bin/bu)(64:/system/bin/appwidget)
loader
源码如下,实际就是在加载时拿到/system/bin/app_process
的zygisk_inject_entry
地址进行调用(这里的app_process
实际是经过替换的magisk
),卸载时unload_first_stage
这里需要明确一点:app_process(32/64)
在实际运行过程中已经被替换为了magisk
,理论依据就是Android会在init.rc
设置各个阶段的具体时机,而trigger zygote-start
也就是触发zygote-start
是在post-fs-data
被触发之后的,而在post-fs-data
被触发后,之前注入init.rc
的内容决定了在post-fs-data
被触发后magisk启动监听进程并且对app_process(32/64)
进行替换,所以之后zygote-start
时实际启动的是被替换后的上面提到LD_PRELOAD
先于其他动态链接库加载了loader
,先分析zygisk_inject_entry
,首先就是恢复LD_PRELOAD
为原本的环境变量,然后恢复MAGISKTMP_ENV
。通过sanitize_environ
确保该文件中的环境变量是经过清理的、符合预期的。重点分析hook_functions
,设置了对fork
unshare
androidSetCreateThreadFunc
android_log_close
的PLT Hook
重点关注对androidSetCreateThreadFunc
和fork
的Hook,在原本的androidSetCreateThreadFunc
被执行前调用hook_jni_env
,该函数Replace the function table in JNIEnv to hook RegisterNatives
即通过替换function table实现了对RegisterNatives
的Hookenv_RegisterNatives
则对注册的jni函数进行了Hook,并调用了hookAndSaveJNIMethods
对nativeForkAndSpecialize
、nativeSpecializeAppProcess
和nativeForkSystemServer
这三个方法进行了Hook。无论是哪个安卓版本Magisk对于nativeForkAndSpecialize
的Hook都是三步策略ctx.nativeForkAndSpecialize_pre
-> nativeForkAndSpecializ ->
ctx.nativeForkAndSpecialize_post
,而Android源码中forkAndSpecialize
也是三步走,ZygoteHooks.preFork ->
nativeForkAndSpecialize
-> ZygoteHooks.postForkCommon
,其中第二步才调用到了nativeForkAndSpecialize
ZygoteHooks.preFork()
是一个钩子方法,它会在Zygote fork新进程之前被调用,进行一些fork
前操作例如通过在Java层记录fork事件来跟踪进程创建
预加载新进程可能需要的类或资源
申请和预热新进程可能需要的内存
nativeForkAndSpecialize
在底层执行实际的process fork
操作创建新进程并处理针对新进程的native堆初始化等操作。ZygoteHooks.postForkCommon
在native层的fork之后在新进程中调用,负责新进程Java层环境的初始化。在Hook的nativeForkAndSpecialize
函数中首先是nativeForkAndSpecialize_pre
, 重点是fork_pre
,在fork_pre
中pid = old_fork();
预先进行了fork
,而到原本的nativeForkAndSpecialize
进行fork
时实际是调用的被Hook 的fork
,这次fork
不会实际进行fork
,而是返回pid
。run_modules_pre
将之前Magisk
在post_fs_data
阶段安装模块时的内存描述符进行处理,对动态链接库进行加载并删除描述符,然后对加载的模块进行初始化。sanitize_fds
则用于清理/限制进程文件描述符的,这可以防止app传入不需要的FD,从而限制app可访问的资源,同时又保留app需要的FD不被关闭,实现最小特权的FD访问控制。实际上提前进行fork是为了能够控制在denylist
中的APP不加载模块,如果需要加载模块在nativeForkAndSpecialize_pre
结尾调用了app_specialize_pre
,该函数调用run_modules_pre
将之前Magisk
在post_fs_data
阶段安装模块时的内存描述符进行处理,对动态链接库进行加载并删除描述符,然后对加载的模块进行初始化。zygisk注入原理 | d0nuts (d0nuts33.github.io):zygisk 这种在 fork 后加载模块的方式使得它可以在不重启的情况下更新 lsposed 代码,而且能够选择性加载模块。对比 xposed 的方式,xposed 在的 XposedInit 里就把模块加载进来,再进行正常的 ZygoteInit 启动流程,使得 zygote 之后 fork 的每一个进程都带有模块。