ASM hook隐私方法调用,防止App被下架

Posted 冬天的毛毛雨

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了ASM hook隐私方法调用,防止App被下架相关的知识,希望对你有一定的参考价值。

好文推荐:
作者:蓝师傅

一、前言

工信部对于App索权问题越来越重视,先后多个大厂App被下架要求整改:

其中最关键的问题是用户同意隐私协议之前,不能有收集用户隐私信息的行为,例如获取deviceId、androidId等信息,除此之外,对于频繁申请权限、超范围申请权限也是需要注意的。

除了开迭代针对性整改,从技术角度思考,有没有一劳永逸的办法,杜绝隐私调用不合规问题呢?

这就是这篇文章要介绍的方案, 前期通过运行时hook技术高效检测隐私方法调用,

后期通过Gradle Plugin+Transform+ASM 来hook并替换隐私方法调用,管控App和第三方SDK的隐私行为,彻底解决隐私不合规问题。

二、运行时hook技术

在隐私整改前期,通过上传apk到史宾格平台,然后平台会安装apk并运行,就能动态监测隐私方法调用,如下图:

完成整个流程,打包-上传-检测,少说也要50分钟~

关于隐私行为实时监控,实现原理无非是利用运行时hook技术,记录方法调用信息。

理论上我们也可以使用运行时hook技术,实现线下快速检测隐私方法调用以及获取调用堆栈的功能。

那么运行时hook技术有哪些呢?

2.1 Xposed

如果你对Xposed比较熟悉,并且手头有个root的设备安装了Xposed框架,那么直接开发一个Xposed模块来hook指定方法就可以了。

关于Xposed的源码分析感兴趣可以参考这一篇文章:抱歉,Xposed真的可以为所欲为——终 · 庖丁解码,作者有一系列Xposed文章。

由于我的测试设备是有root权限的,Xposed方案对我来说难度不大,不过对于普通用户,有没有免root的方式呢?

有的~

2.2 VirtualXposed

VirtualXposed 是基于VirtualApp 和 epic 在非ROOT环境下运行Xposed模块的实现(支持5.0~10.0)。

VirtualXposed其实就是一个支持Xposed的虚拟机,我们把开发好的Xposed模块和对应需要hook的App安装上去就能实现hook功能。

由于VirtualApp 2017年就闭源转商业,开源版存在不少问题,而且由于其hook大量系统的函数,所以存在不少兼容性问题,有些App安装之后可能打不开,所以如果手头的设备刚好遇到兼容性问题,那可以考虑换个手机啦~

2.3 epic

阿里2014年开源了Dexposed 项目,它能够在Dalvik虚拟机上无侵入地实现运行时方法拦截,

但是Android 5.0开始使用ART虚拟机后,不支持ART的Dexposed 就沦为历史。

之后维术大佬在ART上重新实现了Dexposed,有着与Dexposed完全相同的能力和API,项目地址是epic

所以如果不想折腾 Xposed 或者 VirtualXposed,只要在应用内接入epic,就可以实现应用内Xposed hook功能,满足运行hook需求。

2.3.1 epic 原理:

原理是通过修改ArtMethod的入口函数,把入口函数的前8个字节修改为一段跳转指令,跳转到执行hook操作的函数,原理跟阿里的热修复框架AndFix差不多,如下图所示。

详细原理可以看原文: 我为Dexposed续一秒——论ART上运行时 Method AOP实现

