Java 应用安全之 JEB Floating License 绕过

最近一朋友单位采购了 JEB Pro 用于 Android 逆向,但使用的是 Floating License,因此只能在公司内网中使用。这样一来朋友在节假日就没法卷了,于是找到了我看有没有兴趣研究一下。虽然笔者之前搞过一段时间 Java 逆向,但那主要针对 Android 应用,对于 PC 应用那是大姑娘坐花轿 —— 头一回。本着学习新知识的心态,就接下了这个任务。

前言

以防有朋友不了解,JEB 是一个逆向工程工具,主要用于 Android APK 的逆向分析,针对 smali 字节码反编译的功能类似于 JADX,但是也支持对 SO 等二进制程序的逆向分析。

一般来说 JEB Pro 采用订阅机制,根据使用的机器进行收费,一机一密。但对于企业而言通常采用浮动授权,即 Floating License,允许一个或者多个不固定的机器同时使用。JEB floating controller 是官方用于运行在服务端的私有浮动授权服务器,其本质上是一个 HTTP 服务器,运行后直接访问会显示授权信息。

float
JEB Floating Controller

客户端则需要在 jeb-client.cfg 中配置 .ControllerInterface.ControllerPort 等信息,或者在启动 JEB Pro 的时候填入服务端地址,从而实现授权。

逆向分析

对于静态分析而言,和 Android 应用中的静态分析流程基本一致,都是拖到 JADX 中然后搜索某些关键字,比如授权失败的提示信息,或者配置的信息。

首先我们将授权服务的地址指定为 127.0.0.1:23477,然后用 NC 监听该地址,启动后很快收到了请求:

Listening on 0.0.0.0 23477
Connection received on localhost 52330
POST /probe HTTP/1.1
User-Agent: PNF Software UP
Content-Type: application/x-www-form-urlencoded
Content-Length: 189
Host: 127.0.0.1:23477
Connection: Keep-Alive
Accept-Encoding: gzip

data=5400000035E5...

可见 JEB Pro 和浮动授权服务器是通过 HTTP 请求进行通讯的。

$ zipgrep /probe bin/app/jeb.jar
grep: (standard input): binary file matches

直接搜索请求的路径 /probe,可以定位到下面的代码:

/* loaded from: jeb.jar:com/pnfsoftware/jebglobal/ga.class */  
public class ga {  
    private static String nz = cns.nz("1150...");  
    private static String Fj = cns.nz(new byte[]{117, 90, 69, 74, 69}, 2, 120);  
    private Net jU;  
    private String Fx;  
    private int tQ;  
    private String Fh;  
  
    public ga(Net net, String str, int i, int i2) {  
        if (net == null) {  
            throw new IllegalArgumentException();  
        }  
        if (str == null || str.isEmpty()) {  
            throw new IllegalArgumentException("Controller Interface is not set");  
        }  
        if (i <= 0 || i >= 65535) {  
            throw new IllegalArgumentException("Illegal Controller Port value must be between 0 and 65535");  
        }  
        this.jU = net;  
        this.Fx = str;  
        this.tQ = i;  
        Object[] objArr = new Object[3];  
        objArr[0] = i2 == 1 ? "https" : NetProxyInfo.TYPE_HTTP;  
        objArr[1] = str;  
        objArr[2] = Integer.valueOf(i);  
        this.Fh = Strings.ff("%s://%s:%d/probe", objArr);  
    }
    // ...
    public String nz(String str) {  
        return JebNet.post(this.jU, this.Fh, str);  
    }  
  
    public void Fj(String str) {  
        JebNet.post(this.jU, this.Fh, str);  
    }
}

看起来像是通过的 JebNet.post 发送请求的。接下来有两个策略,一是继续静态分析不断查看交叉应用来来定位请求发送点,二是使用动态分析来验证该处是否确实被调用。懒人首选必然是后者。

动态分析

说起动态分析就有点怀念移动端常用的 frida 了。针对 Android 应用可以通过 hook 某些特定函数去检查参数、调用栈,并且对参数和返回值进行修改。理论上 frida 在 PC 端也是可以使用的,但是只支持了一部分版本的 OpenJDK,实测笔者的 JDK 17 支持并不是很好。

由于前一段时间研究过 Java Web 应用,那时用得最多的工具要数 arthas 了。这是一个阿里巴巴开源的线上 Java 诊断利器,基于 JVMTI 注入应用,避免了调试器中断的繁琐流程。该工具使用 ASM 来动态修改 Java 字节码,不同于 frida 基于二进制的注入,ASM 对于 JDK 而言有很高的兼容性。

由于 arthas 使用 OGNL 作为表达式,因此对于 hook 的过滤和修改也具备很强的灵活性。关于其具体的使用方法可以参考官方文档,这里就不再赘述了。

直接使用 as.sh 指定 JEB 的进程 ID,然后 hook JebNet.post 方法,查看调用堆栈:

