真牛系列 - 一步步解决 App 隐私违规问题
作者:蓝师傅
链接:https://juejin.cn/post/7043399520486424612
一、前言
工信部对于App索权问题越来越重视,先后多个大厂App被下架要求整改
工信部对于App索权问题越来越重视,先后多个大厂App被下架要求整改:
除了开迭代针对性整改,从技术角度思考,有没有一劳永逸的办法,杜绝隐私调用不合规问题呢?
这就是这篇文章要介绍的方案, 前期通过运行时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")
2 val reader = BufferedReader(InputStreamReader(inputStream))
3 val result = StringBuilder()
4 var line: String? = ""
5 while (reader.readLine().also { line = it } != null) {
6 result.append(line)
7 }
8
9 val configEntity = Gson().fromJson(result.toString(), PrivacyMethod::class.java)
10 configEntity.methods.forEach {
11 hookPrivacyMethod(it)
12 }
13
14复制代码
json配置如下,放在assets目录:
1{
2 "methods": [
3 {
4 "name_regex": "android.app.ActivityManager.getRunningAppProcesses",
5 "message": "读取当前运行应用进程"
6 },
7 {
8 "name_regex": "android.telephony.TelephonyManager.listen",
9 "message": "监听呼入电话信息"
10 },
11 ...
12 ]
13}
14复制代码
根据读取的配置,进行hook
1 private fun hookPrivacyMethod(entity: PrivacyMethodData) {
2 if (entity.name_regex.isNotEmpty()) {
3 val methodName = entity.name_regex.substring(entity.name_regex.lastIndexOf(".") + 1)
4 val className = entity.name_regex.substring(0, entity.name_regex.lastIndexOf("."))
5 try {
6 val lintClass = Class.forName(className)
7 DexposedBridge.hookAllMethods(lintClass, methodName, object : XC_MethodHook() {
8 override fun beforeHookedMethod(param: XC_MethodHook.MethodHookParam?) {
9 super.beforeHookedMethod(param)
10
11 Log.i(TAG, "beforeHookedMethod $className.$methodName")
12 Log.d(TAG, "stack= " + Log.getStackTraceString(Throwable()))
13 }
14 })
15 } catch (e: Exception) {
16 Log.w(TAG, "hookPrivacyMethod:$className.$methodName,e=${e.message}")
17 }
18 }
19 }
20
21
22复制代码
运行效果如下:
如图所示,运行时输出隐私方法调用堆栈的功能基本实现了,支持通过json配置需要hook的方法。
tip:epic 存在兼容性问题,例如Android 11 只支持64位App,所以建议只在debug环境使用。
三、编译时hook技术
使用epic只解决了验证隐私方法调用问题,针对如下问题无能为力:
release环境如何监控隐私方法调用?
如何管控第三方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文件。
相关源码可以看TaskManager的 createPostCompilationTasks方法,编译流程源码都在这里面~
截图只是贴了自定义Transform的源码,后面还有系统的Transform,例如 appliesCustomClassTransforms,用于Profile插件底层实现。
Transform是跟taskFactory关联的,可以这样理解,一个Transform对应Gradle的一个Task。
知道了Transform的大概原理,我们可以通过自定义Plugin,注册一个自定义的Transform到编译流程中去,目的是拿到所有.class文件,再结合ASM 工具修改字节码。
自定义Gradle Plugin,注册Transform,代码如下所示
1class Plugin : Plugin<Project> {
2
3 override fun apply(project: Project) {
4
5 if (project.plugins.hasPlugin("com.android.application")) {
6 val extension = project.extensions.getByName("android") as AppExtension
7 extension.registerTransform(CommonTransform(project))
8 }
9 }
10}
11复制代码
想要理解为什么自定义插件要这么写,可以看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文件,后面只要通过ClassVistor和MethodVistor,可以分别拿到每个类和方法的字节码,
以 ActivityManager#getRunningAppProcesses 为例,我们要替换成 PrivacyUtil#getRunningAppProcesses,流程图如下:
核心hook代码如下所示:
1 classNode.methods.forEach { method ->
2 method.instructions?.iterator()?.forEach { insnNode ->
3
4 if (insnNode is MethodInsnNode) {
5
6 //命中方法,替换
7 if (insnNode.desc == "android/app/ActivityManager.getRunningAppProcesses ()Ljava/util/List;" &&
8 insnNode.name == "getRunningAppProcesses" &&
9 insnNode.opcode == Opcodes.INVOKESPECIAL
10 ) {
11 //方法指令替换
12 insnNode.opcode = Opcodes.INVOKESTATIC
13 //调用类替换
14 insnNode.owner = "com/lanshifu/asm_plugin_library/privacy/PrivacyUtil"
15 //方法名替换
16 insnNode.name = "getRunningAppProcesses"
17 //参数替换
18 insnNode.desc = "com/lanshifu/asm_plugin_library/privacy/PrivacyUtil.getRunningAppProcesses (Landroid/app/ActivityManager;)Ljava/util/List;"
19
20 }
21 }
22 }
23 }
24复制代码
解释:
通过遍历每个方法的字节码指令,判断是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 自定义注解
1@Target(ElementType.METHOD)
2@Retention(RetentionPolicy.CLASS)
3public @interface AsmMethodReplace {
4 Class oriClass();
5
6 String oriMethod() default "";
7
8 int oriAccess() default AsmMethodOpcodes.INVOKESTATIC;
9}
10复制代码
注解是对方法生效,需要知道需要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方法
1 public List<RunningAppProcessInfo> getRunningAppProcesses() {
2 try {
3 return getService().getRunningAppProcesses();
4 } catch (RemoteException e) {
5 throw e.rethrowFromSystemServer();
6 }
7 }
8复制代码
由于这个是成员方法,那么注解的写法如下:
1 @JvmStatic
2 @AsmMethodReplace(oriClass = ActivityManager::class, oriAccess = AsmMethodOpcodes.INVOKEVIRTUAL)
3 fun getRunningAppProcesses(manager: ActivityManager): List<RunningAppProcessInfo?> {
4 //hook 处理
5 }
6复制代码
5.2.2 例子2:hook静态方法
假如要替换掉Settings.System的getString方法
1public static String getString(ContentResolver resolver, String name) {
2 return getStringForUser(resolver, name, resolver.getUserId());
3}
4复制代码
由于是静态方法,那么注解的写法如下:
1 @JvmStatic
2 @AsmMethodReplace(oriClass = Settings.System::class, oriAccess = AsmMethodOpcodes.INVOKESTATIC)
3 fun getString(resolver: ContentResolver, name: String): String? {
4 //处理AndroidId
5 if (Settings.Secure.ANDROID_ID == name) {
6 }
7 return Settings.System.getString(resolver, name)
8 }
9复制代码
详细可以参考文末的源码。
5.3 流程图
最终的流程如上,应该比较清晰了吧~
5.4 注意事项
ASM hook 需要有迹可循,必须明确字节码修改的地方,可以打印log,可以保存记录到文件中,如果出现问题可以从hook日志中排查。
5.5 小结
进阶方案主要做了这几件事:
用一个注解处理的Transform,编译期收集自定义注解信息,生成一份hook配;
用另一个Transform,读取hook配置,hook对应方法;
隐私方法hook之后,增加缓存,解决SDK频繁读取隐私信息问题;
在用户没有同意隐私协议之前,如果调用隐私方法,可以给toast提示,并打印调用堆栈,如下所示,问题一目了然。
六、其它
目前大厂也有一些开源的编译时插桩的库,例如饿了么开源的lancet,原理也是 Gradle Plugin+Transform+ASM。
如果想深入学习字节码插桩,推荐滴滴开源的dokit,里面有好多字节码操作可以学习,例如大图监控,网络监控等等。
由于Gradle 版本更新比较快,大家最好是在项目中尝试自己搭建编译时hook基础框架,这样出问题的话,自己比较好解决,同时也能提升自己字节码开发的技术。
七、总结
本文从工信部隐私合规要求作为切入点,大概介绍了如下知识点:
运行时hook框架介绍和应用
epic使用和原理
编译时hook框架
从apk编译流程介绍Transform的原理和应用
编译时hook方案对比
最终实现可配置的编译时方法替换方案,彻底解决隐私方法调用不合规问题
本文难度其实不算非常大,主要是把Gradle插件和字节码修改的整个流程串起来,涉及到的技术基本都有所提及,最终搭建了一个编译时方法hook框架,之后可以基于这个hook框架做很多东西,例如慢方法检测、全埋点、监控线程调用等~
本文源码
相关参考文章:
一步步治理隐私权限 | 安卓黑魔法
一起玩转Android项目中的字节码
去哪儿 Android 客户端隐私安全处理方案
booster
春节前最后一篇文章,提前祝大家新年快乐!
推荐阅读
『Android自定义View实战』让你的轮播指示器“粘”起来