2.3.2 基于epic 实现一个可配置的运行时hook框架

  1. 读取配置:
        val inputStream = context.resources.assets.open("privacy_methods.json")
        val reader = BufferedReader(InputStreamReader(inputStream))
        val result = StringBuilder()
        var line: String? = ""
        while (reader.readLine().also  line = it  != null) 
            result.append(line)
        

        val configEntity = Gson().fromJson(result.toString(), PrivacyMethod::class.java)
        configEntity.methods.forEach 
            hookPrivacyMethod(it)
        
  1. json配置如下,放在assets目录:

  "methods": [
    
      "name_regex": "android.app.ActivityManager.getRunningAppProcesses",
      "message": "读取当前运行应用进程"
    ,
    
      "name_regex": "android.telephony.TelephonyManager.listen",
      "message": "监听呼入电话信息"
    ,
    ...
  ]

  1. 根据读取的配置,进行hook
    private fun hookPrivacyMethod(entity: PrivacyMethodData) 
        if (entity.name_regex.isNotEmpty()) 
            val methodName = entity.name_regex.substring(entity.name_regex.lastIndexOf(".") + 1)
            val className = entity.name_regex.substring(0, entity.name_regex.lastIndexOf("."))
            try 
                val lintClass = Class.forName(className)
                DexposedBridge.hookAllMethods(lintClass, methodName, object : XC_MethodHook() 
                    override fun beforeHookedMethod(param: XC_MethodHook.MethodHookParam?) 
                        super.beforeHookedMethod(param)

                        Log.i(TAG, "beforeHookedMethod $className.$methodName")
                        Log.d(TAG, "stack= " + Log.getStackTraceString(Throwable()))
                    
                )
             catch (e: Exception) 
                Log.w(TAG, "hookPrivacyMethod:$className.$methodName,e=$e.message")
            
        
    
  1. 运行效果如下:

如图所示,运行时输出隐私方法调用堆栈的功能基本实现了,支持通过json配置需要hook的方法。

tip:epic 存在兼容性问题,例如Android 11 只支持64位App,所以建议只在debug环境使用。

三、编译时hook技术

使用epic只解决了验证隐私方法调用问题,针对如下问题无能为力:

  1. release环境如何监控隐私方法调用?
  2. 如何管控第三方SDK频繁调用隐私方法问题?

对于这两个问题,可以使用编译时hook技术来解决。

说到编译时hook,首先需要了解编译流程

3.1 编译流程

我们使用Android Studio开发,使用Gradle 编译工具,对于apk编译流程大家应该都知道,如下图:

apk编译流程无非就是以下这些大的步骤:
1.打包资源文件,生成R.java文件
2.将AIDL文件编译成java文件
3.将java文件通过javac命令编译成.class文件
4.将class文件打包成dex文件
5.通过apkbuilder工具将dex文件和资源文件打包成apk
6.apk签名
7.apk对齐(可以没有这一步)

其中第四步(将class文件打包成dex文件),中间就涉及到Gradle的一个Transform流程

3.2 了解Transform

Transform原理图如下所示

将class文件、jar文件、资源文件作为输入,经过一系列的Transform处理,

首先是自定义的Transform处理,然后是系统的Transform处理,最后一个Transform是负责生成dex文件。

相关源码可以看TaskManagercreatePostCompilationTasks方法,编译流程源码都在这里面~

截图只是贴了自定义Transform的源码,后面还有系统的Transform,例如 appliesCustomClassTransforms,用于Profile插件底层实现。

Transform是跟taskFactory关联的,可以这样理解,一个Transform对应Gradle的一个Task

知道了Transform的大概原理,我们可以通过自定义Plugin,注册一个自定义的Transform到编译流程中去,目的是拿到所有.class文件,再结合ASM 工具修改字节码。

自定义Gradle Plugin,注册Transform,代码如下所示

class Plugin : Plugin<Project> 

    override fun apply(project: Project) 

      if (project.plugins.hasPlugin("com.android.application")) 
          val extension = project.extensions.getByName("android") as AppExtension
          extension.registerTransform(CommonTransform(project))
      
    

想要理解为什么自定义插件要这么写,可以看App编译插件源码AppPlugin

创建AppExtension,name是android,最终是保存到ExtensionsStorage类里面的一个叫extensions的LinkedHashMap变量里面,大家感兴趣可以去看源码。

前面的eproject.extensions.getByName,最终就是从LinkedHashMap中读取的。

拿到.class文件之后,怎么修改呢?这就涉及到修改字节码方案选型。

3.3 字节码修改框架选择

目前主流的字节码修改框架除了ASM,还有Javaassist,两者对比:

由于项目对性能、包体积方面要求比较高,所以无疑采用ASM方案比较合适。

