使用BlackHook(黑钩) 可以Hook一切java或者kotlin方法

前言

之前做内存优化的时候,为了实现对线程的使用监控,借助了一个第三方的hook框架(epic),这个框架可以hook一切java方法,使用也简单,但是最大的问题是它有较严重的兼容性问题,部分机型会出现闪退的现象,这就导致它不能被带到线上使用,只能在线下使用,为了实现在线上监控线程的使用,于是我便开发了BlackHook插件,也可以hook一切java方法,而且很稳定,没有兼容性问题,真是十足的黑科技

简介

BlackHook 是一个实现编译时插桩的gradle插件,基于ASM+Tranfrom实现,理论上可以hook任意一个java方法或者kotlin方法,只要代码对应的字节码可以在编译阶段被Tranfrom扫描到,就可以使用ASM在代码对应的字节码处插入特定字节码,从而hook该方法

优点

  1. 用DSL(领域特定语言)使用该插件,使用简单,配置灵活,而且插入的字节码可以使用
    ASM Bytecode Viewer Support Kotlin 插件自动生成,上手难度低
  2. 理论上可以hook任意一个java方法,只要代码对应的字节码可以在编译阶段被Tranfrom扫描到
  3. 基于ASM+Tranfrom实现,在编译阶段直接修改字节码,效率高,没有兼容性问题

使用

在app下面的build.gradle文件添加如下代码

apply plugin: 'com.blackHook'

/**
 * 返回hook线程构造函数的字节码,Hook 线程的构造函数,让每次在调用Thread的构造函数的时候就会调用
 * ThreadCheck类的 printThread方法,从而在控制台打印线程的构造函数的调用堆栈,这些代码可以借助
 * ASM Bytecode Viewer Support Kotlin生成,MethodVisitor是ASM提供的一个类,用于修改字节码
 */
void createHookThreadByteCode(MethodVisitor mv, String className) {
    mv.visitTypeInsn(Opcodes.NEW, "com/quwan/tt/asmdemoapp/ThreadCheck")
    mv.visitInsn(Opcodes.DUP)
    mv.visitMethodInsn(Opcodes.INVOKESPECIAL, "com/quwan/tt/asmdemoapp/ThreadCheck", "<init>", "()V", false)
    mv.visitLdcInsn(className)
    mv.visitMethodInsn(Opcodes.INVOKEVIRTUAL, "com/quwan/tt/asmdemoapp/ThreadCheck", "printThread", "(Ljava/lang/String;)V", false)
}

/**
 * 返回需要被hook的方法,需要被hook的方法是Thread的构造函数
 */
List<HookMethod> getHookMethods() {
    List<HookMethod> hookMethodList = new ArrayList<>()
    hookMethodList.add(new HookMethod("java/lang/Thread", "<init>", "()V", { MethodVisitor mv -> createHookThreadByteCode(mv, "java/lang/Thread") }))
    return hookMethodList
}

blackHook {
    //表示要处理的数据类型是什么,CLASSES 表示要处理编译后的字节码(可能是 jar 包也可能是目录),RESOURCES 表示要处理的是标准的 java 资源
    inputTypes BlackHook.CONTENT_CLASS
    //表示Transform 的作用域,这里设置的SCOPE_FULL_PROJECT代表作用域是全工程
    scopes BlackHook.SCOPE_FULL_PROJECT
    //表示是否支持增量编译,false不支持
    isIncremental false
    //表示hook的方法
    hookMethodList = getHookMethods()
}

以上的代码其实是hook的Thread的构造函数,将ThreadCheck的printThread方法hook到了Thread的构造函数中,每次调用线程的构造函数的时候就会调用ThreadCheck的printThread方法,这个方法会打印出Thread的构造函数的调用堆栈,从而可以在控制台知道哪个页面的哪行代码实例化了Thread,ThreadCheck的代码如下

class ThreadCheck {

    var isCanAppendLog = false
    private val tag = "====>ThreadCheck"

    fun printThread(name : String){

        println("====>printThread:${name}")

        val es = Thread.currentThread().stackTrace

        val normalInfo = StringBuilder(" \nThreadTrace:")
            .append("\nthreadName:${name}")
            .append("\n====================================threadTraceStart=======================================")

        for (e in es) {

            if (e.className == "dalvik.system.VMStack" && e.methodName == "getThreadStackTrace") {
                isCanAppendLog = false
            }

            if (e.className.contains("ThreadCheck") && e.methodName == "printThread") {
                isCanAppendLog = true
            } else {
                if (isCanAppendLog) {
                    normalInfo.append("\n${e.className}(lineNumber:${e.lineNumber})")
                }
            }
        }
        normalInfo.append("\n=====================================threadTraceEnd=======================================")

        Log.i(tag, normalInfo.toString())
    }

}

