利用ASM完成第三方SDK安全整改

关于整改

首先还是得说下ASM能帮我们整改什么东西吧,不然没有头绪的往下看代码会感觉好乱。ASM能够对JAVA字节码修改,最字节码进行增删改,比如下面这个方法:

Environment.getExternalStorageDirectory() 

获取外部SDK目录,这属于隐私权限了,一般是不能随便调用的。如果这行代码在我们自己代码里面的话,那我们可以直接修改,但是如果是第三方SDK的话,那就麻烦了,要不是升级SDK,期待厂商能解决,要不就只能我们自己想办法了。

而字节码技术,就给了我们一种手段,比如我想把项目内所有调用这个方法的的地方,全却换成我自己的一个方法:

    fun getExternalDir(): File {
        // 控制动态执行
        if( hasPermission ) {
            ...
        } else {
            ...
        }
    }

这样就能防止SDK在同意隐私协议前去调用隐私方法了,或者进一步反射执行getExternalStorageDirectory规避静态代码扫描。

明白这样一个例子后,我们就能举一反三了,比如下面一些例子:

// 动态注册广播漏洞
context.registerReceiver(...)

// WebView组件跨域访问风险
setting.setJavaScriptEnabled(true)

// SQL数据库注入漏洞
database.execSQL(...)

我们都能将他们转到我们的逻辑,并对它们进行修改,以满足要求。

字节码与ASM

关于字节码和ASM我不想写太多,在网上看到有写文章写的非常不错了,可以移步学习一下:

美团技术团队: 字节码增强技术探索

Android - ASM 插桩你所需要知道的基础

Android ASM插桩


下面我们就开始ASM整改之旅吧!

创建插件

要使用ASM,首先我们得创建一个插件,让这个插件参与gradle构建的过程,在所有字节码及JAR包都准备好时,对它们所有进行修改。

创建插件部分上一篇文章已经写到了,可以看下: Gradle自定义插件实践与总结,不过我试了下使用Composing build插件去做有点问题(是我太菜了),还是用本地maven的方式吧,后面我再研究研究。

以privacy-plugin为例,要在原先插件基础上修改,让他支持Transform和ASM,首先需要添加一些依赖,修改build.gradle文件,加入下面内容:

dependencies {
    // 需要用的的API,少了编译报错
    implementation gradleApi()
    implementation localGroovy()

    // Transform用到的依赖
    implementation 'com.android.tools.build:gradle:4.0.0'

    // ASM依赖
    implementation 'org.ow2.asm:asm:9.1'
    implementation 'org.ow2.asm:asm-commons:9.1'
}

注意这里要求gradle插件最低版本是4.0.0,对应gradle版本是6.1.1,可以打开你的project structure看一下是否符合要求,做好升级。 

ddd.png

插件部分我就不多讲了,上篇文章写的很详细了,注意下privacy-plugin的目录结构,多出来的两个文件就是我们的Transform和ASM代码: 

d.png

使用Transform

Transform是gradle的一个工具,盗用一张别人的图

image.png

Transform就是能够在字节码打包成dex之前,遍历所有class以及jar包,让我们访问并进行修改,修改的工具就是ASM(或者Javassist等)。


言归正传,在上面添加好依赖后,我们就可以在PrivacyPlugin同目录下新建一个PrivacyTransform.groovy文件来写我们的transform,这里最好使用已有的模板,不容易出错!!

下面是我找到别人的一个模板代码:

package silencefly96.privacy

import com.android.build.api.transform.*
import com.android.build.gradle.internal.pipeline.TransformManager
import com.silencefly96.privacy.PrivacyClassVisitor
import org.apache.commons.codec.digest.DigestUtils
import org.apache.commons.io.FileUtils
import org.apache.commons.io.IOUtils
import org.objectweb.asm.ClassReader
import org.objectweb.asm.ClassWriter

import java.util.jar.JarEntry
import java.util.jar.JarFile
import java.util.jar.JarOutputStream
import java.util.zip.ZipEntry

class PrivacyTransform extends Transform {

    @Override
    String getName() {
        return "PrivacyTransform"
    }

    /**
     * 需要处理的数据类型,有两种枚举类型
     * CLASS->处理的java的class文件
     * RESOURCES->处理java的资源
     * @return
     */
    @Override
    Set<QualifiedContent.ContentType> getInputTypes() {
        return TransformManager.CONTENT_CLASS
    }

    /**
     * 指 Transform 要操作内容的范围,官方文档 Scope 有 7 种类型:
     * 1. EXTERNAL_LIBRARIES        只有外部库
     * 2. PROJECT                   只有项目内容
     * 3. PROJECT_LOCAL_DEPS        只有项目的本地依赖(本地jar)
     * 4. PROVIDED_ONLY             只提供本地或远程依赖项
     * 5. SUB_PROJECTS              只有子项目。
     * 6. SUB_PROJECTS_LOCAL_DEPS   只有子项目的本地依赖项(本地jar)。
     * 7. TESTED_CODE               由当前变量(包括依赖项)测试的代码
     * @return
     */
    @Override
    Set<? super QualifiedContent.Scope> getScopes() {
        return TransformManager.SCOPE_FULL_PROJECT
    }