3.4 了解ASM框架

我们通过自定义Transform 能拿到.class文件,之后的字节码处理就通过ASM工具,关于ASM的使用就不介绍了,大家可以参考:

Android 中看似高大上的字节码修改,这样学就对了!

Gradle Plugin + Transform ,这套框架的搭建基本都是模板代码,为了节约时间成本和试错成本,本文直接参考dokit,采用booster api作为插件的底层实现,booster屏蔽了不同Gradle版本api的差异。

说了那么多,最重要的还是要看方案设计~

四、初级hook方案

上一步我们通过自定义Transform可以拿到所有.class文件,后面只要通过ClassVistorMethodVistor,可以分别拿到每个类和方法的字节码,

ActivityManager#getRunningAppProcesses 为例,我们要替换成 PrivacyUtil#getRunningAppProcesses,流程图如下:

核心hook代码如下所示:

        classNode.methods.forEach  method ->
            method.instructions?.iterator()?.forEach  insnNode ->

                if (insnNode is MethodInsnNode) 

                    //命中方法,替换
                    if (insnNode.desc  == "android/app/ActivityManager.getRunningAppProcesses ()Ljava/util/List;" &&
                        insnNode.name == "getRunningAppProcesses" &&
                        insnNode.opcode == Opcodes.INVOKESPECIAL
                    ) 
                        //方法指令替换
                        insnNode.opcode = Opcodes.INVOKESTATIC
                        //调用类替换
                        insnNode.owner = "com/lanshifu/asm_plugin_library/privacy/PrivacyUtil"
                        //方法名替换
                        insnNode.name = "getRunningAppProcesses"
                        //参数替换
                        insnNode.desc = "com/lanshifu/asm_plugin_library/privacy/PrivacyUtil.getRunningAppProcesses (Landroid/app/ActivityManager;)Ljava/util/List;"

                    
                
            
        

解释:

通过遍历每个方法的字节码指令,判断是ActivityManager.getRunningAppProcesses这个方法调用,就替换成PrivacyUtil#getRunningAppProcesses调用,涉及到的字节码操作是比较基础的。

tip:为什么要遍历每个方法的字节码指令?因为需要hook的方法是系统的方法,没有被打包到apk中, 单纯遍历方法名是找不到的,必须遍历每个方法里面调用的字节码指令。

到此我们初级版本的编译时隐私方法hook功能就实现了,但是存在几个问题:

1、硬编码,不好维护,增加hook方法比较麻烦;

2、对工具类 PrivacyUtil 有依赖,如果后面其它工程使用了这个插件,但是没有引入PrivacyUtil,或者后面插件升级,PrivacyUtil没升级,就会报Class Not Found Exception;

3、开发需要熟悉 ASM 字节码,每次新增一个隐私方法 hook 都需要对比前后字节码变化进行修改验证,麻烦得很;

五、进阶方案

想要解决初级方案存在的三个问题,关键在于实现”可配置“,

需要在编译期能够读取hook配置,用注解会比较合适。

进阶方案思路如下:

  • 用第一个Transform来收集注解信息,生成一份hook配置;

  • 用第二个Transform来读取hook配置,替换隐私方法。

5.1 自定义注解

@Target(ElementType.METHOD)
@Retention(RetentionPolicy.CLASS)
public @interface AsmMethodReplace 
    Class oriClass();

    String oriMethod() default "";

    int oriAccess() default AsmMethodOpcodes.INVOKESTATIC;

注解是对方法生效,需要知道需要hook的方法的类名、方法名、方法类型(静态方法/成员方法)

5.2 注解处理,生成配置

替换一个方法,我们需要的配置如下:

原方法信息(替换前):oriClass、oriMethod、oriAccess、oriDesc

目标方法信息(替换后):targetClass、targetMethod、targetAcces、targetDesc

目标方法信息我们通过ClassNode就能拿到,但是原方法信息,都放到AsmMethodReplace 注解上就不太合适了,因为oriDesc写起来比较麻烦, 所以这里约定好一个注解使用规则,然后oriDesc在代码里读取就行了。

