博客

  • 世界,您好!

    世界,您好!

    欢迎使用 WordPress。这是您的第一篇文章。编辑或删除它,然后开始写作吧!

  • 基于eBPF实现一个简单的隐蔽脱壳工具-eBPFDexDumper

     
     
     

    0x00 参考

    1. https://blog.seeflower.dev/archives/84/#title-7
    1. https://evilpan.com/2021/12/26/art-internal/
    1. https://zhuanlan.zhihu.com/p/523692715
    1. https://blog.csdn.net/weixin_47668107/article/details/114251185
    1. https://juejin.cn/post/7045575502991458340
    1. https://juejin.cn/post/7384992816906747913
    1. https://blog.quarkslab.com/dji-the-art-of-obfuscation.html
     
    💡
    如果在Android12+ 出现无法解析libart.so或自己也无法找到对应符号的情况可能需要进行art回滚,参考https://devblog.lac.co.jp/entry/20221021

    0x01 引言

    eBPF作为运行在内核的的一大追踪利器,其有着隐蔽性高,不需要重新编译内核或系统等优点。而在Android平台中,字节码主要在ART虚拟机中被执行,那么其实只需要追踪ART虚拟机执行过程中的一些关键函数即可被动的捕捉到DexFile,从而实现脱壳。因此实现此小Demo,旨在抛砖引玉。
    该工具可作为FRIDA-DEXDump等被动式脱壳工具的替代,并且有着更好的隐蔽性,完全不需要PTRACE附加调试目标程序或注入动态链接库。但是由于eBPF的局限性,其无法替代FART等基于主动调用的脱壳工具。除此之外,对于代码抽取等情况并未实现,各位大佬有需要可以自行实现。
     

    0x02设计与实现

    1. ART虚拟机解释器

    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.0ArtMethod的结构,其中entry_point_from_quick_compiled_code_ 保存了这个方法的执行地址。
    当要执行某个方法时,运行时必须首先检查该方法所属的类是否已加载。如果未加载,则Runtime将加载并链接该类。在这个过程中ART会调用 UpdateClassAfterVerification更新已经验证过的类中所有方法的入口点。如果能够使用 nterp,则将原先使用switch interpreter的方法切换到 nterp
    具体做法是遍历该类的所有方法,检查它们当前的入口点是否为QuickToInterpreterBridge,如果是,则调用 Instrumentation::InitializeMethodsCode 来更新入口点。这样,完成验证的类就可以切换到更高效的 nterp 解释器执行
     

    2. 设计与实现

    因此在设计上,通过eBPFUprobe去动态追踪libart.so三个关键函数,并从参数中提取ArtMethod指针,进一步从中获取DexFile的指针并且获取DexFile的起始地址和size,即可实现被动式脱壳,即当方法被执行时即可获取到其所在DexFile。
    具体而言,在内核层通过eBPF追踪三个关键函数,提取到DexFile 的起始地址和size信息,然后将其传回用户态go程序。
    notion image
    go程序读取到事件缓存区中的数据后,立即通过远程进程内存访问获取到dex文件并进行Dump。Linux远程进程内存访问可通过 process_vm_readvprocess_vm_writev来进行。但是调用这两个 syscall 来实现远程进程访问会被目标检测到。原理是通过是否内存缺页(例如通过mincore)来判断特定内存是否被访问过。但是在脱壳场景中是没有任何问题的。
    💡
    踩坑:前面在没有想到通过process_vm_readv 读取内存时,试图在eBPF程序内直接读取用户内存,并且将其分片带出到用户态(缓存区有大小限制),但是这个过程中会有大量的数据传输,极易丢失数据,从而使dump出的dex可用性较差。
    notion image
     

    3. 现有不足

    • 非主动调用脱壳
      • 因为是基于eBPF的脱壳方式,只能实现被动的脱壳,无法实现类似于FART的主动调用脱壳,这是一个遗憾。需要哪部分的代码就必须使虚拟机执行到对应的部分才可以成功Dump,但是这对于一般的场景已经够用了。
    • 暂未实现抽取壳被抽取代码的部分
      • 本项目只做一个小的demo,旨在抛砖引玉,对于代码抽取等情况并未实现,各位大佬有需要可以自行实现。
     

    0x03 测试与使用说明

    1. 环境说明

    需要有一台支持eBPF的设备,最好内核版本为5.10及以上,需要获取root权限,保证其Uprobe是可用的(一般是开启的)
    notion image
     

    2. 删除OAT优化文件

    如果不删除优化文件 ,代码将会以其他方式执行从而无法在上述两个函数位置进行拦截,或获得的dump文件是cdex结构,不方便从中提取dex。因此,阻止代码的优化执行是一切的开端。
    💡
    注意可以选择(非必要)关闭dex文件的checksum校验以防止出现不可预期的校验错误,以JADX 1.5.1为例
    notion image
     

    3. (可选)提取偏移

    程序自动解析并获取所需要的三个函数的偏移,但是如果失败了则需要手动指定。
    这一步需要获取设备上libart.so并进行反编译,从中提取三个函数的偏移。
    以测试设备Android13为例,其他版本类似
    • 首先需要找到ExecuteNterpImpl 函数的偏移0x200090 ,这是最主要的解释器,大部分方法都会走这里。
      • notion image
    • 其次还需要art::interpreter::Execute 的偏移,防止漏掉走Switch解释的方法。
      • notion image
    • 还需要art::verifier::ClassVerifier::VerifyClass 的偏移,防止漏掉其他动态加载的dex
      • notion image
     

    4. 开始Dump

     
    notion image
    notion image
     

    5. 编译方法

    拉取代码后简单修改Makefile
    notion image
    然后make即可,在这之前需要有go的编译环境
     

  • Android 悬浮窗覆盖攻击

     
     

    参考

    1. https://mp.weixin.qq.com/s/04pcy3V3mhyru4YCwiGJLg
    1. https://github.com/Rtannu/Cloak-DaggerAttack
    1. https://www.anquanke.com/post/id/86880
    1. https://blog.nviso.eu/2021/05/11/new-malware-family-now-also-targets-belgian-financial-apps/
    1. https://www.tasfa.cn/2018/10/07/ADB配置提权漏洞-CVE-2017-13212-原理与利用分析/
    1. https://cloak-and-dagger.org/
    1. https://docs.google.com/presentation/d/16Eo30PVafib-PQSXKcGA6VkUznhKLa7piJxQt3yDowM/edit#slide=id.g1db8f66241_0_201
     
    💡
    1. https://github.com/NoahS96/Cloak-And-Dagger 中包含了一些Android覆盖攻击的示例代码
    1. https://github.com/LLeavesG/Android-Overlay-Hijack 中是我实现的简单demo

    0x00 引言

    下面的内容引用自参考1
    点击劫持Tapjacking,是一种欺骗用户进行点击的攻击技术,可存在于任何操作系统和浏览器之中,尽管原理简单,对于普通用户危害却极大,是一种容易忽视的安全威胁。Android中的点击劫持原理如图1所示,当出现重要的、需要进行用户确认的安全对话框时,申请悬浮窗权限的恶意APP在受害APP之上进行部分覆盖,显示一个虚假的界面,隐藏了与安全相关的重要提示信息,但并未覆盖正常APP原有的按钮,诱骗用户进行错误地点击操作,点击事件结果最终传递到受害APP,造成严重的安全后果。

    notion image
     
     

    0x01 攻击实施

     

    1. 悬浮窗权限申请

    在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并且授予对应的悬浮窗权限。
     

    2. 悬浮窗服务

    悬浮窗以服务的方式进行实现,主要为了保活和监控劫持时机,这就需要组件有独立的生命周期,那就需要Service 来实现
    需要注意其中的几个参数:
    • FLAG_NOT_TOUCH_MODAL 参数如果被设置,那么就意味着用户的触摸事件会被悬浮窗劫持,即可以在悬浮窗上接收到点击的坐标位置,那么也能进行对应的绘图,但是这样触摸事件就不会被传递到下层的其他App,仅能记录用户点击,也就是无法实现欺骗(但是如果完全伪造界面全覆盖就有可能欺骗),一般用于窃取用户输入数据等。
    • FLAG_NOT_TOUCHABLE 参数如果被设置,悬浮窗仅悬浮在应用界面,而无法触摸也。意味着用户的点击会被传递到下层,但是无法记录点击事件,即可以欺骗无法窃取。
    • FLAG_WATCH_OUTSIDE_TOUCH参数可用于监听用户在对话框之外的点击,可用于虚假对话框的退出时机。
    其中下面的代码用于调整悬浮窗大小,下面的是铺满全屏的意思。
     

    3. 欺骗点击

    恶意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应用的
    notion image
    在悬浮窗service中添加MotionEvent.ACTION_OUTSIDE 事件的处理,即点击到悬浮窗外部事件,此时需要移除悬浮窗windowManager.removeView(button),以使得悬浮窗在用户进行点击操作后与弹窗一起消失,防止被发现。当用户点击OK时,其实已经卸载了恶意APP请求卸载的package,而此时恶意APP也已经监听到了ACTION_OUTSIDE事件,于是主动退出。在整个欺骗过程中,用户难以察觉自己其实已经确认了一次卸载操作。
    除了”主动请求、主动劫持“这种恶意APP可以主动控制劫持时机的情况,恶意APP还可以监听用户确认对话框出现的其他时机,例如有特定的广播事件、特定的通知,在时机出现的时候进行劫持。例如,在重要对话框出现时,将出现一个通知,恶意APP可以监听通知该通知的出现,通过实现一个NotificationListenerService,捕捉特定的通知,并启动服务,在原有对话框之上悬浮一个欺骗的对话框n漏洞案例:CVE-2020-0394nCVE-2020-0394 即为这种情况,当蓝牙配对发生时,蓝牙APP会发送一个通知,用户点击通知以后就会出现蓝牙配对对话框,供用户确认或取消
     

    4. 劫持记录

    notion image
    下面的代码则是自定义的悬浮窗布局,可以设置为拦截后在悬浮窗绘图,实现信息窃取等攻击。
    效果可以见SekaiCTF2024 Hijacker赛题 ,可以在设备装入恶意App,并且授予任何权限,模拟用户点击输入PIN,要求窃取PIN时就可以使用上面的代码,设置六个不同颜色的框来记录按键顺序,即可读取用户输入的PIN码。当然这里的场景是不出网截图,真实攻击场景是记录左边直接传回,不需要绘图以免被发现。还有一个问题就是点击事件不能传递到下层,这也会暴露。
    notion image
    notion image
     

    5. 测信道击键推断

    该部分内容来源https://cloak-and-dagger.org/
     
    • Android为了安全引入了一个标志FLAG_WINDOW_IS_OBSCURED来保护用户点击事件的安全性。每次点击时,接收点击事件的控件会收到一个MotionEvent对象,该对象存储了相关信息。在MotionEvent对象中添加了该标志(简称为“遮挡标志”)。如果点击事件在到达最终目的地(例如“确定”按钮)之前经过了一个不同的覆盖层,则该标志会被设置为true。该机制本用于确定在点击控件上层有无覆盖层,依此对用户点击事件进行安全处理。但该机制能够被用于一种测信道攻击,达到推断按键的目的。
    • FLAG_WATCH_OUTSIDE_TOUCH 标志允许覆盖层接收到任何点击事件的通知,即使点击发生在应用程序之外。为了安全起见,如果点击发生在覆盖层所属的应用程序之外,点击事件的精确坐标会被设置为(0,0)。只有当点击发生在应用程序内部时,才会提供精确的坐标。这种机制防止攻击者通过坐标推断用户点击的位置,从而保护用户隐私和安全。因此通过FLAG_WATCH_OUTSIDE_TOUCH 记录外部App点击坐标的攻击是不成立的。
      • notion image
    攻击者在底层按一定的顺序添加透明覆盖层,这些覆盖层不拦截点击事件直接将事件传递到底层。即通过添加TYPE_SYSTEM_ALERTFLAG_NOT_FOCUSABLEFLAG_NOT_TOUCHABLEFLAG_WATCH_OUTSIDE_TOUCH 标志。这些标志确保每个覆盖层不会拦截用户的点击(即,当用户点击键盘的键时,点击会到达键盘)。
    在四个按键的情况下,按1234的顺序创建覆盖层,使得视图堆栈中的覆盖层保持这个顺序(覆盖层#1在栈底,覆盖层#4在栈顶)。当点击不同按键时由于FLAG_WATCH_OUTSIDE_TOUCH标志,每个覆盖层都会接收到一个MotionEvent对象,但这些点击事件不包含用户实际点击位置的信息。对于每个覆盖层,obscured标志的设置取决于用户是否点击了其上方的覆盖层。例如,如果用户点击覆盖层#4,所有接收到的事件的obscured标志都设置为0,即传递给覆盖层#1,#2,#3的事件的obscured标志将设置为1。从而推断出用户点击了第四个按键
    notion image
    同理可以推断出其他按键的点击
    notion image
    在实际攻击场景中,如下图所示的应用中,为每一个键添加一个覆盖层,覆盖层按照特定的顺序创建,并组织成堆栈:覆盖层#0(左上角)在堆栈底部,而覆盖层#42(右下角)在堆栈顶部。
    notion image
     

    6. 攻击时机

    上文讲了很多,但是一直没讲到在哪个时间点实施攻击。
    • 在主动的情况下,例如主动发起卸载App的请求时的情况下,攻击实施时机是可控的,不需要做其他的感知行为。
    • 在被动的情况下,由多种方法实现感知,例如:
      • 通过获取顶层App的信息来判断是否可以进行攻击,但是这种感知手段在高版本Android系统中显然不可行。
      • 还有上文提到的NotificationListenerService ,在高版本Android中显然也不可能实现。
      • context-aware 感知:创建一个全屏的不透明覆盖层,捕捉所有用户的点击。在屏幕上创建一个特定位置的“洞”,攻击者希望用户点击这个位置。或通过创建多个覆盖层来形成一个“洞”,洞周围的覆盖层捕捉所有点击,而洞上的覆盖层允许点击通过。由于只有一个“洞”,用户的点击只有一种方式情况能穿透到最底层的App。或者在设置FLAG_WATCH_OUTSIDE_TOUCH 的情况下,如果事件的坐标被设置为(0,0),则表示用户点击了“洞”(否则,事件的坐标会被设置为实际值)。通过这些信息来判断用户是否点击了某些位置,进而确定是否实施下一步的攻击。
     
    💡
    到这里其实还有一种很危险的机制导致的攻击,就是无障碍服务。在用户授权无障碍服务后即可实现任意键盘记录和任意点击。
    notion image
    在恶意应用申请使用无障碍服务时会向用户声明危险性和其他信息,但是通过覆盖攻击能够覆盖其中的一些内容,神不知鬼不觉就使得用户进行了授权。如下图中的红框中的内容都能被覆盖实施攻击。在较新版本的 Android 上,如果覆盖处于活动状态,则无法配置辅助功能设置,或者 Android 在进入辅助功能设置页面时会自动禁用任何覆盖,因此不再赘述。
    notion image
     

    0x02 防护

     

    1. 系统应用屏蔽第三方悬浮窗

    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,为了安全申请了权限并且设置了对应标志位。
     
     

    2. 普通应用隐藏第三方悬浮窗

    为了让开发者能够更好地控制用户在与开发者的应用互动时会看到什么内容,Android 12 引入了隐藏由具有 SYSTEM_ALERT_WINDOW 权限的应用绘制的叠加窗口的功能。声明 HIDE_OVERLAY_WINDOWS 权限后,应用可以调用 setHideOverlayWindows() 以指明当应用自己的窗口可见时所有 TYPE_APPLICATION_OVERLAY 类型的窗口都应隐藏。在显示敏感屏幕(如交易确认流程)时,应用可能会选择这样做。显示 TYPE_APPLICATION_OVERLAY 类型窗口的应用应考虑可能更适合其用例的替代方案,如画中画气泡
    Android 12将默认开启setFilterTouchesWhenObscuredtrue在有其他APP覆盖的情况下,自动阻止APP接受输入事件。除了下面的特例:
    • 被自己覆盖:APP被自己的窗体覆盖
    • 被受信任窗体覆盖:包括辅助服务窗体、输入法窗体和Assistant窗体
    • 可见度不高的窗体:安卓认为这样的窗体难以展现欺骗内容。
     
    也可以针对重要对话框的View,传入true到如下方法public void setFilterTouchesWhenObscured (boolean enabled)
    或者设置View的布局文件属性android:filterTouchesWhenObscuredtrue,均可以防止点击劫持攻击。此时,如果防御对话框被其他恶意窗体(包括toast、dialog或window)覆盖时,所有的输入事件将会被过滤,防御对话框不再会对点击事件进行响应,也就防止了点击劫持。另外一种方法是重写View的onFilterTouchEventForSecurity方法,在该方法中可以检测其他APP覆盖的情况

  • Uprobe及其对抗

     
     

    0x00 probe

     

    1. 概述

    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挂钩各种调用
     

    2. 实现

    在设置 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>/,以及一些文件(enableid 等)。
    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
    notion image
    综上,其实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这个函数主要做两件事:
    1. handler_chain(find_active_uprobe())执行这个uprobe的处理程序。例如,被eBPF程序使用的perf_event
    1. 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,以保持地址计算的正确性。以便程序可以正常继续执行。

    0x01 对抗

     

    1.断点检测

    程序可以读取待检测函数的前几个字节,以检测某个函数是否被挂钩,但是这样显然是不现实的,因为uprobe可以实现任意位置的挂钩,不仅局限于某个函数的开头。除此之外,检测函数需要不间断的检测,其开销十分巨大。同样的也可以去强制覆写函数的开头取消断点以实现uprobe监控逃逸。
     

    2. 内存映射检测

    另一种方法是在触发 uprobe 后检测它。我们知道在创建指令副本用于执行原本的指令时,创建了一个特殊的内存映射,称为 [uprobes]。因此可以读取 /proc/self/maps,并搜索这些内容。
    notion image

    3. 监控逃逸

    在进行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加载器,在这些库加载到程序内存前设置权限,为每个库添加写属性。当然在这么操作存在很多问题,包括破坏程序原本的安全性等等。
     

    4. 返回虚假信息

    这个部分就不再赘述,一个方法是条件竞争,创建一个线程发送虚假数据。另一个方法就是使用ptrace实现对自身的调试,相当于实现了一个调试器,在关键位置修改上下文以实现返回虚假数据。
     

    参考

    1. https://blog.quarkslab.com/defeating-ebpf-uprobe-monitoring.html
    1. https://www.cnxct.com/defeating-ebpf-uprobe-monitoring/
    1. https://zhuanlan.zhihu.com/p/466319667
     

  • ByteDance-AppShark静态分析工具

     

    0x00 引言

    在前期对App进行漏洞挖掘的过程中发现,以纯人工的方式去逆向某些App并且发现其中的漏洞已经不太现实,主要有以下几点原因:
    • Android系统体系十分庞大,App代码量巨大,存在超大型App(抖音目前已经有150万个函数),人工分析极度不现实
    • 对Android静态分析精力耗费巨大,自动化静态分析完全可以简化大量重复的工作。
     
    Appshark 是一个针对安卓的静态分析工具,它的设计目标是针对超大型App的分析(抖音目前已经有150万个函数). Appshark支持众多特性:
    • 基于json的自定义扫描规则,发现自己关心的安全漏洞以及隐私合规问题
    • 灵活配置,可以在准确率以及扫描时间空间之间寻求平衡
    • 支持自定义扩展规则,根据自己的业务需要,进行定制分析
     

    0x01 AppShark说明

    1. 概述

    notion image
     
    apk文件预处理
    主要是提取app中的基本信息,比如导出组件,manifest解析,以及发现一些manifest中常见的漏洞. 这里面还有一个最重要的工作就是会使用jadx对apk进行反编译,其生成的java源码会在最后的漏洞详情中展示.
    代码预处理
    代码预处理最主要有三个功能:
    1. 生成SSA
    1. 生成基本的call graph
    1. 根据配置进行各种指令的patch,比如callback注入.
    用户自定义规则解析
    该部分主要的功能就是将模糊的用户自定义规则翻译为准确的source以及sink,然后根据用户的规则配置,查找相关的分析入口,生成TaintAnalyzer. 所谓的TaintAnalyzer 就是source,sink,entry的一个综合体.
    指针以及数据流分析
    该模块的输入主要是一个所谓的入口函数,当然也包含了一系列用户自定义的或者系统预置的分析规则. 通过较长时间的指针分析,生成AnalyzeContextAnalyzeContext 里面包含了从指定的入口分析以后,得到的指针指向关系以及数据流流向关系. 该模块的主要思想主要是参考了论文: P/Taint: unified points-to and taint analysis
    漏洞查找
    该模块的输入主要用三部分:
    1. TaintAnalyzer,查找其中的source到sink的路径
    1. AnalyzeContext, 包含了数据流图
    1. 关联规则中的Sanitizer,用于过滤掉不符合要求的路径.
    该模块会依据AnalyzeContext提供的数据流图,查找从source到sink的路径,如果找到,并且该路径没有被Sanitizer过滤掉,那么就会在最终结果中添加一个漏洞.
    Sanitizer
    该模块的功能就是根据用户自定的sanitizer,过滤掉不符合要求的路径.
    报告生成模块
    每个漏洞会以用户可以阅读的方式进行展示. 同时会给一个result.json,这里面包含了所有的漏洞信息.
     

    2. 使用

    • 首先clone项目,并且下载发行的jar或者自己编译,建议自己编译,因为使用v0.1.2发行版和教程有些部分不符,比如文档中的规则写法是sanitizer,并且在源码中也为sanitizer,但是并没有进行编译和发行,所以只能使用旧的规则写法sanitize
    • 修改config文件
        1. 将apkPath修改为你想要扫描的apk绝对路径.
        1. 指明你要使用的规则,以逗号分隔.并且这些规则应该都放在config/rules目录下. 因为appshark是通过这个路径来查找这些规则的.
        1. 指定输出结果保存的目录,默认是当前目录下的out文件,你可以指定一个其他目录.
    • 启动appshark java -jar AppShark-0.1.2-all.jar config/config.json5
     

    3. 规则撰写

     
    #分析入口
    使用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的名字是fieldNamefield签名不支持模糊匹配指定,必须准确给出
    💡
    需要格外注意在签名中的类名后必须紧接着:,否则将会无法处理函数签名导致分析失败,即不能是<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的指定要简单许多,种类也比较单一. 例如:
    那么:
    这里面的ffileOutputStream都会是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为例:
    它的限制有两个:
    1. TaintCheck 从source出发,传播到的所有变量中,是否污染到了<java.lang.String: boolean contains(java.lang.CharSequence)>这个函数的this指针. 比如:
    那么这里的path就是contains的this指针,
    1. 参数取值的限制 "p0":["..*"]的含义是: 常量字符串..*要能污染到contains的参数0.
    1. NotTaint 这个和TaintCheck格式一样,意思相反,要求函数的这些地方不能被source污染到.
    这两个条件之间也是与的关系,因此:
    满足我们的sanitizer,从source(path)到sink(file)的这条传播路径就会被sanitize掉.
    反之: 下面的例子中,就不能被sanitize掉.
    这个例子中s.contains("../")满足了对p0的检验,但是没有满足TaintCheck的检验. 而path.contains("root")满足了对TaintCheck的检验,但是没有满足对p0的检验. 所以这条传播路径是有效的.
    sanitizer总结
    sanitizer针对的是,已经找到了一条从source到sink的路径,再根据source污染到的所有变量进行过滤,如果满足条件就删掉这条路径,否则保留.
     

    4. 四种mode的特殊性

    #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,它表示只关心这个数组里面的数值. 比如:
     

    5. 结果解读

    SecurityInfo
    这里的安全漏洞会根据你在规则中desc指定的categoryname进行分类. 方便程序处理,也方便人工阅读. 其中vulners字段是这种类型漏洞的列表. 其中每个漏洞都有一个hash字段,该字段可以认为是漏洞的唯一标识. details字段包含了漏洞的大量信息:
    • Source 规则中source字段匹配到的变量.
    • Sink 规则中sink字段匹配到的变量
    • position source对应变量所在的函数
    • entryMethod 分析的入口
    • target 污点在变量之间传播的过程.
    • url 以html格式展示的污点在变量之间传播的过程.
    ComplianceInfo
    ComplianceInfo专门针对隐私合规问题. 如果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代码.
     

    6. 隐私合规问题

    隐私数据流分析就是数据流分析的其中一种,但绝大部分时候你不需要编写entry和sanitizer,你应该更关心规则中的source和sink。
    具体来说,你可以将source指定为某个获取隐私信息的API,例如:
    这个API的return是设备唯一标识IMEI。
    同时,将sink指定为你认为会存在隐私数据泄露的方法,例如写文件:
    当你所关注的隐私数据来源并非API,而是对象的某个field时,你依然可以按照通用规则编写中field类型source的格式编写规则,例如设备序列号作为source:
    最后,你需要使用SliceMode,可以减少分析的时间。
    完整的规则文件:

    0x02 经典案例

     

    1. content provider目录遍历漏洞

    我们的app有一个content provider,用来共享sandbox目录下的文件.
    作者的意图是只共享sandbox目录,但是他直接把用户path作为参数传递给了File,这意味着,如果path中包含"../",那么就可以绕过sandbox目录限制. 可以轻松构造出一个poc:
    notion image
     关键就是定义source,sink以及sanitizer. 明显openFile的参数0也就是uri是用户可控制的,一般把外部用户可直接或间接控制的变量视为source. 而sink点比较合适的一个地方是ParcelFileDescriptor.open的参数0, 因为如果source能够控制ParcelFileDescriptor.open参数0,那么基本上就可以读取任何文件了.
    然后给出运行配置
    notion image
    notion image
    假设修复方法如下,通过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的路径上,如果:
    1. String.contains的this指针被污染了,并且这个函数调用位置的p0参数能够被"..*"这个常量污染到
    1. 并且String.startWith的this指针也被污染了.
    校验如果以app的内部路径开头,就抛出异常. 我们可以通过软链接,既不包含..,也不以app的内部路径开头. poc代码如下:
    可以存在两种有效的修复方式:
    修复方式1
    注意这里的startsWith 检查的是sandbox的path,所以我们就没法在自己的目录中创建一个软链接了.
    修复方式2
    这里通过getCanonicalFile来解析软链接,这样获取到的就是真实的路径了. 所以这里的条件是:
    1. 通过getCanonicalFile获取到真实的路径
    1. 通过startsWith校验真实路径是否以sandbox路径开头.
    大家可能有疑问.containsDotDot这个sanitizer存在漏报问题啊,case3中的修复方式明明是无效的,但是仍然会被引擎因为是修复了的,这实际上导致了漏报. 这里只能说一下sanitizer的局限性了,它只能根据source污染到的变量的范围来确定要不要去掉一条路径. 真实的修复方式: path.startsWith(root.getPath())和有问题的修复方式 path.startsWith(internalDir.getCanonicalPath())从形式上看是没什么区别的. 让appshark去识别这种逻辑上的区别,是非常困难的, 这也是appshark的局限.n

    2. Intent Redirection

    LaunchAnywhere是安卓最为经典的漏洞类型之一,现在被Google称为Intent Redirection:support.google.com/faqs/answer… 无恒实验室一直对该类型漏洞有研究,我们把这一类问题比作“安卓上的SSRF”,其中Intent就像一个HTTP请求,而未经验证完全转发了这个请求在安卓上会导致严重的安全问题。关于这类漏洞的逻辑与利用,推荐阅读retme.net/index.php/2… 这篇文章。
    基础的规则如下
    可以看到这个规则仅仅考虑从 getParcelable 到 startActivity 的数据流,且不考虑 sanitizer。这和我们实际使用的规则有一些差别,但足够说明问题。
     

    3. DirtyStream

    notion image
     
    详见Android安全问题中的DirtyStream 一节。
    sanitizer
    notion image
    加入sanitizer
    notion image
    无法扫描到漏洞,已修复。
    notion image
     

    4. MAC 隐私获取

     

    5. ZIP 解压缩路径穿越

    6. JSInterface_analyzer

    请看项目:
     

    0x03 总结

    静态分析能极大的减小人工分析的精力消耗,能够快速锁定调用链,但是依旧存在一些问题
    • 比如对于复杂调用环境的调用链可能会断,例如需要用户操作触发进一步的调用时,链子就会断掉,这种情况只能进行手动分析。
    • 对于加固的App将可能无法进行静态分析,现有静态分析框架基本基于完整APK进行分析,脱壳后一般会得到dex文件,现有框架无法综合各个DEX进行静态分析,如果可以尝试将脱壳内容重新组包的情况下,说不定可行,请自行拓展尝试。
    然还可以尝试一下其他的静态分析框架,这里仅对AppShark进行体验。
     

    0x04 参考

    1. https://github.com/bytedance/appshark/blob/main/doc
    1. https://juejin.cn/post/7161660793870614564
    1. Remediation for Intent Redirection Vulnerability
     

  • CVE-2021-0928漏洞分析

     
     

    0x01 漏洞分析

    该漏洞在Android 12 Developer Preview 3版本是上被发现,并在Android 12 release中就已经完成了修复。安全公告:Android 安全公告 – 2021 年 11 月  |  Android 开源项目
    notion image
     

    1. Android广播

    BroadcastReceiver(广播接收器) 是Android APP的四大组件之一,APP可以通过在AndroidManifest.xml文件中添加一条<receiver>的声明来完成组件的声明或者通过动态注册的方式进行广播接收器的注册。其他APP通过构造一个Intent并通过sendBroadcast方法即可发送广播,实际上进行了进程间的通信,广播接收器则可以接收到广播和数据。
     
    当应用程序进程最初启动时调用IActivityManager.attachApplication(),以此来传递IApplicationThread句柄,系统使用该句柄获得应用程序的控制权。
    notion image
    当系统希望在应用程序进程中执行清单注册的BroadcastReceiver时,它使用IApplicationThread 句柄调用scheduleReceiver方法。进而通过Bindertransact实现跨进程调用。下跨进程通过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 的任意代码执行。

    2. Parcel MisMatch Point

    OutputConfiguration类,其中有个序列化的字段 sensorPixelModesUsed ArrayList<Integer>类型,在反序列化该字段时,使用的是Parcel.readList方法,其调用链路是:
    • Parcel.readList
    • Parcel.readListInternal
    • Parcel.readValue
    最终是使用 readValue读取 List中每个元素的值。因此实际上可以读出任何 readValue支持的类型,比如 ParcelableIBinder等,并不局限于 Integer
    仅仅进行反序列化并不会出现任何问题,只不过在使用具体的元素时,如果我们实际读取的类型无法转换为整数,就会出现 ClassCastException异常
    OutputConfiguration在反序列化时用try-catch包裹了createFromParcel方法,这意味着如果在反序列化过程中抛出异常,对象的读取会提前结束,但之前写入的数据不会被读取,造成数据长度不匹配。在这个漏洞场景中,我们并不会使用这个数组的元素,因此我们可以指定任意的序列化类。又由于我们需要使目标类在序列化/反序列化过程产生不匹配,那么就需要找到一个类,使得该类可以在 system_server中成功反序列化,但是在应用 B 中出现ClassNotFoundException异常
     

    0x02 漏洞利用

    漏洞利用思路就是通过 intent参数的反序列化数据残留,间接地修改 info参数,因为应用 B 会使用 ActivityInfo中的数据去实例化代码,具体来说就是应用会通过传入的 ActivityInfo 构造 LoadedApk,其中会使用内部的 zipPaths 创建 ClassLoader,攻击者如果可以控制这个字段,就能实现针对应用 B 的任意代码执行。
    接着上文Parcel MisMatch Point继续:
    • 上文提到为了让系统服务能序列化但应用不能反序列化,可以使用只在系统服务中存在的类,如PackageManagerException。但需要额外通过不指定ClassLoaderreadList来绕过类加载约束,因为如果指定了ClassLoader,那么应用B就可以在类加载器中找到对于的类,从而不会抛出异常,而如果没有指定ClassLoader,会默认只在BOOTCLASSPATH中搜索类,而不会去system_server类路径中查找,从而使system_server也反序列化失败。原作者的利用是使用了 PackageManagerException,这是一个 Serializable类而不是 Parcelable反序列化Serializable对象时,会使用ObjectInputStream它在反序列化类时,会自动从堆栈轨迹中选择最近的非空非BootClassLoader的ClassLoader。这里由于在系统服务的onTransact等方法在堆栈中,ObjectInputStream会优先使用包含system_server类的ClassLoader,所以可以成功加载到系统服务中定义的可序列化类。但在应用中反序列化该对象时,由于堆栈中没有系统服务的类,会导致ClassNotFoundException
    • 目标是在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中。
        • notion image
      • 但后面还有一些readString调用,为了完全控制不匹配后的未读数据,可以OutputConfiguration中放一个Bundle类型的原始数据,然后用ZenPolicy类将OutputConfiguration包裹起来,以在反序列化时跳过Bundle的前三个int字段VAL_BUNDLE 、长度和魔术字)。这样当触发PackageManagerException时会停止继续正常反序列化,而留下了Bundle数据,再由ZenPolicy 反序列化过程中后三个readInt读取Bundle字段的三个int字段,并且将后续内容暴露出来,_arg114 = (ActivityInfo) ActivityInfo.CREATOR.createFromParcel(data);继续读取剩下的Bundle数据作为ActivityInfo 数据
        • notion image
       
      • handleReceiver调用了getPackageInfoNoCheck,并传入了可以被攻击者控制的ApplicationInfogetPackageInfoNoCheck会根据ApplicationInfo生成一个LoadedApk实例。这里攻击者传入一个新的packageName,以确保会创建新实例。然后调用ContextImpl.getClassLoader,首次调用会代理到LoadedApkgetClassLoader,然后会调用createOrUpdateClassLoaderLockedcreateOrUpdateClassLoaderLocked通过makePaths生成类加载的路径zipPaths其中主要使用了ApplicationInfo中的sourceDir信息。攻击者控制的ApplicationInfosourceDir设置为攻击者的apk路径。n因此makePaths将生成包含攻击者apkzipPaths,交给createClassLoader加载。n最终BroadcastReceiver会从攻击者的apk中加载类并执行,实现了代码执行。
       
      notion image

      0x03 漏洞修复

      这个漏洞本身对终端用户的影响不是很大,毕竟只在 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 实现中,ParcelableList 等类型会在序列化数据的元素开头单独存储长度信息。不过,这个 patch 并不会影响非 Bundle 造成的反序列化漏洞,比如这个漏洞。

      0x04 参考

       
      1. CVE-2021-0928 – Android Code Search
      1. Android 安全公告 – 2021 年 11 月  |  Android 开源项目  |  Android Open Source Project (google.cn)
      1. Android parcels: the bad, the good and the better – Introducing Android’s Safer Parcel (blackhat.com)
      1. Android 反序列化漏洞攻防史话 – evilpan
      1. Android Parcel Mismatch系列漏洞整理(二) | 失眠想睡觉的blog
      1. ReparcelBug2
        michalbednarskiUpdated Feb 21, 2025

      1. Broadcast 源码分析 | 小强的开发笔记 (solarqiang.github.io)

    • Zygisk源码分析

       
       
      接上文:

      Magisk源码分析

      Magisk源码分析

       

      一、日志

       

      二、Zygisk

       

      1. 概述

      每个应用进程都从一个名为 Zygote 的现有进程进行fork得到的。系统启动并加载通用框架代码和资源时,Zygote 进程随之启动。为启动新的应用进程,系统会fork Zygote进程,然后在新进程中加载并运行应用代码。Zygote 是 Android 所有其他应用进程的父进程。
      notion image
      Zygisk 的得名非常直白 —— Magisk注入Zygote

      2. Zygisk-SETUP

      在Magisk启动过程中,通过挂载实现了/system/bin/app_process(32/64)magisk(32/64) 的替换,因此当启动app_process(32/64)时实际是执行了magisk(32/64) ,而magisk本身入口位于native/src/core/applets.cppmain ,内部会校验当前是app_process(32/64)被执行还是其他,如果是app_process(32/64),则进入app_process_main
      通过zygisk_request(ZygiskRequest::SETUP) 发送SETUP请求,由守护进程处理,处理完成后设置LD_PRELOADMAGISKTMP_ENV环境变量,LD_PRELOAD在Linux系统中用于指定要在程序运行前加载的共享库。当应用程序启动时,动态链接器会优先加载指定的共享库,然后再加载其他库。这通常用于劫持库的加载,即用自己的库替换原来系统的库,或者在运行时注入代码,实际上这里添加的是后面提到的loader32.soloader64.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_processzygisk_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 时实际启动的是被替换后的

      3. zygisk_inject_entry

      上面提到LD_PRELOAD 先于其他动态链接库加载了loader ,先分析zygisk_inject_entry ,首先就是恢复LD_PRELOAD 为原本的环境变量,然后恢复MAGISKTMP_ENV 。通过sanitize_environ确保该文件中的环境变量是经过清理的、符合预期的。
      重点分析hook_functions,设置了对fork unshare androidSetCreateThreadFunc android_log_closePLT Hook
      重点关注对androidSetCreateThreadFuncfork的Hook,在原本的androidSetCreateThreadFunc 被执行前调用hook_jni_env ,该函数Replace the function table in JNIEnv to hook RegisterNatives 即通过替换function table实现了对RegisterNatives 的Hook
      env_RegisterNatives 则对注册的jni函数进行了Hook,并调用了hookAndSaveJNIMethodsnativeForkAndSpecializenativeSpecializeAppProcess nativeForkSystemServer 这三个方法进行了Hook。
       

      4. nativeForkAndSpecialize

      无论是哪个安卓版本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 将之前Magiskpost_fs_data 阶段安装模块时的内存描述符进行处理,对动态链接库进行加载并删除描述符,然后对加载的模块进行初始化。
      sanitize_fds 则用于清理/限制进程文件描述符的,这可以防止app传入不需要的FD,从而限制app可访问的资源,同时又保留app需要的FD不被关闭,实现最小特权的FD访问控制。
      实际上提前进行fork是为了能够控制在denylist中的APP不加载模块,如果需要加载模块在nativeForkAndSpecialize_pre结尾调用了app_specialize_pre ,该函数调用run_modules_pre 将之前Magiskpost_fs_data 阶段安装模块时的内存描述符进行处理,对动态链接库进行加载并删除描述符,然后对加载的模块进行初始化。
      zygisk注入原理 | d0nuts (d0nuts33.github.io)zygisk 这种在 fork 后加载模块的方式使得它可以在不重启的情况下更新 lsposed 代码,而且能够选择性加载模块。对比 xposed 的方式,xposed 在的 XposedInit 里就把模块加载进来,再进行正常的 ZygoteInit 启动流程,使得 zygote 之后 fork 的每一个进程都带有模块。
       
       

      参考

      1. zygisk注入原理 | d0nuts (d0nuts33.github.io)
      1. gist.github.com/5ec1cff/bfe06429f5bf1da262c40d0145e9f190#file-zygisk-md