    /**
     * 是否增量编译
     * @return
     */
    @Override
    boolean isIncremental() {
        return false
    }

    @Override
    void transform(TransformInvocation transformInvocation) throws TransformException, InterruptedException, IOException {
        super.transform(transformInvocation)
        println "开始PrivacyOptimizeTransform"
        _transform(transformInvocation.context, transformInvocation.inputs, transformInvocation.outputProvider)
        println "结束PrivacyOptimizeTransform"
    }

    /**
     *
     * @param context
     * @param inputs 有两种类型,一种是目录,一种是 jar 包,要分开遍历
     * @param outputProvider 输出路径
     */
    void _transform(Context context, Collection<TransformInput> inputs, TransformOutputProvider outputProvider) throws IOException, TransformException, InterruptedException {
        if (!incremental) {
            //不是增量更新删除所有的outputProvider
            outputProvider.deleteAll()
        }
        inputs.each { TransformInput input ->
            //遍历目录
            input.directoryInputs.each { DirectoryInput directoryInput ->
                handleDirectoryInput(directoryInput, outputProvider)
            }
            // 遍历jar 第三方引入的 class
            input.jarInputs.each { JarInput jarInput ->
                handleJarInput(jarInput, outputProvider)
            }
        }
    }

    static void handleDirectoryInput(DirectoryInput directoryInput, TransformOutputProvider outputProvider) {
        if (directoryInput.file.isDirectory()) {
            directoryInput.file.eachFileRecurse { File file ->
                String name = file.name
                if (filterClass(name)) {
                    // 用来读 class 信息
                    ClassReader classReader = new ClassReader(file.bytes)
                    // 用来写
                    ClassWriter classWriter = new ClassWriter(0 /* flags */)
                    //todo 改这里就可以了
                    def classVisitor = new PrivacyClassVisitor(classWriter)
                    classVisitor.setClassName(file.absolutePath)
                    // 下面还可以包多层
                    classReader.accept(classVisitor, ClassReader.EXPAND_FRAMES)
                    // 重新覆盖写入文件
                    byte[] code = classWriter.toByteArray()
                    FileOutputStream fos = new FileOutputStream(
                            file.parentFile.absolutePath + File.separator + name)
                    fos.write(code)
                    fos.close()
                }
            }
        }
        // 把修改好的数据,写入到 output
        def dest = outputProvider.getContentLocation(directoryInput.name, directoryInput.contentTypes,
                directoryInput.scopes, Format.DIRECTORY)
        FileUtils.copyDirectory(directoryInput.file, dest)
    }

    static void handleJarInput(JarInput jarInput, TransformOutputProvider outputProvider) {
        if (jarInput.file.absolutePath.endsWith(".jar")) {
            // 重名名输出文件,因为可能同名,会覆盖
            def jarName = jarInput.name
            def md5Name = DigestUtils.md5Hex(jarInput.file.getAbsolutePath())
            if (jarName.endsWith(".jar")) {
                jarName = jarName.substring(0, jarName.length() - 4)
            }
            JarFile jarFile = new JarFile(jarInput.file)
            Enumeration enumeration = jarFile.entries()
            File tmpFile = new File(jarInput.file.getParent() + File.separator + "classes_temp.jar")
            if (tmpFile.exists()) {
                tmpFile.delete()
            }
            JarOutputStream jarOutputStream = new JarOutputStream(new FileOutputStream(tmpFile))
            //用于保存
            while (enumeration.hasMoreElements()) {
                JarEntry jarEntry = (JarEntry) enumeration.nextElement()
                String entryName = jarEntry.getName()
                ZipEntry zipEntry = new ZipEntry(entryName)
                InputStream inputStream = jarFile.getInputStream(jarEntry)
                //插桩class
                if (filterClass(entryName)) {
                    //class文件处理
                    jarOutputStream.putNextEntry(zipEntry)
                    ClassReader classReader = new ClassReader(IOUtils.toByteArray(inputStream))
                    ClassWriter classWriter = new ClassWriter(0)
                    //todo 改这里就可以了
                    def classVisitor = new PrivacyClassVisitor(classWriter)
                    classVisitor.setClassName(jarEntry.getName())
                    // 下面还可以包多层
                    classReader.accept(classVisitor, ClassReader.EXPAND_FRAMES)
                    byte[] code = classWriter.toByteArray()
                    jarOutputStream.write(code)
                } else {
                    jarOutputStream.putNextEntry(zipEntry)
                    jarOutputStream.write(IOUtils.toByteArray(inputStream))
                }
                jarOutputStream.closeEntry()
            }
            //结束
            jarOutputStream.close()
            jarFile.close()
            def dest = outputProvider.getContentLocation(jarName + md5Name,
                    jarInput.contentTypes, jarInput.scopes, Format.JAR)
            FileUtils.copyFile(tmpFile, dest)
            tmpFile.delete()
        }
    }

