基于 Booster ASM API的配置化 hook 方案封装

1,382 阅读7分钟

本文示例代码:AndroidSimpleHook-github

背景

booster 是一款专门为移动应用设计的易用、轻量级且可扩展的质量优化框架,其目标主要是为了解决随着 APP 复杂度的提升而带来的性能、稳定性、包体积等一系列质量问题。

booster 项目地址:didi/booster: 🚀Optimizer for mobile applications (github.com)

booster 项目提供了非常多的拿来即用的好用的工具,包括通用 api 封装、性能优化插件、包体积插件、系统 bug 修复插件等等,具体详情可以参考项目 wiki。  而我们今天,就将会使用过 booster 封装的 ASM api 来二次封装个简单的 hook 框架。

无处不在的 hook 需求

不管项目大小,总归有一些通过 hook 技术来进行切面编程的需求,比如:

  1. 检查或者禁用掉项目对于隐私敏感 API 的调用
  2. 特殊日子全局置灰,Activity 可以通过ActivityLifecycleCallbacks切入,但是像 DialogPopupWindow 散落各地且没法简单同意注入的就可以简单的 hook 一下关键方法就行
  3. 想让项目中的 logcat 在线上不打印,也可以通过 hook android.util.Log 来实现
  4. so文件不内置且需要动态下发,就可以通过 hook System.load 进入到自定义的下载、检查、加载逻辑

当然,上面提到的这些只是一些比较基础的 hook 需求举例,甚至部分可能不通过 hook 也可以做到,这里只是作为需求引入。

好了,那么当我们有了 hook 的需求后,就要去想法子进行实现了,这很容易让人想到 Transform APIgradle比较远古的版本就支持了  Transform API,允许第三方插件在将已编译的 class 文件转换为 dex 文件之前对其进行操作。所以我们今天的 hook 框架就用 Transform API 来实现。

那就会有同学问,你为啥不自己写个 Transform 插件,而要用 booster 呢?答案很简单,booster 封装过一层 API 后,使用起来更简单哈,「懒」

怎样进行 hook 更加简单

接下来,我们可以开始考虑考虑,我们的 hook 工具需要变成什么样子才会使用起来更加简单,结论显而易见:敲最少次数的键盘 + 容易阅读,那就是所谓的配置化了,配置化的优点有以下:

  1. 添加一个 hook 需求只需要做个简单配置
  2. 能一眼看全配置了哪些 hook 条目

给大家举个例子,当大家在项目中想要 hook 所有的Dialog#show()方法,在弹任何 Dialog 的时候,全局进行计数,如果只需要写下面这些代码,是不是非常简单:

HookClass(
    needHookClass = "android/app/Dialog",
    afterHookClass = "com/test/instrument/TestDialog",
    hookMethod = HookMethod(methodName = "show")
)

#TestDialog.java
public static dialogCnt = 0;
public static void show(Dialog dialog) {
    dialog.show();
    dialogCnt++;
}

我们上面只干了以下几个事情:

  1. 指定要 hook 的类和方法
  2. 实现 hook 后的逻辑及指定 hook 后的替换类和方法

这样大家就可以开心的进行想要的 hook,而不需要思考如何去实现 hook 过程。

配置化的 hook 我们希望指定哪些条目可以配置

接下来我们可以开脑洞想想,我们在配置 hook 项时需要有哪些配置,其实大体可以分为两部分:

  1. 指定 hook  处的配置项
  2. 排除不想 hook 处的 filter

那么我们可以大概列出来:

  1. needHookClass - 需要 hookclass
  2. afterHookClass - hook 后逻辑实现的 class
  3. hookMethods - 需要被 hook 的方法们
  • methodName - 方法名
  • methodDesc - 方法描述,包括参数类型、返回值类型,比如 (Ljava/lang/String;)Ljava/lang/String; 就表明入参是 string 类型,返回值也是 string 类型
  • methodFilter - 提供一个自定义的过滤器
  1. doNotHookPackages - 哪些包下面的被 hook 类的指定方法调用不进行 hook,当然也可以实现成类似 filter 的东西

当然,如果要做的更加完善,肯定还会有其他更多的配置项,我这里做演示只加这些哈

如何描述 hook 配置

一般来说,在 Android 项目中经常能见到的描述配置化的东西有以下几种方式:

  1. 类似 build.gradle 中的 DSL 方案
  • groovy dsl
  • kotlin dsl
  1. XML 配置解析

  2. json 配置文件

  3. 其他

在这篇文章里,我们将一个不采用,为啥呢?嫌麻烦,由于 kotlin 支持命名参数,我们直接把配置写在 kt 代码里,key用命名参数描述就行了,虽然用 dsl 方案会更加优雅。

那我们将这样描述我们的 hook 配置:

