0x00 背景
最近学习了两篇关于Java内存攻击的文章:
Java内存攻击技术漫谈
其中我对无文件的Java agent技术挺感兴趣。于是乎花了点时间从头学习了一波,尤其是游望之师傅在Linux下植入无文件java agent的技术。本文将会对这个技术再进行一个梳理。但因我二进制的基础比较薄弱,所以本文在说明Linux下无文件Java Agent时,也会较详细的说明一些二进制相关的基础。希望能帮助同样二进制基础薄弱的同学学习和理解,二进制还是很有趣哒。
本文会浅涉如下方面:
- Java agent
- JDK编译及调试
/proc/self/maps
和/proc/self/mem
ELF
文件解析- 汇编
- 调试方法
下面就开始吧。文章的代码已上传至github,地址在文末。本文所调试使用的jdk版本是11。
0x01 常规Java agent
Java agent可以简单理解为:动态修改字节码的技术。它可以独立于正常程序,作为一个新应用运行。其应用非常广泛,如可以在运行状态下的Java程序中,往函数开头和函数结尾插入调试信息,达到监测、调试、甚至保护Java程序的功能。具体实现需要借助Java自带的instrument
包。下面简单实现一个修改运行时Java字节码的Demo:往正常程序的函数末尾添加一行print语句。
首先有一个正常程序,功能是每一秒打印字符串
class Info{
public static void print(){
System.out.println("Info...");
}
}
public class PrintInfo {
public static void main(String[] args) throws Exception{
while (true) {
Info.print();
Thread.sleep(1000L);
}
}
}
将这个程序运行起来后。我们就可以编写Java agent了。Java agent实际上是一个jar包,触发方式分为命令行式和运行时agent。命令行式就是在启动java程序时,跟一个参数-javaagent:
。由于命令行式是启动时agent,而内存马一般是打在运行时的中间件中,不符合需求。所以本文讨论的是运行时agent的触发方式。
运行时agent需要一个独立于目标JVM(其实也可以self attach,但是高版本JDK有一些限制什么的,绕过不在本文讨论范围内)的Java程序,负责找到目标JVM。代码如下:
Attach.java
import com.sun.tools.attach.VirtualMachine;
import com.sun.tools.attach.VirtualMachineDescriptor;
public class Attach {
public static void main(String[] args) throws Exception{
for (VirtualMachineDescriptor virtualMachineDescriptor : VirtualMachine.list()) { //遍历当前运行的所有JVM
if (virtualMachineDescriptor.displayName().equals("PrintInfo")) { //一般vm标识符名称就是main函数的类名
VirtualMachine attach = VirtualMachine.attach(virtualMachineDescriptor.id());//根据JVM id,对运行中的JVM进行attach
attach.loadAgent("/path/to/javaAgent.jar"); //为attach住的JVM指定一个Java agent jar包,从而触发运行时agent
}
}
}
}
接下来编写Java agent jar包。jar包的目录结构如下
META-INF
- MANIFEST.MF
Main.java
idea里的结构
MANIFEST.MF内容如下,Agent-Class
用来指定运行时agent的类;Can-Redefine-Classes
表示允许Java agent修改类。注意末尾需要有一个空格
Manifest-Version: 1.0
Agent-Class: Main
Can-Redefine-Classes: true
运行时agent的Java agent,需要存在一个agentmain()
函数,里面写agent的相关逻辑。这里我们的主逻辑是:修改前文Info类
的print方法
,使其函数末尾打印一行信息。关于类字节码的修改,使用javassist库会非常方便。
import javassist.*;
import java.lang.instrument.ClassDefinition;
import java.lang.instrument.Instrumentation;
public class MyMain {
public static void agentmain(String agentArgs, Instrumentation instrumentation) throws Exception{
String className = "Info";//要被修改的类名
//不可以用Class.forName找类,需要用getAllLoadedClasses()
for (Class loadedClass : instrumentation.getAllLoadedClasses()) {
if (loadedClass.getName().equals(className)) {
//javassist的用法,需要将正常Java程序的ClassPath添加到ClassPool中
ClassClassPath classClassPath = new ClassClassPath(loadedClass);
ClassPool classPool = new ClassPool();
classPool.insertClassPath(classClassPath);
//javassist修改Info#print()方法,往末尾添加一行print
//此时修改的只是原始类的一个拷贝,不是真正的类
CtClass ctClass = classPool.get(className);
CtMethod method = ctClass.getDeclaredMethod("print");
method.insertAfter("System.out.println(\"[+] debug info...\");");
//将修改好的类转成字节码,利用redefineClasses(),真正修改JVM中的类字节码
byte[] bytes = ctClass.toBytecode();
ClassDefinition classDefinition = new ClassDefinition(loadedClass, bytes);
instrumentation.redefineClasses(classDefinition);
}
}
}
}
接下来就让idea帮我们打jar包。点击"File -> Project Structure... -> Project Settings -> Artifacts",点击+
号添加一个JAR,选择"From modules with dependencies..."。"Create JAR from Modules"的选项窗口不需要修改默认即可。设置完毕后。还需要手动将javassist包打进Java agent包中,进行如下设置
准备妥当后,点击"Build -> Build Artifacts... -> Build",即可将当前项目打成Jar包。
生成好Jar包后,修改Attach类
的attach.loadAgent()
路径为刚刚生成的Jar包。随即运行Attach类
。可以看到程序的输出突然多了一行"[+] debug info..."。达成了先前的预期:往函数末尾添加一行print语句
基本的Java agent修改类字节码就是这样。简单归纳步骤如下:
- 选定被修改的类
- 编写Java agent的Attach程序:遍历JVM,attach JVM,加载Java agent jar包
- 编写Java agent。主要编写
agentmain()
方法 - 运行Attach程序
0x02 JDK编译及调试
为了方便接下来的静调和动调Java的native方法,编译jdk及其调试是非常重要的前置工作。本文采用Ubuntu18编译和调试jdk。
我们可在http://hg.openjdk.java.net/jdk/jdk11/tags下载jdk11。下载好后将之解压。解压完成后找到doc/building.md
文件,该文件说明了编译jdk的步骤。
首先我们需要装好基本依赖,这里最好用gcc7:
sudo apt-get install build-essential gcc-7 g++-7 clang cmake autoconf -y
如果是多版本gcc共存的,可以用以下命令切换gcc/g++版本
sudo update-alternatives --install /usr/bin/gcc gcc /usr/bin/gcc-7 7
sudo update-alternatives --install /usr/bin/g++ g++ /usr/bin/g++-7 7
接下来需要一个已经编译好的,编译版本前一大版本的jdk,作为boot jdk。本文要编译的是jdk11,所以我们还需要下载一个jdk10作为boot jdk。
前置依赖安装下载妥当后,开始编译。建议使用如下选项。这些选项说明是参考building文档的。其中:
--with-boot-jdk
为BootJDK路径
--disable-warnings-as-errors
必加这个参数。不然编译到一半会因为Warning终止编译。这个卡了我好久
bash configure --with-jvm-variants=server --with-debug-level=slowdebug --enable-dtrace --with-boot-jdk=/path/to/bootjdk --disable-warnings-as-errors
configure
中途肯定会遇到缺依赖的报错。同时也会给提示,按照提示安装依赖即可
configure
完成后。执行make images
命令。然后就可以快乐使用编译好的JDK了
make images
./build/*/images/jdk/bin/java -version
CLion本地调试JDK
我用的IDE工具是CLion。CLion中"File -> Open"打开jdk源码包的根目录。第一次打开注意不要点clean
。不然会把编译好的jdk/bin
删掉的。如果误删除了需要重新make
。
配置"Configuration"。如图
此时直接点调试,不需要下断点。jdk中会抛出各种中断信号,CLion会自动断点的。若看到CLion能正常断点,程序也能正常输出即可。
CLion和Idea协同调试
既然要调试Java的native方法,让CLion和Idea协同便是很关键的一步。如果在Idea中点击步入,就能进入CLion里的native方法具体实现,多是一件美事。为了CLion和Idea协同调试,我的思路是:在Idea中将程序打包成Jar;Clion中用-jar
参数运行这个Jar包,并开启remote debug server;Idea连接remote debug进行java层调试,Clion则在对应的native函数中下好断点,等待Idea执行到native函数。
Idea建一个Java项目。
按照jar包格式写好基本demo。这里的demo就用存在native函数的FileInputStream
。写好后执行build生成一个Jar包:
CLion中设置Java参数,Program arguments
参数如下:
-agentlib:jdwp=transport=dt_socket,server=y,suspend=y,address=5005 -jar /path/to/demo/demo.jar
设置妥当后,接下来是往native函数中下断点。FileInputStream
构造函数中调用的native函数是open0
。在CLion中搜索Method: open0
,找到.h
文件预定义的函数:
java_io_FileInputStream.h
/*
* Class: java_io_FileInputStream
* Method: open0
* Signature: (Ljava/lang/String;)V
*/
JNIEXPORT void JNICALL Java_java_io_FileInputStream_open0
(JNIEnv *, jobject, jstring);
再根据函数名Java_java_io_FileInputStream_open0
,最后找到对应的jdk源码FileInputStream.c
FileInputStream.c
JNIEXPORT void JNICALL
Java_java_io_FileInputStream_open0(JNIEnv *env, jobject this, jstring path) {
fileOpen(env, this, path, fis_fd, O_RDONLY);
}
在该函数中下好断点。注意断点不可以打在.h
文件上,必须打在切实的函数上。CLion开启调试。开启调试时中间有几个中断,需要手动点击调试的绿色箭头过掉。调试端口开启后,CLion应该卡在这样的输出:
......
Listening for transport dt_socket at address: 5005
这是在等待调试端Idea的接入。回到Idea中,添加一个Remote JVM Debug
的Configurations
,使用默认配置即可。
Idea也配置好后,点击Idea的调试按钮。不出意外的话,CLion在经过几个中断之后,就会在断点处停下
掌握Idea和CLion协同调试native函数,可以帮助实现无文件Java agent的动态调试,方便调试和问题排查。
0x03 无文件Java agent的思路
关于无文件Java agent的思路,本文开头游望之师傅和rebeyond师傅的文章都讲的十分详尽。这里我就简单提一下:
对于内存马来说,Java agent最有用的功能就是redefineClasses
。但普通的Java agent需要提供一个Jar包,有文件落地。如果去了解下JDK源码,看看其内部如何实现redefineClasses
的,尝试通过Java伪造这一过程,或者构造数据,手动调用函数,也许能找到实现无文件Java agent的方式。
跟进InstrumentationImpl#redefineClasses()
函数,可以看到其调用了native函数redefineClasses0()`
public class InstrumentationImpl implements Instrumentation {
// needs to store a native pointer, so use 64 bits
private final long mNativeAgent;
public void
redefineClasses(ClassDefinition... definitions)
throws ClassNotFoundException {
.....
redefineClasses0(mNativeAgent, definitions);
}
private native void
redefineClasses0(long nativeAgent, ClassDefinition[] definitions)
throws ClassNotFoundException;
}
既然java层可以看到这个native函数,意味着我们可以通过反射对其进行调用。但传递的参数就成了关键:ClassDefinition[]
还好伪造,就是被修改类的字节码。但nativeAgent
注释写着是一个指针,指针可是二进制层面的东西,若要伪造,Java语言需要有能直接操作内存的接口。幸运的是,Java提供了Unsafe接口,让Java开发拥有开辟堆内存的能力;并且Linux下的/proc/self/mem
也能直接操作内存。那么Java在二进制层面伪造数据的困难也就解决了。
JPLISAgent结构体
接下来要研究native函数redefineClasses0()
的实现,以便伪造nativeAgent
指针。我们需要下载jdk源码进行翻阅,必要时也需要动态调试。openjdk的源码可以在http://hg.openjdk.java.net/jdk/进行下载。下面以jdk11的源码为例。
全局项目搜索后,发现native函数redefineClasses0()
对应的jdk源码是JPLISAgent.c中的redefineClasses()
函数,下面只抽取了该函数相关的最关键的部分
JPLISAgent.c
/*
* jnienv不需要java层关心
* agent是native函数中的long nativeAgent
* classDefinitions是native函数中的ClassDefinition[] definitions
*/
void
redefineClasses(JNIEnv * jnienv, JPLISAgent * agent, jobjectArray classDefinitions) {
jvmtiEnv* jvmtienv = jvmti(agent);
.....
errorCode = (*jvmtienv)->RedefineClasses(jvmtienv, numDefs, classDefs); //redefineClass的关键代码
.....
}
关键操作就两个:通过agent
获取jvmtiEnv*
。利用(*jvmtienv)->RedefineClasses()
执行redefineClass。我们现在专注让Java层顺利调用native的redefineClasses0()
,使程序能够走到(*jvmtienv)->RedefineClasses()
这一步。(*jvmtienv)->RedefineClasses()
内部逻辑比较复杂,先尝试让程序走到这一步,再慢慢动调会是更好的选择。
为了能在Java层顺利调用native函数redefineClasses0()
,需要伪造JPLISAgent* agent
。并且对于JPLISAgent * agent
,redefineClasses()
函数只是对其进行了宏jvmti()
的操作,得到一个jvmtiEnv*
。还需要下面了解下其结构
JPLISAgent.h
struct _JPLISEnvironment {
jvmtiEnv * mJVMTIEnv; /* the JVM TI environment */
JPLISAgent * mAgent; /* corresponding agent */
jboolean mIsRetransformer; /* indicates if special environment */
};
typedef struct _JPLISEnvironment JPLISEnvironment;
struct _JPLISAgent {
JavaVM * mJVM; /* JVM指针,但RedefineClasses()没有用到,可以忽略,全填充0即可 */
JPLISEnvironment mNormalEnvironment; /* _JPLISEnvironment结构体 */
..... //无关紧要的成员
};
typedef struct _JPLISAgent JPLISAgent;
#define jvmti(a) a->mNormalEnvironment.mJVMTIEnv //实际功能就是取_JPLISAgent.mNormalEnvironment。类型是_JPLISEnvironment
整个结构体有用的部分就这些。我们可以发现,若是要伪造一个JPLISAgent
,需要获取jvmtiEnv*
指针。
获取jvmtiEnv指针
梳理下流程,从宏jvmti()
取出来的就是jvmtiEnv*
。这个指针要如何获取呢?
游望之师傅的思路是:创建JPLISAgent
的函数createNewJPLISAgent()
中,有设置jvmtiEnv*
指针的操作:创建一个空的jvmtiEnv* jvmtienv
,将之传入(*vm)->GetEnv()
中。调用结束后,jvmtienv
将存放jvmtiEnv*
指针。
JPLISAgent.c
JPLISInitializationError
createNewJPLISAgent(JavaVM * vm, JPLISAgent **agent_ptr) {
jvmtiEnv * jvmtienv = NULL;
jnierror = (*vm)->GetEnv( vm,
(void **) &jvmtienv,
JVMTI_VERSION_1_1);
....
}
vm
是JavaVM
指针。找到JavaVM
结构体。其中存放着GetEnv()
函数的函数指针。也就是说,如果能得到JavaVM
指针,那么伪造JPLISAgent
的条件:JavaVM*
和jvmtiEnv*
这两个指针都能满足。
jni.h
typedef JavaVM_ JavaVM;
struct JavaVM_ {
const struct JNIInvokeInterface_ *functions;
.....
jint GetEnv(void **penv, jint version) {
return functions->GetEnv(this, penv, version);
}
}
struct JNIInvokeInterface_ {
....
jint (JNICALL *GetEnv)(JavaVM *vm, void **penv, jint version);
};
哪里可以得到JavaVM
指针呢?有一个JNI_GetCreatedJavaVMs()
函数可以获取:只需要传入JavaVM **
指针,就会把创建好的JavaVM
地址传给这个指针
jni.cpp
_JNI_IMPORT_OR_EXPORT_ jint JNICALL JNI_GetCreatedJavaVMs(JavaVM **vm_buf, jsize bufLen, jsize *numVMs) {
.....
if (vm_created == 1) {
if (numVMs != NULL) *numVMs = 1;
if (bufLen > 0) *vm_buf = (JavaVM *)(&main_vm);
}
}
调用jdk函数
通过前文分析我们可以知道,JNI_GetCreatedJavaVMs()
函数能够得到JavaVM*
指针,间接得到jvmtiEnv*
指针。但这个函数并没有暴露给Java层的native
函数,要如何调用这个函数呢?
游望之师傅给出了一个特别妙的方法:Linux下,每个程序都有自己的/proc/self/mem
。通过这个文件,可以使程序访问甚至修改内存。这里需要注意的是,程序只能访问和修改自身程序的那一块内存,不可以越界访问其他程序的内存。下面简单用C写一个demo,演示操作/proc/self/mem
修改内存:
demo
#include <stdio.h>
#include <stdlib.h>
int main(){
int a = 1;
unsigned long offset = &a;
FILE *f = fopen("/proc/self/mem", "w+");
printf("original value: %i\n", a);
printf("memery address: %p\n", offset);
fseek(f, offset, SEEK_SET );
fputc(2, f);
fclose(f); //关闭文件流后,会自动将缓冲区里的数据真正写进文件中
printf("modify value: %i\n", a);
return 0;
}
结果
original value: 1
memery address: 0x7ffe374b11a4
modify value: 2
既然能修改内存了,我们可以找一个合适Java native函数,手动修改函数内容为调用JNI_GetCreatedJavaVMs()
以获取JavaVM*
指针。调用结束后再将Java native函数修改回去,便可不破坏内存。
但要调用JNI_GetCreatedJavaVMs()
,就得知道函数的内存地址。函数的内存地址由 基址+偏移 组成。基址也就是存放函数的链接库(ELF文件)被加载进内存的位置,偏移就是函数在链接库中的位置。
基址在Linux下可通过/proc/self/maps
获得。同一个链接库可能会有好几个显示,但地址都是连续的,只要找最开始的地址即可。
$ cat /proc/self/maps
7f99d9373000-7f99d955a000 r-xp 00000000 fd:00 1052964 /lib/x86_64-linux-gnu/libc-2.27.so
7f99d955a000-7f99d975a000 ---p 001e7000 fd:00 1052964 /lib/x86_64-linux-gnu/libc-2.27.so
7f99d975a000-7f99d975e000 r--p 001e7000 fd:00 1052964 /lib/x86_64-linux-gnu/libc-2.27.so
7f99d975e000-7f99d9760000 rw-p 001eb000 fd:00 1052964 /lib/x86_64-linux-gnu/libc-2.27.so
.....
偏移需要解析ELF文件获得,下文会详细说。
综合以上探索,可以得到一个Linux下无文件Java agent的思路:
- 解析
/proc/self/maps
和ELF文件,得到JNI_GetCreatedJavaVMs()
函数及一个Java native函数地址 - 修改内存中Java native函数的内容,使之调用
JNI_GetCreatedJavaVMs()
函数。调用结束后要对Java native函数复原 - 拿到
JavaVM*
和jvmtiEnv*
之后,便可反射调用Java中InstrumentationImpl#redefineClasses0
,达到动态修改字节码的效果 - 调用
InstrumentationImpl#redefineClasses0
时需要有JDK源码辅助动调,以便排查一些错误
0x04 基址寻找
我们可以通过/proc/self/maps
得到链接库基址。注意/proc/self/xxx
只能获取程序自身的信息,若想获取其他程序的信息,需要将self
换成对应的pid
。
由于jdk有很多链接库,如何确定JNI_GetCreatedJavaVMs()
函数在哪一个链接库中呢?JNI_GetCreatedJavaVMs()
函数的源文件是src/hotspot/share/prims/jni.cpp
,jdk源码全局搜索jni.cpp
,可以在编译后的文件build/linux-x86_64-normal-server-slowdebug/hotspot/variant-server/libjvm/objs/jni.d
找到。.d
文件是依赖文件,包含了输出.o
文件所需要的依赖。.o
文件是对象文件,包含了编译好的代码。
jni.d
/usr/local/src/jdk11-1ddf9a99e4ad/build/linux-x86_64-normal-server-slowdebug/hotspot/variant-server/libjvm/objs/jni.o: \
/usr/local/src/jdk11-1ddf9a99e4ad/src/hotspot/share/prims/jni.cpp \
.....
可以得知jni.cpp
会被编译为jni.o
文件。.o
文件还需要进行链接操作,才会生成最终的链接库。根据其目录名libjvm
可以猜测,最终jni.o
应该是被链接到libjvm.so
中。我们可以通过readelf
工具解析libjvm.so
来验证:
$ readelf -s lib/server/libjvm.so | grep JNI_GetCreatedJavaVMs
355: 00000000008f6030 61 FUNC GLOBAL DEFAULT 13 JNI_GetCreatedJavaVMs@@SUNWprivate_1.1
55588: 00000000008f6030 61 FUNC GLOBAL DEFAULT 13 JNI_GetCreatedJavaVMs
可以发现libjvm.so
确实有JNI_GetCreatedJavaVMs()
。注意这里显示了两个,一个序号是355
一个序号是55588
。这里先注意一下,后期ELF解析的时候有大坑。
知道JNI_GetCreatedJavaVMs()
函数所在的链接库之后,便可通过读取/proc/self/maps
得到链接库基址。基本代码如下:
RandomAccessFile mapsReader = new RandomAccessFile("/proc/self/maps", "r");
long libMemeryAddress = 0L;
String procSelfMem;
//libjvm.so address
long JNI_GetCreatedJavaVMsAddress = 0L;
while((procSelfMem = mapsReader.readLine()) != null) {
if (procSelfMem.contains("libjvm.so")) {
System.out.println("[+] maps String: " + procSelfMem);
String[] address = procSelfMem.split(" ");
String[] addressArr1 = address[0].split("-");
libMemeryAddress = Long.valueOf(addressArr1[0], 16);
break;
}
}
mapsReader.close();
System.out.println("[+] offset: " + libMemeryAddress);
输出如下
[+] maps String: 7f06a2e5c000-7f06a3fe4000 r-xp 00000000 fd:00 1310891 xxx/lib/server/libjvm.so
[+] offset: 139666479497216
最后再将上述代码封装成类,解析基址的代码就完成了。
0x05 ELF解析
要获取函数的偏移地址,解析ELF是必须的步骤。在没有工具包的情况下,只能手写解析。由于解析代码篇幅过长,这里只浅析ELF的基本结构和解析思路,点到找函数偏移地址为止。具体的解析代码放在文末的github地址中了。ELF的结构不难,一起来看看吧
ELF格式文档:
Oracle的文档,可以和上面的互补着看
本文解析的ELF均以x64-bit为例
一个ELF文件,分为数个区:File header
、Section table
、Symbol table
....等。对于本文的需求,我们只需要知道以下区的作用即可。
File header
File header
区是ELF文件开头64 byte
大小的数据(X64的ELF)。主要作用是说明ELF的基本信息,告诉解析者其他区的位置和大小。
File header
的结构如下:
typedef struct
{
unsigned char e_ident[16]; /*16 byte*/
Elf64_Half e_type;
Elf64_Half e_machine;
Elf64_Word e_version;
Elf64_Addr e_entry;
Elf64_Off e_phoff;
Elf64_Off e_shoff; /* 8 byte */
Elf64_Word e_flags;
Elf64_Half e_ehsize;
Elf64_Half e_phentsize;
Elf64_Half e_phnum
Elf64_Half e_shentsize;
Elf64_Half e_shnum; /* 2 byte */
Elf64_Half e_shstrndx; /* 2 byte */
} Elf64_Ehdr;
我们需要用到的字段有:
e_ident[5]
:1
表示小端序,2
表示大端序。默认情况下是小端序
e_shoff
:Section header
在ELF文件中的偏移位置,用于定位Section header
e_shnum
:ELF中Section header
的数量
e_shstrndx
:Section header
对应的字符串Section
,是第几个Section header
不理解这些字段含义没关系,只要知道这些信息能够定位Section table
即可。接下来继续了解Section table
是什么。
Section header
前文说过,ELF文件中被分为很多个“区”,而这个区正是Section
。Section header
的作用是识别对应的Section
及其位置。
Section header
的结构如下:
typedef struct
{
Elf64_Word sh_name; /* 4 byte */
Elf64_Word sh_type;
Elf64_Xword sh_flags;
Elf64_Addr sh_addr;
Elf64_Off sh_offset; /* 8 byte */
Elf64_Xword sh_size; /* 8 byte */
Elf64_Word sh_link;
Elf64_Word sh_info;
Elf64_Xword sh_addralign;
Elf64_Xword sh_entsize; /* 8 byte */
} Elf64_Shdr;
我们需要用到的字段有:
sh_name
: Section table
的名字,是基于.shstrtab
字符串表中的偏移。
sh_offset
: Section
在ELF文件中的偏移位置,用于定位Section
sh_size
: 当前Section table
旗下所有Section
的大小
sh_entsize
: 当前Section table
旗下单个Section
的大小
这些字段中,可以通过sh_entsize
/sh_size
算出Section table
旗下Section
的数量。
sh_name
可能有点抽象,它表示Section table
的名称。名称是字符串,Section header
字符串存放在一个字符串表.shstrtab
中。sh_name
的值便是Section table
名称在字符串表.shstrtab
中的位置。也就是字符串在字符串表中的位置。
从File header
解析到Section header
的流程大致如下:
- 解析
File header
,根据e_shoff
得到Section header
的位置,根据e_shnum
加载所有的Section header
。此时并不知道每个Section header
都是什么。 - 根据
File header
的e_shstrnds
得知.shstrtab
是第几个Section header
- 根据
.shstrtab
的sh_offset
,找到实际的字符串表(String table
) - 根据每个
Section header
中sh_name
指定的偏移,在字符串表中得到对应字符串,字符串结束符是0x00
。得到的字符串就是Section header
的名称
简单图示:
经过字符串表的关联,解析者就可以知道每个Section header
具体的名称,便可通过名称区分每一个Section
。
在那么多Section
中,本文需要用到的Section
有:
-
.shstrtab
-
.symtab
和其字符串表.strtab
.dynsym
和其字符串表.dynstr
Symbol table
Symbol table
是一种Section
,中文名是符号表。其作用是关联程序中的符号,类似指针的作用。符号也就是标识符,函数也是一种符号。函数的偏移地址就是记录在符号表中的。也就是说,我们解析了符号表,就能得到函数的偏移地址,我们解析ELF的目的也就达成了。
存放Symbol table
的Section
只有.symtab
和.dynsym
这俩。.symtab
中存放了所有的符号,而.dynsym
存放的是动态链接的符号。我们的解析需求是找特定函数的偏移地址,优先从.dynsym
中遍历寻找,毕竟数量少遍历的次数也比较少。
Symbol table
的结构如下:
typedef struct
{
Elf64_Word st_name; /* 4 byte */
unsigned char st_info; /* 1 byte */
unsigned char st_other;
Elf64_Half st_shndx;
Elf64_Addr st_value; /* 8 byte */
Elf64_Xword st_size; /* 8 byte */
} Elf64_Sym;
我们需要用到的字段有:
st_name
: Symbol table
的名字,是基于对应字符串表中的偏移。字符串关联流程和上文的Section header
一样。
st_info
: 虽是一个字节,却记录的两个信息:高4位是绑定属性(binding attributes),低4位是符号类型(symbol type)。对本文来说,需要解析的是低4位符号类型,值为2
表示当前Symbol table
是函数实体指针。要得到低4位,只需要st_info & 00001111
即可,即st_info & 0xf
。
st_value
: 指向符号的偏移地址
st_size
: 指向符号的大小
综上所述可以知道,只要解析函数的Symbol table
,就能通过st_value
拿到函数的偏移地址。
整体解析流程:
至此,函数的偏移地址也可以得到。再加上前文"基址寻找"得到的基址,相加就是函数在内存中的地址。
0x06 合适的Java native函数
通过上文的解析,我们已经可以得到函数在内存中的地址了。按照无文件Java agent的思路,我们需要得到JNI_GetCreatedJavaVMs()
函数地址和一个Java native函数地址。Java native函数的作用是调用JNI_GetCreatedJavaVMs()
,得到JavaVM*
指针,将之通过函数返回值返回。所以选择合适的Java native函数也是关键的一步,选择条件如下:
- 函数返回值是
long
,因为要返回一个指针 - 不是Java核心的native函数,不能影响程序正常运行,最好是偏门点的native函数
- 函数内部空间足够大,因为我们需要植入调用
JNI_GetCreatedJavaVMs()
的二进制代码
综合这些条件考虑,本文选择FileInputStream#skip0
函数,对应在JDK源码里的函数是libjava.so中的Java_java_io_FileInputStream_skip0
。
0x07 汇编编写
JNI_GetCreatedJavaVMs()
函数是没有暴露在Java层的接口的,为了能够调用它,在Linux下,可以通过操作/proc/self/mem
,达到读写内存效果。本文的思路是修改Java_java_io_FileInputStream_skip0()
函数内容,植入调用JNI_GetCreatedJavaVMs()
函数的代码,并将JavaVM*
指针返回。那么如何编写调用代码呢?这里需要一点汇编的知识。
汇编
本文不会细讲每个汇编指令,汇编指令网上已经讲的很详细了。这里只说明编写调用函数的汇编的思路。
C和汇编混编
在Linux&gcc环境下,C和汇编混编的简易流程如下:
1)在c文件
中使用extern
关键字表示有外部函数
extern void call();
int main(){
call();
return 0;
}
2)在s文件
中,编写c文件
中预设好的函数。Linux下的gcc汇编默认是AT&T
语法
.text
.global call
.type call, %function
call:
mov $0x1,%rax
ret
3)使用命令gcc -o A ./A.c ./A.s -g
编译c文件
和s文件
4)可以使用命令objdump -d -M intel ./A
查看可执行文件的汇编和指令码
.....
000000000000060f <call>:
60f: 48 c7 c0 01 00 00 00 mov rax,0x1
616: c3 ret ; 实际上我们只写到这里。下面可能是gcc自动生成的,也可能是垃圾数据,忽略即可
617: 66 0f 1f 84 00 00 00 nop WORD PTR [rax+rax*1+0x0]
61e: 00 00
.....
汇编调用JNI_GetCreatedJavaVMs
在Linux的汇编层面中,调用函数时,传参是放在寄存器中的,各寄存器对应的函数形参如下:
rdi -> arg1
rsi -> arg2
rdx -> arg3
rcx -> arg4
r8 -> arg5
r9 -> arg6
再看到JNI_GetCreatedJavaVMs()
函数:
jni.cpp
_JNI_IMPORT_OR_EXPORT_ jint JNICALL JNI_GetCreatedJavaVMs(JavaVM **vm_buf, jsize bufLen, jsize *numVMs) {
.....
if (vm_created == 1) {
if (numVMs != NULL) *numVMs = 1;
if (bufLen > 0) *vm_buf = (JavaVM *)(&main_vm);
}
}
只需要bufLen>0
,就会将已有的JavaVM*
传递给vm_buf
。汇编构造参数如下:
JavaVM **vm_buf
:x64中指针长度为8 byte
,需要让栈中预设8 byte
的空间以存放JavaVM*
。虽然是指针的指针,但是代码中只取了一层值,传递一个一级指针即可。将这8 byte
空间的地址传递给rdi
。jsize bufLen
:需要让其>0
。让rsi=1
即可。jsize *numVMs
:函数中对其进行了判空,最好还是在栈中预设4 byte
空间并设置点值,并将这4 byte
空间传递给rdx
结合这些条件,编写调用JNI_GetCreatedJavaVMs()
函数的汇编如下。注意此时拿到的只是JavaVM*
,下文还需要继续拿jvmtiEnv*
push %rbp
#平栈
mov %rsp,%rbp
#定义好每块空间放什么数据
#栈是从高地址到低地址生长的,rsp减小则栈空间变大
#为了方便理解,可以把rps+偏移当作一个变量名来看
#8 byte for int* rsp+0x8
#8 byte for JavaVM*. rsp
push $0x1
sub $0x8,%rsp
#传递形参
#rsp is * vm
lea (%rsp), %rdi
mov $0x1, %rsi
#rsp+0x8 is int*
lea 0x8(%rsp), %rdx
#调用GetCreatedJavaVms(),这里的函数地址是上文解析基址和偏移得到的,需要动态组装,这里暂时占位
mov $0xffffffffffffffff, %rax
call %rax
#调用结束后,rsp指向的空间应当被赋值成了JavaVM*指针
#平栈,回收前面开辟的两个指针变量的空间:一个push 一个sub 0x8
add $0x10,%rsp
pop %rbp
ret
汇编调用GetEnv
在上文顺利得到JavaVM*
指针后,需要顺着这个指针,调用GetEnv()
方法得到jvmtiEnv*
。
在汇编层面,我们没法用变量名这种符号来表示调用哪个方法、使用哪个函数指针。汇编层面访问结构体成员用的方法是偏移。所以我们有必要再看一看相关的代码,确定结构体成员的偏移量:
jni.h
typedef JavaVM_ JavaVM;
struct JavaVM_ {
/*functions是第一个成员,其偏移量是0*/
const struct JNIInvokeInterface_ *functions;
.....
jint GetEnv(void **penv, jint version) {
return functions->GetEnv(this, penv, version);
}
}
struct JNIInvokeInterface_ {
....
/*前面有6个函数指针,所以GetEnv函数指针的偏移量是 6*8=48*/
jint (JNICALL *GetEnv)(JavaVM *vm, void **penv, jint version);
};
简单看了GetEnv()
对应的函数,其经过层层调用来到如下代码。很明显jvmtiEnv*
指针被赋值给了形参penv
。形参penv
的值便是函数要返回的内容。
jvmtiExport.cpp
jint
JvmtiExport::get_jvmti_interface(JavaVM *jvm, void **penv, jint version) {
....
*penv = jvmti_env->jvmti_external();
}
汇编构造参数如下:
JavaVM **jvm
:上文刚刚获取到的,直接传栈上地址即可。void **penv
:栈中预设16 byte
空间,这是二级指针,虽然没细究,但是只传一级指针是不行的。该空间用以存放心心念念的jvmtiEnv*
指针。jint version
:关于该值的设置,游望之师傅使用了JVMTI_VERSION_1_2
的值0x30010200
:
jvmti.h
enum {
JVMTI_VERSION_1 = 0x30010000,
JVMTI_VERSION_1_0 = 0x30010000,
JVMTI_VERSION_1_1 = 0x30010100,
JVMTI_VERSION_1_2 = 0x30010200,
JVMTI_VERSION_9 = 0x30090000,
JVMTI_VERSION_11 = 0x300B0000,
JVMTI_VERSION = 0x30000000 + (11 * 0x10000) + (0 * 0x100) + 0 /* version: 11.0.0 */
};
感觉如果这个值设置太高,可能会导致Jdk报版本不正确的错误,保险起见本文采用的值也是JVMTI_VERSION_1_2
,也就是0x30010200
。
结合这些条件,我们需要在上文调用JNI_GetCreatedJavaVMs()
的汇编基础上进行修改和增加,让程序接着调用GetEnv()
函数,最后将jvmtiEnv*
的值传递给寄存器rax
:
.text
.global call
.type call, %function
call:
push %rbp
mov %rsp,%rbp
#准备栈空间,存放如下数据:
#8 byte for int* rsp+0x18
#8 byte for JavaVM* rsp+0x10
#8 byte for void *penv rsp+0x8
#8 byte for void **penv rsp
push $0x1
sub $0x18,%rsp
#分配函数传参
#JNI_GetCreatedJavaVMs(JavaVM **vm_buf, jsize bufLen, jsize *numVMs)
lea 0x18(%rsp), %rdx
mov $0x1, %rsi
lea 0x10(%rsp), %rdi
#动态组装函数地址,这里暂时占位
mov $0x1122334455667788, %rax
call %rax
#分配函数传参
#JvmtiExport::get_jvmti_interface(JavaVM *jvm, void **penv, jint version)
mov $0x30010200, %rdx
#设置二级指针,思路: 将栈上地址传递给寄存器,再将寄存器值传递给栈上另一块空间
lea 0x8(%rsp), %r8
mov %r8, (%rsp)
#经过上两个指令,此时rsp指向的空间 指向 rsp+8地址。
lea (%rsp), %rsi
mov 0x10(%rsp), %rdi
#调用GetEnv(),在C中调用的原型是:
#vm->functions->GetEnv(....);
#需要先取出JavaVM*,得到JNIInvokeInterface_*,最后根据偏移得到GetEnv()指针
#1. 取出JavaVM*
mov 0x10(%rsp), %r8
#2. 取出JNIInvokeInterface_*
mov (%r8), %r9
#3. 根据偏移6*8,得到GetEnv()指针
mov 0x30(%r9), %r8
call %r8
#jvmtiEnv指针在rsp指向的空间,将之传递给rax,作为函数返回值
mov (%rsp), %rax
#平栈
#1个push, 0x8 byte
#sub 0x18 byte
#总计: 0x20 byte
add $0x20,%rsp
pop %rbp
ret
整合机器码
为了得到前文汇编生成的机器码,需要进行gcc的编译。编译方式可参考前文"C和汇编混编"一节。生成可执行文件后,可采用objdump
得到机器码,整理之后用Java字节数组存放。特别注意:调用JNI_GetCreatedJavaVMs()
的函数地址,需要单独扣出来替换成真实的函数地址。
Java代码如下:
byte[] codeInsert1 = new byte[]{
..... //一直到机器码的0x48, 0xb8
};
byte[] codeInsert3 = new byte[]{
.... //从0xff,0xd0 (call rax)一直到0xc3(ret)
};
//中间扣出来的8 byte地址
//将Long类型的函数地址转成bytep[]类型
byte[] codeInsert2 = new byte[8];
String hexString = Long.toHexString(jni_getCreatedJavaVMsAddress);
int codeInsert2Offset = 0;
for (int i = hexString.length(); i >= 2; i-=2) {
String substring = hexString.substring(i - 2, i);
Integer decode = Integer.decode("0x" + substring);
byte aByte = decode.byteValue();
codeInsert2[codeInsert2Offset++] = aByte;
}
//统合最终的byte[] 机器码
codeOverwrite = new byte[codeInsert1.length + codeInsert2.length + codeInsert3.length];
System.arraycopy(codeInsert1, 0, codeOverwrite, 0, codeInsert1.length);
System.arraycopy(codeInsert2, 0, codeOverwrite, codeInsert1.length, codeInsert2.length);
System.arraycopy(codeInsert3, 0, codeOverwrite, codeInsert1.length+codeInsert2.length, codeInsert3.length);
调试验证
现在,我们有函数地址,有获取jvmtiEnv*
的机器码。接下来就是实际写入内存,查看效果了。具体代码太长这里不列出来,可移步至文末github地址获取。这里就简单说说如何调试及验证。
在IDEA里调试代码,经过获取地址 -> 写入机器码后,调用被修改过的FileInputStream#skip()
,断点停住:
为了能看到汇编层的指令,需要使用ide进行attach。若没有ide,Linux下也可以使用edb来对程序进行attach(不会用gdb)。这里我就用edb进行调试。
运行edb,在"File -> Attach"中搜索java
,找到运行中的Java进程,选择进程号最大的那一个。
选择后,需要在汇编层找到函数位置。可以右键"Goto Expression",输入IDEA输出的函数地址,使视图跳转到Java_java_io_FileInputStream_skip0()
函数中,打下断点,并点击左上方的"Run"按钮,使edb attach住进程:
随后在IDEA中步过,此时程序就会在edb中停住,便可以在edb中继续单步调试了。
调用完函数之后,可以看到rax中已经被写入了jvmtiEnv*
的地址
Java层也能顺利拿到返回值
0x08 构造JPLISAgent结构体
前文铺垫了这么多,又是解析函数地址又是改内存的,现在终于得到了jvmtiEnv*
,可以着手构造JPLISAgent
结构体了。
再复习下其结构
JPLISAgent.h
struct _JPLISEnvironment {
jvmtiEnv * mJVMTIEnv; /* the JVM TI environment */
JPLISAgent * mAgent; /* corresponding agent */
jboolean mIsRetransformer; /* indicates if special environment */
};
typedef struct _JPLISEnvironment JPLISEnvironment;
struct _JPLISAgent {
JavaVM * mJVM; /* JVM指针,但RedefineClasses()没有用到,可以忽略,全填充0即可 */
JPLISEnvironment mNormalEnvironment; /* _JPLISEnvironment结构体 */
..... //无关紧要的成员
};
typedef struct _JPLISAgent JPLISAgent;
一个struct _JPLISAgent
中至少需要25 byte
的空间,前8 byte
可填充为0。后17 byte
是struct _JPLISEnvironment
的空间。其中mIsRetransformer
暂时没看到有什么用处,暂且设置一个1
吧。
在Java中,可以使用Unsafe
来开辟堆内存。对于本文的需求,代码如下:
Field theUnsafe = Unsafe.class.getDeclaredField("theUnsafe");
theUnsafe.setAccessible(true);
Unsafe unsafe = (Unsafe) theUnsafe.get(null);
long JPLISAgent = unsafe.allocateMemory(25L);
//JavaVM*
unsafe.putLong(JPLISAgent, 0L);
//_JPLISEnvironment
unsafe.putLong(JPLISAgent+8L, jvmtiEnv);
unsafe.putLong(JPLISAgent+16L, JPLISAgent);
unsafe.putByte(JPLISAgent+24L, (byte)0x1);
0x09 无文件Java agent
有了struct _JPLISAgent
,接下来可以尝试直接调用redefineClasses()
了。首先构建一个恶意类,并拿到其字节码
Demo.class
package com;
public class Demo {
public void print(){
System.out.println("[+] Inject Success!");
}
}
直接调用redefineClasses()
,并对比前后Demo#print()
的输出
.....
/*
* Test no file Java Agent
* */
Demo demo = new Demo();
//
demo.print();
byte[] evilClassClassBytes = .../* 恶意类字节码 */;
/*
* 调用 InstrumentationImpl.redefineClasses()
* */
Class instrumentationImplClass = Class.forName("sun.instrument.InstrumentationImpl");
Constructor instrumentationImplConstructor = instrumentationImplClass.getDeclaredConstructor(long.class, boolean.class, boolean.class);
instrumentationImplConstructor.setAccessible(true);
Object instrumentationImpl = instrumentationImplConstructor.newInstance(JPLISAgent, true, false);
ClassDefinition[] classDefinitions = new ClassDefinition[1];
classDefinitions[0] = new ClassDefinition(com.Demo.class, evilClassClassBytes);
Method redefineClassesMethod = instrumentationImplClass.getDeclaredMethod("redefineClasses", ClassDefinition[].class);
redefineClassesMethod.setAccessible(true);
redefineClassesMethod.invoke(instrumentationImpl, new Object[]{classDefinitions});
//
demo.print();
.....
可是运行时程序却报错了,报错信息里也没说原因。但根据报错信息可以推测,程序是在redefineClasses()
的native函数里头出错了。很有可能是哪一步出现了if
判断。我们需要协同CLion一起调试寻找原因。
经过调试,会发现程序在这一行判断中没有通过。这一行判断其实也就是本文""常规Java agent"中,MANIFEST.MF中的Can-Redefine-Classes
jvmtiEnter.cpp
.....
if (jvmti_env->get_capabilities()->can_redefine_classes == 0) {
return JVMTI_ERROR_MUST_POSSESS_CAPABILITY;
}
can_redfineClass绕过
很显然,如果要通过这个判断,还需要对内存进行额外的修改。can_redefine_classes
的值在内存中实际上就是一个偏移。看C源码并不能很直观的看出来偏移是多少,但是如果看汇编或者反编译的代码,就能很直观的看出来了。
使用IDA进行attach动调,步骤如下:
1)先将IDA的linux_server64
拷贝到Linux中,并运行。
2)IDEA中运行代码,让断点卡在反射调用redefineClasses()
3)IDA中 "Debugger -> Attach -> Remote Linux Debugger"。Hostname填写Linux的ip。进程选择最后一个pid的java进程
4)Attach之后,在Modules窗口中找到libjvm.so
,并找到里头的jvmti_RedefineClasses
函数
5)在函数调用上打下断点,并点击IDA上方的绿色箭头,开始Attach调试
6)IDEA中放行代码,让程序卡在IDA的断点上
可以动调后,我们需要先找到jvmti_env
是怎么被赋值的,源码中是根据第一个形参,调用JvmtiEnv::JvmtiEnv_from_jvmti_env
获得
jvmtiEnter.cpp
static jvmtiError JNICALL
jvmti_RedefineClasses(jvmtiEnv* env,
jint class_count,
const jvmtiClassDefinition* class_definitions) {
....
JvmtiEnv* jvmti_env = JvmtiEnv::JvmtiEnv_from_jvmti_env(env);
.....
if (jvmti_env->get_capabilities()->can_redefine_classes == 0) {
return JVMTI_ERROR_MUST_POSSESS_CAPABILITY;
}
}
根据函数形参找,很容易发现,在IDA的反编译中jvmti_env
其实是v15
__int64 __fastcall jvmti_RedefineClasses(__int64 a1, int a2, __int64 a3)
{
....
v15 = ((__int64 (__fastcall *)(__int64))ZN12JvmtiEnvBase23JvmtiEnv_from_jvmti_envEP9_jvmtiEnv)(a1);
}
IDA中高亮追踪v15
,不难发现源码中的if (jvmti_env->get_capabilities()->can_redefine_classes == 0)
其实是
else if ( (*(_BYTE *)(((__int64 (__fastcall *)(__int64))ZN12JvmtiEnvBase16get_capabilitiesEv)(v15) + 1) & 2) != 0 )
点进ZN12JvmtiEnvBase16get_capabilitiesEv()
函数,函数内容是:
__int64 __fastcall ZN12JvmtiEnvBase16get_capabilitiesEv(__int64 a1)
{
return a1 + 408;
}
再将v15
的地址和IDEA中输出的jvmtiEnv
指针地址比较,发现v15 = jvmtiEnv - 8
综上所述,通过jvmtiEnv*
指针找到can_redefine_classes
地址运算方式是:jvmtiEnv - 8 + 408 + 1
。我们需要设置这块地址的值为2
,即可绕过can_redefine_classes
的判断。
最终成果
在调用redefineClasses()
前,手动设置jvmtiEnv - 8 + 408 + 1
的空间值为2
。
unsafe.putByte(jvmtiEnv - 8 + 408 + 1, (byte)0x2);
添加好后再次运行Demo,可以发现成功修改了类
不同版本的适用性
简单研究了下发现,不同版本的JDK,对应的can_redefine_classes
偏移位置不一样。而且release版和debug版的偏移似乎也不一样(也有可能是openjdk和oraclejdk的偏移不一样)。搜集不同版本jvmtiEnv*
指针到can_redefine_classes
的偏移量就是人工苦力活了,下面列出几个:
jdk11.0.13, jdk12.0.2 377
jdk1.8.202, jdk10.0.2 361
最后还需要注意,如果想同一个字节码能在多个JDk版本中顺利redefineClasses的话,编译字节码那个JDK版本要选低版本的。