    static boolean filterClass(String className) {
        return (className.endsWith(".class")
                && !className.startsWith("R$")
                && "R.class" != className
                && "BuildConfig.class" != className
                // 这两个我加的,android库和直接替换代码的文件不要改
                && (!className.startsWith("android"))
                && "AsmMethods.class" != className)
    }
}

上面代码有两个TODO的地方,就是我们要通过ASM的ClassVisitor去修改的地方,我这是PrivacyClassVisitor,并加了个ClassName传进去,更好打印类所在位置。

需要注意的一个问题就是JAR包要特别处理,好多文章就一个FileUtils.copyDirectory,真不知道他们试了没有,搞得我的SDK一个都没处理,让我找了很久的BUG。。。

编写好PrivacyTransform记得去Plugin里面注册下:

package silencefly96.privacy

import org.gradle.api.Plugin
import org.gradle.api.Project

public class PrivacyPlugin implements Plugin<Project>{

    @Override
    void apply(Project project) {
        println("PrivacyPlugin")
        project.android.registerTransform(new PrivacyTransform())
    }
}

注册好了,要uploadArchives一下才能发布到本地maven仓库生效。

使用ASM

字节码和ASM前面已经分了一篇说了,这里就是来具体看看如何使用,以及一些大坑。。。

这里ASM代码是Java代码,需要再项目的main目录下面根据包名再建几个目录,比如我这是“com\silencefly96\privacy\”,里面编写PrivacyClassVisitor.java文件:

package com.silencefly96.privacy;


import org.objectweb.asm.ClassVisitor;
import org.objectweb.asm.MethodVisitor;
import org.objectweb.asm.Opcodes;


public class PrivacyClassVisitor extends ClassVisitor {


    private String className;
    public void setClassName(String className) {
        this.className = className;
    }

    public PrivacyClassVisitor(ClassVisitor classVisitor) {
        // 我这要使用ASM6不然报错,不知道为什么,太高了太低了都不行
        super(Opcodes.ASM6, classVisitor);
    }