//用 kotlin 的命名参数来描述 key-value,先突出一个格式整齐
val hookConfig = HookConfig(
    listOf(
        HookClass(
            needHookClass = "android/app/ActivityManager",
            afterHookClass = "com/test/instrument/ShadowActivityManager",
            hookMethod = HookMethod(methodName = "getRunningAppProcesses")
        ),
        HookClass(
            needHookClass = "android/app/Dialog",
            afterHookClass = "com/test/instrument/TestDialog",
            hookMethod = HookMethod(methodName = "show")
        ),
        HookClass(
            needHookClass = "java/lang/System",
            afterHookClass = "com/test/instrument/ShadowSystem",
            hookMethods = listOf(
                HookMethod(methodName = "load"),
                HookMethod(methodName = "loadLibrary"),
            ),
            doNotHookPackages = listOf("com/test/base/tools/soloader")
        ),
        HookClass(
            needHookClass = "android/util/Log",
            afterHookClass = "com/test/instrument/ShadowLog",
            hookMethods = listOf(
                HookMethod(methodName = "v"),
                HookMethod(methodName = "d"),
                HookMethod(methodName = "i"),
                HookMethod(methodName = "w"),
                HookMethod(methodName = "e"),
                HookMethod(methodName = "wtf"),
                HookMethod(methodName = "println")
            )
        ),
        HookClass(
            needHookClass = "android/content/pm/PackageManager",
            afterHookClass = "com/taou/instrument/ShadowPackageManager",
            hookMethods = listOf(
                HookMethod(methodName = "getInstalledPackages"),
                HookMethod(methodName = "getInstalledApplications"),
                HookMethod(methodName = "queryIntentActivities"),
                HookMethod(methodName = "getPackagesForUid"),
            ),
            doNotHookPackages = listOf("com/tencent/")
        ),
    )
)

上述这份配置表里将会涉及以下几个 class 文件。一个是 HookConfig,就是我们所谓的配置,HookClass 是我们需要 hook 的目标类,HookMethod 是我们需要 hook 的目标方法。

class HookConfig(val hookClassList: List<HookClass>)

data class HookClass(
    val needHookClass: String,
    val afterHookClass: String,
    val hookMethods: List<HookMethod>,
    val doNotHookPackages: List<String> = emptyList()
) {
    constructor(
        needHookClass: String,
        afterHookClass: String,
        hookMethod: HookMethod,
        doNotHookPackages: List<String> = emptyList()
    ) : this(
        needHookClass,
        afterHookClass,
        listOf(hookMethod),
        doNotHookPackages
    )
}

data class HookMethod(
    val methodName: String,
    val methodDesc: String = "",
    val methodFilter: ((methodNode: MethodInsnNode) -> Boolean) = { _ -> true },
)

如何实现配置下就能完成 hook

我们参考How to Create Customized Transformer · didi/booster Wiki (github.com) 来自定义 Transformer,具体实现步骤为:

  1. 自定义一个 HookTransformer 来实现ClassTransformer接口,同时加上注解@AutoService(ClassTransformer::class)

  2. HookTransformer 放到 buildSrc 包中

  3. 没了。。

在我们实际的项目中,我们可能大部分的 hook 都是在 hook 某个对象方法或者静态方法,所以我们这篇文章里 只介绍针对对象方法、静态方法的 hook ,而且这两种场景也是最容易实现的,因此作为演示内容。

大家可以想一想,当我们要 hook 某个方法并且转到另一个实现方法时,怎么做会更简单呢?假如是一个对象方法:

Dialog dialog = new Dialog(context);
dialog.show();

我们要 hook 它的话,有两种方式:

1. 继承 Dialog

重写 show 方法,同时将对象构造给替换掉,即:

Dialog dialog = new DialogTest(context);
dialog.show();

public class DialogTest extends Dialog{
    @override
    publich void show(){
        xxx
    }
}

2. 替换 show 方法

替换掉 show 方法,转成静态方法实现,即:

Dialog dialog = new Dialog(context);
DialogUtils.show(dialog);

public class DialogUtils{
    publich static void show(dialog){
        xxx
        dialog.show();
    }
}

出于以下两点考虑,本文将采用转静态方法的 hook 方式来进行 hook:

  1. 实现简单,只需两步:
  • 修改 opcodeOpcodes.INVOKESTATIC
  • 如果是对象方法,在方法的参数列表中加上 hookClass 本身,就可以将调用对象加在参数里进行传递
  1. 对象方法和静态方法的 hook 实现逻辑几乎没有区别

好,那就开整。

我们先描述一下我们的 hook 实现大体流程:

  1. 针对每个 class 文件,遍历 class 的每个 method
  2. 针对每个 method,遍历操作指令,找出所有的方法指令
  3. 针对这些方法指令进行我们的 hook 过滤
  • owner 是我们的 hookClass
  • owner 不能是我们的 afterHookClass
  • owner 不能在我们的 doNotHookPackages
  • opcode 必须是 INVOKESTATIC、INVOKEVIRTUAL、INVOKEINTERFACEopcode 参考
  • methodName匹配
  • methodDesc 匹配
  • methodFilter 不能过滤
  1. 针对每个需要 hookmethod 进行处理
  • opcode 统一替换成 INVOKESTATIC

  • owner 统一替换成 afterHookClass

  • 如果是对象方法,在 desc 上,加上该对象参数

具体代码如下:

