知乎 Android 客户端三方库敏感代码扫描机制 - FindDanger

更多移动技术文章请关注本文集:知乎移动平台专栏

背景

知乎非常重视用户隐私数据的保护,安全团队一直在为此提供各种保护机制;另外国内外一些知名的 Android 商店,如 Google Play 等也会针对用户隐私数据进行一系列的上架审核,一旦出问题会被严重警告甚至下架。为全面保护用户的隐私数据,同时避免被商店拒审或下架的风险,Android 移动平台团队建立了一套敏感代码扫描机制,取名为 FindDanger。

FindDanger 简介

在 App 开发过程中,对于自己的代码中涉及用户隐私的部分很容易约束,但对于 App 中引用的第三方库是否会有此类行为就无法知晓了。对于这种情况,FindDanger 机制应运而生,FindDanger 是一套敏感代码扫描机制,主要用于扫描第三方库是否存在获取用户隐私之类的高风险行为。

机制介绍

整个机制的运行过程如下图所示:


image.png

由于我们使用 GitLab + Jenkins 进行代码管理和持续集成,所以普通的工程师也可以很快上手,具体的流程如下:
首先,工程师提交检测 jar 包的 merge request 后,会触发 Jenkins 上的扫描任务,扫描结束将报告链接发送给 GitLab 的 merge request 页面,如下图所示:


image.png

然后,点击页面中的链接打开报告,如下图所示:
image.png

最后,移动平台的工程师对报告进行分析,并给出使用意见。

规则支持

目前 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 包中,所以自定义规则主要有五步:

  1. 创建 Java Library 工程
  2. 实现自己的 Detector
  3. 创建对应的 Issue
  4. 实现自己的 Registry 以便将规则注册到 Lint
  5. 在 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 提示:


    image.png

    我们看下 Android 是如何检测的:

class AlarmDetector : Detector(), SourceCodeScanner {
    ...
    // AlarmDetector 只关心 setRepeating 方法调用
    override fun getApplicableMethodNames(): List<String>? = 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<Issue>
        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 处理流程:


image.png

简单介绍一下上图中的几个流程:

  1. 启动 Lint 后会先解析参数并做一些准备工作
  2. 之后我们自定义的规则和系统自己的规则会统一放到了一个 Map 里
  3. 然后根据项目的文件类型按顺序扫描文件,扫描过程中报告的问题对象都存在内存中,
  4. 全部扫描结束会使用具体 Reporter(如 HtmlReporter、XMLReporter 等)创建报告文件。

成果展示

FindDanger 上线后,我们对正在评审中的一个第三方库进行扫描,发现其有两处上报用户数据的行为:

  1. 上报用户已安装的应用列表
  2. 上报用户正在运行的应用列表
    以上两个问题的风险较大,不但有可能被 Google Play 商店拒审,更重要的是可能造成我们的用户数据泄露,这个是我们的底线绝对不能被触碰。
    另外还有若干个地方也存在一定的风险,不过由于影响不大,所以就不在这里一一列举了。
    最终我们的处理结果是:直接移除了此库,改用别的方案。

FindDanger 后续计划

目前最新的 Android Lint 版本是 26.1.4,从注释看还处于 Beta 版,接口可能会发生变化,并且第一版 FindDanger 支持的功能有限,后续还会有一些改进计划,如:

  1. 添加对 aar 包的支持(官方暂不支持,但需求较大)
  2. 丰富检测规则
  3. 增加更复杂的逻辑检测,比如发现存在读取隐私的代码后还能进一步检测到将隐私泄露的代码
  4. 随着 Android Lint 版本变化,持续维护相关 Api

最后

由于本人的水平有限,如有错误和疏漏,欢迎各位同学指正。
另外,知乎移动平台团队也在招人中,欢迎各位小伙伴的加入,和我们一起做一些酷事情!具体招聘信息在这里 https://app.mokahr.com/apply/zhihu#/job/7b1b32c2-f30c-4638-93ce-09c2ac9a52d8

关于作者

zkwlx,知乎 Android 资深基础架构工程师,目前负责知乎 Android App 性能监控相关工作。

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 158,117评论 4 360
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 66,963评论 1 290
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 107,897评论 0 240
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 43,805评论 0 203
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 52,208评论 3 286
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 40,535评论 1 216
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 31,797评论 2 311
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 30,493评论 0 197
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 34,215评论 1 241
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 30,477评论 2 244
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 31,988评论 1 258
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 28,325评论 2 252
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 32,971评论 3 235
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 26,055评论 0 8
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 26,807评论 0 194
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 35,544评论 2 271
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 35,455评论 2 266

推荐阅读更多精彩内容