上面的代码获取了调用堆栈,并且打印到控制台

实现原理

首先它是一个gradle 的自定义Plugin,其次它是通过在编译阶段修改字节码实现Hook,在编译阶段通过Tranfrom扫描所有的字节码,然后根据在使用插件的时候设置的需要被Hook的方法,插入需要被插入的字节码,
需要被插入的字节码也是在使用的时候设置的,例如下面的代码

/**
 * 返回hook线程构造函数的字节码,Hook 线程的构造函数,让每次在调用Thread的构造函数的时候就会调用
 * ThreadCheck的 printThread方法,从而在控制台打印线程的构造函数的调用堆栈,这些代码可以借助
 * ASM Bytecode Viewer Support Kotlin生成,MethodVisitor是ASM提供的一个类,用于修改字节码
 */
void createHookThreadByteCode(MethodVisitor mv, String className) {
    mv.visitTypeInsn(Opcodes.NEW, "com/quwan/tt/asmdemoapp/ThreadCheck")
    mv.visitInsn(Opcodes.DUP)
    mv.visitMethodInsn(Opcodes.INVOKESPECIAL, "com/quwan/tt/asmdemoapp/ThreadCheck", "<init>", "()V", false)
    mv.visitLdcInsn(className)
    mv.visitMethodInsn(Opcodes.INVOKEVIRTUAL, "com/quwan/tt/asmdemoapp/ThreadCheck", "printThread", "(Ljava/lang/String;)V", false)
}

准备过程

实现这个gradle插件需要我们有足够的预备知识,如下:

实现过程

1.自定义gradle plugin

因为这是一个gradle插件,所以需要我们自定义一个gradle的plugin

1. 新建一个模块

在工程中新建一个模块,命名为"buildSrc",注意,一定要命名为buildSrc,否则在工程中必须要将代码发布到本地或者远程maven仓库中才能正常使用,这样调试不方便,如下所示:

[图片上传失败...(image-88703d-1634713340383)]

2. 然后配置gradle脚本,代码如下所示:

plugins {
    id 'java-library'
    id 'maven'
    id 'groovy'
}

java {
    sourceCompatibility = JavaVersion.VERSION_1_8
    targetCompatibility = JavaVersion.VERSION_1_8
}

dependencies {
    implementation gradleApi()//gradle sdk
    implementation localGroovy()
    implementation "com.android.tools.build:gradle:3.4.1"
    implementation 'org.ow2.asm:asm:9.1'
    implementation 'org.ow2.asm:asm-commons:9.1'
}

3. 实现Plugin类

新建groovy文件夹,新建BlackHookPlugin类,继承Transform类,实现Plugin接口

[图片上传失败...(image-843eb6-1634713340383)]

BlackHookPlugin代码如下所示:

package com.blackHook.plugin

class BlackHookPlugin extends Transform implements Plugin<Project> {

    ....此处省略了很多代码

    @Override
    void apply(Project target) {
        println("注册了")
        project = target
        target.extensions.getByType(BaseExtension).registerTransform(this)
        target.extensions.create("blackHook", BlackHook.class)
    }
    
     ....此处省略了很多代码
}

新建resources文件夹,新建com.blackHook.properties文件,如下所示

[图片上传失败...(image-2c65fe-1634713340383)]

com.blackHook.properties文件的代码如下:

implementation-class=com.blackHook.plugin.BlackHookPlugin

implementation-class的值即是BlackHookPlugin的完整路径,另外,com.blackHook.properties文件的文件名既是使用插件的时候的插件名,如下代码:

apply plugin: 'com.blackHook'

2. 实现BlackHook扩展类

新建BlackHook类,代码如下

public class BlackHook {

    Closure methodHooker;

    List<HookMethod> hookMethodList = new ArrayList<>();

    public static final String CONTENT_CLASS = "CONTENT_CLASS";
    public static final String CONTENT_JARS = "CONTENT_JARS";
    public static final String CONTENT_RESOURCES = "CONTENT_RESOURCES";

    public static final String SCOPE_FULL_PROJECT = "SCOPE_FULL_PROJECT";
    public static final String PROJECT_ONLY = "PROJECT_ONLY";

    String inputTypes = CONTENT_CLASS;

    String scopes = SCOPE_FULL_PROJECT;

    boolean isNeedLog = false;

