更多移动技术文章请关注本文集:知乎移动平台专栏
背景
知乎非常重视用户隐私数据的保护,安全团队一直在为此提供各种保护机制;另外国内外一些知名的 Android 商店,如 Google Play 等也会针对用户隐私数据进行一系列的上架审核,一旦出问题会被严重警告甚至下架。为全面保护用户的隐私数据,同时避免被商店拒审或下架的风险,Android 移动平台团队建立了一套敏感代码扫描机制,取名为 FindDanger。
FindDanger 简介
在 App 开发过程中,对于自己的代码中涉及用户隐私的部分很容易约束,但对于 App 中引用的第三方库是否会有此类行为就无法知晓了。对于这种情况,FindDanger 机制应运而生,FindDanger 是一套敏感代码扫描机制,主要用于扫描第三方库是否存在获取用户隐私之类的高风险行为。
机制介绍
整个机制的运行过程如下图所示:
由于我们使用 GitLab + Jenkins 进行代码管理和持续集成,所以普通的工程师也可以很快上手,具体的流程如下:
首先,工程师提交检测 jar 包的 merge request 后,会触发 Jenkins 上的扫描任务,扫描结束将报告链接发送给 GitLab 的 merge request 页面,如下图所示:
然后,点击页面中的链接打开报告,如下图所示:
最后,移动平台的工程师对报告进行分析,并给出使用意见。
规则支持
目前 FindDanger 支持下列检测规则,其中危险级别的数值越大越危险:
- StealAPPListClazz 读取设备已安装的 App 列表 10
- StealRunningList 读取设备正在运行的 App 列表 10
- RuntimeCommand 通过 Runtime 接口执行终端命令 10
- StealAddressList 读取通讯录信息 10
- StealAccount 读取设备账号信息 9
- StealBluetoothInfo 读取设备的蓝牙信息 5
- StealNFCInfo 读取设备的 NFC 信息 5
- StealSensorInfo 读取设备的传感器信息 5
- StealTelephonyInfo 读取设备的电话信息 5
- StealWifiInfo 读取设备的 WIFI 信息 5
FindDanger 原理之自定义 Lint 规则
从生成的报告可以看出,FindDanger 是利用 Android Lint 进行代码扫描的,而这之中最关键的就是如何自定义 Lint 规则以满足我们的需求。
Android Lint 规则会打包到一个 Jar 包中,所以自定义规则主要有五步:
- 创建 Java Library 工程
- 实现自己的 Detector
- 创建对应的 Issue
- 实现自己的 Registry 以便将规则注册到 Lint
- 在 App 中使用
第一步,创建 Java Library 工程
为了方便开发和调试,建议创建一个空的 Android App 工程,然后添加 Java Module 用于编写规则代码。
之后在 Java Module 中添加 lint-api 依赖:
dependencies {
compileOnly "com.android.tools.lint:lint-api:26.1.4"
}
第二步,实现自己的 Detector
创建 Java 工程后,就要开始写规则代码了,每个规则都要实现一个 Detector,首先继承抽象类 Detector,然后根据需求实现一个或多个 Scanner 接口。
Scanner 类有如下几种:
- SourceCodeScanner 扫描 Java 或符合JVM规范的源码文件(如 kotlin)
- ClassScanner 扫描编译后的 class 文件
- BinaryResourceScanner 扫描二进制资源文件(如.png)
- ResourceFloderScanner 扫描资源目录
- XmlScanner 扫描 xml 文件
- GradleScanner 扫描 Gradle 文件
-
OtherFileScanner 扫描其他文件
最常用的就是 SourceCodeScanner 和 ClassScanner,由于目标文件的差异(源码文件和 class 文件),这两个 Scanner 的实现方式完全不同,下面分别介绍。(当前 lint 最新版本是26.1.4。从源码看到 Detector 类被标记为 Beta,里面还声明了所有 Scanner 的方法,其实这些方法我们无法使用,个人猜测 Google 未来可能会用 Detector 封装所有 Scanner 以达到简化接口的目的)
先看 SourceCodeScanner,从源码看到这个接口声明了很多 getApplicableXXX 和 visitXXX 方法,这些方法是成对使用的,比如我想扫描方法调用就实现 getApplicableMethodNames() 和 visitMethod() 方法,getApplicableMethodNames() 返回一个列表,包含了所有关心的方法名字,当扫描到关心的方法调用时 visitMethod() 会被回调,在 visitMethod() 里实现具体检测逻辑,下面看个 Android 的例子。
如果我们在 App 中不正确的使用 AlarmManager.setRepeating 方法,会有 lint 提示:
我们看下 Android 是如何检测的:
class AlarmDetector : Detector(), SourceCodeScanner {
...
// AlarmDetector 只关心 setRepeating 方法调用
override fun getApplicableMethodNames(): List? = listOf("setRepeating")
// 扫描到方法名字为 setRepeating 的方法调用
override fun visitMethod(context: JavaContext, node: UCallExpression, method: PsiMethod) {
val evaluator = context.evaluator
// 判断此方法是否是 android.app.AlarmManager 类中的,并且有 4 个参数
if (evaluator.isMemberInClass(method, "android.app.AlarmManager") &&
evaluator.getParameterCount(method) == 4) {
// 判断索引为1的参数是否小于5000
ensureAtLeast(context, node, 1, 5000L)
// 判断索引为2的参数是否小于60000
ensureAtLeast(context, node, 2, 60000L)
}
}
// 如果参数小于最小值,上报错误
private fun ensureAtLeast(context: JavaContext, node: UCallExpression, parameter: Int, min: Long) {
val argument = node.valueArguments[parameter]
val value = getLongValue(context, argument)
if (value < min) {
val message = "Value will be forced up to $min as of Android 5.1; " +
"don't rely on this to be exact"
context.report(ISSUE, argument, context.getLocation(argument), message)
}
}
}
可以看到在 visitMethod 里,根据三个参数 JavaContext、UCallExpression、PsiMethod 可以很方便的获取方法的参数列表、所在类等信息。
(这里的 UCallExpression 和 PsiMethod 其实是源码抽象语法树 AST 的两种实现,最早 Android Lint 用 Lombok 解析 AST,由于 Lombok 只能支持到 Java6 并且功能有限,在 AndroidStudio 2.2 之后 Lint 改用 PSI 解析语法树,但 PSI 也只是过渡方案,因为他不支持 kotlin,后面 Lint 会使用 UAST 作为抽象语法树解析库,PSI 和 UAST 都是 JetBrains 为 IDEA 开发的,UAST 是基于 PSI 扩展而来所以很容易移植,UAST 的优势是不止支持 Java 源码,理论上能够支持任何 JVM 类型的语言)
同样的,ClassScanner 也声明了一些 getApplicableXXX 和 checkXXX 方法,在 FindDanger 中用的最多的就是 getApplicableAsmNodeTypes() 和 checkInstruction() 方法,getApplicableAsmNodeTypes() 方法返回我们关心的指令列表,当扫描到关心的指令时会调用 checkInstruction() 方法,下面看个 FindDanger 中的例子:
/**
* @author zhoukewen
* @since 2018/8/20
*/
class StealNFCInfoDetector : Detector(), ClassScanner {
/**
* 返回这个 Detector 适用的 ASM 指令
*/
override fun getApplicableAsmNodeTypes(): IntArray? {
//这里关心的是与方法调用相关的指令,其实就是以 INVOKE 开头的指令集
return intArrayOf(AbstractInsnNode.METHOD_INSN)
}
/**
* 扫描到 Detector 适用的指令时,回调此接口
*/
override fun checkInstruction(context: ClassContext, classNode: ClassNode, method: MethodNode, instruction: AbstractInsnNode) {
if (instruction.opcode != Opcodes.INVOKEVIRTUAL) {
return
}
val callerMethodSig = classNode.name + "." + method.name + method.desc
val methodInsn = instruction as MethodInsnNode
// 这里逻辑是:调用 NfcAdapter 中的任何方法都会报告异常
if (methodInsn.owner == "android/nfc/NfcAdapter") {
val message = "SDK 中 $callerMethodSig 调用了 " +
"${methodInsn.owner.substringAfterLast('/')}.${methodInsn.name} 的方法来获取 NFC 信息,需要注意!"
context.report(ISSUE, method, methodInsn, context.getLocation(methodInsn), message)
}
}
}
通过 checkInstruction() 方法的四个参数可以方便的获取当前指令的上下文环境。
有了这些信息便可知道源码或者字节码是否存在问题代码,当发现问题时可以用ClassContext、JavaContext 的 report 接口上报。不同接口的 report 参数不同,我们只要自定义好 message 即可。report 还有个可选的 LintFix 参数,这个参数作用是提供一个快速修复的功能,这里不做介绍,感兴趣的同学可以自行查看源码。
第三步,创建对应的 Issue
Issue 代表具体的问题对象,对象包含问题的类型、描述、级别,还有上报问题的 Detector 和对应的 Scope。
下面看个 Issue 的例子:
val ISSUE = Issue.create(
"StealNFCInfo",//问题 Id
"",//问题的简单描述,会被 report 接口传入的描述覆盖
"",//问题的详细描述
Category.CORRECTNESS,//问题类型
6,//问题严重程度,0~10,越大严重
Severity.ERROR,//问题严重程度
//Detector 和 Scope 的对应关系
Implementation(StealNFCInfoDetector::class.java, EnumSet.of(Scope.CLASS_FILE, Scope.JAVA_LIBRARIES)))
通常情况下 Issue 和 Detector 是一一对应的,所以将 Issue 声明为 Detector 的静态属性会比较直观。
第四步,创建 Registry
有了 Issue 之后还要手动注册,首先实现一个 IssueRegistry,然后复写 getIssues 返回我们的 Issue 列表:
class DangerIssueRegistry : IssueRegistry() {
override val issues: List
get() {
return Arrays.asList(
StealAPPListClazzDetector.ISSUE,
StealRunningListDetector.ISSUE,
...
StealWifiInfoDetector.ISSUE)
}
override val api: Int
get() = CURRENT_API
}
接下来需要在 manifest 中声明 Registry,在 build.gradle 里添加:
jar {
manifest {
attributes("Lint-Registry-v2": "com.zhihu.android.findDanger.DangerIssueRegistry")
}
}
大功告成,编译打包后就会生成包含自定义规则的 Jar 包。
第五步,在 App 中应用
最后一步就是将自定义规则应用到 App 中了,最新版的 Android Gradle 插件提供了简便的方式(不用再自己包装 AAR 了),在 App 的 dependencies 中添加 lintChecks,和使用 implementation 没有任何区别:
dependencies {
implementation fileTree(include: ['*.jar'], dir: 'libs')
lintChecks 'zhihu.find.danger:FindDanger:local'
//或者
lintChecks project(':findDangerRules')
}
然后执行:
./gradlew app:lintDebug
即可看到输出的报告文件。
Android Lint 执行原理
为了便于大家理解,我们可以看看 Android Lint 是如何使用我们定义的规则的,下图是简单的 Android Lint 处理流程:
简单介绍一下上图中的几个流程:
- 启动 Lint 后会先解析参数并做一些准备工作
- 之后我们自定义的规则和系统自己的规则会统一放到了一个 Map 里
- 然后根据项目的文件类型按顺序扫描文件,扫描过程中报告的问题对象都存在内存中,
- 全部扫描结束会使用具体 Reporter(如 HtmlReporter、XMLReporter 等)创建报告文件。
成果展示
FindDanger 上线后,我们对正在评审中的一个第三方库进行扫描,发现其有两处上报用户数据的行为:
- 上报用户已安装的应用列表
- 上报用户正在运行的应用列表
以上两个问题的风险较大,不但有可能被 Google Play 商店拒审,更重要的是可能造成我们的用户数据泄露,这个是我们的底线绝对不能被触碰。
另外还有若干个地方也存在一定的风险,不过由于影响不大,所以就不在这里一一列举了。
最终我们的处理结果是:直接移除了此库,改用别的方案。
FindDanger 后续计划
目前最新的 Android Lint 版本是 26.1.4,从注释看还处于 Beta 版,接口可能会发生变化,并且第一版 FindDanger 支持的功能有限,后续还会有一些改进计划,如:
- 添加对 aar 包的支持(官方暂不支持,但需求较大)
- 丰富检测规则
- 增加更复杂的逻辑检测,比如发现存在读取隐私的代码后还能进一步检测到将隐私泄露的代码
- 随着 Android Lint 版本变化,持续维护相关 Api
最后
由于本人的水平有限,如有错误和疏漏,欢迎各位同学指正。
另外,知乎移动平台团队也在招人中,欢迎各位小伙伴的加入,和我们一起做一些酷事情!具体招聘信息在这里 https://app.mokahr.com/apply/zhihu#/job/7b1b32c2-f30c-4638-93ce-09c2ac9a52d8
关于作者
zkwlx,知乎 Android 资深基础架构工程师,目前负责知乎 Android App 性能监控相关工作。