实验室老师为了加快项目开发速度,买了一个叫做 Jeeplus 的 Java 快速开发框架。Jeeplus 简单来说就是一个 Java+Vue 的项目脚手架,另外包含一个前后端代码生成器工具,对一些常见的 CURD 前后端代码,能够一键生成。且不说这个框架的代码质量咋样,但用起来确实方便,一些刚入实验室的小伙伴也能零基础快速上手。

使用 Jeeplus 是需要授权 License 的,当然也没有很贵。前段时间,寒假放假在家,将实验室项目从工位迁移到笔记本上的过程中,顺手研究了一下把 Jeeplus 破解了,免得再占用一个授权名额。

破解 Java 程序与破解其他语言如 C++ 的程序非常不同。后者只能反汇编为天书般的汇编代码,需要不断的断点追踪和分析才能写出一个注册机,而 Java 依赖 JVM 虚拟机,编译生成的是结构清晰的字节码,且能够轻易的通过工具反编译成可读性极高的 Java 代码。从这点来讲,Java 程序天生没有源码防护,只能做一做代码混淆层面的保护。

破解过程

背景介绍完,进入正题。破解的版本是 Jeeplus 8.0,使用的 IDE 是 IDEA 2021.3.1。

破解的思路是反编译相关代码并修改验证逻辑,然后利用 Java 类加载机制使得修改后的代码覆盖原始代码。

首先打开 Chrome 浏览器抓包,得到验证相关 api 接口和接口中验证失败的提示词。然后在代码仓库里全文搜索提示词,果然,啥也搜不到。可以断定验证相关的代码是被封装成 Jar 包然后通过 Maven 导入的。

在项目里所有 Pom.xml 中用关键词搜索,不难找到该包:

IDEA内置了一个高性能的反编译器 FernFlower,可以直接浏览、调试 class 文件的源码。由于文件数量不是很多,我就依次浏览了各个文件,很快定位到一个工具类代码:C7.class

其反编译后的部分源码如下:

public class C7 {  
    // ...
  
 public C7() {  
    }  
  
    public static String getM() {  
        return "V" + C12.getAllSn();  
 }  
  
    public static String getSerial(String license) {  
        RSAPublicKey pubKey = C11.getPublicKey(module, publicKey);  
 String ming = "";  
  
 try {  
            ming = C11.decryptByPublicKey(license, pubKey);  
 } catch (Exception var4) {  
            ming = "ERROR";  
 }  
  
        return ming;  
 }

不难看出,getM 函数的功能是获取机器码,getSerial 是一个RSA签名的简单应用,即用一个内置的公钥解密 license 然后返回解密后的明文。这里我猜测解密后的明文应该就是机器码,然后程序通过比对前面 getM 函数的返回,判断 license 的合法性。

因为本科选修过密码学,对 RSA 有些了解。这里的 license 计算和一般的算号不同,RSA 加密算法如果没有私钥,只能验证一个 license 是否合法(也就是公钥签名),而不能生成 license。至此,基本可以打消写一个算号器的想法了,还是得修改逻辑。

getMgetSerial 函数里设置断点,启动单步调试,发现 getSerial 的返回值正是我笔记本的机器码,基本可以确定,系统是通过 getMgetSerial 函数判等来进行本地验证。

修改代码如下:

public static String getM() {  
    return "V123456789"; // 去掉机器码计算,加快速度  
}

public static String getSerial(String license) {
	return "V123456789";
}

保存为 C7.java ,在后端 src 里按照包名路径创建文件夹,放入该文件即可。因为 JVM 类加载机制中,是通过一个类的全限定名来获取二进制流,因而我们可以在代码源码里新建一个同样限定名的类,其优先级会高于被修改的类。

重启服务器,怪事发生了:系统第一次可以成功激活,然后刷新后却提示 license 非法。

继续阅读了包里的其他反编译代码,最后定位到了生成器的核心源码 D8.class 。该文件包含了 license 验证(调用之前的 C7.class)和代码生成器的功能实现代码。借位吐槽一下原来这里的代码生成器就是字符串拼接,还是直接在代码里拼接的那种…

第200行开始的代码如下:

String machineCode = C7.getM();  
if (this.license != null && !this.license.equals("")) {  
    if (!C7.getSerial(this.license).equals(machineCode)) {  
        return DsAjaxJson.error("您的license非法").put("serial", machineCode).put("code", 700);  
 } else {

这里也能印证之前的猜测。值得一提的是,我也有尝试过反编译 D8.class 直接去掉验证逻辑,然而不知道是因为这个文件代码量太大(1500多行),还是因为代码做了混淆,反编译的代码会有一大堆难以解决的语法错误,因担心暴力修改会破坏生成器的功能,遂放弃。

在200行处设置断点,单步调试走了一遍验证流程。原来验证失败的原因是因为这个包还有在线验证。具体的,在该类的静态初始化里初始化了两个验证 url 静态常量:

static {  
	user = ace + "/getGenTemplate?";  
	init = ace + "/initGenTemplate?";  
}

系统把机器码和 license 发送给 user 常量指向的网址进行在线验证,验证结果保存在本地数据库里。而系统会优先调用数据库里的结果,验证失败则会弹出 license 非法。这也解释了之前的怪现象。

通过亿点点尝试,摸清了这个服务器的验证逻辑,大概是:返回 0 表示验证通过,返回 1 表示验证不通过,而返回 -2 表示网络超时或者其他错误。那么破解思路就清晰了,只需要掉包这里的 user 变量的值就行。

问题来了,前面说到,我们很难直接修改 D8.class 文件本身,那么有没有一个办法可以不修改 class 文件而改变 class 类里的私有变量呢?方法当然是有的,那就是使用 Java 的反射。

Java 的反射机制,简单来说就是指在程序运行的过程中,构造、访问和修改一个类的对象。Java反射机制主要提供了以下功能: 在运行时判断任意一个对象所属的类;在运行时构造任意一个类的对象;在运行时访问、修改任意一个类所具有的成员变量和方法;在运行时调用任意一个对象的方法;生成动态代理等等。

继续修改 C7.java,加入修改逻辑,使用反射修改 D8 的静态常量 user。

if(!setFinalStatic(D8.class, "user",   
"https://jeeplus-crack.vercel.app/api/jeeplus?r=0"))  
    logger.error("破解失败!");

其中,替换的 URL 是用 vercel 简单搭建的一个假验证服务器,只返回 0;setFinalStatic 函数的实现如下:

static boolean setFinalStatic(Class clazz, String fieldName, Object newValue){  
    try {  
		Field field = clazz.getDeclaredField(fieldName);  
        field.setAccessible(true);  
        Field modifiers = field.getClass().getDeclaredField("modifiers");  
        modifiers.setAccessible(true);  
        modifiers.setInt(field, field.getModifiers() & ~Modifier.FINAL);  
        field.set(null, newValue);  
    } catch (Exception e) {  
        return false;  
    }  	
    return true;  
}

其中第三行使用 Class::getDeclaredField 获取待修改的 Field 成员,由于该成员是 private 的,第四行又使用 setAccessible 设置权限。5~7行使用了位操作的技巧关闭了 Final 标识。第 8 行设置新值。

修改完重启,一切正常。至此,也就完成了 jeeplus 的破解~