最近一朋友单位采购了 JEB Pro 用于 Android 逆向,但使用的是 Floating License,因此只能在公司内网中使用。这样一来朋友在节假日就没法卷了,于是找到了我看有没有兴趣研究一下。虽然笔者之前搞过一段时间 Java 逆向,但那主要针对 Android 应用,对于 PC 应用那是大姑娘坐花轿 —— 头一回。本着学习新知识的心态,就接下了这个任务。
以防有朋友不了解,JEB[1] 是一个逆向工程工具,主要用于 Android APK 的逆向分析,针对 smali 字节码反编译的功能类似于 JADX,但是也支持对 SO 等二进制程序的逆向分析。
一般来说 JEB Pro 采用订阅机制,根据使用的机器进行收费,一机一密。但对于企业而言通常采用浮动授权,即 Floating License,允许一个或者多个不固定的机器同时使用。JEB floating controller[2] 是官方用于运行在服务端的私有浮动授权服务器,其本质上是一个 HTTP 服务器,运行后直接访问会显示授权信息。
客户端则需要在 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[3] 了。这是一个阿里巴巴开源的线上 Java 诊断利器,基于 JVMTI 注入应用,避免了调试器中断的繁琐流程。该工具使用 ASM[4] 来动态修改 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 思路就很多了,我们可以直接把线程 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
重新打包即可。
虽然重打包可以修改原始的程序代码逻辑,但却对原始代码进行了修改,jar 包的签名也进行了改变。如果目标程序本身有完整性校验很可能被检测出来。更好的方法就是使用 Java Agent 去对目标代码进行动态修改,即使用 JVM 本身的 -javaagent
参数注入一个 jar 去动态修改原始程序逻辑(或者通过动态 attach 的方式)。arthas 本身和常用的 RASP 都是基于这个原理。
关于 Java Agent[5] 可以参考官方的文档,对于开发者而言只需要关注几个重点:
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[6] 动态生成字节码,并覆写 transform 方法替换目标 Class 的字节码,从而修改目标方法的实现。
完整的代码可以参考 HookAgent[7],为了方便以后每次逆向破解新项目的时候不用频繁编译,将待劫持的类名、方法名和方法体使用参数进行传递,同时将修改后的方法体保存在新文件中。
基于上述 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 动态注入的方式去实现代码逻辑修改的持久化,从而实现“破解”的效果。
[1]
JEB: https://www.pnfsoftware.com/jeb/
[2]
JEB floating controller: https://www.pnfsoftware.com/jeb/manual/floating/
[3]
arthas: https://github.com/alibaba/arthas
[4]
ASM: https://asm.ow2.io/
[5]
Java Agent: https://docs.oracle.com/javase/8/docs/api/java/lang/instrument/package-summary.html
[6]
javassist: https://www.javassist.org/
[7]
HookAgent: https://github.com/evilpan/HookAgent