长亭百川云 - 文章详情

JVMTI 加密字节码详解

Y4Sec Team

75

2024-07-13

0x00 介绍

这不是新思路,但网上的文章不够深入和详细,因此有了这篇文章

上周偶然看到一篇文章 

https://juejin.cn/post/6844903487784894477

以及 Github 仓库代码

https://github.com/sea-boat/ByteCodeEncrypt

感觉是一个很有趣的项目,对于 Jar 包以及 Class 的保护通常是使用 ProGuard 等工具,对 Class 文件本身的混淆。而该文章提到了一种更巧妙的办法,总体来说是以下的思路:

  1.  用 C 编写加密算法,调用 JNI 加密指定类名的 Class 文件并保存

  2.  启动 JVM 时利用 JVMTI 在加载 Class 文件时解密

参考原作者文章:利用JDK中JVM的某些类似钩子机制和事件监听机制,监听加载 Class 事件,使用本地方式完成 Class 的解密。C/C++ 被编译后想要反编译就很麻烦了,另外还能加壳

可以看到加密后的 Class 文件不是合法字节码文件(开头魔数做了特殊处理,让这个文件看起来是 Class 文件)

原文章和原项目有一些小问题:

  1. 原文章固定了包名,用户想加密自己的包名需要重新编译 DLL

  2. 原文章加密和解密 DLL 是同一个,这样只用 JNI 调用下加密即可解决

  3. 原文章的代码仅是 Demo 级别,无法直接上手测试和使用

  4. 原文章没有加入具体的加密算法,仅是简单的运算,需要加强

  5. 原文章的代码存在一些 BUG 和优化空间

  6. 补充:原文章没有提到这种加密如何绕过

这个思路很有意思,于是我打算深入研究下,在原作者文章基础上做一些详细的补充,并且写一些代码,尝试做一个可以直接使用的工具

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 获取类文件数据时但在构造该类的内存中表示之前发送此事件

参考上图这个点需要重点关注两件事:

  1.  必须使用 allocate 函数为修改后的类文件数据缓冲区分配空间

  2.  如果修改类文件必须修改 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

相关推荐
关注或联系我们
添加百川云公众号,移动管理云安全产品
咨询热线:
4000-327-707
百川公众号
百川公众号
百川云客服
百川云客服

Copyright ©2024 北京长亭科技有限公司
icon
京ICP备 2024055124号-2