Java 代码解密:使用 Frida 还原 JVMTI Agent 加密保护的java类&Linux 环境下的Frida 使用

admin 2024年11月10日16:00:37评论15 views字数 10132阅读33分46秒阅读模式

从一个奇怪jar开始の奇妙分析

在一次平常的代码审计中,我在尝试反编译一个 JAR 文件,发现大部分的CLASS反编译失败了,返回的结果一片空白

Java 代码解密:使用 Frida 还原 JVMTI Agent 加密保护的java类&Linux 环境下的Frida 使用

这一定是被什么东西加密了,平常也经常遇到java agent的加密,先检查一下JVM启动参数

/bin/java -- -Dsun.misc.URLClassPath.disableJarChecking=true -agentpath:./xxxagent.so

但是这次好像不太一样,agentpath 参数对应的不是JAR 而是一个so库,这里只能先试着逆着看看了

Java 代码解密:使用 Frida 还原 JVMTI Agent 加密保护的java类&Linux 环境下的Frida 使用

从零开始の代码还原

SO分析

接下来就开始使用ida分析这个so了

Java 代码解密:使用 Frida 还原 JVMTI Agent 加密保护的java类&Linux 环境下的Frida 使用

这看起来不太妙

Java 代码解密:使用 Frida 还原 JVMTI Agent 加密保护的java类&Linux 环境下的Frida 使用

IDA仅识别出了一部分函数,并且大多数函数体无法正确反编译。我不太擅长逆向,这里节约时间只能使用其他方法了。

JVMTI

Java 代码解密:使用 Frida 还原 JVMTI Agent 加密保护的java类&Linux 环境下的Frida 使用

不同于常见的Java Agent ,目标项目使用了一种更底层的Agent类型 JVM Tool Interface (JVM TI) Agent。它是通过JVM导出的C++ API 对JVM 进行操作,来实现某些特定功能。

通过阅读JVMTI的相关文档发现 JVMTI Agent 可以通过注册 ClassFileLoadHook 来拦截 JVM 加载的类文件,从而实现在类加载时对其进行解密。

//场景的伪代码示例
void JNICALL ClassFileLoadHook(jvmtiEnv *jvmti_env, JNIEnv *jni_env, jclass class_being_loaded,
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) {
// 在这里可以插入解密代码, 它会加载class文件之前进行解密操作

std::cout << "Class file loaded: " << name << std::endl;

// 例如,解密后的字节码存放到new_class_data中
*new_class_data_len = class_data_len; // 设置新的字节码长度
*new_class_data = const_cast<unsigned char *>(class_data); // 这里假设没有改变数据

// 继续加载原始或解密后的class数据
}
jvmtiEnv *jvmti = nullptr;
JavaVM *jvm = nullptr;
JNIEXPORT jint JNICALL Agent_OnLoad(JavaVM *vm, char *options, void *reserved) {
// 获取jvmti环境
jint res = vm->GetEnv(reinterpret_cast<void **>(&jvmti), JVMTI_VERSION);
if (res != JNI_OK || jvmti == nullptr) {
std::cerr << "Error: Unable to access JVMTI version." << std::endl;
return res;
}

// 注册ClassFileLoadHook回调
jvmtiEventCallbacks callbacks = {0};
callbacks.ClassFileLoadHook = &ClassFileLoadHook;

res = jvmti->SetEventCallbacks(&callbacks, sizeof(callbacks));
if (res != JVMTI_ERROR_NONE) {
std::cerr << "Error: Unable to set event callbacks." << std::endl;
return res;
}

// 启动ClassFileLoadHook事件
res = jvmti->SetEventNotificationMode(JVMTI_EVENT_CLASS_FILE_LOAD, JVMTI_ENABLE, nullptr);
if (res != JVMTI_ERROR_NONE) {
std::cerr << "Error: Unable to enable class file load event." << std::endl;
return res;
}

return JNI_OK;
}

显然,我们只要 HOOK Agent中定义的 ClassFileLoadHook函数就可以拿到解密后的class字节码。

但是加壳后Agent 的解密函数难以定位,这里我们只能转到JVM的代码中分析了。

JVM

目标使用了Open JDK,到官网就可以下到历史版本的源码(Oracle JDK 并没有提供JVM部分的源码)。