[arthas@70354]$ stack com.pnfsoftware.jeb.client.JebNet post params.length==3
Press Q or Ctrl+C to abort.
Affect(class count: 1 , method count: 2) cost in 53 ms, listenerId: 4
ts=2024-04-02 13:21:06;thread_name=Thread-9;id=1a;is_daemon=true;priority=5;TCCL=jdk.internal.loader.ClassLoaders$AppClassLoader@531d72ca
    @com.pnfsoftware.jeb.client.JebNet.post()
        at com.pnfsoftware.jebglobal.ga.nz(SourceFile:71)
        at com.pnfsoftware.jebglobal.aW.jU(SourceFile:194)
        at com.pnfsoftware.jebglobal.aW.run(SourceFile:81)
        at java.lang.Thread.run(Thread.java:833)

果然是这里,核心的 jU 方法如下:

/* loaded from: jeb.jar:com/pnfsoftware/jebglobal/aW.class */  
public class aW implements Runnable {
    private long jU() {  
        Fg Fj2;  
        try {  
            ByteArrayOutputStream byteArrayOutputStream = new ByteArrayOutputStream();  
            LEDataOutputStream lEDataOutputStream = new LEDataOutputStream(byteArrayOutputStream);  
            lEDataOutputStream.writeInt(0);  
            lEDataOutputStream.writeLong(this.sM);  
            lEDataOutputStream.writeLong(this.Pm);  
            lEDataOutputStream.close();  
            String nz2 = this.Fh.nz(Fg.nz(Fg.nz(te.Fj(), byteArrayOutputStream.toByteArray(), new int[1])));  
            if (nz2 == null || (Fj2 = Fg.Fj(Fg.nz(nz2.trim()))) == null) {  
                return -1L;  
            }  
            this.fR = Fj2.tQ();  
            LEDataInputStream lEDataInputStream = new LEDataInputStream(new ByteArrayInputStream(Fj2.dv()));  
            if (lEDataInputStream.readInt() != 1) {  
                return -1L;  
            }  
            return lEDataInputStream.readLong();  
        } catch (Exception e) {  
            nz.catching(e);  
            return -1L;  
        }  
    }
    // ...
}

