背景
在移动应用开发过程中,隐私保护是一项至关重要的工作。以往我们采用了一种动态隐私检查工具,通过xposed方式实现,然而,这种方案存在着诸多限制。需要特定型号的手机和复杂的安装操作,不适用于集成到自动化测试系统中。
一、两种方案的比较
xposed 方式的实现
以往我们采用了xposed框架实现了动态隐私检查功能。该方案通过修改Android系统的运行时环境,拦截应用程序的方法调用,实现隐私检查。但该方案在使用过程中发现存在一些局限性:
- 对手机型号要求高,仅适用于部分支持
xposed框架的手机型号。 - 操作复杂,需要用户安装多个软件并进行繁琐配置。
- 不适用于集成到自动化测试系统中进行测试。
插桩方式的实现
插桩的方式通过自定义gradleTask,在项目构建过程中扫描项目中的类文件,并使用ASM库定位隐私方法,修改类文件,以达到检查的目的。该方案的优势在于:
- 灵活性高,适用于各种手机型号。
- 操作简单,无需安装额外软件,可集成到自动化测试系统中进行测试。
综上所述,我们选择通过插桩方式来优化动态隐私检查,以解决xposed方式存在的限制和不足。
二、方案实现
方案概述
在本方案中,我们采用gradle Task扫描项目中的所有类文件,利用ASM库在这些class文件中定位隐私方法。随后,在隐私方法执行结束时,我们通过插入一段代码来收集堆栈数据。一旦用户同意隐私政策,收集到的数据将被输出到JSON文件中,以供进一步分析和处理。
方案设计
如图所示,我们自定义了一个名为 gradle_plugin 的 gradle插件,其中包含了一个名为 DynamicPrivacyTask 的任务。该任务的主要功能是利用 ASM库对项目中的所有.class文件和 .jar文件进行插桩操作。
具体而言,DynamicPrivacyTask任务的工作流程如下:
-
首先,它会将
privacy_check模块中的检测隐私方法的collect方法插入到需要检查的类文件中。 -
然后,经过插桩处理的类文件会生成
transformedClass。 -
接下来,修改后的
transformedClass将用于生成或更新output.jar文件。 -
文件
output.jar随后将被转换为.dex文件,以便最终打包成APK文件。
在应用运行时,用户同意隐私政策后,会调用 privacy_check 模块中的 save方法。该方法的目的是收集检测到的隐私方法调用,并将结果生成为 result.json文件。
功能实现
gradle_pllugin中的主要实现
在 gradle_plugin模块中,我们实现了一个名为 DynamicPrivacyTask 的自定义 gradle Task,主要用于实现插桩功能,以下是该插件的关键功能类:
- PrivacyPlugin
PrivacyPlugin是自定义的一个插件,主要功能就是为编译中的每个变体应用自定义的gradleTask:DynamicPrivacyTask,主要代码如下:
val extension = project.extensions.create("privacyCheck", PrivacyExtension::class.java)
androidComponents.onVariants { variant ->
if (!extension.enable) {
println("privacyCheck disable")
return@onVariants
}
val privacyTransformTask = project.tasks.register(
"privacyCheck${variant.name.capitalized()}", DynamicPrivacyTask::class.java
) {
it.intermediateDir.set(project.layout.buildDirectory.dir("intermediates/privacy/${variant.name}"))
}
variant.artifacts.forScope(ScopedArtifacts.Scope.ALL).use(privacyTransformTask).toTransform(
ScopedArtifact.CLASSES,
DynamicPrivacyTask::allJars,
DynamicPrivacyTask::allDirectories,
DynamicPrivacyTask::outputJar
)
}
- DynamicPrivacyTask
DynamicPrivacyTask是自定义的gradleTask,它定义了任务的输入inputJars、inputDirectory和输出output.jar,并适配了增量编译。对输入的class文件和jar文件使用ASM进行修改,然后把修改后的class文件更新到输出文件output.jar文件中,并使用了WorkActionAPI异步操作。
在处理增量更新时,如果一个class文件被修改,那么整个jar文件将被标记为已修改状态,这意味着我们需要在output.jar文件中更新该jar文件中的所有class文件,这会导致效率大大降低。为了解决这个问题,我们引入了getJarsChangeClass方法。该方法通过计算每个jar文件中每个class文件的哈希值来确定jar文件中class文件的增删改状态。当jar文件中的class文件发生变化时,我们只需更新已修改的class文件到output.jar,从而避免了不必要的文件修改,提高了效率。
private suspend fun getJarsChangedClasses(
jars: FileCollection,
previousHashFile: File
): List<ClassInfo> = coroutineScope {
val previousContentHash = runCatching {
ObjectInputStream(previousHashFile.inputStream().buffered()).use {
it.readObject() as Map<String, String>
}
}.getOrElse { emptyMap() }
val (currentContentHash, entries) = jars
.map { jarFile ->
val jarPath = jarFile.toPath()
async(Dispatchers.Default) {
ZipArchive(jarPath).use { zip ->
zip.listEntries()
.map {
var byteArray = zip.getContent(it).array()
val hash = Hashing.sha256()
.hashBytes(byteArray)
.toString()
it to (hash to byteArray)
}
}
}
}
.flatMap {
it.await() }
.associate {
it }
.let {
it.mapValues { (_, value) -> value.first } to it.mapValues { (_, value) -> value.second }
}
ObjectOutputStream(previousHashFile.outputStream().buffered()).use {
it.writeObject(currentContentHash)
}
val maybeModified = currentContentHash.keys.intersect(previousContentHash.keys)
val added = currentContentHash.filterKeys { !maybeModified.contains(it) }.keys
val removed = previousContentHash.filterKeys { !maybeModified.contains(it) }.keys
val modified = maybeModified.filter { currentContentHash[it] != previousContentHash[it] }
added.map {
ClassInfo(
name = it,
changeType = ChangeType.ADDED,
content = entries[it]!!
)
} + modified.map {
ClassInfo(
name = it,
changeType = ChangeType.MODIFIED,
content = entries[it]!!
)
} + removed.map {
ClassInfo(
name = it,
changeType = ChangeType.REMOVED,
content = entries[it]?: ByteArray(0)
)
}
}
上述代码首先读取上次编译保存的jar文件中class文件的哈希值,然后遍历本次jar文件中的所有class文件,并保存哈希值到本地。对比前后两次的哈希值集合,得到增删改的class文件,并封装成ClassInfo供后续使用。
- PrivacyClassVisitor
PrivacyClassVisitor继承了ClassVisitor,访问项目中class文件中的每个方法,并对每一个方法在PrivacyMethodVisitor类中进行处理,PrivacyMethodVisitor继承了AdviceAdapter,通过visitMethodInsn方法收集隐私方法的调用,并在相应的位置插入收集到的隐私方法调用堆栈信息。下面是PrivacyMethodVisitor中的关键代码:
override fun visitMethodInsn(
opcodeAndSource: Int,
owner: String?,
nameInsn: String?,
descriptor: String?,
isInterface: Boolean
) {
privacyMethodList.forEach { classMethod ->
if (owner == classMethod.className && nameInsn == classMethod.methodName) {
mv.visitLdcInsn("${className}#$name")
mv.visitLdcInsn("${owner.replace("/", ".")}#$nameInsn")
mv.visitMethodInsn(
INVOKESTATIC,
"com/xx/privacycheck/PrivacyCollectUtil",
"appendData",
"(Ljava/lang/String;Ljava/lang/String;)V",
false
)
}
}
super.visitMethodInsn(opcodeAndSource, owner, nameInsn, descriptor, isInterface)
}
}
其中privacyMethodList是本地保存的待检测的隐私方法列表, 这里如果检测到了隐私方法的调用就把当前调用堆栈信息插入相应位置。
- TransformClassWork
TransformClassWork是一个gradle中的异步操作,把class文件的插桩处理放在异步线程中,提高执行的效率。WorkAction的使用方法这里不赘述,感兴趣的同学可以查询相关文档。
fun doTransform(input: ByteArray): ByteArray {
val cr = ClassReader(input)
val cw = ClassWriter(ClassWriter.COMPUTE_MAXS)
cr.accept(PrivacyClassVisotor(Opcodes.ASM9, cw), ClassReader.EXPAND_FRAMES)
return cw.toByteArray()
}
override fun execute() {
ZipArchive(outPutJar.toPath()).use { zip ->
// println("execute-------:"+zip.path)
classInfos.forEach {
when (it.changeType) {
ChangeType.MODIFIED,
ChangeType.REMOVED -> {
// println("execute delete-------:"+it.name)
zip.delete(it.name)
}
else -> {}
}
}
classInfos.forEach {
when (it.changeType) {
ChangeType.ADDED,
ChangeType.MODIFIED -> {
if(it.canTransform){
zip.add(BytesSource(doTransform(it.content), it.name, Deflater.NO_COMPRESSION))
}else {
// println("execute add-------:"+it.name)
zip.add(BytesSource(it.content, it.name, Deflater.NO_COMPRESSION))
}
}
else -> {}
}
}
}
}
在代码中,outPutJar 表示最终要生成的 jar文件。首先,我们遍历 outPutJar文件,如果某个 class文件处于修改或删除状态,我们会直接将 outPutJar文件中相应的class文件删除。
对于新增或修改状态的 class文件,如果需要进行修改,我们将执行 transform操作,并将transform后的 class文件字节数组放入 outPutJar文件。如果不需要进行transform处理,则直接将原始class文件字节数组放入 outPutJar文件中。
隐私方法的收集与保存
在 privacy_check模块中,我们定义了隐私方法的收集和保存功能。正如前文所述,TransformClassWork负责将检测隐私的方法插入相应的 class文件中。当用户同意隐私政策后,调用 save方法将检测结果输出为.json文件,以供后续展示和保存使用。由于代码比较简单,这里就不再详述。
插件性能
应用插件后,全量编译一次的时间如下,其中计算jar文件中修改class的时间是614ms,整个插桩和合并到outout.jar的过程是801ms。
Task :app:privacyCheckFlavorsTest_arm64Debug
Added classes count: 32439
Compute changed classes time: 614ms
Transform classes time: 801ms
三、自动化测试的实现
我们采用了 Python 结合 uiautomator2 实现自动化测试。Python程序负责实现待检测应用的安装和用户同意隐私政策的操作。我们的 APM 后台通过调用 Python程序来执行隐私方法的检测和数据的保存和展示。
##需要开启开发者模式中的可模拟点击功能
if __name__ == '__main__':
print(sys.argv)
global adbPath
global apkPath
if len(sys.argv) == 3:
adbPath = sys.argv[1]
apkPath = sys.argv[2]
else :
print("python params error")
installApk()
startCheck()
loadJson()
上面是Python程序的主要代码,执行这段python程序需要在手机开发者模式中打开可模拟点击的功能,以便uiautomator2可以执行自动化操作。installApk方法执行了卸载和安装apk的操作,startCheck是uiautomator2对手机的操作,loadJosn方法会把结果传递给APM后台。这部分 Python功能与 uiautomator2 的操作并不复杂,有兴趣的同学可以参考相关文档。
四、总结与展望
以上方案已经实现了动态隐私方法的检测,但仍存在优化空间。例如,可以增加白名单功能,排除某些功能模块不插入检测代码,以提升插桩速度。同时,现有方案不支持属性调用的检测,例如 android.os.Build.SERIAL,可以通过代理替换属性调用的方法来解决。这些都是我们接下来需要解决的问题。尽管新方案相较于之前的xposed方式具有更广泛的适用性和更好的扩展性,但仍有改进的空间。希望本文介绍的方案对大家的移动应用开发工作有所帮助。
👀关注公众号:Android老皮!!!欢迎大家来找我探讨交流👀