简单的搜索了下JVM的源码,通过API关键字定位到了一个函数hotspotsrcsharevmprimsjvmtiExport.cpp:post_to_env

  void post_to_env(JvmtiEnv* env, bool caching_needed) {
unsigned char *new_data = NULL;
jint new_len = 0;
/*......*/
JvmtiClassFileLoadEventMark jem(_thread, _h_name, _class_loader,
_h_protection_domain,
_h_class_being_redefined);
JvmtiJavaThreadEventTransition jet(_thread);
JNIEnv* jni_env = (JvmtiEnv::get_phase() == JVMTI_PHASE_PRIMORDIAL)?
NULL : jem.jni_env();
jvmtiEventClassFileLoadHook callback = env->callbacks()->ClassFileLoadHook;
if (callback != NULL) {
(*callback)(env->jvmti_external(), jni_env,
jem.class_being_redefined(),
jem.jloader(), jem.class_name(),
jem.protection_domain(),
_curr_len, _curr_data,
&new_len, &new_data);
}
if (new_data != NULL) {
// this agent has modified class data.
if (caching_needed && *_cached_class_file_ptr == NULL) {
// data has been changed by the new retransformable agent
// and it hasn't already been cached, cache it
JvmtiCachedClassFileData *p;
p = (JvmtiCachedClassFileData *)os::malloc(
offset_of(JvmtiCachedClassFileData, data) + _curr_len, mtInternal);
if (p == NULL) {
vm_exit_out_of_memory(offset_of(JvmtiCachedClassFileData, data) + _curr_len,
OOM_MALLOC_ERROR,
"unable to allocate cached copy of original class bytes");
}
p->length = _curr_len;
memcpy(p->data, _curr_data, _curr_len);
*_cached_class_file_ptr = p;
}

if (_curr_data != *_data_ptr) {
// curr_data is previous agent modified class data.
// And this has been changed by the new agent so
// we can delete it now.
_curr_env->Deallocate(_curr_data);
}

// Class file data has changed by the current agent.
_curr_data = new_data;
_curr_len = new_len;
// Save the current agent env we need this to deallocate the
// memory allocated by this agent.
_curr_env = env;
}
}
/*......*/
};

在JVM加载class字节码的时候,JVM会判断当前是否存在jvmtiEventClassFileLoadHook。如果存在就先执行hook,如果执行后字节码被修改则使用修改后的字节码。

一路跟随,我找到了这个函数,这个函数看起来非常适合HOOK,这里就决定使用它了。

// this entry is for class file load hook on class load, redefine and retransform
void JvmtiExport::post_class_file_load_hook(Symbol* h_name,
Handle class_loader,
Handle h_protection_domain,
unsigned char **data_ptr,
unsigned char **end_ptr,
JvmtiCachedClassFileData **cache_ptr) {
JvmtiClassFileLoadHookPoster poster(h_name, class_loader,
h_protection_domain,
data_ptr, end_ptr,
cache_ptr);
poster.post();
}

JVM 的模块名是libjava.so,找到目标的JDK位置,寻找模块并保存下来。用IDA 反编译,找到对应函数的地址偏移以备后续使用。注意这里必须和目标环境上的完全一致。

Java 代码解密:使用 Frida 还原 JVMTI Agent 加密保护的java类&Linux 环境下的Frida 使用

现在HOOK所需的条件已经齐全了,下面需要找到一个合适的HOOK框架来HOOK这个函数。

Frida

Frida 是一个易用的HOOK框架,它可以大大的简化HOOK的流程。它只要编写简单的JS代码就可以在不同的平台完成一些复杂的HOOK操作。

网上大部分的文章都写的是Frida在安卓平台下的HOOK教程,但是该工具其实是跨平台的,其中Frida也支持linux和windows平台。

Linux平台下的远程 Frida HOOK
HOOK环境搭建

被调试机配置很简单,不需要安装python环境,这里只需要将frida-server的二进制文件上传到目标服务器就可以了。

(吐槽一下 虽然它的体积有十几MB,但并不是静态编译的,如果遇到特殊环境,可能需要自行进行静态编译)

Java 代码解密:使用 Frida 还原 JVMTI Agent 加密保护的java类&Linux 环境下的Frida 使用

在目标环境上启动Frida服务器 0.0.0.0 使用默认端口27042

frida-server -l 0.0.0.0:27042

在本地安装完整的frida命令行工具集 frida-tools

pip install frida-tools

连接目标测试下

frida-ps -H 192.168.12.1

如果连接成功,它会列出服务器上的进程列表

Java 代码解密:使用 Frida 还原 JVMTI Agent 加密保护的java类&Linux 环境下的Frida 使用

现在就可以开始远程HOOK了。frida -f 指定执行的文件路径,后面可以跟上命令行参数

frida -H 192.168.12.1 -f /bin/java -- -agentpath:./xxxxx.so  -jar /xxxxxx.jar

如果连接成功,会启动一个控制台。和常见的调试工具一样,启动HOOK后进程是暂停状态,这是可以预先写一些脚本指令。除了几个基本的指令外,都是使用js语法的语句执行一些操作,具体参考官方文档