    boolean isIncremental = false;

    public Closure getMethodHooker() {
        return methodHooker;
    }

    public void setMethodHooker(Closure methodHooker) {
        this.methodHooker = methodHooker;
    }

    public List<HookMethod> getHookMethodList() {
        return hookMethodList;
    }

    public void setHookMethodList(List<HookMethod> hookMethodList) {
        this.hookMethodList = hookMethodList;
    }

    public String getInputTypes() {
        return inputTypes;
    }

    public void setInputTypes(String inputTypes) {
        this.inputTypes = inputTypes;
    }

    public String getScopes() {
        return scopes;
    }

    public void setScopes(String scopes) {
        this.scopes = scopes;
    }

    public boolean getIsIncremental() {
        return isIncremental;
    }

    public void setIsIncremental(boolean incremental) {
        isIncremental = incremental;
    }

    public boolean getIsNeedLog() {
        return isNeedLog;
    }

    public void setIsNeedLog(boolean needLog) {
        isNeedLog = needLog;
    }
}

这个类用于接收开发人员使用插件的时候设置的参数和需要被Hook的方法以及参与Hook的字节码,我们在使用blackHook插件的时候可以使用DSL的方式来使用,如下代码所示:

blackHook {
    //表示要处理的数据类型是什么,CLASSES 表示要处理编译后的字节码(可能是 jar 包也可能是目录), RESOURCES 表示要处理的是标准的 java 资源
    inputTypes BlackHook.CONTENT_CLASS
    //表示Transform 的作用域,这里设置的SCOPE_FULL_PROJECT代表作用域是全工程
    scopes BlackHook.SCOPE_FULL_PROJECT
    //表示是否支持增量编译,false不支持
    isIncremental false
    //表示hook的方法
    hookMethodList = getHookMethods()
}

之所以可以这么做是因为我们在BlackHookPlugin将BlackHook类添加到了target.extensions(扩展属性)中,
如下代码:

class BlackHookPlugin extends Transform implements Plugin<Project> {
    @Override
    void apply(Project target) {
        target.extensions.create("blackHook", BlackHook.class)
    }
}

3.开始实现扫描

