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
流程大致是:
- 获取 exe 所在目录。
- 读取同名 JSON 配置,例如
xx.json。
- JSON 反序列化到
xx.io/java-wrapper/settings.Settings。
- 根据配置拼接 Java 启动参数。
- 启动
xxjre/bin/java.exe 或 javaw.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.key、zis.key 和 io/xx/zipx/ZipxInputStream.class,但没有很直白的 VZXE 引用。这种情况一般有两种可能:
- magic 按整数常量比较。
- 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.key 或 zis.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.key 和 zis.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.key 和 URLClassPath.key 一般是 static key,Zipx key 可能在 Source.class 或 ZipxInputStream.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()