[Remote::java ]-> %help
Available commands:
%resume(0) - resume execution of the spawned process
%load(1) - Load an additional script and reload the current REPL state
%reload(0) - reload (i.e. rerun) the script that was given as an argument to the REPL
%unload(0) - no description
%autoperform(1) - receive on/off as first and only argument, when switched on will wrap any REPL code with Java.performNow()
%autoreload(1) - disable or enable auto reloading of script files
%exec(1) - execute the given file path in the context of the currently loaded scripts
%time(1+) - measure the execution time of the given expression and print it to the screen
%help(0) - print a list of available REPL commands
HOOK JVM

为了HOOK JVM的目标函数,我们需要找到libjava.so模块的基址

Module.enumerateExports("libjava.so", {
onMatch: function(exp) {
console.log("Exported function: " + exp.name + " | Address: " + exp.address);
},
onComplete: function() {
console.log("Finished enumerating exports.");
}
});

frida 需要输入%resume指令来继续进程的执行。不出意外的话,你可能会发现获取不到你要找的模块。

那是因为脚本执行时,目标模块可能还没有被加载。为了解决这种情况,可以先让程序执行一段时间加载模块后,再执行脚本。

但是这里其实还有一种更优雅的方法,我们可以HOOK C标准库的dlopen 函数,拦截进程内动态库的动态加载过程。在检测到我们需要的模块加载后,我们再去获取模块基址。

HOOK dlopen

先查看下dlopen的函数声明

void *dlopen(const char *filename, int flags);

第一个参数filename就是我们需要的参数,我们可以根据加载的模块名来判断出合适的HOOK时机。

*filename是一个指向字符串的指针,这里可以直接使用Frida预先定义好的方法readCString 一键读取指针对应的C字符串。

args[0].readCString();

Interceptor.attach 是最基础的hook 语法,它可以HOOK一个函数的调用。

onEnter会在函数被调用前被触发,onLeave会在函数调用完成被触发

// HOOK模块加载
const moduleName = "libjvm.so"; // JVM模块
var dlopen = Module.findExportByName(null, "dlopen"); //查找dlopen函数的地址
if(dlopen != null){
// 使用Interceptor来对dlopen函数进行拦截
Interceptor.attach(dlopen,{
// 当dlopen函数被调用时触发 这时模块还没有被加载
onEnter: function(args){
// 读取第一个参数,即加载的模块名称 *filename
var soName = args[0].readCString();
console.log(soName);
// 检查模块名称是否包含目标模块名
if(soName.indexOf(moduleName) != -1){
this.hook = true;// 标记已找到目标模块
}
},
// 当dlopen函数执行完返回时触发 也就是模块加载完成后
onLeave: function(retval){
if(this.hook){
hookFunction();// 执行我们的HOOK函数
}
}
});
}

这下应该就可以获取到libjvm模块的基址了。

拿到的基址加上我们之前用IDA寻找的地址偏移,我们就可以得到目标函数实际上在目标机器的函数地址。

const moduleName = "libjvm.so";
const functionOffset = 0x7A5C60; // 函数的实际偏移量
const baseAddress = Module.findBaseAddress(moduleName);
if (baseAddress === null) {
console.log("Module not found: " + moduleName);
return;
}
const targetAddress = baseAddress.add(functionOffset);
HOOK JVMTI

接下来开始HOOK目标函数,下面是目标函数的声明。

static void post_class_file_load_hook(Symbol* h_name, Handle class_loader,
Handle h_protection_domain,
unsigned char **data_ptr, unsigned char **end_ptr,
JvmtiCachedClassFileData **cache_ptr) NOT_JVMTI_RETURN;

h_name 是加载的类名,我们需要利用这个参数还原class文件名(虽然也可以全部dump完用框架读字节码的类名),但它不是一个标准的C字符串类型。而是一个 Symbol 对象,它是JVM 内部用来表示字符串的结构。

稍微看一下这个结构体

class SymbolBase : public MetaspaceObj {
public:
ATOMIC_SHORT_PAIR(
volatile short _refcount, // needs atomic operation
unsigned short _length // number of UTF8 characters in the symbol (does not need atomic op)
);
int _identity_hash;
};

class Symbol : private SymbolBase {
friend class VMStructs;
friend class SymbolTable;
friend class MoveSymbols;
private:
jbyte _body[1];
.....
}

在内存中的布局

| _refcount (2 bytes) | _length (2 bytes) | _identity_hash (4 bytes) | _body[1] (variable length) |

_refcount 是引用计数,用于内存管理。_length是 _body(字符串)的长度。

根据这些信息,我们就可以通过简单的偏移计算来读出Java字符串了。

function getSymbolString(symbolAddr) {
// 读取 Symbol 的 _length 字段,它是一个 unsigned short 类型(2 字节)
const length = ptr(symbolAddr).add(2).readU16();
// 读取 _body 数组的首地址,从它开始读取 _length 长度的字节数据
const symbolBodyAddr = ptr(symbolAddr).add(8); // _body 的起始偏移是 8 字节
// 使用 readByteArray 读取字节,再将其转换为 UTF-8 字符串
const byteArray = symbolBodyAddr.readByteArray(length);
const str = Memory.readUtf8String(byteArray, length);
return str;
}