需要在BlackHookPlugin的transform()方法中扫描全局代码,代码如下:

  @Override
    void transform(TransformInvocation transformInvocation) throws TransformException, InterruptedException, IOException {
        Collection<TransformInput> inputs = transformInvocation.inputs
        TransformOutputProvider outputProvider = transformInvocation.outputProvider
        if (outputProvider != null) {
            outputProvider.deleteAll()
        }
        if (blackHook == null) {
            blackHook = new BlackHook()
            blackHook.methodHooker = project.extensions.blackHook.methodHooker
            blackHook.isNeedLog = project.extensions.blackHook.isNeedLog
            for (int i = 0; i < project.extensions.blackHook.hookMethodList.size(); i++) {
                HookMethod hookMethod = new HookMethod()
                hookMethod.className = project.extensions.blackHook.hookMethodList.get(i).className
                hookMethod.methodName = project.extensions.blackHook.hookMethodList.get(i).methodName
                hookMethod.descriptor = project.extensions.blackHook.hookMethodList.get(i).descriptor
                hookMethod.createBytecode = project.extensions.blackHook.hookMethodList.get(i).createBytecode
                blackHook.hookMethodList.add(hookMethod)
            }
        }
        inputs.each { input ->
            input.directoryInputs.each { directoryInput ->
                handleDirectoryInput(directoryInput, outputProvider)
            }
            //遍历jarInputs
            input.jarInputs.each { JarInput jarInput ->
                //处理jarInputs
                handleJarInputs(jarInput, outputProvider)
            }
        }
        super.transform(transformInvocation)
    }

    void handleDirectoryInput(DirectoryInput directoryInput, TransformOutputProvider outputProvider) {
        if (directoryInput.file.isDirectory()) {
            directoryInput.file.eachFileRecurse { file ->
                String name = file.name
                if (name.endsWith(".class") && !name.startsWith("R$drawable")
                        && !"R.class".equals(name) && !"BuildConfig.class".equals(name)) {
                    ClassReader classReader = new ClassReader(file.bytes)
                    ClassWriter classWriter = new ClassWriter(classReader, ClassWriter.COMPUTE_MAXS)
                    ClassVisitor classVisitor = new AllClassVisitor(classWriter, blackHook)
                    classReader.accept(classVisitor, EXPAND_FRAMES)
                    byte[] code = classWriter.toByteArray()
                    FileOutputStream fos = new FileOutputStream(
                            file.parentFile.absolutePath + File.separator + name)
                    fos.write(code)
                    fos.close()
                }
            }
        }

        //处理完输入文件之后,要把输出给下一个任务
        def dest = outputProvider.getContentLocation(directoryInput.name,
                directoryInput.contentTypes, directoryInput.scopes,
                Format.DIRECTORY)
        FileUtils.copyDirectory(directoryInput.file, dest)
    }

    void handleJarInputs(JarInput jarInput, TransformOutputProvider outputProvider) {
        if (jarInput.file.getAbsolutePath().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 (entryName.endsWith(".class") && !entryName.startsWith("R$")
                        && !"R.class".equals(entryName) && !"BuildConfig.class".equals(entryName)) {
                    //class文件处理
                    jarOutputStream.putNextEntry(zipEntry)
                    ClassReader classReader = new ClassReader(IOUtils.toByteArray(inputStream))
                    ClassWriter classWriter = new ClassWriter(classReader, ClassWriter.COMPUTE_MAXS)
                    ClassVisitor cv = new AllClassVisitor(classWriter, blackHook)
                    classReader.accept(cv, 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()
        }
    }

扫描的过程中会将扫描到的所有类的信息(包含类名,父类名,方法名等)交给AllClassVisitor类,AllClassVisitor类代码如下所示:

public class AllClassVisitor extends ClassVisitor {
    private String className;
    private BlackHook blackHook;
    private String superClassName;

    public AllClassVisitor(ClassVisitor classVisitor, BlackHook blackHook) {
        super(ASM6, classVisitor);
        this.blackHook = blackHook;
    }

    @Override
    public void visit(int version, int access, String name, String signature, String superName, String[] interfaces) {
        super.visit(version, access, name, signature, superName, interfaces);
        className = name;
        superClassName = superName;
    }

    // 扫描到每个类中的方法的时候会回调到这个方法
    @Override
    public MethodVisitor visitMethod(int access, String name, String descriptor, String signature, String[] exceptions) {
        MethodVisitor mv = cv.visitMethod(access, name, descriptor, signature, exceptions);
        // 新建AllMethodVisitor类,将扫描到类和方法的信息以及BlackHook类存储的参数交给    AllMethodVisitor对象,由AllMethodVisitor来判断是否需要Hook指定的方法
        return new AllMethodVisitor(blackHook, mv, access, name, descriptor, className, superClassName);
    }

然后在AllClassVisitor类中会将将扫描到的类和方法的信息以及BlackHook扩展类存储的参数交给AllMethodVisitor对象,由AllMethodVisitor来判断是否需要Hook指定的方法,AllMethodVisitor代码如下:

class AllMethodVisitor extends AdviceAdapter {
    private final String methodName;
    private final String className;
    private BlackHook blackHook;
    private String superClassName;

    protected AllMethodVisitor(BlackHook blackHook, org.objectweb.asm.MethodVisitor methodVisitor, int access, String name, String descriptor, String className, String superClassName) {
        super(ASM5, methodVisitor, access, name, descriptor);
        this.blackHook = blackHook;
        this.methodName = name;
        this.className = className;
        this.superClassName = superClassName;
    }

    @Override
    protected void onMethodEnter() {
        super.onMethodEnter();
    }

    @Override
    public void visitMethodInsn(int opcode, String owner, String methodName, String descriptor, boolean isInterface) {
        super.visitMethodInsn(opcode, owner, methodName, descriptor, isInterface);
        if (blackHook.isNeedLog) {
            System.out.println("====>methodInfo:" + "className:" + owner + ",methodName:" + methodName + ",descriptor:" + descriptor);
        }
        if (blackHook != null && blackHook.hookMethodList != null && blackHook.hookMethodList.size() > 0) {
            for (int i = 0; i < blackHook.hookMethodList.size(); i++) {
                HookMethod hookMethod = blackHook.hookMethodList.get(i);
                //这里根据开发人员设置的需要hook的方法以及扫描到的方法来判断是否需要hook
                if ((owner.equals(hookMethod.className) || superClassName.equals(hookMethod.className) || className.equals(hookMethod.className)) && methodName.equals(hookMethod.methodName) && descriptor.equals(hookMethod.descriptor)) {
                    hookMethod.createBytecode.call(mv);
                    break;
                }
            }
        }
    }
}

在这个类中根据开发人员调用插件的时候设置的需要hook的方法以及扫描到的方法来判断是否需要hook

4.源码

https://github.com/18824863285/BlackHook

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

推荐阅读更多精彩内容