    @Override
    public MethodVisitor visitMethod(int access, String name, String descriptor, String signature, String[] exceptions) {

        //判断方法
        MethodVisitor methodVisitor = cv.visitMethod(access, name, descriptor, signature, exceptions);

        // 判断方法
        if (methodVisitor != null) {
            return new MethodVisitor(Opcodes.ASM6, methodVisitor) {
                @Override
                public void visitMethodInsn(int opcode, String owner, String name, String descriptor, boolean isInterface) {
                    // 替换说明:
                    // 1. 路径以”/“分割,而不是包名里面的”.“
                    // 2. owner前不带”L“字符,descriptor内都要加上”L“字符
                    // 3. descriptor里面参数及返回值类型后的”;“不能省,特别是参数列表最后一个参数后的”;“
                    // 4. descriptor里面基本类型(比如V、Z)后不能添加”;“,否则匹配不上
                    // 5. 方法签名一定要写对,参数及返回值的类型,抛出的异常不算方法签名
                    // 6. 替换方法前后变量一定要对应,实例方法0位置是this,改为静态方法时,要用第一个参数去接收;
                    // 7. 替换方法前后,参数加返回值的数量要相等

                    // 替换调用 Environment.getExternalStorageDirectory() 的地方为应用程序的本地目录
                    if (opcode == Opcodes.INVOKESTATIC && owner.equals("android/os/Environment") && name.equals("getExternalStorageDirectory") && descriptor.equals("()Ljava/io/File;")) {
                        System.out.println("处理SD卡数据泄漏风险: " + className);
                        mv.visitMethodInsn(Opcodes.INVOKESTATIC, "com/silencefly96/module_base/utils/AsmMethods", "getExternalDir", "()Ljava/io/File;", false);
                    }

                    // 判断是否调用了 ContextWrapper 类的 registerReceiver 方法
                    else if (opcode == Opcodes.INVOKEVIRTUAL && name.equals("registerReceiver") && descriptor.equals("(Landroid/content/BroadcastReceiver;Landroid/content/IntentFilter;)Landroid/content/Intent;")) {
                        // && owner.equals("android/content/Context")
                        System.out.println("处理动态注册广播: " + className);
                        // 调用你自定义的方法,并传递 Context 和参数
                        mv.visitMethodInsn(Opcodes.INVOKESTATIC, "com/silencefly96/module_base/utils/AsmMethods", "registerZxyReceiver", "(Landroid/content/Context;Landroid/content/BroadcastReceiver;Landroid/content/IntentFilter;)Landroid/content/Intent;", false);
                    }

                    // SQL数据库注入漏洞: rawQuery
                    else if (opcode == Opcodes.INVOKEVIRTUAL && owner.equals("android/database/sqlite/SQLiteDatabase") && name.equals("rawQuery") && descriptor.equals("(Ljava/lang/String;[Ljava/lang/String;)Landroid/database/Cursor;")) {
                        System.out.println("处理SQL数据库注入漏洞 rawQuery: " + className);
                        mv.visitMethodInsn(Opcodes.INVOKESTATIC, "com/silencefly96/module_base/utils/AsmMethods", "rawZxyQuery", "(Landroid/database/sqlite/SQLiteDatabase;Ljava/lang/String;[Ljava/lang/String;)Landroid/database/Cursor;", false);
                    }

                    // SQL数据库注入漏洞: execSQL
                    else if (opcode == Opcodes.INVOKEVIRTUAL && owner.equals("android/database/sqlite/SQLiteDatabase") && name.equals("execSQL") && descriptor.equals("(Ljava/lang/String;)V")) {
                        System.out.println("处理SQL数据库注入漏洞 execSQL: " + className);
                        mv.visitMethodInsn(Opcodes.INVOKESTATIC, "com/silencefly96/module_base/utils/AsmMethods", "execZxySQL", "(Landroid/database/sqlite/SQLiteDatabase;Ljava/lang/String;)V", false);
                    }

                    // ZipperDown漏洞
                    else if (opcode == Opcodes.INVOKEVIRTUAL && owner.equals("java/util/zip/ZipEntry") && name.equals("getName") && descriptor.equals("()Ljava/lang/String;")) {
                        System.out.println("处理ZipperDown漏洞: " + className);
                        mv.visitMethodInsn(Opcodes.INVOKESTATIC, "com/silencefly96/module_base/utils/AsmMethods", "getZipEntryName", "(Ljava/util/zip/ZipEntry;)Ljava/lang/String;", false);
                    }

                    // 日志函数泄露风险: 只改方法签名为 (Ljava/lang/String;Ljava/lang/String;)I 的
                    else if (opcode == Opcodes.INVOKESTATIC && owner.equals("android/util/Log") && descriptor.equals("(Ljava/lang/String;Ljava/lang/String;)I")) {
                        System.out.println("处理日志函数泄露风险 " + name + ": " + className);
                        if (name.equals("e")) {
                            // 错误日志还是有用的
                            mv.visitMethodInsn(Opcodes.INVOKESTATIC, "com/silencefly96/module_base/utils/AsmMethods", "optimizeLogE", "(Ljava/lang/String;Ljava/lang/String;)I", false);
                        }else {
                            mv.visitMethodInsn(Opcodes.INVOKESTATIC, "com/silencefly96/module_base/utils/AsmMethods", "optimizeLog", "(Ljava/lang/String;Ljava/lang/String;)I", false);
                        }
                    }

                    // Webview组件跨域访问风险
                    else if (opcode == Opcodes.INVOKEVIRTUAL && owner.equals("android/webkit/WebSettings") && name.equals("setJavaScriptEnabled") && descriptor.equals("(Z)V")) {
                        System.out.println("处理Webview组件跨域访问风险: " + className);
                        mv.visitMethodInsn(Opcodes.INVOKESTATIC, "com/silencefly96/module_base/utils/AsmMethods", "setZxyJsEnabled", "(Landroid/webkit/WebSettings;Z)V", false);
                    }

                    else {
                        super.visitMethodInsn(opcode, owner, name, descriptor, isInterface);
                    }
                }
            };
        }

        return methodVisitor;
    }
}

对应替换的AsmMethods:

object AsmMethods {

    // ASM替换代码勿动: 替换获取外部文件
    fun getExternalDir(): File {
        var result = File("")
        // ...
        return result
    }

    // ASM替换代码勿动: 替换直接动态注册广播
    fun registerMyReceiver(
        context: Context,
        receiver: BroadcastReceiver?,
        filter: IntentFilter?
    ): Intent? {
        var result: Intent? = null
        // ...
        return result
    }

    // ASM替换代码勿动: 处理SQL数据库注入漏洞: rawQuery
    fun rawMyQuery(
        database: SQLiteDatabase,
        sql: String?,
        selectionArgs: Array<String?>?
    ): Cursor? {
        var result: Cursor? = null
        // ...
        return result
    }

    // ASM替换代码勿动: 处理SQL数据库注入漏洞: rawQuery
    fun execMySQL(database: SQLiteDatabase, sql: String?) {
        // ...
    }

    // ASM替换代码勿动: ZipperDown漏洞
    fun getZipEntryName(entry: ZipEntry): String {
        var result = ""
        // ...
        return result
    }

    // ASM替换代码勿动: 日志函数泄露风险
    fun optimizeLog(tag: String?, msg: String?): Int {
        var result = 0
        if (BuildConfig.DEBUG) {
            // 要防止这里被替代,引发StackOverflow问题
            result = Log.d(tag, msg!!)
        }
        return result
    }

    // ASM替换代码勿动: 日志函数泄露风险
    fun optimizeLogE(tag: String?, msg: String?): Int {
        var result = 0
        if (BuildConfig.DEBUG) {
            // 要防止这里被替代,引发StackOverflow问题
            result = Log.e(tag, msg!!)
        }
        return result
    }

