吾爱破解 - 52pojie.cn

 找回密码
 注册[Register]

QQ登录

只需一步,快速开始

查看: 9|回复: 0
收起左侧

[原创] 对某java保护器本体的逆向分析

[复制链接]
am768 发表于 2026-7-10 02:55

0x00 前言

最近在翻视频的时候发现了一个 Java 保护器,其官网宣称:

最佳的 Java 源代码保护方案

然后一看价格,799 USD 一年,这么贵,那我可得看看你值不值了,开干。

对于这个商业软件的名称,这里做三次 b64 化:

WTBoS2RtUkhWbXBrUnpsNVRrZHZQUT09

自行还原。下文所有出现这个名称以及公司的名称,统一以 xx 代称。

0x01 分析主程序

这次我们使用 GPT-5.5 接入 IDA 的 MCP 辅助分析。对于主程序 xx.exe,查壳后发现是 Go 编译的,而且无保护壳。拖入 IDA 后发现符号竟然被保留了,说明作者忘了 strip,这对逆向来说算是开局送了一盏灯。

入口

通过分析发现这个程序本质上是一个 Java Wrapper/Launcher,主逻辑在:

main.main @ 0x4e7de0

流程大致是:

  1. 获取 exe 所在目录。
  2. 读取同名 JSON 配置,例如 xx.json
  3. JSON 反序列化到 xx.io/java-wrapper/settings.Settings
  4. 根据配置拼接 Java 启动参数。
  5. 启动 xxjre/bin/java.exejavaw.exe

也就是说,xx.exe 自己不负责真正的 Java 保护逻辑,它更像是一个外壳启动器,负责把 JRE、配置、子进程管理这些东西串起来。

反编译出来大概长这样,变量名是按语义整理过的:

// main.main 伪代码
exePath = os.Executable();
baseDir = filepath.Dir(exePath);
appName = trimExt(filepath.Base(exePath));

jsonPath = filepath.Join(baseDir, appName + ".json");
raw = os.ReadFile(jsonPath);
settings = json.Unmarshal(raw, Settings);

javaBin = filepath.Join(baseDir, "xxjre", "bin",
    settings.HideConsole ? "javaw.exe" : "java.exe");

args = buildJavaArgs(settings);
cmd = exec.Command(javaBin, args...);
cmd.Dir = baseDir;
cmd.Start();

这段逻辑看完基本就能确定:主程序更像“壳外壳”,真正值钱的东西不在这里。

辅助线程

main.main.func2 @ 0x4e93a0

执行 os/exec.Cmd.Run(),等待 Java 子进程结束。失败时写状态到:

%TEMP%\xx\status

main.main.func3 @ 0x4e9000

等待 Java 子进程 PID 出现。如果 JavaVersion == 8,会调用:

xxjre/bin/pidkiller <wrapper_pid> <java_pid>

这个点也侧面说明:主 exe 的主要工作不是加密/解密 Java 类,而是启动、看护和包装。

真正的核心在 xxlib 文件夹里。里面有一堆 jar 包,但所有 jar 都无法直接打开,文件前缀如下:

56 5A 58 45 ...  => "VZXE"

很明显,这是自定义加密容器。

0x02 decoder.exe

注意到 xxjre/bin 里有很多疑似解密组件,例如:

decoder.exe
guard.exe
pidkiller.exe
pidchecker.exe
cl.key
cl.data
dec.key
zis.key
cl.meta

其中 decoder.exe 很可疑。查壳后发现它也是 Go 程序,无壳。

继续丢进 IDA,发现它不是一个简单的“文件 AES 解密器”,而是内嵌了一套小型 JVM/classfile 解释器,用来执行被加密的 Java 解密逻辑。

核心入口:

main.main @ 0x5697a0

流程大致是:

