本文作者:烧麦
目前,国内对应用程序平安隐衷问题监管变的越来越严格。各个利用市场对 APP 上架也有比拟严格的查看。云音乐往年也在 Google Play 上上架了一些海内的社交类业务。Google Play 在审核利用的时候,也有相应的政策。当咱们每次遇到问题的时候,须要依据查看方的信息对一些代码逻辑进行排查。
这是一个相对来说十分低效的过程。开发在平时写代码的时候个别不会应用敏感的 API。大部分的敏感 API 调用都在一些三方 SDK 外面,或者一些敏感级别不是很高的 API,会存在屡次调用的状况,例如:
- 某利用在 Google Play 上架,云音乐外部的根底 SDK 里包含了一些国内的三方 SDK,这些 SDK 应用了热修复或者动静下发 so 的性能。被 Google Play 发现拒审。
- 某利用在三方的查看中发现对地理位置的获取存在每 30 秒一次的频繁调用。
为了防止这类问题连累 APP 上架,也为了晋升查看的准确性和效率。笔者开发了一个针对 Android APK 的敏感办法调用的动态查看工具。
查看关键字,对于一些敏感 API 调用,例如 oaid、androidId 相干的调用。咱们其实只有能检测到这些相干 API 里的一些关键字,找出整个 APP 外面有哪些地方间接调用了这些办法就能够了。
针对的上述的一些场景,这个工具具备两个方向的工作:
- APK 包的扫描,查看出整个 APK 中,哪些地方有对蕴含下面这些 API 关键字的间接调用。
- 运行时查看。针对运行时频繁调用这个场景,还是须要在运行时辅助查看特定 API 的调用状况。
工具计划
运行时
运行时的检测须要晓得咱们的办法在什么时候被调用了。那么被检测办法如果能有调用栈,那么咱们在整改运行时的一些场景就会比拟容易。这里咱们用一个 Gradle 插件在 transfrom 里给咱们须要检测的办法插入一行打印调用栈的代码即可。
这里利用 Javassist
给找到的办法插入一行调用栈打印就能够。
method.insertBefore("android.util.Log.e(\" 隐衷办法调用栈 \", android.util.Log.getStackTraceString(new Throwable()));"
)
产物扫描
对 APK 的扫描思路其实也很简略,咱们的诉求就是查看所有的代码。然而咱们这时候只有一个 APK 文件。那最间接并且扫描简略的的就是想方法把咱们的包转成 Java 代码,逐行扫描咱们的 Java 代码,查看是否有敏感的 API 调用。
如果咱们平时想看一个 APK 包外面的代码咱们会怎么做呢,最简略的就是反编译这个 APK,而后把外面的 dex 文件转成 Java 去查看。咱们能够用脚本把这个流程再实现一遍。
- 第一步先解压 APK 文件,把外面的 dex 文件独自拿进去。
- 应用
dex2jar
把 dex 文件转成 jar 文件。 - jar 文件转成 java 文件
- 逐行扫描 java 文件
那么如何把 jar 文件转成 java 文件呢?咱们平时点开 Android Studio 外面的 jar 或者 aar 就能够看到 Java 文件。咱们也能够参考 Android Studio 的做法。
在 IDEA
的目录外面,咱们其实能够找到相干性能依赖的 jar 包,也能够 clone IDEA 源码外面的相干模块本人打一个 jar。
扫描工具的工作流程如图:
多 APP 配置
云音乐目前旗下 APP 比拟多,不同的 APP 也有可能会有不同的扫描类型和不同的关键字规定。
配置如下:
每个 APP 目前最多会有两份配置:
gp.json 和 privacy.json 别离对应 google play 扫描和隐衷合规扫描。
外面的配置包含
- keys 扫描的关键字。
- filterPackages 过滤掉的包名。如果咱们关注是不是某些三方 SDK 写了一些不合规的代码,那么咱们能够把本人的包名给过滤。防止输入后果太多。
扫描后果会输入一个 json 文件和 html 文件。json 文件能够比照上次的扫描后果,增量的输入新增的扫描后果。html 文件则用来展现扫描后果,辅助对应的排查人排查相干的问题。
例如,热修复等动静下发的相干技术,都会有对于 getField
、ClassLoader
之类的关键字存在。
咱们能够找到间接调用这些 API 的中央,从下图咱们能够看到,很多调用都是在三方 SDK 外面找到的。
优化
第一版的合规扫描开发完后,在应用上还是有一些问题:
- 运行时:查看咱们本人代码内的办法很容易,然而如果想要检测零碎 API 的时候就有效了。因为 Android Framework 的 API 不会参加打包。天然也不可能插入字节码。
- 产物扫描:jar 转 java 的过程十分的耗时。整体扫描时间会被拉到 3 - 5 分钟。
- jar 转 java 的过程实际上也是一种反编译过程。因为 java 和 kotlin 语法的问题,某些会 decompile 失败。这种状况多了的话,其实扫描是会有脱漏的。
- 扫描 java 文件是逐行遍历,把其余中央的关键字也扫描进来了,比方 field、import。这些扫描后果实际上是多余的。
针对下面这些问题,进行了针对性的优化。
运行时如果要检测零碎 API 的调用,想到两种计划:
- transform 解决每个 class 和 jar 文件的时候,都去看下 class 外部的 method 有没有去调用这个零碎 API。然而这个依赖字节码操作库的反对。
- 用一个专门的手机,用 xposed 之类的插件去 hook 零碎 API。
第二种实现老本会比拟高,不适宜。然而运气比拟好的是应用的 javassist 是反对第一种思路的操作的。
javassist 的 CtMethod
继承自 CtBehavior
对象。包含一个 instrument
办法。这个办法会找到办法内的表达式并容许替换。这里的表达式就包含 MethodCall
。
这样咱们通过这个性能找到所有的调用就能够给间接调用了零碎 API 的这个办法插入调用栈的打印。
运行期的查看就变成了:
实现这个优化之后,咱们能够发现实际上在编译期的办法扫描,咱们是通过间接读取 class 文件去做的。那么对于 APK 包,咱们也能够采取相似的思路。用雷同办法去读取 dex2jar 之后解压进去的 jar 包里的 class 文件。
然而再认真想想,Android 在 class 文件之后会有 dex 文件。Android 虚拟机间接执行的应该是 dex 文件。而 dex 文件实质上只是一种二进制格局,最终会依据这个文件格式里的内容,依照汇编去执行。
思路到这里就清晰了,如果咱们试着把 dex 文件间接反汇编成 smali 文件,去遍历 smali 文件可能成果会更好。
smail 语法介绍
一个 smail 文件对应一个 Java 的类,精确来说,是对应一个 .class 或者 .dex 文件。
外部类则会依照 ClassName$InnerClassA
、ClassName$InnerClassB
的格局来命名。
smail 外面存在的根本类型,别离对应 Java 的根本类型,如下表所示:
类型关键字 | Java 根本类型 |
---|---|
V | void |
Z | boolean |
B | byte |
S | short |
C | char |
I | int |
J | long |
F | float |
D | double |
smail 一些常见的根本指令如下表:
指令 | 含意 |
---|---|
.class | 包名和类名 |
.super | 父类 |
.source | 源文件名称 |
.implements | 接口实现 |
.field | 变量 |
.method | 办法 |
.end method | 办法完结 |
.line | 行数 |
.param | 函数参数 |
.annotation | 注解 |
.end annotation | 注解完结 |
办法的调用也分为以下几种指令:
指令 | 含意 |
---|---|
invoke-virtua | 调用虚办法 |
invoke-static | 调用静态方法 |
invoke-direct | 调用没有被 override 的办法,例如 private 和构造方法 |
invoke-super | 调用父类的办法 |
invoke-interface | 调用接口办法 |
咱们看一个示例 smali
文件的格局:
.class public abstract Lcom/horcrux/svg/RenderableView;
.super Lcom/horcrux/svg/VirtualView;
.source "RenderableView.java"
# static fields
.field private static final CAP_BUTT:I = 0x0
.field static final CAP_ROUND:I = 0x1
# instance fields
.field public fillOpacity:F
.field public fillRule:Landroid/graphics/Path$FillType;
.method static constructor <clinit>()V
.registers 1
.line 97
invoke-static {v0}, Ljava/util/regex/Pattern;->compile(Ljava/lang/String;)Ljava/util/regex/Pattern;
return-void
.end method
.method resetProperties()V
.registers 4
.line 635
invoke-virtual {p0}, Ljava/lang/Object;->getClass()Ljava/lang/Class;
invoke-virtual {v1, v2}, Ljava/lang/Class;->getField(Ljava/lang/String;)Ljava/lang/reflect/Field;
return-void
.end method
smali
文件的结尾会通知类名、父类、源文件名。
这个文件的类名就是 com.horcrux.svg.RenderableView
。父类是 com.horcrux.svg.VirtualView
,源文件名为 RenderableView.java
。
外面的变量和办法都有结尾和完结的标记。
在 .method
里,咱们能够看到
line
结尾会标记行号invoke-
结尾会标记办法的调用
下面例子里包含两个办法:
- 构造方法。97 行调了一个静态方法。
resetProperties
办法。在 635 行,调用了 getClass() 和 getField() 这两个虚函数。
对应的 java 代码则是:
// line 635
Field field = this.getClass().getField((String)this.mLastMergedList.get(i));
这里咱们根本能定义出 smali 文件的扫描形式:
- 逐行读取一个 smali 文件,读到后面三行的时候,读取类的根本信息。
- 读取到
.method
和.end method
的时候标记为读取到本人的办法。 - 读取到
.line
和下一个.line
的时候,标记为读取到办法内的具体行号。 - 读取到
invoke-
结尾的行,标记为读取到办法调用。如果此行开端的办法签名满足咱们的关键字匹配,就记录为扫描后果之一。
在实践中,咱们能够应用开源的 baksmali.jar
进行 dex 转 smali 的操作。应用上述规定间接扫描 smali 文件。防止了下面提到的缺点。扫描时间也有很大的晋升。基本上在半分钟左右都能够实现整个全量的扫描。省略了反编译 jar 包的巨长时间。
这个工具最终出现为一个 jar 文件,通过命令行运行。在排查隐衷合规可疑的 API 调用的时候,十分实用。
总结
通过这个工具,在 APK 的隐衷合规问题查看的时候,咱们能够获取比拟残缺的可疑调用来辅助咱们进行合规方面工作的解决。
这个工具的劣势在于:
- APK 包是最终产物,扫描内容比拟残缺。
- 不在编译期进行扫描,不会升高开发效率。
然而这个工具还有一些不足之处,例如
- 不能精确定位到隐衷函数调用具体归因在哪个模块或者 aar,难以集成在 CI/CD 进行归因解决。
- 比拟难以获取残缺的函数调用链。
所以咱们还会持续进行编译期的合规查看工作。两者联合来欠缺相干的工作。
本文公布自 网易云音乐大前端团队,文章未经受权禁止任何模式的转载。咱们长年招收前端、iOS、Android,如果你筹备换工作,又恰好喜爱云音乐,那就退出咱们 grp.music-fe(at)corp.netease.com!