    // ASM替换代码勿动: WebView组件跨域访问风险
    fun setMyJsEnabled(settings: WebSettings, flag: Boolean) {
        // ...
    }
}

不是很复杂,因为我这就改了visitMethod这一个方法,下面主要想说的就是我在这踩了好多坑,总结了下面一些经验:

  1. 路径以”/“分割,而不是包名里面的”.“
  2. owner前不带”L“字符,descriptor内都要加上”L“字符
  3. descriptor里面参数及返回值类型后的”;“不能省,特别是参数列表最后一个参数后的”;“
  4. descriptor里面基本类型(比如V、Z)后不能添加”;“,否则匹配不上
  5. 方法签名一定要写对,参数及返回值的类型,抛出的异常不算方法签名
  6. 替换方法前后变量一定要对应,实例方法0位置是this,改为静态方法时,要用第一个参数去接收;
  7. 替换方法前后,参数加返回值的数量要相等

这些坑会导致很多奇奇怪怪的问题,我也总结了一下:

  1. D8编译问题
  2. Different stack heights at jump target: 0 != 1
  3. 找不到fileProvider、Application
  4. multidex错误

如果你也出现了这些问题,还请排查下上面替换的的方法签名有没有问题,我一开始都不懂,改的头都麻了。

使用

把上面插件、Transform、ASM代码都弄好后,uploadArchives一下,到app里面引入repo以及插件:

// project的build.gradle
buildscript {
    ext.kotlin_version = "1.4.21"
    repositories {
        // 依赖本地仓库
        maven{ url './privacy_repo' }
    }
    dependencies {
        // 从本地仓库中加载自定义插件 group + artifactId + version,不要多手打空格!
        classpath 'silencefly96.privacy:privacy-plugin:1.0.0'
    }
}

// module的build.gradle
plugins {
    id 'silencefly96.privacy'
}

app模块的MainActivity里面随便写个有调用隐私调用的方法:

fun onTestRegisterZxyReceiver() {
    val cw: ContextWrapper = object : ContextWrapper(this) {
        override fun registerReceiver(
            receiver: BroadcastReceiver?,
            filter: IntentFilter
        ): Intent? {
            Log.d("TAG", "ContextWrapper registerReceiver: ")
            return super.registerReceiver(receiver, filter)
        }
    }
    val receiver: BroadcastReceiver = object : BroadcastReceiver() {
        override fun onReceive(context: Context, intent: Intent) {
            Log.d("TAG", "onReceive: " + intent.action)
        }
    }
    val intentFilter = IntentFilter()
    intentFilter.addAction(ConnectivityManager.CONNECTIVITY_ACTION)
    Log.d("TAG", "registerZxyReceiver: invoke before")
    cw.registerReceiver(receiver, intentFilter)
}

rebuild一下,看下输出台内容,我这整改了很多,不过终点看下那个“处理动态注册广播”,这个我们添加的测试代码生效了: 

f.png

嘿嘿,第三方SDK安全整改搞定!Demo可以看下我练手的仓库: Fundark!

推荐文章

找资料的过程中看到了很多写的很好的文章,读者可以看下:

ASM hook隐私方法调用,防止App被下架

自定义Gradle Plugin+字节码插桩

ASM 修改字节码 引发的R8 编译报错

Android - ASM 插桩你所需要知道的基础

ASM 字节码插桩:实现双击防抖

ASM 字节码插桩:助力隐私合规


使用AsmClassVisitorFactory完成安全整改

Transform在AGP7.0被标记为废弃,作为一个好奇的安卓开发,我觉得还是有必要学学被废弃后的新方法的-_-||,于是花了点时间,找了下资料,尝试了下,顺便记录下。

Gradle版本要求

这里gradle版本当然需要升级到7.x才能使用,打算升级并且想用kts的话可以看下我之前的文章:

《记录迁移gradle到kts》

不想升级还想使用ASM修改字节码的话,可以看下Transform那种方法(这里也要求gradle升级到6.1.1,AGP版本4.0):

《利用ASM完成第三方SDK安全整改》


这里说下我的版本配置: Gradle Version 7.5.1,AGP 7.4.2。

编写插件

关于Gradle插件编写的内容,我之前也写了一篇文章,有需要的可以看下:

《Gradle自定义插件实践与总结》

选择使用buildSrc编写插件的话,可以跳过这节,直接看AsmClassVisitorFactory部分,代码放buildSrc里面就行。


使用Transform方法的那篇文章里,我用的是发布到本地maven仓库的形式使用插件,当时没搞懂Composing build里面的插件,又学了学,这篇文章就用Composing build来做吧。

Composing build编写插件

这里从头说清楚吧,Composing build实际就是多项目构建,我们先创建一个项目,在根目录下新建一个build-plugins目录,里面创建两个文件以及代码目录,结构如下:

// 用我代码举例了,包名自己定义
build-plugins
|--src/main/java/com/silencefly96/plugins/privacy
|--|--PrivacyPlugin.kt
|--build.gradle.kts
|--settings.gradle.kts