1. 解析 -v / -version
2. 校验父进程必须和自己在同一目录
3. 在 %TEMP%\xxxjre\<ppid>.lck 创建锁文件
4. 校验父进程/JRE 组件 MD5
5. 反调试:NtQueryInformationProcess(ProcessDebugPort=7)
6. 读取输入文件
7. 可选去掉末尾 6 字节
8. 用硬编码 AES-CBC key 解第一层
9. 把解出的内容当 Java class 解析
10. 内置 JVM 执行 class 里的静态方法:
    limitTime()V
    doDecrypt([B)V
11. 从 doDecrypt 结果里取 byte[],写到 stdout,末尾补 0x00

第一层 AES key 在 main.main 里:

A6 CB F3 AC D8 94 C5 F4 33 F3 E5 8C 80 11 18 ED

对应算法函数:

xx/xvm/protector/encrypt.Decrypt @ 0x4b8220

它是:

AES-CBC + PKCS5/PKCS7 unpadding

并且 IV 直接使用 key 的前 16 字节,也就是:

IV = key

第二层 class 解密在:

xx/xvm/protector/rtda/heap.(*ClassLoader).readClass @ 0x4d7140

如果 class bytes 满足:

len > 5 && data[0] == 'V' && data[1] == 'L' && data[2] == 'X'

就跳过前 5 字节,然后用另一个硬编码 key 解密:

F8 20 C3 0D ED E3 0D 76 80 23 3A 4D 36 FD F2 98

同样是 AES-CBC,IV 也是 key 本身。

另外,decoder.exe 里有 Java native bridge:

xx_xvm_protector_native_vlinx.fileKeyEncrypt @ 0x563780
xx_xvm_protector_native_vlinx.aesEncrypt     @ 0x564020
xx_xvm_protector_native_vlinx.aesDecrypt     @ 0x564260

这些函数给被解释执行的 Java class 调用。本质上仍然是 byte[] + key 做 AES-CBC 加解密。

所以到这里,第一条链路已经比较清楚了:

decoder.exe
  -> 反调试/完整性校验
  -> AES 解密输入 payload
  -> 内置 JVM 执行解密 class
  -> doDecrypt([B)V 输出最终数据

它负责的是 cl.key/cl.data/dec.key/zis.key 这一套运行时 key material 的处理,而不是直接把 xxlib 里的 VZXE jar 全部解开。

decoder.exe 的关键路径可以整理成下面这段伪代码:

// decoder.exe main.main 伪代码
input = readFile(argv[1]);

if (hasTailFlag) {
    input = input[:len(input) - 6];
}

plain1 = aesCbcDecrypt(input, FIRST_LAYER_KEY, FIRST_LAYER_KEY);

clazz = xvm.ClassLoader.readClass(plain1);
xvm.InvokeStatic(clazz, "limitTime", "()V");
xvm.InvokeStatic(clazz, "doDecrypt", "([B)V", plain1);

out = getResultByteArray();
stdout.write(out);
stdout.write(0x00);

AES 解密函数本身也没绕什么弯:

func Decrypt(data []byte, key []byte) []byte {
    block := aes.NewCipher(key)
    mode := cipher.NewCBCDecrypter(block, key[:16])
    plain := make([]byte, len(data))
    mode.CryptBlocks(plain, data)
    return pkcs7Unpad(plain)
}

比较有意思的是它没有把 IV 单独存起来,而是直接 IV = key

0x03 jvm.dll 改造点

decoder.exe 负责解 key 文件,但完整 VZXE 容器的读取逻辑并不在它里面。

继续看 xxjre/bin/server/jvm.dll

先从字符串交叉点下手:

dec.key
zis.key
ZipxInputStream.class
io/xx/zipx/ZipxInputStream.class
VZXE

字符串表里能看到 dec.keyzis.keyio/xx/zipx/ZipxInputStream.class,但没有很直白的 VZXE 引用。这种情况一般有两种可能:

  1. magic 按整数常量比较。
  2. magic 藏在数据块或者 Java 层逻辑里。

先看 JVM_LoadZipLibrary,但它只是普通 HotSpot 的 zip 库加载器,没看到核心改造。

真正的改造点更像在 ClassLoader 初始化和类读取过程中:当 JVM 请求某些特殊类时,它不返回原始 JDK 资源,而是从 dec.key/zis.key/cl.data 中解出 patched class。

按 xref 找到了两个比较集中的区域:

0x18020B5xx
0x18066B3xx

其中:

sub_18020B0A0

是 ClassLoader 的 class 读取/返回包装。它在加载特定类名时,会返回伪造或解密后的 class bytes,而不是原始资源。

另一个关键函数:

sub_18066B370

负责读 cl.key,派生 16 字节 key,再解 dec.keyzis.key

这里开始能看到整个保护器的设计思路:不是简单给 jar 套一层壳,而是把 JRE 里的类加载链路改掉,让加密 jar 的读取过程变成“JVM 自己会解”。

ClassLoader 包装点的伪代码大概如下:

// jvm.dll: sub_18020B0A0 伪代码
byte[] loadClass(name) {
    if (name == "java/lang/ClassLoader") {
        return patchedClassLoaderFromClData();
    }

    if (name == "jdk/internal/loader/URLClassPath") {
        return patchedURLClassPathFromClData();
    }

    if (name == "io/xx/zipx/Source") {
        return decryptKeyFile("dec.key");
    }

    if (name == "io/xx/zipx/ZipxInputStream") {
        return decryptKeyFile("zis.key");
    }

    return originalLoadClass(name);
}

真正的 decryptKeyFile() 走到另一个函数,核心是:

// jvm.dll: sub_18066B370 伪代码
clKey = readFile("cl.key");
rc4Key = reverse(md5(clKey));

encrypted = readFile("dec.key" or "zis.key");
plainClass = rc4(encrypted, rc4Key);
return plainClass;

这也解释了为什么只解 xxlib 不够:JVM 运行时会先把自己的类加载逻辑换掉,然后再用换掉的逻辑读 VZXE。

0x04 运行时 key 文件

几个关键文件的作用大致如下:

cl.key   -> 解密逻辑 class,格式为 VLX01/VLXcc
cl.data  -> patched ClassLoader / URLClassPath
dec.key  -> io/xx/zipx/Source.class
zis.key  -> io/xx/zipx/ZipxInputStream.class
cl.meta  -> 运行时元数据

cl.data 不是明文 class bundle。它至少有两层:

第一层:AES-CBC,key = A6 CB F3 AC D8 94 C5 F4 33 F3 E5 8C 80 11 18 ED
第二层:取决于 cl.key 内的 doDecrypt() 逻辑

解开后结构很直接:

u32 ClassLoader长度
ClassLoader.class bytes
u32 URLClassPath长度
URLClassPath.class bytes

dec.keyzis.key 则使用:

RC4(data, reverse(MD5(cl.key)))

解开后分别是:

io/xx/zipx/Source.class
io/xx/zipx/ZipxInputStream.class

这两个类才是 VZXE 容器读取时真正参与工作的 Java 侧逻辑。

这部分用 Python 复现起来非常短,核心就是:

FIRST_LAYER_KEY = bytes.fromhex("a6cbf3acd894c5f433f3e58c801118ed")

first = aes_cbc_unpad(cl_data, FIRST_LAYER_KEY)
payload = aes_cbc_unpad(first, second_key)   # stock 或 dynamic 模式
bundle = rc4(payload, md5(cl_key))

cl_len = u32(bundle, 0)
classloader = bundle[4:4 + cl_len]
ucp_len = u32(bundle, 4 + cl_len)
urlclasspath = bundle[8 + cl_len:8 + cl_len + ucp_len]

dec.key/zis.key 则更直接:

def decrypt_runtime_key_file(path, cl_key):
    return rc4(read(path), md5(cl_key)[::-1])

如果解出来的前四字节是 CA FE BA BE,基本就说明这一步方向没错。

0x05 cl.key 的三种形态

分析过程中遇到了几套不同版本的 cl.key/cl.data,一开始脚本只能解当前 JRE 里的那套,对 sample 里的 p4j-xxxx 全部失败。

后来发现不是文件坏了,而是 cl.data 第二层 key 派生方式不一样。

目前见到三种模式。

passthrough

第一层 AES 解完后,直接 RC4:

bundle = RC4(first_layer_plain, MD5(cl.key))

这种比较适合本地测试生成。

stock

第一层 AES 解完后,还有一层固定 AES key:

E2 4A F1 96 18 E2 61 FB E7 B4 39 4C 9F E8 E5 20

流程:

first  = AES-CBC(cl.data, first_key)
second = AES-CBC(first, stock_key)
bundle = RC4(second, MD5(cl.key))

当前主 JRE 里的 Java 21 模板就是这种。

dynamic

sample 里的 p4j-xxxx 用的是这种。

cl.key 是一个 VLX01/VLXcc 包装的 Java class,解出来是 VlxDecrypt。重点在:

doDecrypt(byte[] data)

它内部硬编码两组东西:

seed[16]
idx[16]

实际派生流程:

seed[16]
  -> generateSBOX(seed)
  -> 按 idx[16] 从 SBOX 取 16 字节
  -> 再过 AES S-box
  -> 得到 cl.data 第二层 AES key

也就是:

dynamic_key = AES_SBOX[ generateSBOX(seed)[idx[i]] ]

然后:

first  = AES-CBC(cl.data, first_key)
second = AES-CBC(first, dynamic_key)
bundle = RC4(second, MD5(cl.key))

这也是 sample 目录里几套 JRE 模板一开始解不开的原因。

VlxDecrypt.doDecrypt() 反出来后,会看到这种很典型的硬编码数组:

public static void doDecrypt(byte[] data) {
    byte[] seed = new byte[] {
        0x32, 0x5a, 0x5b, 0x3c,
        0x10, 0x6b, 0x0b, (byte)0x90,
        0x2c, 0x6e, (byte)0xef, (byte)0x92,
        0x7c, 0x25, (byte)0xa9, 0x7a
    };

    int[] idx = new int[] {
        21, 122, 217, 20,
        44, 134, 3, 112,
        173, 222, 140, 68,
        120, 71, 105, 40
    };

    new VlxDecrypt().decrypt(data, seed, idx);
}

对应的 key 派生伪代码:

def dynamic_key(seed, idx):
    box = generate_sbox(seed)
    raw = bytes(box[i] for i in idx)
    return bytes(AES_SBOX[x] for x in raw)

这个点挺容易误判,因为 cl.key 本身看起来像一个“加密 class”,但真正有用的信息不是类名,而是 doDecrypt() 里塞的这两组常量。

解开后能对应不同 Java 版本:

Java 8  -> class major 52
Java 11 -> class major 55
Java 17 -> class major 61
Java 21 -> class major 65
Java 25 -> class major 69

0x06 VZXE 容器结构

回到 xxlib 里的加密 jar。

典型头部形态:

0000: 56 5A 58 45 ...        VZXE
0030: 63 E2 F0 B4 67 C5 ...  固定 16 字节参数
0040: 56 5A 58 45 ...        第二个 VZXE 块
0070: 58 85 B3 4B 56 87 ...  固定 16 字节参数

后来把整个结构拆出来,大致可以分成三层:

Entry records
Encrypted central directory
Footer

几个 magic:

VZXE -> entry
VZXC -> central directory
VZXF -> footer

每个 entry 的固定头大小是 48 字节:

ENTRY_FIXED_SIZE = 48

footer 固定 144 字节:

FOOTER_SIZE = 144

footer 里记录 entry 数量、central directory 偏移等信息。central directory 本身也是加密的,需要先用运行时派生出的 VZXE AES key 解开。

解析 footer 的逻辑可以写成这样:

FOOTER_SIZE = 144

footer = data[len(data) - FOOTER_SIZE:]
assert footer[0:4] == b"VZXF"

entry_count = u32(footer, 4)
central_off = u64(footer, 8)

assert data[central_off:central_off + 4] == b"VZXC"
encrypted_cen = data[central_off + 4:len(data) - FOOTER_SIZE]
central = aes_cbc_unpad(encrypted_cen, vzxe_key)

entry 头部也比较规整:

assert data[off:off + 4] == b"VZXE"
fixed = data[off:off + 48]
encrypted_body = data[off + 48:off + 48 + encrypted_size]
plain_body = aes_cbc_unpad(encrypted_body, vzxe_key)

所以 VZXE 不是 zip 魔改一点点 header,而是自己维护了一份 central directory 和 entry 结构,最后再还原成普通 zip/jar。

0x07 VZXE AES key

真正用于解 VZXE jar 的 AES key,不是 decoder.exe 里的固定 key。

它来自运行时 patched class 里的三个 key:

ClassLoader.key
URLClassPath.key
Zipx key

最终:

VZXE_AES_KEY = MD5(ClassLoader.key || URLClassPath.key || Zipx key)

对当前 Java 21 运行时,解出来是:

ClassLoader.key : 52c1e9ffb4b0293a80c7bb335852c923
URLClassPath.key: 6ef0ccd7a32e03f765c0194b8c1b6c7d
Zipx key        : 24d16b5f704614dc55cdffdfa4b4b7fb
VZXE AES key    : bf21c65121d55aae52c8ae58c5b92c66

到这里就能解释为什么单看 decoder.exe 得不到完整 jar 解密逻辑:decoder.exe 解决的是 runtime class/key material,而 VZXE 容器的 AES key 要从被注入的 patched Java class 里继续提取。

0x08 解密流程

完整解密一个 VZXE jar 的流程如下。

1. 恢复 runtime material

读取:

cl.key
cl.data
dec.key
zis.key

先解 cl.data

first = AES-CBC(cl.data, A6CBF3ACD894C5F433F3E58C801118ED)

然后按模式尝试:

passthrough
stock
dynamic

最终得到:

patched ClassLoader.class
patched URLClassPath.class

再解:

dec.key -> Source.class
zis.key -> ZipxInputStream.class

2. 提取三个 key

从 class 字节码里找 key 字段初始化。

ClassLoader.key
URLClassPath.key
Zipx key

其中 ClassLoader.keyURLClassPath.key 一般是 static key,Zipx key 可能在 Source.classZipxInputStream.class 里。

3. 计算 VZXE AES key

aes_key = MD5(ClassLoader.key || URLClassPath.key || Zipx key)

实际提 key 时不需要完整反编译 class,扫字节码就够了。思路是找 key 字段赋值前最近的 16 次 bastore

def extract_key_field_initializer(class_bytes, prefer_static=False):
    cp, methods, field_name_desc = parse_class(class_bytes)
    for method in methods:
        code = code_bytes(method.attrs["Code"])
        for pos, op in enumerate(code):
            if op not in (PUTSTATIC, PUTFIELD):
                continue
            name, desc = field_name_desc(index_after(op))
            if name == "key" and desc == "[B":
                return last_16_bastore_values_before(code, pos)

这个方法比较粗暴,但对这种“硬编码 byte[] key”的场景很好用,而且不用依赖 CFR/Procyon 反编译出来的 Java 源码是否好看。

4. 解 central directory

从文件尾部找到 VZXF,读取 central directory 偏移。

central directory 以 VZXC 开头,内容 AES 加密。

解出后得到每个 entry 的:

entry name
offset
compressed size
plain size
crc
timestamp
method

entry name 本身也可能是加密的。

5. 解每个 VZXE entry

每个 entry 以 VZXE 开头。

entry 数据经过 AES 解密后,再按 method 判断是否需要 inflate。

最后按普通 zip/jar 结构重新写出:

META-INF/MANIFEST.MF
xxx.class
resource files
...

到这里,xxlib 里的 jar 就能恢复为普通 jar。

还原时写普通 jar 的逻辑就很朴素了:

with ZipFile(out, "w") as jar:
    for entry in central.entries:
        body = decrypt_entry(entry)
        if entry.method == DEFLATED:
            body = zlib.decompress(body, -15)
        jar.writestr(entry.name, body)

真正麻烦的是前面找 key 和还原 central directory;到了这一步,基本就回到了普通 zip 工程活。

0x09 最终链路

把前面的东西串起来,最终链路大概是这样:

xx.exe
  -> Go 写的 Java Wrapper/Launcher
  -> 读取 xx.json
  -> 启动 xxjre/bin/java 或 javaw

xxjre/bin/decoder.exe
  -> Go 写的小型 JVM/classfile 解释器
  -> 解 cl.key/cl.data/dec.key/zis.key
  -> 执行 VlxDecrypt.doDecrypt([B)V

xxjre/bin/server/jvm.dll
  -> 改造过的 HotSpot
  -> ClassLoader 读取特殊 class 时返回 patched class
  -> 注入 Source.class / ZipxInputStream.class

patched ClassLoader / URLClassPath / ZipxInputStream
  -> 提供 VZXE jar 读取能力
  -> 从运行时 key material 派生 VZXE AES key

xxlib/*.jar
  -> 实际是 VZXE 容器
  -> 解 central directory
  -> 解 entry
  -> 还原普通 jar

从设计上看,它不是“给 jar 加一层 AES”这么简单,而是把启动器、JRE、ClassLoader、ZIP 输入流、加密容器几层绑在了一起。这个方案的门槛主要不在算法多复杂,而在链路长、组件多、干扰点多。

但只要把链条拆开,核心还是那几件事:

找到 key material
还原 patched class
提取 VZXE AES key
解析 VZXE 容器
还原 jar

到这里,VZXE 解密链路就基本闭环了。

0x0A 后续验证

解密链路跑通以后,后面的事情就比较顺了。

为了验证前面对格式和 key 派生的理解是否正确,可以反过来做一套最小生成流程:

1. 准备或生成 cl.key
2. 根据 cl.key 派生 runtime key
3. 生成 cl.data / dec.key / zis.key
4. 用同一套 key 加密普通 jar 为 VZXE jar
5. 放回 xxlib / xxjre 对应位置
6. 让原启动链路正常加载

这个过程里最重要的不是“把文件放回去”,而是所有地方的 key 必须一致:

runtime 里的 ClassLoader.key
runtime 里的 URLClassPath.key
Source / ZipxInputStream 里的 Zipx key
VZXE jar 使用的 AES key

只要其中一个不匹配,表现出来可能就是:

ClassNotFoundException
Zip 读取失败
VZXE central directory 解不开
或者 JVM 直接加载异常

另外,原程序里有一些试用版、完整性校验、父进程校验、JRE 版本绑定之类的逻辑。做验证时也需要注意这些运行时细节,否则很容易把问题误判成“加密格式没搞对”。

简单来说,后续验证可以分成三块:

替换逻辑:确认 JVM 加载到的是自己生成的 cl.data/dec.key/zis.key
本地生成:确认每次生成的 key material 和 VZXE AES key 能互相推导
重打包:确认加密后的 jar 能被原始启动链路正常识别和执行

整个分析下来,这套保护的核心价值不在某个单点算法,而在它把 Go wrapper、定制 JRE、patched ClassLoader、ZipxInputStream 和 VZXE 容器串成了一条比较长的链。链条长确实能挡住不少静态工具,但只要顺着运行路径把每一环拆开,最后还是会回到 class bytes、AES key 和容器格式这几个老朋友身上。


评价

怎么说呢,这个加密器的强度还好,但是性价比极低,作者的底气完全不是来自它的技术壁垒,而是来自信息差——赌别人不懂Java底层,赌别人懒得去研究如何用 JVMTI(JVM工具接口) + 自定义类加载器 实现类似的效果,不如java混淆器,你怎么加密最终都得解密,且这个加密器犯了很多如忘了去掉符号这种低级错误,在商业产品中实在不应该。


最后附上解密脚本

import argparse
import datetime
import hashlib
import pathlib
import struct
import sys
import zipfile
import zlib

try:
    from Crypto.Cipher import AES
    from Crypto.Util.Padding import unpad
except ImportError:
    raise SystemExit("pip install pycryptodome")

DEFAULT_KEY = bytes.fromhex("bf21c65121d55aae52c8ae58c5b92c66")
FIRST_KEY = bytes.fromhex("a6cbf3acd894c5f433f3e58c801118ed")
STOCK_KEY = bytes.fromhex("e24af19618e261fbe7b4394c9fe8e520")

ENTRY_MAGIC = b"VZXE"
CEN_MAGIC = b"VZXC"
FOOTER_MAGIC = b"VZXF"
FOOTER_SIZE = 144
ENTRY_FIXED = 48

AES_SBOX = bytes([
    0x63,0x7c,0x77,0x7b,0xf2,0x6b,0x6f,0xc5,0x30,0x01,0x67,0x2b,0xfe,0xd7,0xab,0x76,
    0xca,0x82,0xc9,0x7d,0xfa,0x59,0x47,0xf0,0xad,0xd4,0xa2,0xaf,0x9c,0xa4,0x72,0xc0,
    0xb7,0xfd,0x93,0x26,0x36,0x3f,0xf7,0xcc,0x34,0xa5,0xe5,0xf1,0x71,0xd8,0x31,0x15,
    0x04,0xc7,0x23,0xc3,0x18,0x96,0x05,0x9a,0x07,0x12,0x80,0xe2,0xeb,0x27,0xb2,0x75,
    0x09,0x83,0x2c,0x1a,0x1b,0x6e,0x5a,0xa0,0x52,0x3b,0xd6,0xb3,0x29,0xe3,0x2f,0x84,
    0x53,0xd1,0x00,0xed,0x20,0xfc,0xb1,0x5b,0x6a,0xcb,0xbe,0x39,0x4a,0x4c,0x58,0xcf,
    0xd0,0xef,0xaa,0xfb,0x43,0x4d,0x33,0x85,0x45,0xf9,0x02,0x7f,0x50,0x3c,0x9f,0xa8,
    0x51,0xa3,0x40,0x8f,0x92,0x9d,0x38,0xf5,0xbc,0xb6,0xda,0x21,0x10,0xff,0xf3,0xd2,
    0xcd,0x0c,0x13,0xec,0x5f,0x97,0x44,0x17,0xc4,0xa7,0x7e,0x3d,0x64,0x5d,0x19,0x73,
    0x60,0x81,0x4f,0xdc,0x22,0x2a,0x90,0x88,0x46,0xee,0xb8,0x14,0xde,0x5e,0x0b,0xdb,
    0xe0,0x32,0x3a,0x0a,0x49,0x06,0x24,0x5c,0xc2,0xd3,0xac,0x62,0x91,0x95,0xe4,0x79,
    0xe7,0xc8,0x37,0x6d,0x8d,0xd5,0x4e,0xa9,0x6c,0x56,0xf4,0xea,0x65,0x7a,0xae,0x08,
    0xba,0x78,0x25,0x2e,0x1c,0xa6,0xb4,0xc6,0xe8,0xdd,0x74,0x1f,0x4b,0xbd,0x8b,0x8a,
    0x70,0x3e,0xb5,0x66,0x48,0x03,0xf6,0x0e,0x61,0x35,0x57,0xb9,0x86,0xc1,0x1d,0x9e,
    0xe1,0xf8,0x98,0x11,0x69,0xd9,0x8e,0x94,0x9b,0x1e,0x87,0xe9,0xce,0x55,0x28,0xdf,
    0x8c,0xa1,0x89,0x0d,0xbf,0xe6,0x42,0x68,0x41,0x99,0x2d,0x0f,0xb0,0x54,0xbb,0x16,
])

class VzxeError(Exception):
    pass

def u32(buf, off):
    return struct.unpack_from(">I", buf, off)[0]

def u64(buf, off):
    return struct.unpack_from(">Q", buf, off)[0]

def s2(buf, off):
    return struct.unpack_from(">h", buf, off)[0]

def md5(data):
    return hashlib.md5(data).digest()

def rc4(data, key):
    s = list(range(256))
    j = 0
    for i in range(256):
        j = (j + s[i] + key[i % len(key)]) & 0xff
        s[i], s[j] = s[j], s[i]
    out = bytearray()
    i = j = 0
    for b in data:
        i = (i + 1) & 0xff
        j = (j + s[i]) & 0xff
        s[i], s[j] = s[j], s[i]
        out.append(b ^ s[(s[i] + s[j]) & 0xff])
    return bytes(out)

def aes_unpad(cipher, data, what):
    if not data:
        return b""
    if len(data) % AES.block_size:
        raise VzxeError(f"{what} length not aligned: {len(data)}")
    try:
        return unpad(cipher.decrypt(data), AES.block_size)
    except ValueError:
        raise VzxeError(f"unpad failed for {what}")

def aes_cbc_unpad(data, key, what):
    if not data or len(data) % AES.block_size:
        raise VzxeError(f"{what} length not aligned: {len(data)}")
    try:
        return unpad(AES.new(key, AES.MODE_CBC, key).decrypt(data), AES.block_size)
    except ValueError:
        raise VzxeError(f"unpad failed for {what}")

class ClassReader:
    def __init__(self, data):
        self.data = data
        self.pos = 0

    def read(self, n):
        out = self.data[self.pos:self.pos + n]
        if len(out) != n:
            raise VzxeError(f"truncated at {self.pos}")
        self.pos += n
        return out

    def u1(self):
        return self.read(1)[0]

    def u2(self):
        return struct.unpack(">H", self.read(2))[0]

    def u4(self):
        return struct.unpack(">I", self.read(4))[0]

VLXCC_TAGS = {
    0x54: 1, 0x59: 3, 0x25: 4, 0x68: 5, 0x7D: 6,
    0x17: 7, 0x6C: 8, 0x14: 9, 0x38: 10, 0x48: 11,
    0x6B: 12, 0x64: 15, 0x7B: 16, 0x5C: 18,
}

def decode_vlxcc_class(data):
    if data.startswith(b"VLX\x01\x00"):
        data = aes_cbc_unpad(data[5:], bytes.fromhex("f820c30dede30d7680233a4d36fdf298"), "cl.key outer")

    r = ClassReader(data)
    if r.read(4) != b"VLX\xcc":
        raise VzxeError("not VLXcc")
    out = bytearray(b"\xca\xfe\xba\xbe")
    out.extend(r.read(4))
    cp_count = r.u2()
    out.extend(struct.pack(">H", cp_count))
    names = [None] * cp_count
    i = 1
    while i < cp_count:
        tag0 = r.u1()
        tag = VLXCC_TAGS.get(tag0, tag0)
        out.append(tag)
        if tag == 1:
            length = r.u2()
            raw = r.read(length)
            if tag0 == 0x54:
                raw = aes_cbc_unpad(raw, bytes.fromhex("732a2e9d20ffb2673df2302cc53c6536"), "cl.key utf8")
            out.extend(struct.pack(">H", len(raw)))
            out.extend(raw)
            names[i] = raw.decode("utf-8", "replace")
        elif tag in (3, 4):
            out.extend(r.read(4))
        elif tag in (5, 6):
            out.extend(r.read(8))
            i += 1
        elif tag in (7, 8, 16, 19, 20):
            out.extend(r.read(2))
        elif tag in (9, 10, 11, 12, 18):
            out.extend(r.read(4))
        elif tag == 15:
            out.extend(r.read(3))
        else:
            raise VzxeError(f"unknown VLXcc tag {tag} at #{i}")
        i += 1

    out.extend(r.read(6))
    iface_count = r.u2()
    out.extend(struct.pack(">H", iface_count))
    out.extend(r.read(iface_count * 2))
    out.extend(decode_vlxcc_members(r, names))
    out.extend(decode_vlxcc_members(r, names))
    out.extend(decode_vlxcc_attrs(r, names))
    if r.pos != len(r.data):
        raise VzxeError(f"trailing bytes: {len(r.data) - r.pos}")
    return bytes(out)

def decode_vlxcc_members(r, names):
    count = r.u2()
    out = bytearray(struct.pack(">H", count))
    for _ in range(count):
        out.extend(r.read(6))
        out.extend(decode_vlxcc_attrs(r, names))
    return bytes(out)

def decode_vlxcc_attrs(r, names):
    count = r.u2()
    out = bytearray(struct.pack(">H", count))
    for _ in range(count):
        name_idx = r.u2()
        body = r.read(r.u4())
        if name_idx < len(names) and names[name_idx] == "Code":
            body = decode_vlxcc_code(body, names)
        out.extend(struct.pack(">H", name_idx))
        out.extend(struct.pack(">I", len(body)))
        out.extend(body)
    return bytes(out)

def decode_vlxcc_code(body, names):
    r = ClassReader(body)
    out = bytearray(r.read(4))
    enc = r.read(r.u4())
    code = aes_cbc_unpad(enc, bytes.fromhex("b8547970f3a07d1c221652f7b0439376"), "cl.key code")
    out.extend(struct.pack(">I", len(code)))
    out.extend(code)
    ex_count = r.u2()
    out.extend(struct.pack(">H", ex_count))
    out.extend(r.read(ex_count * 8))
    out.extend(decode_vlxcc_attrs(r, names))
    if r.pos != len(body):
        raise VzxeError(f"trailing in Code: {len(body) - r.pos}")
    return bytes(out)

def parse_class(data):
    r = ClassReader(data)
    if r.read(4) != b"\xca\xfe\xba\xbe":
        raise VzxeError("not a class")
    r.read(4)
    cp_count = r.u2()
    cp = [None] * cp_count
    i = 1
    while i < cp_count:
        tag = r.u1()
        if tag == 1:
            raw = r.read(r.u2())
            cp[i] = ("Utf8", raw.decode("utf-8", "replace"))
        elif tag in (3, 4):
            cp[i] = ("Num4", r.read(4))
        elif tag in (5, 6):
            cp[i] = ("Num8", r.read(8))
            i += 1
        elif tag in (7, 8, 16, 19, 20):
            cp[i] = (tag, r.u2())
        elif tag in (9, 10, 11):
            cp[i] = (tag, r.u2(), r.u2())
        elif tag == 12:
            cp[i] = ("NameAndType", r.u2(), r.u2())
        elif tag == 15:
            cp[i] = ("MethodHandle", r.read(3))
        elif tag == 18:
            cp[i] = ("InvokeDynamic", r.read(4))
        else:
            raise VzxeError(f"unknown cp tag {tag} at #{i}")
        i += 1

    def utf(idx):
        entry = cp[idx]
        return entry[1] if entry and entry[0] == "Utf8" else None

    def field_name_desc(idx):
        if idx <= 0 or idx >= len(cp):
            return None, None
        entry = cp[idx]
        if not entry or entry[0] != 9:
            return None, None
        nat = cp[entry[2]]
        if not nat or nat[0] != "NameAndType":
            return None, None
        return utf(nat[1]), utf(nat[2])

    r.read(6)
    for _ in range(r.u2()):
        r.read(2)
    for _ in range(r.u2()):
        r.read(6)
        skip_attrs(r)

    methods = []
    for _ in range(r.u2()):
        access = r.u2()
        name = utf(r.u2())
        desc = utf(r.u2())
        attrs = read_attrs(r, utf)
        methods.append({"access": access, "name": name, "desc": desc, "attrs": attrs})
    skip_attrs(r)
    return cp, methods, field_name_desc

def skip_attrs(r):
    for _ in range(r.u2()):
        r.read(2)
        r.read(r.u4())

def read_attrs(r, utf):
    attrs = {}
    for _ in range(r.u2()):
        name = utf(r.u2())
        body = r.read(r.u4())
        attrs[name] = body
    return attrs

def code_bytes(code_attr):
    r = ClassReader(code_attr)
    r.read(4)
    length = r.u4()
    return r.read(length)

def byte_const_before_bastore(code, pos):
    end = pos - 1 if pos >= 1 and code[pos - 1] == 0x91 else pos
    if end >= 2 and code[end - 2] == 0x10:
        return code[end - 1] & 0xff
    if end >= 3 and code[end - 3] == 0x11:
        return s2(code, end - 2) & 0xff
    if end >= 1 and 0x03 <= code[end - 1] <= 0x08:
        return code[end - 1] - 0x03
    if end >= 1 and code[end - 1] == 0x02:
        return 0xff
    return None

def int_const_before_store(code, pos):
    if pos >= 2 and code[pos - 2] == 0x10:
        return code[pos - 1] & 0xff
    if pos >= 3 and code[pos - 3] == 0x11:
        return s2(code, pos - 2) & 0xff
    if pos >= 1 and 0x03 <= code[pos - 1] <= 0x08:
        return code[pos - 1] - 0x03
    if pos >= 1 and code[pos - 1] == 0x02:
        return 0xff
    return None

def gen_vlx_sbox(seed):
    sbox = list(range(256))
    key = [seed[i % len(seed)] for i in range(256)]
    j = 0
    for i in range(256):
        j = (j + sbox[i] + key[i]) & 0xff
        sbox[i] = (sbox[i] ^ sbox[j]) & 0xff
        sbox[j] = (sbox[j] ^ sbox[i]) & 0xff
        sbox[i] = (sbox[i] ^ sbox[j]) & 0xff
    return bytes(sbox)

def extract_dynamic_clkey(cl_key):
    data = decode_vlxcc_class(cl_key)
    _, methods, _ = parse_class(data)
    method = None
    for m in methods:
        if m["name"] == "doDecrypt":
            method = m
            break
    if not method or "Code" not in method["attrs"]:
        raise VzxeError("no doDecrypt Code")
    code = code_bytes(method["attrs"]["Code"])
    seed = []
    idxs = []
    for pos, op in enumerate(code):
        if op == 0x54:
            v = int_const_before_store(code, pos)
            if v is not None:
                seed.append(v)
        elif op == 0x4f:
            v = int_const_before_store(code, pos)
            if v is not None:
                idxs.append(v)
    if len(seed) < 16 or len(idxs) < 16:
        raise VzxeError("could not extract cl.data key material")
    expanded = gen_vlx_sbox(bytes(seed[:16]))
    key = bytes(expanded[idx] for idx in idxs[:16])
    return bytes(AES_SBOX[x] for x in key)

def find_last_array_before(code, end, size=16):
    vals = []
    for pos in range(end):
        if code[pos] != 0x54:
            continue
        v = byte_const_before_bastore(code, pos)
        if v is not None:
            vals.append(v)
    if len(vals) < size:
        return None
    return bytes(vals[-size:])

def extract_key_init(data, prefer_static=False):
    _, methods, fnd = parse_class(data)
    candidates = []
    for m in methods:
        body = m["attrs"].get("Code")
        if not body:
            continue
        code = code_bytes(body)
        pos = 0
        while pos + 2 < len(code):
            op = code[pos]
            if op in (0xb3, 0xb5):
                idx = struct.unpack_from(">H", code, pos + 1)[0]
                name, desc = fnd(idx)
                if name == "key" and desc == "[B" and ((op == 0xb3) == prefer_static or not prefer_static):
                    arr = find_last_array_before(code, pos)
                    if arr:
                        candidates.append((m["name"], op, arr))
                pos += 3
            else:
                pos += 1
    if not candidates:
        kind = "static " if prefer_static else ""
        raise VzxeError(f"could not extract {kind}key")
    if prefer_static:
        for c in candidates:
            if c[1] == 0xb3:
                return c[2]
    return candidates[0][2]

def parse_cl_bundle(bundle):
    if len(bundle) < 12:
        return None
    cl_len = u32(bundle, 0)
    if cl_len <= 0 or 4 + cl_len + 4 > len(bundle):
        return None
    cl = bundle[4:4 + cl_len]
    ucp_len = u32(bundle, 4 + cl_len)
    end = 8 + cl_len + ucp_len
    if ucp_len <= 0 or end != len(bundle):
        return None
    ucp = bundle[8 + cl_len:end]
    if not cl.startswith(b"\xca\xfe\xba\xbe") or not ucp.startswith(b"\xca\xfe\xba\xbe"):
        return None
    return cl, ucp

def decrypt_key_file(path, cl_key):
    return rc4(pathlib.Path(path).read_bytes(), md5(cl_key)[::-1])

def recover_runtime(jre_bin):
    jre = pathlib.Path(jre_bin)
    cl_key = (jre / "cl.key").read_bytes()
    cl_data = (jre / "cl.data").read_bytes()

    first = aes_cbc_unpad(cl_data, FIRST_KEY, "cl.data first")
    candidates = [("passthrough", first)]
    try:
        candidates.append(("stock", aes_cbc_unpad(first, STOCK_KEY, "cl.data stock")))
    except VzxeError:
        pass
    try:
        dyn = extract_dynamic_clkey(cl_key)
        candidates.append(("dynamic", aes_cbc_unpad(first, dyn, "cl.data dynamic")))
    except VzxeError:
        pass

    mode = "unknown"
    payload = None
    for m, p in candidates:
        bundle = rc4(p, md5(cl_key))
        parsed = parse_cl_bundle(bundle)
        if parsed:
            mode, payload = m, parsed
            break
    if payload is None:
        raise VzxeError("could not recover ClassLoader bundle")

    cl, ucp = payload
    source = decrypt_key_file(jre / "dec.key", cl_key)
    zis = decrypt_key_file(jre / "zis.key", cl_key)
    return {
        "mode": mode,
        "classloader": cl,
        "urlclasspath": ucp,
        "source": source,
        "zipxinputstream": zis,
    }

def get_vzxe_key(jre_bin, dump_dir=None):
    material = recover_runtime(jre_bin)
    cl_key = extract_key_init(material["classloader"], prefer_static=True)
    ucp_key = extract_key_init(material["urlclasspath"], prefer_static=True)
    try:
        zipx_key = extract_key_init(material["source"], prefer_static=False)
    except VzxeError:
        zipx_key = extract_key_init(material["zipxinputstream"], prefer_static=False)
    key = md5(cl_key + ucp_key + zipx_key)

    if dump_dir:
        out = pathlib.Path(dump_dir)
        out.mkdir(parents=True, exist_ok=True)
        (out / "vlx_patched_ClassLoader.class").write_bytes(material["classloader"])
        (out / "vlx_patched_URLClassPath.class").write_bytes(material["urlclasspath"])
        (out / "Source.class").write_bytes(material["source"])
        (out / "ZipxInputStream.class").write_bytes(material["zipxinputstream"])

    return key, {
        "cl_mode": material["mode"],
        "classloader_key": cl_key,
        "urlclasspath_key": ucp_key,
        "zipx_key": zipx_key,
    }

def zip_timestamp(ts):
    if not ts:
        return (1980, 1, 1, 0, 0, 0)
    seconds = ts / 1000.0 if ts > 10_000_000_000 else float(ts)
    try:
        dt = datetime.datetime.fromtimestamp(seconds)
    except (OverflowError, OSError, ValueError):
        return (1980, 1, 1, 0, 0, 0)
    if dt.year < 1980:
        return (1980, 1, 1, 0, 0, 0)
    if dt.year > 2107:
        return (2107, 12, 31, 23, 59, 58)
    return (dt.year, dt.month, dt.day, dt.hour, dt.minute, dt.second)

def parse_cen(buf, cipher):
    if len(buf) < FOOTER_SIZE:
        raise VzxeError("file too small")
    footer = len(buf) - FOOTER_SIZE
    if buf[footer:footer + 4] != FOOTER_MAGIC:
        raise VzxeError("missing VZXF")

    count = u32(buf, footer + 4)
    cen_off = u64(buf, footer + 8)
    if cen_off >= len(buf):
        raise VzxeError(f"CEN offset out of range: 0x{cen_off:x}")
    if buf[cen_off:cen_off + 4] != CEN_MAGIC:
        raise VzxeError(f"missing VZXC at 0x{cen_off:x}")

    pos = cen_off + 4
    entries = []
    for idx in range(count):
        if pos + ENTRY_FIXED > len(buf):
            raise VzxeError(f"truncated entry #{idx}")
        if buf[pos:pos + 4] != ENTRY_MAGIC:
            raise VzxeError(f"missing VZXE at 0x{pos:x}")

        ts = u64(buf, pos + 4)
        crc = u64(buf, pos + 12)
        csize = u64(buf, pos + 20)
        size = u64(buf, pos + 28)
        off = u64(buf, pos + 36)
        name_len = u32(buf, pos + 44)
        name_start = pos + ENTRY_FIXED
        name_end = name_start + name_len
        if name_end > len(buf):
            raise VzxeError(f"truncated name for entry #{idx}")

        name = aes_unpad(cipher, buf[name_start:name_end], f"entry {idx}").decode("utf-8")
        entries.append({
            "idx": idx,
            "name": name,
            "ts": ts,
            "crc": crc & 0xffffffff,
            "csize": csize,
            "size": size,
            "off": off,
        })
        pos = name_end
    return entries

def read_entry(buf, entry, cipher):
    off = entry["off"]
    if off + ENTRY_FIXED > len(buf):
        raise VzxeError(f"{entry['name']}: local header outside file")
    if buf[off:off + 4] != ENTRY_MAGIC:
        raise VzxeError(f"{entry['name']}: missing VZXE at 0x{off:x}")

    name_len = u32(buf, off + 44)
    data_start = off + ENTRY_FIXED + name_len
    data_end = data_start + entry["csize"]
    if data_end > len(buf):
        raise VzxeError(f"{entry['name']}: data truncated")
    if entry["size"] == 0 and entry["csize"] == 0:
        return b""

    packed = aes_unpad(cipher, buf[data_start:data_end], entry["name"])
    try:
        data = zlib.decompress(packed)
    except zlib.error:
        data = packed

    if len(data) != entry["size"]:
        raise VzxeError(f"{entry['name']}: size mismatch")
    if (zlib.crc32(data) & 0xffffffff) != entry["crc"]:
        raise VzxeError(f"{entry['name']}: CRC mismatch")
    return data

def decrypt_vzxe(src, dst, key):
    buf = pathlib.Path(src).read_bytes()
    cipher = AES.new(key, AES.MODE_ECB)
    entries = parse_cen(buf, cipher)

    pathlib.Path(dst).parent.mkdir(parents=True, exist_ok=True)
    with zipfile.ZipFile(dst, "w") as zf:
        for entry in entries:
            info = zipfile.ZipInfo(entry["name"])
            info.date_time = zip_timestamp(entry["ts"])
            info.external_attr = 0o40755 << 16 if entry["name"].endswith("/") else 0o100644 << 16

            data = b"" if entry["name"].endswith("/") else read_entry(buf, entry, cipher)
            info.compress_type = zipfile.ZIP_DEFLATED if data else zipfile.ZIP_STORED
            zf.writestr(info, data)
    return entries

def main():
    parser = argparse.ArgumentParser(description="vzxe decrypt")
    parser.add_argument("inputs", nargs="+", help="files to decrypt")
    parser.add_argument("-o", "--out-dir", default=".analysis_decrypted")
    parser.add_argument("-k", "--key", help="AES key in hex")
    parser.add_argument("--jre-bin", help="derive key from vlxjre/bin")
    parser.add_argument("--dump-runtime", help="dump runtime classes to dir")
    args = parser.parse_args()

    if args.jre_bin:
        key, info = get_vzxe_key(args.jre_bin, args.dump_runtime)
        print(f"[key] mode: {info['cl_mode']}")
        print(f"[key] ClassLoader.key: {info['classloader_key'].hex()}")
        print(f"[key] URLClassPath.key: {info['urlclasspath_key'].hex()}")
        print(f"[key] Zipx key: {info['zipx_key'].hex()}")
        print(f"[key] AES key: {key.hex()}")
    else:
        key = bytes.fromhex(args.key or DEFAULT_KEY.hex())
    if len(key) != 16:
        raise SystemExit("key must be 16 bytes")

    out_dir = pathlib.Path(args.out_dir)
    for src in args.inputs:
        src_path = pathlib.Path(src)
        suffix = ".decrypted.jar" if src_path.suffix.lower() in (".jar", ".jarx") else ".decrypted.zip"
        dst = out_dir / (src_path.stem + suffix)
        entries = decrypt_vzxe(src, dst, key)
        print(f"{src} -> {dst} ({len(entries)} entries)")

if __name__ == "__main__":
    main()

发帖前要善用论坛搜索功能,那里可能会有你要找的答案或者已经有人发布过相同内容了,请勿重复发帖。

您需要登录后才可以回帖 登录 | 注册[Register]

本版积分规则

返回列表

RSS订阅|小黑屋|处罚记录|联系我们|吾爱破解 - 52pojie.cn ( 京ICP备16042023号 | 京公网安备 11010502030087号 )

GMT+8, 2026-7-10 15:00

Powered by Discuz!

Copyright © 2001-2020, Tencent Cloud.

快速回复 返回顶部 返回列表