//1. build.gradle 中 implementation 下 booster-api 的依赖
implementation "com.didiglobal.booster:booster-transform-asm:$boosterVersion"
//2. 实现自定义的 Transformer
@AutoService(ClassTransformer::class)
class HookTransformer : ClassTransformer {
    override fun transform(context: TransformContext, klass: ClassNode): ClassNode {
        klass.methods.forEach { method ->
            method.instructions?.iterator()?.asIterable()
                ?.filterIsInstance(MethodInsnNode::class.java)?.let { methodInsnNode ->
                    for (i in methodInsnNode.indices) {
                        val methodNode = methodInsnNode[i]
                        //检查owner,先匹配当前方法调用的 owner,根据 owner 找到 HookClass,找不到则说明不需要hook
                        val hookClass = hookConfig.hookClassList.firstOrNull {
                            //1. owner要匹配,2. 当前处理的 class 不能是 afterHookClass,3. 当前处理的 class 不能在过滤的包中
                            (methodNode.owner == it.needHookClass || context.klassPool[it.needHookClass].isAssignableFrom(
                                methodNode.owner
                            )) &&
                                    klass.name != it.afterHookClass &&
                                    it.doNotHookPackages.none { doNotHookPackage ->
                                        klass.name.startsWith(
                                            doNotHookPackage
                                        )
                                    }
                        }
                        hookClass ?: continue

                        //检查方法类型不匹配,https://blog.csdn.net/LuoZheng4698729/article/details/104971966
                        if (methodNode.opcode != Opcodes.INVOKESTATIC && methodNode.opcode != Opcodes.INVOKEVIRTUAL && methodNode.opcode != Opcodes.INVOKEINTERFACE) {
                            continue
                        }

                        //检查方法名、方法描述不匹配,或者方法名、方法描述被过滤
                        var methodNeedHook = false
                        for (index in hookClass.hookMethods.indices) {
                            val hookMethod = hookClass.hookMethods[index]
                            if (hookMethod.methodName == methodNode.name
                                && (hookMethod.methodDesc == methodNode.desc || hookMethod.methodDesc.isEmpty())
                                && hookMethod.methodFilter(methodNode)
                            ) {
                                methodNeedHook = true
                                break
                            }
                        }
                        if (!methodNeedHook) {
                            continue
                        }

                        //开始hook
                        methodNode.owner = hookClass.afterHookClass
                        //对象方法、接口方法需要调整为static方法
                        if (methodNode.opcode != Opcodes.INVOKESTATIC) {
                            methodNode.desc =
                                methodNode.desc.replace("(", "(L${hookClass.needHookClass};")
                        }
                        methodNode.opcode = Opcodes.INVOKESTATIC
                        methodNode.itf = false
                    }
                }
        }
        return klass
    }
}

写在最后

至此,我们实现了一个「实现容易,使用容易」的 hook 框架,想要 hook 一个方法仅需要如下两步:

1. 在指定配置文件里加上 hook 配置
HookClass(
    needHookClass = "android/app/Dialog",
    afterHookClass = "com/test/instrument/TestDialog",
    hookMethod = HookMethod(methodName = "show")
)

2. 实现一下 hook 后的替换方法
#TestDialog.java
public static dialogCnt = 0;
public static void show(Dialog dialog) {
    dialog.show();
    dialogCnt++;
}

当然,由于是基础版本,实际上并没有实现的非常完善,比如:

  1. 未实现构造函数的 hook
  2. 只能 hook 项目代码,无法 hook Framework 代码
  3. 无法 hook native 代码

但是,这并不影响这个 hook 框架非常好用哈。

展望

后续我会基于本文中描述的 hook 框架:

  1. 进行功能扩展,支持 hook 构造方法

  2. 分享相关的使用案例,如 so 动态下载、全局置灰等,当支持 hook 构造方法后,就可以用来做线程优化了。

  3. 其他...

你可能感兴趣

Android QUIC 实践 - 基于 OKHttp 扩展出 Cronet 拦截器 - 掘金 (juejin.cn)

Android启动优化实践 - 秒开率从17%提升至75% - 掘金 (juejin.cn)

如何科学的进行Android包体积优化 - 掘金 (juejin.cn)

Android稳定性:Looper兜底框架实现线上容灾(二) - 掘金 (juejin.cn)

基于 Booster ASM API的配置化 hook 方案封装 - 掘金 (juejin.cn)

记 AndroidStudio Tracer工具导致的编译失败 - 掘金 (juejin.cn)

Android 启动优化案例-WebView非预期初始化排查 - 掘金 (juejin.cn)

chromium-net - 跟随 Cronet 的脚步探索大致流程(1) - 掘金 (juejin.cn)

Android稳定性:可远程配置化的Looper兜底框架 - 掘金 (juejin.cn)

一类有趣的无限缓存OOM现象 - 掘金 (juejin.cn)

Android - 一种新奇的冷启动速度优化思路(Fragment极度懒加载 + Layout子线程预加载) - 掘金 (juejin.cn)

Android - 彻底消灭OOM的实战经验分享(千分之1.5 -> 万分之0.2) - 掘金 (juejin.cn)