在settings.gradle.kts填入如下代码:

@file:Suppress("UnstableApiUsage")
pluginManagement {
    repositories {
        // 是用于从 Gradle 插件门户下载插件的默认仓库。
        gradlePluginPortal()
        google()
        mavenCentral()
    }
}
dependencyResolutionManagement {
    repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
    repositories {
        google()
        mavenCentral()
    }
}

rootProject.name = "build-plugins"
include (":build-plugins")

注意下这里把rootProject指向了build-plugins,这样就不分项目的build.gradle.kts和模块的build.gradle.kts了,两个放一起了。

下面就是两个放一起的build.gradle.kts,代码如下:

buildscript {
    // 我这不加有问题,按道理repositories是不用的
    repositories {
        gradlePluginPortal()
        google()
        mavenCentral()
    }
    dependencies {
        classpath("com.android.tools.build:gradle:7.4.2")
        classpath("org.jetbrains.kotlin:kotlin-gradle-plugin:1.6.0")
    }
}

plugins {
    `kotlin-dsl`
}

// 插件的依赖关系
dependencies {
    implementation(gradleApi())
    implementation("com.android.tools.build:gradle:7.4.2")
    implementation("org.jetbrains.kotlin:kotlin-gradle-plugin:1.6.0")
}

gradlePlugin {
    // 注册插件
    plugins.register("privacyPlugin") {
        id = "privacy-plugin"
        implementationClass = "com.silencefly96.plugins.privacy.PrivacyPlugin"
    }
}

细心的可能会发现gradle和kotlin-gradle-plugin我们引入了两次,注意下buildscript里面的是给gradle脚本用的(classpath),下面dependencies里面的是给自己代码使用的(implementation)。

配置好这些我们就来写PrivacyPlugin的代码:

package com.silencefly96.plugins.privacy

import org.gradle.api.Plugin
import org.gradle.api.Project

class PrivacyPlugin : Plugin<Project> {

    override fun apply(project: Project) {
        println("PrivacyPlugin")
    }
}

这里就随便打印了下名称,sync一下就能在项目的module中使用了。

在主项目的根目录的settings.gradle.kts(和build-plugins区分开来)中引入build-plugins模块,要使用includeBuild:

...
include(":app")
...
includeBuild("build-plugins")

然后在要使用的模块的build.gradle.kts中根据插件id配置:

plugins {
    id("privacy-plugin")
    ...
}

build一下,控制台应该就会打印"PrivacyPlugin"了,至此插件我们就写好了,接下来就是重点的AsmClassVisitorFactory环节。

AsmClassVisitorFactory使用

我觉得嘛,其实AsmClassVisitorFactory就是我们之前的Transform,这里在上面PrivacyPlugin同目录下新建一个PrivacyTransform(命名随意),里面来写AsmClassVisitorFactory代码:

package com.silencefly96.plugins.privacy;

import com.android.build.api.instrumentation.*
import org.objectweb.asm.ClassVisitor

// 注意这里需要一个抽象类!
abstract class PrivacyTransform: AsmClassVisitorFactory<InstrumentationParameters.None> {

    override fun createClassVisitor(
        classContext: ClassContext, nextClassVisitor: ClassVisitor): ClassVisitor {
        // 创建自定义的ClassVisitor并返回
        return PrivacyClassVisitor(nextClassVisitor, classContext.currentClassData.className)
    }

    // 过滤处理的class
    override fun isInstrumentable(classData: ClassData): Boolean {
        // 处理className: com.silencefly96.module_base.base.BaseActivity
        val className = with(classData.className) {
            val index = lastIndexOf(".") + 1
            substring(index)
        }

        // 筛选要处理的class
        return !className.startsWith("R$")
                && "R" != className
                && "BuildConfig" != className
                // 这两个我加的,代替的类小心无限迭代
                && !classData.className.startsWith("android")
                && "AsmMethods" != className
    }
}

这里就两步,一个是创建自定义的ClassVisitor,里面实现ASM代码逻辑,第二个是对class的过滤,看自己需要吧,直接返回true也行。

写好AsmClassVisitorFactory后,需要在上面的PrivacyPlugin里面注册下:

package com.silencefly96.plugins.privacy

import com.android.build.api.instrumentation.FramesComputationMode
import com.android.build.api.instrumentation.InstrumentationScope
import com.android.build.api.variant.AndroidComponentsExtension
import org.gradle.api.Plugin
import org.gradle.api.Project

class PrivacyPlugin : Plugin<Project> {

    override fun apply(project: Project) {

        val androidComponents =
            project.extensions.getByType(AndroidComponentsExtension::class.java)

        androidComponents.onVariants { variant ->
            // 控制是否需要扫描依赖库代码, ALL / PROJECT
            variant.instrumentation.transformClassesWith(
                PrivacyTransform::class.java,
                InstrumentationScope.ALL
            ) {}

            // 可设置不同的栈帧计算模式
            variant.instrumentation.setAsmFramesComputationMode(
                FramesComputationMode.COMPUTE_FRAMES_FOR_INSTRUMENTED_METHODS
            )
        }
    }
}

