0x00 介绍
这不是新思路,但网上的文章不够深入和详细,因此有了这篇文章
上周偶然看到一篇文章
https://juejin.cn/post/6844903487784894477
以及 Github 仓库代码
https://github.com/sea-boat/ByteCodeEncrypt
感觉是一个很有趣的项目,对于 Jar 包以及 Class 的保护通常是使用 ProGuard 等工具,对 Class 文件本身的混淆。而该文章提到了一种更巧妙的办法,总体来说是以下的思路:
用 C 编写加密算法,调用 JNI 加密指定类名的 Class 文件并保存
启动 JVM 时利用 JVMTI 在加载 Class 文件时解密
参考原作者文章:利用JDK中JVM的某些类似钩子机制和事件监听机制,监听加载 Class 事件,使用本地方式完成 Class 的解密。C/C++ 被编译后想要反编译就很麻烦了,另外还能加壳
可以看到加密后的 Class 文件不是合法字节码文件(开头魔数做了特殊处理,让这个文件看起来是 Class 文件)
原文章和原项目有一些小问题:
原文章固定了包名,用户想加密自己的包名需要重新编译 DLL
原文章加密和解密 DLL 是同一个,这样只用 JNI 调用下加密即可解决
原文章的代码仅是 Demo 级别,无法直接上手测试和使用
原文章没有加入具体的加密算法,仅是简单的运算,需要加强
原文章的代码存在一些 BUG 和优化空间
补充:原文章没有提到这种加密如何绕过
这个思路很有意思,于是我打算深入研究下,在原作者文章基础上做一些详细的补充,并且写一些代码,尝试做一个可以直接使用的工具
0x01 JVMTI
这里我们先看一下 JVMTI 的功能,学新技术最好的办法是看官方文档
https://docs.oracle.com/javase/8/docs/platform/jvmti/jvmti.html
官方文档较长,参考原作者的介绍:
JVMTI即JVM Tool Interface,提供了本地编程接口,主要是提供了调试和分析等接口。JVMTI非常强大,通过它能做很多事,比如可以监听某事件、线程分析等等。一般使用Agent方式来使用,就是通过-agentlib和-agentpath指定Agent的本地库,然后Java启动时就会加载该动态库。这个时刻可以看成是JVM启动的时刻,而并非是Java层程序启动时刻,所以此时不涉及与Java相关的类和对象什么的。
简单来说,如果是启动阶段的 Agent 必须导出 Agent_Onload
注意到我们是可以通过 agentlib 传递参数的,这里指出参数通过 char *options 传递,这个点的意义在于:可以传入具体的包名,决定通过 JVMTI 解密的包名是什么
注意到下文有一个 Agent_OnAttach 函数,为什么不使用该函数?这个函数是用于 Attach 功能实时代理,也就是 Java Agent 所说的动态 Agent 类型。我们修改字节码显然不可以是动态的,而是需要在启动时处理
文档提出三种字节码检测操作,第一种方式官方的形容是 extremely awkward 忽略不考虑,第二种是加载时检测,第三种是动态监测,看起来是动态 Java Agent 的 RetransformClass 功能。我们的需要正好符合了第二条加载时检测:在 JVM 真正加载 Class 之前,把 Class 文件解密了
文档提到需要关注的事件是 ClassFileLoadHook
当 VM 获取类文件数据时但在构造该类的内存中表示之前发送此事件
参考上图这个点需要重点关注两件事:
必须使用 allocate 函数为修改后的类文件数据缓冲区分配空间
如果修改类文件必须修改 new_class_data 指向新 buffer
ClassFileLoadHook 对应的事件如下图,使用函数 SetEventNotificationMode 即可使事件通知生效
另外 JVMTI 有一个重要结构叫做 jvmtiCapabilities (能力)
大致意思为每个 JVMTI 可以添加具体的功能,官方提到不建议开启全部的功能,因为可能会因其未使用的功能而遭受性能损失
原文代码开启了多个功能,实际上翻阅功能文档,我们需要的只是以下
翻译为代码则是
capabilities.can_generate_all_class_hook_events = 1;
读到这里,我们以及了解了如何通过 JVMTI 解密字节码了
0x02 JVMTI 代码
有了上一章的内容,现在我们编写一个 agentlib dll 库将会很简单
在代码仓库的 start.c 文件中,开头 50 行可能看起来复杂,其实功能很简单。拿到 options 数据,根据 = 号分割,替换包名中的 . 为 / 符号。不得不说,C 语言写这样简单的一个逻辑都得几十行,Go/Java 可能只用几行
第一步初始化 JVMTI
jint ret = (*vm)->GetEnv(vm, (void **) &jvmti, JVMTI_VERSION);
这里的需要用 JVMTI_VERSION 变量,这个版本是导致不同 JVM 版本无法兼容 JVMTI DLL 文件的原因,例如 JDK-11 和 JDK-8 的 JNI.h 头文件中,这个值不一样。如果你想做不同 JDK 版本的库,需要另外编译
第二步设置 JVMTI 能力
LOG("INIT JVMTI CAPABILITIES");
jvmtiCapabilities capabilities;
(void) memset(&capabilities, 0, sizeof(capabilities));
capabilities.can_generate_all_class_hook_events = 1;
LOG("ADD JVMTI CAPABILITIES");
jvmtiError error = (*jvmti)->AddCapabilities(jvmti, &capabilities);
if (JVMTI_ERROR_NONE != error) {
printf("ERROR: Unable to AddCapabilities JVMTI!\n");
return error;
}
上文讨论过,我们 ClassFileLoadHook 功能仅需要开启 can_generate_all_class_hook_events 能力,其他能力开启会消耗性能
第三步设置回调
调用JVMTI提供的SetEventCallbacks函数,向JVMTI注册事件回调
这里的 ClassDecryptHook 函数后续介绍
LOG("INIT JVMTI CALLBACKS");
jvmtiEventCallbacks callbacks;
(void) memset(&callbacks, 0, sizeof(callbacks));
LOG("SET JVMTI CLASS FILE LOAD HOOK");
callbacks.ClassFileLoadHook = &ClassDecryptHook;
error = (*jvmti)->SetEventCallbacks(jvmti, &callbacks, sizeof(callbacks));
if (JVMTI_ERROR_NONE != error) {
printf("ERROR: Unable to SetEventCallbacks JVMTI!\n");
return error;
}
第四步设置事件通知模式
上文已经提到 ClassFileLoadHook 需要设置某个 Event 后才会生效,对应的代码如下
error = (*jvmti)->SetEventNotificationMode(jvmti, JVMTI_ENABLE, JVMTI_EVENT_CLASS_FILE_LOAD_HOOK, NULL);
if (JVMTI_ERROR_NONE != error) {
printf("ERROR: Unable to SetEventNotificationMode JVMTI!\n");
return error;
}
LOG("INIT JVMTI SUCCESS");
最后一步是 ClassFileLoadHook 回调
函数定义和两处关键点参考 JVMTI 文档:使用 Allocate 分配内存;修改 new_class_data 指向的 buffer 内容。_data 是指向 new_class_data 的指针。当你修改 _data 所指向的内存地址的内容时,new_class_data 会发生变化,因为它们指向同一个内存地址
如果包名匹配到传入的参数,才会进行解密处理,否则正常执行
void JNICALL ClassDecryptHook(
jvmtiEnv *jvmti_env,
JNIEnv *jni_env,
jclass class_being_redefined,
jobject loader,
const char *name,
jobject protection_domain,
jint class_data_len,
const unsigned char *class_data,
jint *new_class_data_len,
unsigned char **new_class_data) {
*new_class_data_len = class_data_len;
(*jvmti_env)->Allocate(jvmti_env, class_data_len, new_class_data);
unsigned char *_data = *new_class_data;
if (name && strncmp(name, PACKAGE_NAME, strlen(PACKAGE_NAME)) == 0) {
for (int i = 0; i < class_data_len; i++) {
_data[i] = class_data[i];
}
// ...
decrypt((unsigned char *) _data, class_data_len);
} else {
for (int i = 0; i < class_data_len; i++) {
_data[i] = class_data[i];
}
}
}
0x03 加密解密代码
以上已经有了 JVMTI 解密部分的核心代码,还差具体的加密解密代码
使用 DES/AES 是一种办法,但是使用 C 实现起来比较复杂,也可以考虑使用 OpenSSL 来做,笔者这里抛砖引玉,具体加密解密可以自行发挥
我选择的加密解密算法是:XXTEA 算法 结合 位运算加密
选择 XXTEA 算法由于其 C 实现代码比较简单,且有一定的强度
void tea_encrypt(uint32_t *v, const uint32_t *k) {
uint32_t v0 = v[0], v1 = v[1], sum = 0, i;
uint32_t delta = 0x9e3779b9;
uint32_t k0 = k[0], k1 = k[1], k2 = k[2], k3 = k[3];
for (i = 0; i < 32; i++) {
sum += delta;
v0 += ((v1 << 4) + k0) ^ (v1 + sum) ^ ((v1 >> 5) + k1);
v1 += ((v0 << 4) + k2) ^ (v0 + sum) ^ ((v0 >> 5) + k3);
}
v[0] = v0;
v[1] = v1;
}
void tea_decrypt(uint32_t *v, const uint32_t *k) {
uint32_t v0 = v[0], v1 = v[1], sum = 0xC6EF3720, i;
uint32_t delta = 0x9e3779b9;
uint32_t k0 = k[0], k1 = k[1], k2 = k[2], k3 = k[3];
for (i = 0; i < 32; i++) {
v1 -= ((v0 << 4) + k2) ^ (v0 + sum) ^ ((v0 >> 5) + k3);
v0 -= ((v1 << 4) + k0) ^ (v1 + sum) ^ ((v1 >> 5) + k1);
sum -= delta;
}
v[0] = v0;
v[1] = v1;
}
以上代码来源于网上博客,加密的要求是:输入两个 32 位数,提供四个 32 位数作为密钥,加密得到两个 32 位数
具体的加密还需要特殊处理:需要想办法把 char* 转为 int32 类型,加密完需要把 int32 类型转回 char* 再写入原 chars
具体的 convert 和 revert 函数参考代码仓库,主要是一些位运算
这里暂固定密钥 Y4Sec-Team-4ra1n(这个可以由 options 参数传入)
void internal(unsigned char *chars, int start) {
unsigned char first[4];
for (int i = start; i < start + 4; i++) {
first[i - start] = chars[i];
}
unsigned char second[4];
for (int i = start + 4; i < start + 8; i++) {
second[i - start - 4] = chars[i];
}
uint32_t v[2] = {convert(first), convert(second)};
// key: Y4Sec-Team-4ra1n
// 59345365 632D5465 616D2D34 7261316E
uint32_t const k[4] = {
(unsigned int) 0x65533459, (unsigned int) 0x65542d63,
(unsigned int) 0X342d6d61, (unsigned int) 0x6e316172,
};
tea_encrypt(v, k);
unsigned char first_arr[4];
unsigned char second_arr[4];
revert(v[0], first_arr);
revert(v[1], second_arr);
for (int i = start; i < start + 4; i++) {
chars[i] = first_arr[i - start];
}
for (int i = start + 4; i < start + 8; i++) {
chars[i] = second_arr[i - start - 4];
}
}
按照网上这份 XXTEA 加密代码来看,一次 XXTEA 加密的数据仅 8 个字节,想要完全使用 XXTEA 加密,需要按照每次 8 个的顺序循环加密字节码。这里我仅选择字节码关键部分,加密三组 24 字节。读者可以自行拓展,从这里直接加密到结束,完全加密
关键部分的选择我考虑从第 10 个字节开始,加密到第 32 个字节
参考 ClassFile 结构,magic/version/count 部分不包含敏感信息,没有必要进行加密,从第 10 个字节开始常量池包含了敏感信息,以此开始
// ClassFile {
// u4 magic; (ignore)
// u2 minor_version; (ignore)
// u2 major_version; (ignore)
// u2 constant_pool_count; (ignore)
// cp_info constant_pool[constant_pool_count-1];
// ...
// }
于是 JNI 加密的代码如下
JNIEXPORT jbyteArray JNICALL Java_org_y4sec_encryptor_core_CodeEncryptor_encrypt
(JNIEnv *env, jclass cls, jbyteArray text, jint length) {
jbyte *data = (*env)->GetByteArrayElements(env, text, NULL);
unsigned char *chars = (unsigned char *) malloc(length);
memcpy(chars, data, length);
// 1. asm encrypt
encrypt(chars, length);
LOG("ASM ENCRYPT FINISH");
// 2. tea encrypt
if (length < 34) {
LOG("ERROR: BYTE CODE TOO SHORT");
return text;
}
// {[10:14],[14:18]}
internal(chars, 10);
LOG("TEA ENCRYPT #1");
// {[18:22],[22:26]}
internal(chars, 18);
LOG("TEA ENCRYPT #2");
// {[26:30],[30:34]}
internal(chars, 26);
LOG("TEA ENCRYPT #3");
(*env)->SetByteArrayRegion(env, text, 0, length, (jbyte *) chars);
return text;
}
代码第一步我先对完整字节码做了位运算加密
// 1. asm encrypt
encrypt(chars, length);
LOG("ASM ENCRYPT FINISH");
这部分代码由汇编编写,核心部分如下
- 遍历 char* 字节码,对每一位进行位运算
- 位运算主要包含多次抑或,加减,非操作
link_start:
; if rbx >= rcx goto end
cmp rbx, rcx
jge magic
; al = str[rdi+rbx]
mov al, byte ptr [rdi+rbx]
; al = al - 2
sub al, 002h
; al = al ^ 11h
xor al, 011h
; al = ~al
not al
; al = al + 1
add al, 001h
; al = al ^ 22
xor al, 022h
; str[rdi+rbx] = al
mov byte ptr [rdi+rbx], al
; ebx ++
inc rbx
; loop
jmp link_start
位运算结束后,我特殊处理了 MAGIC 头,使开头按照 JAVA MAGIC 的 CAFE BABE 格式,起到一定程度的混淆
magic:
; magic
mov al, 0CAh
mov byte ptr [rdi+000h], al
mov al, 0FEh
mov byte ptr [rdi+001h], al
mov al, 0BAh
mov byte ptr [rdi+002h], al
mov al, 0BEh
mov byte ptr [rdi+003h], al
在结束时,我做了最后一层加密:取第4位直接和末尾字节交换。简单的字节交换也会导致字节码无法执行和解析报错
; signature
mov rsi, rcx
sub rsi, 001h
mov al, byte ptr [rdi+rsi]
mov ah, byte ptr [rdi+004h]
mov byte ptr [rdi+004h], al
mov byte ptr [rdi+rsi], ah
; reset
xor ah, ah
xor al, al
xor rsi, rsi
0x04 工程化
加密代码如上,解密代码只要你过来即可,可以参考代码仓库,这里不再提及了。接下来我们看 Java 层的代码,逻辑很简单,读取输入 Jar 包,其中匹配到我们期望 PACKAGE NAME 的类调用 JNI 加密方法进行加密,然后把结果写入新的 Jar 包即可
// ...
while (enumeration.hasMoreElements()) {
JarEntry entry = enumeration.nextElement();
InputStream is = srcJar.getInputStream(entry);
int len;
while ((len = is.read(buf, 0, buf.length)) != -1) {
bao.write(buf, 0, len);
}
byte[] bytes = bao.toByteArray();
String name = entry.getName();
if (name.startsWith(packageName)) {
if (name.toLowerCase().endsWith(ClassFile)) {
try {
bytes = CodeEncryptor.encrypt(bytes, bytes.length);
} catch (Exception e) {
logger.error("encrypt error: {}", e.toString());
return;
}
}
}
// ...
}
Java 的 JNI 加载其实是有一些坑的,比如 System.loadLibrary 方法是不支持绝对路径的,只能从系统 lib 以及当前目录加载。这里 JNIUtil.java 我做了一些魔法操作,使得可以加载任意路径的 dll 文件
最终使用一个简单的命令即可加密 Jar 包
java -jar code-encryptor-plus.jar patch --jar your-jar.jar --package com.your.pack
导出解密 DLL 文件:(默认导出到code-encryptor-plus-temp目录)
java -jar code-encryptor-plus.jar export
使用解密DLL启动Jar包:(使用-agentlib参数)
由于 agentlib 的特性,要求必须是绝对路径的 DLL 且结尾去掉 DLL 才可
java -agentlib:D:\abs-path\decrypter=PACKAGE_NAME=com.your.pack --jar your-jar.jar
另外支持了简易的GUI版本,选择需要加密的Jar文件即可一键加密
简单实践一下,加密我自己写的 Fake MySQL Server
java -jar .\code-encryptor-plus-0.0.1-cli.jar patch --jar .\fake-mysql-gui-0.0.3.jar --package me.n1ar4
如果直接使用 java -jar 启动加密后的 jar 包,报错
导出解密 dll 文件
java -jar .\code-encryptor-plus-0.0.1-cli.jar export
使用 agentlib 加载解密 dll 文件启动 jar 包,成功启动
java -agentlib:C:\JavaCode\code-encryptor-plus\target\code-encryptor-plus-temp\decrypter=PACKAGE_NAME=me.n1ar4 -jar .\fake-mysql-gui-0.0.3_enc
rypted.jar
0x05 拓展
如何破解这种加密呢
对于位加密来说,通过 x64dbg 手动调试看汇编是一种办法
通过 IDA F5 能看到更友好的代码
位加密可以很简单的破解
而 XXTEA 加密会稍微复杂一些
可以动态的方式拿到密钥,结合上面的算法进行解密
逆向来做是走了弯路,另有路子解决这种办法
使用 sa-jdi.jar 的 HSDB 即可
java -cp "C:\Program Files\Java\jdk1.8.0_131\lib\sa-jdi.jar" sun.jvm.hotspot.HSDB
使用以下方式即可拿到 Class
sa-jdi.jar 默认会把 class 保存到 C:\User 里
最终成功拿到了代码
HSDB 的缺点是:
- 程序完全卡死,这对于某些业务来说是不可取的行为
- 只能拿到部分 Class 而不是所有的 Class
HSDB只能查看已经加载到JVM中的类,如果某个类尚未被加载,HSDB将无法访问该类的调试信息
0x06 总结
这种加密方式是比较有意思的,我在原作者的基础上,做了进一步的拓展,详细讲解了 JVMTI 部分的代码。其中加密算法部分也是抛砖引玉,使用经典的 XXTEA 算法和位加密。其中还有进一步拓展的地方:比如汇编加密解密算法部分加入花指令,让逆向人员头疼;比如 DLL 可以使用 OLLVM/VMP 混淆
完整的项目代码在
https://github.com/Y4Sec-Team/code-encryptor-plus