规则如下:

  • 对于hook静态方法,注解的方法的参数保持跟原方法一致
  • 对于hook成员方法,注解的方法的第一个参数是Class对象,之后的参数跟原方法保持一致

然后oriDesc就通过targetDesc减去第一个参数计算得出。

例如:
targetDesc=(Landroid/telephony/TelephonyManager;)Ljava/lang/String;
通过字符串截取后得到:
oriDesc= Ljava/lang/String;

举个🌰

5.2.1 例子1:hook成员方法

假如要替换掉ActivityManager的getRunningAppProcesses方法

    public List<RunningAppProcessInfo> getRunningAppProcesses() 
        try 
            return getService().getRunningAppProcesses();
         catch (RemoteException e) 
            throw e.rethrowFromSystemServer();
        
    

由于这个是成员方法,那么注解的写法如下:

    @JvmStatic
    @AsmMethodReplace(oriClass = ActivityManager::class, oriAccess = AsmMethodOpcodes.INVOKEVIRTUAL)
    fun getRunningAppProcesses(manager: ActivityManager): List<RunningAppProcessInfo?> 
        //hook 处理
    

5.2.2 例子2:hook静态方法

假如要替换掉Settings.System的getString方法

public static String getString(ContentResolver resolver, String name) 
		return getStringForUser(resolver, name, resolver.getUserId());

由于是静态方法,那么注解的写法如下:

    @JvmStatic
    @AsmMethodReplace(oriClass = Settings.System::class, oriAccess = AsmMethodOpcodes.INVOKESTATIC)
    fun getString(resolver: ContentResolver, name: String): String? 
        //处理AndroidId
        if (Settings.Secure.ANDROID_ID == name) 
        
        return Settings.System.getString(resolver, name)
    

详细可以参考文末的源码。

5.3 流程图

最终的流程如上,应该比较清晰了吧~

5.4 注意事项

ASM hook 需要有迹可循,必须明确字节码修改的地方,可以打印log,可以保存记录到文件中,如果出现问题可以从hook日志中排查。

5.5 小结

进阶方案主要做了这几件事:

  1. 用一个注解处理的Transform,编译期收集自定义注解信息,生成一份hook配;
  2. 用另一个Transform,读取hook配置,hook对应方法;
  3. 隐私方法hook之后,增加缓存,解决SDK频繁读取隐私信息问题;
  4. 在用户没有同意隐私协议之前,如果调用隐私方法,可以给toast提示,并打印调用堆栈,如下所示,问题一目了然。

六、其它

目前大厂也有一些开源的编译时插桩的库,例如饿了么开源的lancet,原理也是 Gradle Plugin+Transform+ASM

如果想深入学习字节码插桩,推荐滴滴开源的dokit,里面有好多字节码操作可以学习,例如大图监控,网络监控等等。

由于Gradle 版本更新比较快,大家最好是在项目中尝试自己搭建编译时hook基础框架,这样出问题的话,自己比较好解决,同时也能提升自己字节码开发的技术。

七、总结

本文从工信部隐私合规要求作为切入点,大概介绍了如下知识点:

  1. 运行时hook框架介绍和应用
  2. epic使用和原理
  3. 编译时hook框架
  4. 从apk编译流程介绍Transform的原理和应用
  5. 编译时hook方案对比
  6. 最终实现可配置的编译时方法替换方案,彻底解决隐私方法调用不合规问题

本文难度其实不算非常大,主要是把Gradle插件和字节码修改的整个流程串起来,涉及到的技术基本都有所提及,最终搭建了一个编译时方法hook框架,之后可以基于这个hook框架做很多东西,例如慢方法检测、全埋点、监控线程调用等~

以上是关于ASM hook隐私方法调用,防止App被下架的主要内容,如果未能解决你的问题,请参考以下文章

彻底解决隐私方法调用,防止 App 被下架

Asm hook隐私方法调用

Asm hook隐私方法调用

ASM 字节码插桩:隐私合规方法检测

开发者帐号到期导致APP被下架的处理方法

小米应用商店上架app隐私不合规自查整改办法