这里可以着重看下InstrumentationScope.ALL和InstrumentationScope.PROJECT,之前的Transform的Scope可是有七种啊,这里只有两了,如果要对SDK修改的话就设置为ALL吧。

PrivacyClassVisitor编写

上面自定义的ClassVisitor传入了一个PrivacyClassVisitor,下面就写下它的代码:

package com.silencefly96.plugins.privacy

import org.objectweb.asm.ClassVisitor
import org.objectweb.asm.MethodVisitor
import org.objectweb.asm.Opcodes

class PrivacyClassVisitor(nextVisitor: ClassVisitor, private val className: String)
    : ClassVisitor(Opcodes.ASM7, nextVisitor) {

    override fun visitMethod(
        access: Int,
        name: String?,
        descriptor: String?,
        signature: String?,
        exceptions: Array<out String>?
    ): MethodVisitor {

        val methodVisitor = super.visitMethod(access, name, descriptor, signature, exceptions)

        val newMethodVisitor = object: MethodVisitor(Opcodes.ASM7, methodVisitor) {

            override fun visitMethodInsn(
                opcode: Int,
                owner: String,
                name: String,
                descriptor: String,
                isInterface: Boolean
            ) {
                // 替换说明:
                // 1. 路径以”/“分割,而不是包名里面的”.“
                // 2. owner前不带”L“字符,descriptor内都要加上”L“字符
                // 3. descriptor里面参数及返回值类型后的”;“不能省,特别是参数列表最后一个参数后的”;“
                // 4. descriptor里面基本类型(比如V、Z)后不能添加”;“,否则匹配不上
                // 5. 方法签名一定要写对,参数及返回值的类型,抛出的异常不算方法签名
                // 6. 替换方法前后变量一定要对应,实例方法0位置是this,改为静态方法时,要用第一个参数去接收;
                // 7. 替换方法前后,参数加返回值的数量要相等

                // 替换调用 Environment.getExternalStorageDirectory() 的地方为应用程序的本地目录
                if (opcode == Opcodes.INVOKESTATIC && owner == "android/os/Environment" && name == "getExternalStorageDirectory" && descriptor == "()Ljava/io/File;") {
                    println("处理SD卡数据泄漏风险: $className")
                    mv.visitMethodInsn(
                        Opcodes.INVOKESTATIC,
                        "com/silencefly96/module_base/utils/AsmMethods",
                        "getExternalDir",
                        "()Ljava/io/File;",
                        false
                    )
                } else if (opcode == Opcodes.INVOKEVIRTUAL && name == "registerReceiver" && descriptor == "(Landroid/content/BroadcastReceiver;Landroid/content/IntentFilter;)Landroid/content/Intent;") {
                    // && owner.equals("android/content/Context")
                    println("处理动态注册广播: $className")
                    // 调用你自定义的方法,并传递 Context 和参数
                    mv.visitMethodInsn(
                        Opcodes.INVOKESTATIC,
                        "com/silencefly96/module_base/utils/AsmMethods",
                        "registerZxyReceiver",
                        "(Landroid/content/Context;Landroid/content/BroadcastReceiver;Landroid/content/IntentFilter;)Landroid/content/Intent;",
                        false
                    )
                } else if (opcode == Opcodes.INVOKEVIRTUAL && owner == "android/database/sqlite/SQLiteDatabase" && name == "rawQuery" && descriptor == "(Ljava/lang/String;[Ljava/lang/String;)Landroid/database/Cursor;") {
                    println("处理SQL数据库注入漏洞 rawQuery: $className")
                    mv.visitMethodInsn(
                        Opcodes.INVOKESTATIC,
                        "com/silencefly96/module_base/utils/AsmMethods",
                        "rawZxyQuery",
                        "(Landroid/database/sqlite/SQLiteDatabase;Ljava/lang/String;[Ljava/lang/String;)Landroid/database/Cursor;",
                        false
                    )
                } else if (opcode == Opcodes.INVOKEVIRTUAL && owner == "android/database/sqlite/SQLiteDatabase" && name == "execSQL" && descriptor == "(Ljava/lang/String;)V") {
                    println("处理SQL数据库注入漏洞 execSQL: $className")
                    mv.visitMethodInsn(
                        Opcodes.INVOKESTATIC,
                        "com/silencefly96/module_base/utils/AsmMethods",
                        "execZxySQL",
                        "(Landroid/database/sqlite/SQLiteDatabase;Ljava/lang/String;)V",
                        false
                    )
                } else if (opcode == Opcodes.INVOKEVIRTUAL && owner == "java/util/zip/ZipEntry" && name == "getName" && descriptor == "()Ljava/lang/String;") {
                    println("处理ZipperDown漏洞: $className")
                    mv.visitMethodInsn(
                        Opcodes.INVOKESTATIC,
                        "com/silencefly96/module_base/utils/AsmMethods",
                        "getZipEntryName",
                        "(Ljava/util/zip/ZipEntry;)Ljava/lang/String;",
                        false
                    )
                } else if (opcode == Opcodes.INVOKESTATIC && owner == "android/util/Log" && descriptor == "(Ljava/lang/String;Ljava/lang/String;)I") {
                    println("处理日志函数泄露风险 $name: $className")
                    if (name == "e") {
                        // 错误日志还是有用的
                        mv.visitMethodInsn(
                            Opcodes.INVOKESTATIC,
                            "com/silencefly96/module_base/utils/AsmMethods",
                            "optimizeLogE",
                            "(Ljava/lang/String;Ljava/lang/String;)I",
                            false
                        )
                    } else {
                        mv.visitMethodInsn(
                            Opcodes.INVOKESTATIC,
                            "com/silencefly96/module_base/utils/AsmMethods",
                            "optimizeLog",
                            "(Ljava/lang/String;Ljava/lang/String;)I",
                            false
                        )
                    }
                } else if (opcode == Opcodes.INVOKEVIRTUAL && owner == "android/webkit/WebSettings" && name == "setJavaScriptEnabled" && descriptor == "(Z)V") {
                    println("处理Webview组件跨域访问风险: $className")
                    mv.visitMethodInsn(
                        Opcodes.INVOKESTATIC,
                        "com/silencefly96/module_base/utils/AsmMethods",
                        "setZxyJsEnabled",
                        "(Landroid/webkit/WebSettings;Z)V",
                        false
                    )
                } else if (opcode == Opcodes.INVOKEVIRTUAL && owner == "com/tencent/smtt/sdk/WebSettings" && name == "setJavaScriptEnabled" && descriptor == "(Z)V") {
                    println("处理X5Webview组件跨域访问风险: $className")
                    mv.visitMethodInsn(
                        Opcodes.INVOKESTATIC,
                        "com/silencefly96/module_base/utils/AsmMethods",
                        "setZxyX5JsEnabled",
                        "(Lcom/tencent/smtt/sdk/WebSettings;Z)V",
                        false
                    )
                } else {
                    super.visitMethodInsn(opcode, owner, name, descriptor, isInterface)
                }
            }
        }
        return newMethodVisitor
    }
}