aW 这个类本身是个 Runnable,是使用一个额外的线程进行启动的,这里请求被调用的地方如下:

  
    @Override // java.lang.Runnable  
    public void run() {  
        int i = 0;  
        int i2 = 0;  
        boolean z = false;  
        while (true) {  
            long jU2 = jU();  
            try {  
                if (jU2 == 0 || jU2 == 2) {  
                    if (jU2 == 2 && !z) {  
                        nz.warn(cns.nz(new byte[]{26, 54, 26, 7, 82, 106, 15, 7, 98, 67, 15, 5, 12, 11, 26, 84, 73, 26, 83, 79, 3, 8, 1, 23, 82, 84, 28, 9, 15, 78, 84, 28, 13, 69, 67, 12, 1, 26, 6, 29, 3, 0, 9, 23, 82, 89, 22, 26, 85, 65, 19, 23, 69, 67, 12, 1, 0, 11, 6, 23, 17, 1, 68, 84, 27, 78, 1, 116, 59, 79, 65, 23, 25, 6, 13, 68, 85, 27, 11, 29, 8, 21, 6, 23, 17, 1, 68, 66, 7, 13, 9, 23, 31, 6, 29, 94, 12, 89, 22, 26, 85, 83, 27, 7, 26, 25, 8, 68, 85, 5, 20, 5, 21, 17, 69, 89, 22, 26, 7, 82, 83, 28, 9, 18, 3, 22, 19, 23}, 1, 67), new Object[0]);  
                        z = true;  
                    }  
                    if (i != 0 || i2 != 0) {  
                        nz.warn(...);  
                    }  
                    i = 0;  
                    i2 = 0;  
                } else {  
                    nz.warn(...);  
                    boolean z2 = false;  
                    if (jU2 <= -1) {  
                        i++;  
                        if (i >= 3) {  
                            z2 = true;  
                        }  
                    } else {  
                        z2 = true;  
                    }  
                    if (z2) {  
                        //...
                        nz.info("trycnt=%d", Integer.valueOf(i3));  
                        AbstractContext.terminate();  
                    }  
                }  
                Thread.sleep(10000L);  
            } catch (InterruptedException unused2) {  
                return;  
            }  
        }

简单来说:

  1. 这个线程会一直循环执行,且每隔 10 秒执行一次;
  2. 当 jU 的返回结果不为 0 时,且到达了最大的重试次数后,会发送失败事件,并关闭 JEB;
  3. 弹窗相关的文字以及日志信息是经过加密的,即 cns.nz 方法,猜测主要是为了防止黑客搜索关键字。不过并没有对所有字符串都进行加密,所以我们还是能搜到 HTTP 路径信息;

Patch

这里的 patch 思路就很多了,我们可以直接把线程 nop 掉,也可以只针对请求的地方,这里我们选择后者。

patch 方案也有几种,一是直接修改 class 字节码,不过这个流程相对复杂。我们可以先使用 arthas 生成 Java 代码,然后修改反编译的 Java 代码后重新进行编译、加载,如下所示:

jad --source-only com.pnfsoftware.jebglobal.aW > /tmp/aW.java
# 修改 aW.java jU 方法返回 0
# 重新编译
mc /tmp/aW.java
# 加载
redefine com/pnfsoftware/jebglobal/aW.class

如果想要进行持久化,那么只需要将 jeb.jar 解压并替换掉 aW.class 重新打包即可。

Java Agent

虽然重打包可以修改原始的程序代码逻辑,但却对原始代码进行了修改,jar 包的签名也进行了改变。如果目标程序本身有完整性校验很可能被检测出来。更好的方法就是使用 Java Agent 去对目标代码进行动态修改,即使用 JVM 本身的 -javaagent 参数注入一个 jar 去动态修改原始程序逻辑(或者通过动态 attach 的方式)。arthas 本身和常用的 RASP 都是基于这个原理。

关于 Java Agent 可以参考官方的文档,对于开发者而言只需要关注几个重点:

  1. 编译的 agent jar 通过 -javaagent:agent.jar 的方式进行指定,可以在程序启动之前加载;
  2. agent.jar 中需要再 Manifest 指定 Premain-Class,指定的类包含 premain 方法,会在启动时被 JVM 调用;

一个示例 Agent 如下所示:

public class Agent {

    public static void log(String fmt, Object ...args) {
        System.out.printf("[agent] " + fmt + "\n", args);
    }

    public static void premain(String agentArgs, Instrumentation inst) {
        log("premain called.");
        if (!inst.isRetransformClassesSupported()) {
            log("Class retransformation is not supported.");
            return;
        }

        HookTransformer.replace(inst, "com.pnfsoftware.jebglobal.aW", "jU",
        """
        {
            System.out.println("[agent-hook] skip check.");
            return 0;
        }
        """);
    }

}

HookTransformer 是笔者写的一个粗糙的 ClassFileTransformer 实现。主要使用 javassist 动态生成字节码,并覆写 transform 方法替换目标 Class 的字节码,从而修改目标方法的实现。

完整的代码可以参考 HookAgent,为了方便以后每次逆向破解新项目的时候不用频繁编译,将待劫持的类名、方法名和方法体使用参数进行传递,同时将修改后的方法体保存在新文件中。

基于上述 HookAgent.jar,修改 JEB 自带的命令行参数 jvmopt.txt 即可实现注入:

cat jvmopt.txt
-Xmx16G --add-opens java.base/java.lang=ALL-UNNAMED -Xverify:none -javaagent:HookAgent.jar=className=com.pnfsoftware.jebglobal.aW;methodName=jU;methodImplFile=hook.js

随后启动 JEB 观察日志可以看到 agent 成功注入,并且 agent 的日志也每隔 10 秒输出一次:

./jeb_linux.sh
OpenJDK 64-Bit Server VM warning: Options -Xverify:none and -noverify were deprecated in JDK 13 and will likely be removed in a future release.
[agent] premain enter, agentArgs=className=com.pnfsoftware.jebglobal.aW;methodName=jU;methodImplFile=hook.js
[agent] Parsing hook options from agentArgs: className=com.pnfsoftware.jebglobal.aW;methodName=jU;methodImplFile=hook.js
[agent] Reading methodImpl from hook.js
[agent] Loaded com.pnfsoftware.jebglobal.aW->jU: {
    System.out.println("[agent-hook] skip check: " + new java.util.Date());
    return 0;
}
...
[agent] CtMethod: javassist.CtMethod@31736c[private jU ()J]
[agent] byteCode size: 5899
[agent] Hooked: com.pnfsoftware.jebglobal.aW->jU
[I] JEB 5.12.xxx (jeb-pro-floating) is starting...
[I] Memory Usage: 44.1M used (475.9M free, 16.0G max)
[agent] transform: com.pnfsoftware.jebglobal.aW->jU
[agent-hook] skip check: Thu May 02 20:15:49 CST 2024
[agent-hook] skip check: Thu May 02 20:15:59 CST 2024

这里是将循环检查的方法返回值固定成了 0,但实际上可以选择的点很多,比如直接 hook 这个 Runnable 的 run 方法也是可以的。

总结

本文主要以 JEB Floating License 的校验过程为例,学习了 Java PC 客户端应用的一般分析方法。在反编译和逆向分析的基础上,配合动态调试或者 Arthas 等动态分析框架,可以快速找到目标执行路径。随后可以使用静态重打包或者 Java Agent 动态注入的方式去实现代码逻辑修改的持久化,从而实现“破解”的效果。


版权声明: 自由转载-非商用-非衍生-保持署名 (CC 4.0 BY-SA)
原文地址: https://evilpan.com/2024/05/03/java-agent/
微信订阅: 有价值炮灰
TO BE CONTINUED.