之后就可以开始实现dump class了

为了获取解密的结果,我们需要先在进入函数时拿到解密class 的两个偏移存放地址的地址,以备后续函数执行完成使用。

this.ptrAddress = args[3];
this.endPtrAddress = args[4];

在函数执行完成后,这两个地址对应的内容会变成我们需要的数据位置

const ptr = this.ptrAddress.readPointer();
const endPtr = this.endPtrAddress.readPointer();

最后使用拿到的内存位置来读取数据,并写入到以class名命名的文件

function hookFunction() {
const baseAddress = Module.findBaseAddress(moduleName);
if (baseAddress === null) {
console.log("Module not found: " + moduleName);
return;
}
const targetAddress = baseAddress.add(functionOffset);
console.log("Hooking function at: " + targetAddress);
//JvmtiExport::post_class_file_load_hook(name, class_loader(), protection_domain, &ptr, &end_ptr, &cached_class_file);
Interceptor.attach(targetAddress, {
onEnter: function(args)
this.ptrAddress = args[3];
this.endPtrAddress = args[4];
this.name = getSymbolString(args[0]); //偏移量8处存储字符串内容
},
onLeave: function(retval) {
console.log("Entering JvmtiExport::post_class_file_load_hook");
console.log("name: " + this.name );
//读取ptr和end_ptr的值
const ptr = this.ptrAddress.readPointer();
const endPtr = this.endPtrAddress.readPointer();
// 计算读取范围
const length = endPtr.sub(ptr).toInt32();
//>0
if (length > 0) {
// 读取 ptr 到 end_ptr 范围内的数据
const data = ptr.readByteArray(length);
const dumpPath = "/tmp/"+this.name.replaceAll("/",".") +".class";
const file = new File(dumpPath, "wb");
file.write(data);
console.log("Dump file saved to: " + dumpPath);
file.flush();
file.close();
} else {
console.log('Invalid memory range to dump.');
}

}
});

使用 -l 参数加上刚刚写的脚本,执行脚本

frida -H 192.168.12.1 -l hook.js -f /bin/java -- -agentpath:./xxxxx.so  -jar /xxxxxx.jar

Java 代码解密:使用 Frida 还原 JVMTI Agent 加密保护的java类&Linux 环境下的Frida 使用

成功解密class类

Java 代码解密:使用 Frida 还原 JVMTI Agent 加密保护的java类&Linux 环境下的Frida 使用

结束

接下来就可以写一个简单的classloder 来触发所有的class类加载,加载后的class同时会被解密。这样就可以实现完整的jar解密,而不是只能解密已经加载的class。

这种方法是在JVM解析class字节码前拦截的,所以即使class加载出错(少依赖 字节码问题),也可以正常dump。

参考

【JVM】Java agent超详细知识梳理 https://juejin.cn/post/7157684112122183693

JVM(TM) Tool Interface 1.2.3 https://docs.oracle.com/javase/8/docs/platform/jvmti/jvmti.html

Frida JavaScript https://frida.re/docs/examples/javascript/

【作者】:白帽酱
【来源】:https://rce.moe/2024/11/09/Java-Code-Decryption-Using-Frida-Restoring-JVMTI-Agent-Encrypted-Classes-Using-Frida-in-Linux/

原文始发于微信公众号(船山信安):Java 代码解密:使用 Frida 还原 JVMTI Agent 加密保护的java类&Linux 环境下的Frida 使用

免责声明:文章中涉及的程序(方法)可能带有攻击性,仅供安全研究与教学之用,读者将其信息做其他用途,由读者承担全部法律及连带责任,本站不承担任何法律及连带责任;如有问题可邮件联系(建议使用企业邮箱或有效邮箱,避免邮件被拦截,联系方式见首页),望知悉。
  • 左青龙
  • 微信扫一扫
  • weinxin
  • 右白虎
  • 微信扫一扫
  • weinxin
admin
  • 本文由 发表于 2024年11月10日16:00:37
  • 转载请保留本文链接(CN-SEC中文网:感谢原作者辛苦付出):
                   Java 代码解密:使用 Frida 还原 JVMTI Agent 加密保护的java类&Linux 环境下的Frida 使用http://cn-sec.com/archives/3378666.html
                  免责声明:文章中涉及的程序(方法)可能带有攻击性,仅供安全研究与教学之用,读者将其信息做其他用途,由读者承担全部法律及连带责任,本站不承担任何法律及连带责任;如有问题可邮件联系(建议使用企业邮箱或有效邮箱,避免邮件被拦截,联系方式见首页),望知悉.

发表评论

匿名网友 填写信息