还是原来ASM代替的代码,就不多解释了,不过这里明显比之前简单多了啊,不错!

唯一需要注意的是ASM的版本,我这要求Opcodes.ASM7,低了会报错,这问题遇到好多次了-_-||

关于用来替换的AsmMethods类,读者可以自己编写,需要要注意的是这个类里面别被替代搞得无限迭代了,另外一个就是kotlin静态方法记得加上JvmStatic注解:

// 注意包名一致啊!
package com.silencefly96.module_base.utils

object AsmMethods {

    // ASM替换代码勿动: 替换获取外部文件
    @JvmStatic
    fun getExternalDir(): File {
        var result = File("")
        // ...
        return result
    }

使用

上面代码写好的话,目录整体结构如下(忽略我多余的文件): 

d.png

在要使用的地方加入插件,比如我这是app模块:

plugins {
    id("privacy-plugin")
}

app的MainActivity放了个测试用的代码:

fun onTestRegisterZxyReceiver() {
    val cw: ContextWrapper = object : ContextWrapper(this) {
        override fun registerReceiver(
            receiver: BroadcastReceiver?,
            filter: IntentFilter
        ): Intent? {
            Log.d("TAG", "ContextWrapper registerReceiver: ")
            return super.registerReceiver(receiver, filter)
        }
    }
    val receiver: BroadcastReceiver = object : BroadcastReceiver() {
        override fun onReceive(context: Context, intent: Intent) {
            Log.d("TAG", "onReceive: " + intent.action)
        }
    }
    val intentFilter = IntentFilter()
    intentFilter.addAction(ConnectivityManager.CONNECTIVITY_ACTION)
    Log.d("TAG", "registerZxyReceiver: invoke before")
    cw.registerReceiver(receiver, intentFilter)
}

在AS中选择rebuild,一会在控制台就能看到ASM处理的输出了,速度比之前Transform方式还更快(这个是有增量更新的): 

d.png

看下输出,打印了很多,瞄一眼我们在MainActivity内的有打印,如果说你觉得打印不能证明ASM修改成功,我们可以继续看下APK包: 

image.png

点开MainActivity的字节码看一下: 

image.png

根据字节码对应的代码行数,对比下源码位置: 

image.png

第46行对日志的替换,第47行对动态注册广播的替换是不是生效了,(●ˇ∀ˇ●)

文章参考及源码

参考文章:

现在准备好告别Transform了吗? | 拥抱AGP7.0

android官方文档: Android Gradle 插件 API 更新


Demo源码 :fundark/build-plugins at main · silencefly96/fundark · GitHub

总结

这篇文章用了Composing build的方式编写了gradle的插件,并使用gradle7.x的AsmClassVisitorFactory来对项目及SDK的代码进行整改,学习了!

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值