引言
关于静态代码扫描,大家想必都非常熟悉了,比如 lint
、detekt
等,这些也都是常用的扫描工具。但随着隐私合规在国内越来越趋于常态,我们经常需要考虑某些危险api的调用排查等等,此时上述的工具往往不容易实现现有的需求,以及后续扩展。而在这个背景下,ASM
就是解决方式的最佳手段之一。
故此,本篇我们将通过写一个代码扫描插件,从而简单玩转并入门 ASM
:)
Github:Bee-AnalysisPlugin
背景
记得在前司(下厨房)的时候,我们 App
曾被报出存在漏洞问题,具体原因是:
项目中使用了log4j等api,导致存在安全漏洞。
其实当听到这个问题的时候,总感觉略有点离谱,客户端怎么会存在这个问题?
在我的印象中,log4j
似乎是21年时的一个广泛问题,当然主要影响是后端同学,团队内部也还排查过。但因为客户端和这系列库离的相对就比较远了,所以对于客户端的我们没有在意(为后面埋了伏笔)。
所以当真正收到相关部门邮件时,我们先是不相信,然后和另一个同学(化名z)开始着手排查:
结果还真是狠狠打脸了,项目历史代码中存在使用 HttpURLConnection
导致,而 HttpURLConnection
内部又引入了 Log4j
系列库,从而导致相关问题,于是就立即开始分工处理:
- z负责写代码扫描插件,全量扫项目,从而确保已经完全移除相关api;
- 我负责对代码层进行处理,对涉及到相关的
HttpURLConnection
逻辑进行移除与逻辑调整;
最终在收到问题的当天晚上就提了PR流程,总耗时大概3小时,也算是比较迅速。
事后来看, 虽然问题解决了,但同时也暴漏出了一些问题,比如 客户端代码 没有相关 危险代码扫描机制 ,导致这部分隐患一直处于黑盒状态。而从技术角度来思考,实现这个check也非常简单。
如下所示:
- 定义一份线上的漏洞表(定期更新),每次
CI
时拉取最新的;- 定义一个代码扫描插件,每次
PR commit
时进行自动触发,并拉取最新的漏洞表,如果项目中存在相关漏洞,则中断本次打包并通知;
聊聊需求
通过上面的背景,我们大概也能知道本篇的缘由以及一些应用场景,所以如果要从练习角度入手,做一个代码扫描插件,其目的是静态扫描出相关方法的调用次数以及具体调用者,从而便于我们进行排查,应该怎么做?
此时可能会有同学抢答,我直接使用 Android Studio
全局搜索也行啊,为什么还需要专门写个插件扫描呢? 🤔
直接使用AS也能实现类似的需求,但是如果我们需要找出所有相关的调用处,这并不是一件易事,特别是对于复杂的项目而言(当然你要是没事愿意一个一个🔍,那另说了😑)。
而如果使用 ASM
,上述的需求实现起来就比较简单,而且后续的扩展也会相对成本较低,甚至我们还可以做一个调用替换等等,当然这些都是后话。
基础入门
为避免部分同学不太理解 ASM
,故这里选择先简单聊聊 ASM
基础背景,也算科普了(逃跑~)。
什么是ASM?
Java ASM
(Java Bytecode Assembler)是一个用于 生成 和 修改 Java字节码的库。ASM
提供了一种灵活而强大的方式来分析、转换和生成Java类文件。使用 ASM
,我们可以在 不改变源代码 的情况下,通过操纵字节码来实现对代码的定制化需求。这种能力在许多领域中都有应用,包括 编译器 、代码优化、字节码工具、AOP
(面向切面编程)框架等。
ASM与AGP关系
回到 Android
中,我们知道 Android虚拟机 是基于 Dalvik
(5.0是ART),而 Dalvik
也是属于 JVM虚拟机 的一种。所以Android的开发语言是 Java
(Kotlin
会由编译器转为Java
),而我们 Java
代码编译后的 class
文件为了便于 Dalvik
识别,故最终还需要转为dex
文件。
整个过程如下所示:
java
->class
->dex
常用的 AGP
(Android Gradle Plugin) 插件,就是在 class
-> dex
前,为开发者提供了一个时机,允许我们进行二次修改 Class
,从而实现自定义的需求,这也即是 ASM
在 AGP
中的作用由来。
ASM常见API
-
ClassReader
负责对
Class
进行读写,最终调用accpet
加载class
,由ClassVisitor
开始进行处理; -
ClassVisitor
负责对读取到的
Class
进行操作,比如对class
中某一部分信息(方法、属性等)进行修改;
ASM基础操作
总结起来通常就是三步:
- 读取class,创建
ClassReader
; - 进行修改,创建
ClassVisitor
(通常是ClassWriter+其他); - 保存结果,
ClassWriter.toByteArray()
;
伪代码如下:
val cr = ClassReader(classStream)
val cw = ClassWriter(cr, 0)
val cv = xxxClassVisitor(cw)
cr.accept(cv, ClassReader.EXPAND_FRAMES)
FileOutputStream(outClassPath).use {
it.write(cw.toByteArray())
}
ClassVisitor
提供了很多方法,比如当方法被调用时(visitMethod),开发者可以根据需求重写相应的方法,从而在 class
访问过程中,实现 class
修改。当然这些都只是最基础的操作,实际使用时我们还会使用其他更多的一些 Api
,由于本文并不是全面介绍相关 Api 的文章,故这部分就留给读者自行探索了:)
具体思路
要扫描代码,肯定是要先写一个 Plugin
,然后注册一个 Transform
,并在其中其中读取所有 class
与 jar
,从而对其进行处理。具体过程中,如果存在我们指定的方法调用,我们就将当前调用类的位置或者方法保存,最后当 ASM
处理结束后,我们再对结果进行处理。
不过需要注意的是
Transform
在AGP7.0
已经被标注了 废弃,AGP8.0
也已经正式 移除 ,所以我们要实现上述的逻辑,还是需要做一些改动。
故我们选用的是 AndroidComponentsExtension
来进行实现,这个 API 是Android团队专门针对 ASM
做的一个 hook
时机。不过需要注意的是,其并不像 Transform
,我们可以 拿到所有class以及jar直接进行处理,而是当某个 class
被处理时,我们可以有时机进行拦截并处理。故如果我们想确保收集完所有信息,就必须在相应的 Task
之后再进行汇总处理,比如在 transformxxClassesWithAsm
之后。
实现效果
我们以检测业务中 PrintStream
类的调用为例,最终实现效果如下所示:
如上图所示,业务中一共有三处使用 PrintStream
类,分别调用的都是其 print()
以及 println()
方法。
当然对于结果的处理,无论是以文件形式保存还是其他方式,都是由我们自行处理,这里只是将其打印出来。
具体流程
示例Github: Bee-AnalysisPlugin
插件配置
作为开始,我们需要定义一个自己的插件类,需要继承自 Plugin
类,具体代码如下所示:
上述的流程我们分为3步:
- 创建我们的扩展实例(用于传递配置参数);
- 注册
AsmClassVisitor
,用于访问字节码; - 当字节码处理完成后,统计处理结果;
具体的配置扩展类: RuleExtension
open class RuleExtension { var classPackages: Array<String> = emptyArray() var enableLog: Boolean = false }注意:这里需要增加
open
,否则编译失败;
ASM配置
在 AGP 7.0
之后,我们自定义的 ASM
访问器,需要继承自 AsmClassVisitorFactory
,并需要传入一个 InstrumentationParameters
泛型,用于确定是否需要实例化参数,因为我们需要对每个变体进行处理,所以这里传入 buildType
作为分类。当然如果并不需要传参的话,这里的工厂泛型可以直接传入 InstrumentationParameters.None
;
上述的流程如下:
我们定义了一个 字节码工厂访问器,并规定只处理非 Androidx
以及 R.
相关的 class
,这样当字节码在处理时,如果当前class满足条件,就会触发 createClassVisitor() 方法,从而我们就可以创建自己的 字节码访问类,并使用这个处理类对当前字节码进行修改。
当我们在读取
class
时,内部会对相关的方法、构造函数、属性等等都进行一次遍历或者调用,同时也会触发相关的回调方法,在这些回调方法里,也有对应的访问器进行处理,整体类似一个树形结构。
比如当访问 class
中的方法时,此时会调用 visitMethod()
方法,而我们本篇是希望遍历所有方法,所以需要重写该方法,并返回我们自己的方法访问器(MethodVisitor
);
相应的,在具体的 MethodVisitor
里,当这个方法内部去访问其他方法时,或者访问其他对象时等,也都会再次回调相关方法。故此,我们只需要在其访问其他方法时,将其保存到我们自己池子中,从而就可以得到如下信息:
当前类、当前方法、被访问的类、被访问的方法等
而根据这些信息,我们就可以清晰的得知我们自己需要拦截的方法被谁调用了,调用了多少次,调用位置等等。
检测逻辑
具体的检测逻辑就比较简单了,我们只需要定义一个静态处理类,其内部持有一个 Map
结构的结果集(key
为变体名、value
为结果集),而具体的判断规则可以存在一个Set或者List中。比如我们示例中只需要判断是否存在指定包或者类的调用,那么只需要传入 packages
即可,如果有更多的规则,比如方法等等,则可以根据逻辑进行更改。
具体逻辑如上,其中 filterAndAddMethod()
是每次当访问到相关方法时调用,如果满足条件,则将其信息缓存起来;当ASM处理完成后,也就是 transformXXXClassesWithAsm
之后,我们再调用 end()
去统计,从而按照当前 buildType
输出结果。
当然,当拿到结果后,怎么处理那都是题外话题了,比如可以直接打印,或者存储到文件里,也可以抛出异常等等,这些就留给大家自行决断吧。
使用方式
具体的使用方式,比较简单,我们直接在 application
所在的 build.gradle
添加下面的配置语句即可。
//示例
analysis {
classPackages = ["java.io.PrintStream"]
}
示例Github: Bee-AnalysisPlugin
总结
本篇到这里就结束了,严格而言,本篇其实算不上什么ASM高深技巧,只能算的上是基础操作。更多是希望,通过本篇,能使得新手同学对于 ASM
基础使用有一个了解,特别是在 AGP7.0
之后的打开方式。