Java 内存马类型基础与扫描切入点

Java 内存马类型基础与扫描切入点

这篇算是冰蝎 4.1 Agent 型内存马分析之前的铺垫。
先把常见 Java 内存马分个类,看它们一般藏在哪里、代码形态长什么样、扫描工具应该从哪些位置下手。
文里的代码会保留真实扫描时需要识别的危险调用形态,比如 ProcessBuilder
但示例只使用固定的无害命令,不从请求参数读取命令,避免把文章写成可以直接复用的 WebShell。

Java 内存马类型与扫描切入点

目录

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
1. 先把内存马按位置分清楚
2. Web 组件型内存马
2.1 Filter 型
2.2 Servlet 型
2.3 Listener 型
3. Spring 生态里的内存马
3.1 Controller 型
3.2 Interceptor 型
3.3 HandlerMapping 型
4. 容器内部型内存马
4.1 Tomcat Valve 型
4.2 Tomcat Upgrade 型
5. 字节码 / Agent 型内存马
5.1 Java Agent 型
5.2 redefineClasses 型
5.3 Transformer 型
6. 扫描工具怎么落点
7. 小结

1. 先把内存马按位置分清楚

Java 内存马这个词很容易被说得很玄,但拆开看,其实就是一句话:

1
把一段恶意逻辑挂进 Java Web 请求处理链里,并且尽量不落地成普通文件

不同类型的内存马,区别主要在“挂到哪里”。

常见位置大概有几类:

1
2
3
4
Web 组件层:Filter / Servlet / Listener
框架路由层:Spring Controller / Interceptor / HandlerMapping
容器内部层:Tomcat Valve / Upgrade / Pipeline
JVM 字节码层:Java Agent / Instrumentation / redefineClasses

扫描时不能只问“有没有新 Filter”。Filter 型当然常见,但不是全部。像冰蝎 4.1 的 Agent 链,就更偏向 JVM 字节码层。

2. Web 组件型内存马

Web 组件型是最容易理解的一类。它们直接混进 Servlet 规范的请求处理链里。

2.1 Filter 型

Filter 本来就是用来拦请求的,所以它也很适合被滥用。请求进来以后,Filter 可以先看 URI、Header、参数,再决定是否继续往后走。

正常情况下,Filter 常用来做这些事:

1
2
3
4
5
6
登录校验
权限判断
编码处理
日志记录
跨域处理
请求耗时统计

它会排在 Servlet 前面,请求还没进入真正业务代码时就能先拿到 requestresponse。这也是它容易被当成内存马注入点的原因:位置靠前、覆盖面广、能拦截大量路径,还能在命中特定条件后直接写响应并中断后续链路。

实验室里的惰性样例可以长这样:

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
import javax.servlet.Filter;
import javax.servlet.FilterChain;
import javax.servlet.FilterConfig;
import javax.servlet.ServletException;
import javax.servlet.ServletRequest;
import javax.servlet.ServletResponse;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;

public class LabMarkerFilter implements Filter {
@Override
public void init(FilterConfig filterConfig) {
// Filter 初始化时由容器调用。
// 内存马场景里,这里常被用来保存配置、路径或触发条件。
}

@Override
public void doFilter(
ServletRequest request,
ServletResponse response,
FilterChain chain
) throws IOException, ServletException {
// ServletRequest / ServletResponse 是通用接口。
// Web 场景下一般会强转成 HttpServletRequest / HttpServletResponse,
// 这样才能读取 URI、Header、参数,也能直接写 HTTP 响应。
HttpServletRequest req = (HttpServletRequest) request;
HttpServletResponse resp = (HttpServletResponse) response;

// 这里用固定路径模拟“触发条件”。
// 真实内存马里,这个条件可能是特殊 URI、Header、Cookie 或参数。
if ("/lab/filter-marker".equals(req.getRequestURI())) {
// 命中触发条件后执行固定的无害命令。
// 对扫描器来说,ProcessBuilder / Runtime.exec 这类调用属于命令执行 sink。
runFixedLabCommand();

// 直接写响应,说明请求不会继续进入后面的 Servlet / Controller。
resp.setHeader("X-Lab-Marker", "filter");
resp.getWriter().write("filter marker");

// return 会中断过滤器链。
// 这也是 Filter 型内存马常见特征:命中特定条件后提前截断请求。
return;
}

// 未命中特殊条件时,把请求继续交给后面的 Filter / Servlet。
// 这样平时访问业务功能时不容易暴露异常。
chain.doFilter(request, response);
}

@Override
public void destroy() {
// Filter 销毁时调用。普通业务里用来释放资源。
}

private void runFixedLabCommand() throws IOException {
// 这里特意使用固定命令,不从 HTTP 参数里读取命令。
// Windows 下打开记事本;Linux 下执行 true,不产生破坏效果。
if (System.getProperty("os.name").toLowerCase().contains("win")) {
new ProcessBuilder("notepad.exe").start();
} else {
new ProcessBuilder("true").start();
}
}
}

这个例子用的是固定命令,不从请求里取命令参数。这里要看的不是功能本身,而是 Filter 型内存马常见的几个形态:

1
2
3
4
5
6
实现 Filter
重写 doFilter
判断请求特征
命中特征后提前返回
命中后触发 ProcessBuilder / Runtime 等敏感调用
否则继续 chain.doFilter

扫描时可以重点看:

1
2
3
4
运行时 Filter 列表里有没有来源异常的 Filter
FilterClass 是否来自非应用正常路径
doFilter 里是否有 Header / URI / 参数触发逻辑
是否出现 Base64 / AES / defineClass / 反射执行链

2.2 Servlet 型

Servlet 型更直接,它就是挂一个新的请求处理入口。

Servlet 原本就是 Java Web 里处理请求的基础单元。无论上层是 JSP、Spring MVC,还是别的框架,底层都绕不开 Servlet 体系。

正常业务里的 Servlet 一般负责:

1
2
3
4
5
接收 HTTP 请求
读取参数 / Body
处理业务逻辑
写 HTTP 响应
维护 URL 到处理类的映射

它之所以能成为内存马注入点,是因为只要运行时把一个新的 Servlet 实例和 URL 映射塞进容器,请求就能直接打到这个实例上。相比 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
import javax.servlet.ServletException;
import javax.servlet.http.HttpServlet;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;

public class LabMarkerServlet extends HttpServlet {
@Override
protected void doPost(
HttpServletRequest req,
HttpServletResponse resp
) throws ServletException, IOException {
// doPost 只处理 POST 请求。
// Servlet 型内存马通常会把隐藏路径映射到某个 Servlet 实例上。
if ("/lab/servlet-marker".equals(req.getRequestURI())) {
// 命中隐藏路径后执行固定无害命令。
runFixedLabCommand();

// 直接向客户端写响应。
resp.setHeader("X-Lab-Marker", "servlet");
resp.getWriter().write("servlet marker");
return;
}

// 非隐藏路径返回 404,避免暴露太多行为。
resp.sendError(404);
}

private void runFixedLabCommand() throws IOException {
// 扫描器需要关注的是 ProcessBuilder 这个命令执行点,
// 以及它是否被 HTTP 请求路径、参数或 Header 控制。
if (System.getProperty("os.name").toLowerCase().contains("win")) {
new ProcessBuilder("notepad.exe").start();
} else {
new ProcessBuilder("true").start();
}
}
}

这类内存马通常会有一个映射路径。扫描时可以从 Servlet 映射表入手:

1
2
3
4
Servlet 名称是否异常
ServletClass 是否异常
url-pattern 是否很隐蔽
实例是否没有对应的 web.xml / 注解来源

如果运行时能拿到 ServletContext、Tomcat StandardContext 或框架自己的映射表,就可以把“启动基线”和“当前状态”做对比。

2.3 Listener 型

Listener 不一定直接处理请求,但能监听请求创建、Session 创建、上下文初始化等事件。

Listener 原本用来监听 Web 应用生命周期和运行时事件。比如应用启动时初始化资源,Session 创建时记录在线用户,请求创建时做一些上下文准备。

常见 Listener 包括:

1
2
3
4
ServletContextListener
ServletRequestListener
HttpSessionListener
ServletRequestAttributeListener

它适合被当成注入点,是因为它不一定需要独立 URL。只要事件触发,Listener 就会被容器调用。对于 ServletRequestListener 来说,每次请求进入应用时都有机会执行逻辑,所以它更像一个隐藏在事件系统里的入口。

一个无害结构样例:

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
import javax.servlet.ServletRequestEvent;
import javax.servlet.ServletRequestListener;
import javax.servlet.http.HttpServletRequest;

public class LabMarkerListener implements ServletRequestListener {
@Override
public void requestInitialized(ServletRequestEvent sre) {
// 每个请求刚进入 Web 应用时,容器会调用 requestInitialized。
// Listener 型内存马不一定需要单独 URL 映射,因为它靠事件触发。
HttpServletRequest req = (HttpServletRequest) sre.getServletRequest();

// 这里仍然用 URI 做一个简单触发条件。
// 真实场景里也可能通过 Header、参数或 Session 属性判断。
if ("/lab/listener-marker".equals(req.getRequestURI())) {
// 命中特征后触发固定无害命令。
runFixedLabCommand();

// 这里写 request attribute 只是为了留下一个可观察标记。
req.setAttribute("lab.listener.marker", "true");
}
}

@Override
public void requestDestroyed(ServletRequestEvent sre) {
// 请求结束时由容器调用。
// 如果看到这里有敏感逻辑,也需要纳入扫描。
}

private void runFixedLabCommand() {
try {
// Listener 里通常不会直接写 response,
// 所以检测时更要关注它是否调用了 ProcessBuilder、反射、defineClass 等敏感 API。
if (System.getProperty("os.name").toLowerCase().contains("win")) {
new ProcessBuilder("notepad.exe").start();
} else {
new ProcessBuilder("true").start();
}
} catch (Exception ignored) {
}
}
}

Listener 型的麻烦点在于,它不一定有一个明显的 URL 映射。它可能在每个请求进入时都先跑一遍。

扫描时可以看:

1
2
3
4
5
ServletRequestListener / ServletContextListener / HttpSessionListener
Listener 实例的 ClassLoader
Listener 类来源路径
requestInitialized 里是否读取请求参数或 Header
是否存在动态类加载、反射、进程执行等敏感调用

3. Spring 生态里的内存马

Spring 应用里,很多请求不直接看 Servlet 映射,而是进 Spring MVC 的 HandlerMapping。

这类内存马的核心是:把恶意逻辑藏进 Spring 的路由体系里。

3.1 Controller 型

Controller 型很好理解,就是多出一个控制器方法。

Controller 是 Spring MVC 暴露 HTTP 接口的常规方式。正常业务里,@Controller@RestController 会配合 @RequestMapping@GetMapping@PostMapping 把某个 URL 绑定到一个 Java 方法。

它原本负责:

1
2
3
4
接收路由请求
绑定参数
调用业务 Service
返回 JSON / 页面 / 文件

它会被当成内存马注入点,是因为 Spring 应用的路由都集中在 HandlerMapping 里。只要能在运行时注册一个新的 Controller 或 HandlerMethod,就相当于在应用里多了一条隐藏路由。

惰性样例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RestController;

@RestController
public class LabMarkerController {
// @PostMapping 会把这个 Java 方法绑定到 /lab/controller-marker。
// 也就是说,请求命中这个路径时,Spring MVC 会调用 marker()。
@PostMapping("/lab/controller-marker")
public String marker() {
// 这里返回字符串,Spring 会把它写入 HTTP 响应体。
// 如果这个方法里出现命令执行、动态类加载等逻辑,就需要重点检查。
return "controller marker";
}
}

正常业务里 Controller 很多,所以只靠“发现一个新 Controller”没法判断。

更实际的检测思路是:

1
2
3
4
运行时 HandlerMapping 中的路径是否偏离基线
Controller 类是否来自临时目录、上传目录、JSP 编译目录
方法体里是否有反射、Base64、AES、defineClass 等组合
是否有非常规 Header / 参数作为触发条件

3.2 Interceptor 型

Interceptor 和 Filter 很像,但它位于 Spring MVC 层。

Interceptor 原本是 Spring MVC 用来在 Controller 前后插入逻辑的机制。它比 Filter 更贴近 Spring,能拿到即将执行的 handler,也更适合做业务层面的权限、审计和日志。

常见用途包括:

1
2
3
4
5
登录态校验
接口权限判断
审计日志
灰度标记
Controller 前后置处理

它能成为注入点,是因为 preHandle 会在 Controller 方法执行前触发。如果这里判断某个特殊路径或 Header,然后直接写 response 并返回 false,后面的业务 Controller 就不会再执行。

惰性样例:

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
import org.springframework.web.servlet.HandlerInterceptor;

import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;

public class LabMarkerInterceptor implements HandlerInterceptor {
@Override
public boolean preHandle(
HttpServletRequest request,
HttpServletResponse response,
Object handler
) throws Exception {
// preHandle 会在 Controller 方法执行前被调用。
// 这里可以提前读取请求路径、Header、参数,也可以直接写响应。
if ("/lab/interceptor-marker".equals(request.getRequestURI())) {
// 命中特殊路径后触发固定无害命令。
runFixedLabCommand();

// 直接写响应,说明请求在 Spring MVC 层就被截住了。
response.setHeader("X-Lab-Marker", "interceptor");
response.getWriter().write("interceptor marker");

// 返回 false 表示不再继续调用后面的 Controller。
// 这是 Interceptor 型内存马很重要的行为特征。
return false;
}

// 返回 true 表示放行,请求继续进入正常 Controller。
return true;
}

private void runFixedLabCommand() throws Exception {
// 固定命令只用于展示命令执行 sink。
// 扫描时要看这个 sink 是否被请求特征保护起来,或者是否能被外部输入控制。
if (System.getProperty("os.name").toLowerCase().contains("win")) {
new ProcessBuilder("notepad.exe").start();
} else {
new ProcessBuilder("true").start();
}
}
}

扫描时可以看:

1
2
3
4
HandlerExecutionChain 里是否多出陌生 Interceptor
Interceptor 类加载器是否异常
preHandle 里是否有请求特征判断
是否绕过正常 Controller 直接写 response

3.3 HandlerMapping 型

HandlerMapping 型更隐蔽一些。它不是简单加一个 Controller 类,而是直接往 Spring 的映射表里塞一条路由。

HandlerMapping 是 Spring MVC 查找“这个请求该交给谁处理”的核心表。浏览器请求进来以后,Spring 会根据路径、HTTP 方法、Header 等信息,在 HandlerMapping 里找到对应的 HandlerMethod。

它原本负责:

1
2
3
4
保存 URL 到 Controller 方法的映射
根据请求匹配 Handler
维护路径条件、方法条件、参数条件
把请求交给 HandlerAdapter 执行

它适合作为内存马注入点,是因为攻击者不一定需要真的新增一个普通 Controller 类。只要能改运行时映射表,就能让一个已有对象或动态对象接管某个隐藏路径。扫描时如果只看源码里的注解,很容易漏掉运行时被塞进去的映射。

检测时更适合从运行时映射表下手:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
// 从 Spring 容器里取出 RequestMappingHandlerMapping。
// 这张表保存了“URL -> Controller 方法”的运行时映射。
RequestMappingHandlerMapping mapping = applicationContext
.getBean(RequestMappingHandlerMapping.class);

// getHandlerMethods() 可以拿到当前已经注册的所有路由。
// 做扫描器时,可以把这份结果和应用启动时的基线做对比。
Map<RequestMappingInfo, HandlerMethod> methods =
mapping.getHandlerMethods();

for (Map.Entry<RequestMappingInfo, HandlerMethod> entry : methods.entrySet()) {
// RequestMappingInfo 里包含路径、HTTP 方法、参数条件等匹配规则。
RequestMappingInfo info = entry.getKey();

// HandlerMethod 表示最终会被调用的 Controller 方法。
// 重点看它的 beanType、method、ClassLoader 和 class 来源路径。
HandlerMethod method = entry.getValue();

// 这里打印出来只是为了观察运行时路由表。
// 如果某条路径源码里没有、启动基线里也没有,就值得继续追。
System.out.println(info + " -> " + method.getBeanType().getName());
}

这段代码本身是防护侧枚举逻辑,可以用来做基线:

1
2
3
启动时记录 HandlerMapping
运行中定期比对
发现新增路径后检查 HandlerMethod 来源

4. 容器内部型内存马

再往下就是容器内部。以 Tomcat 为例,请求进入应用前后,会经过 Connector、Pipeline、Valve、Wrapper 等组件。

这类内存马不一定出现在 Servlet 规范里的列表中。

4.1 Tomcat Valve 型

Valve 是 Tomcat Pipeline 里的处理节点。

Tomcat 的请求处理不是一下子就到 Servlet。请求会经过一条 Pipeline,Pipeline 里可以挂多个 Valve。Tomcat 自己也用 Valve 做访问日志、错误处理、认证等功能。

Valve 原本常用于:

1
2
3
4
5
访问日志
认证鉴权
Host / Context 级别处理
错误页处理
请求前后置扩展

它会被当成内存马注入点,是因为它比 Filter 更靠近容器底层。Valve 可以在请求进入具体 Web 应用前后执行,而且不一定会出现在 Servlet 规范的 Filter / Servlet / Listener 列表里。只查 Web 组件注册表时,很容易看不到它。

一个无害的结构样例:

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
import org.apache.catalina.Valve;
import org.apache.catalina.connector.Request;
import org.apache.catalina.connector.Response;
import org.apache.catalina.valves.ValveBase;

import javax.servlet.ServletException;
import java.io.IOException;

public class LabMarkerValve extends ValveBase {
@Override
public void invoke(Request request, Response response)
throws IOException, ServletException {
// invoke 是 Valve 的核心入口。
// 请求经过 Tomcat Pipeline 时,每个 Valve 都有机会处理一次。
if ("/lab/valve-marker".equals(request.getRequestURI())) {
// 命中特殊 URI 后触发固定无害命令。
runFixedLabCommand();

// Valve 比 Filter 更靠近 Tomcat 内部,也可以直接写响应。
response.addHeader("X-Lab-Marker", "valve");
response.getWriter().write("valve marker");

// 提前 return,后续 Valve / 应用逻辑可能不会继续执行。
return;
}

// getNext() 拿到 Pipeline 里的下一个 Valve。
// 正常 Valve 处理完后应该继续调用 next.invoke。
Valve next = getNext();
if (next != null) {
next.invoke(request, response);
}
}

private void runFixedLabCommand() throws IOException {
// 这里仍然使用固定无害命令,保留 ProcessBuilder 形态方便讲扫描规则。
if (System.getProperty("os.name").toLowerCase().contains("win")) {
new ProcessBuilder("notepad.exe").start();
} else {
new ProcessBuilder("true").start();
}
}
}

Valve 型扫描可以看:

1
2
3
4
StandardContext / StandardHost / Engine 的 Pipeline
Pipeline 中 Valve 链是否和启动基线一致
Valve 类来源是否异常
invoke 方法里是否直接处理请求并提前返回

4.2 Tomcat Upgrade 型

Upgrade 本来用于协议升级,比如 WebSocket。滥用时可能把特殊请求导到自定义处理逻辑。

HTTP Upgrade 原本是协议升级机制。最常见的场景是 WebSocket:客户端先发一个普通 HTTP 请求,带上 Upgrade 相关 Header,服务端确认后切换到新的通信协议。

它原本负责:

1
2
3
4
识别 Upgrade 请求
切换协议处理器
维护升级后的连接
支持 WebSocket 等长连接场景

它会成为注入点,是因为它处在 Connector / ProtocolHandler 这一层,位置更偏底层。特殊 Header 命中后,请求可能不走普通 Servlet 路由,而是进入自定义 Upgrade 处理器。对于只枚举 Web 应用组件的扫描器来说,这一类比较容易漏。

这类不建议只靠字符串搜,最好结合运行时结构看:

1
2
3
4
Connector ProtocolHandler
UpgradeProtocol 列表
WebSocket / HTTP Upgrade 处理器
是否出现异常协议名或异常实现类

扫描时可以把它当成“容器连接层扩展点”来处理。它不在普通 Servlet 映射表里,漏扫概率比较高。

5. 字节码 / Agent 型内存马

这类和前面的组件型不一样。它不一定新增组件,而是直接改已经加载的类。

冰蝎 4.1 的 Agent 链就是这个方向:

1
2
3
4
找到请求入口类
生成 Hook 版字节码
拿到 Instrumentation
redefineClasses 替换已加载类

5.1 Java Agent 型

Agent 型内存马是这篇前置内容里最应该单独拎出来的一类。因为它不一定往 Web 容器里新增 Filter、Servlet 或 Listener,而是先拿到 Instrumentation,再修改已经加载的类。

Java Agent 原本是 JVM 提供给监控、调试、性能分析和 APM 工具使用的扩展机制。像链路追踪、方法耗时统计、覆盖率采集、热更新,有不少都会用到 Agent。

它原本能做的事包括:

1
2
3
4
5
6
JVM 启动时加载 Agent
运行中 attach 到目标 JVM
注册 ClassFileTransformer
修改类字节码
触发 retransform / redefine
采集方法调用和性能数据

它会被当成内存马注入点,是因为权限太靠近 JVM 核心。一旦拿到 Instrumentation,就不只是“新增一个 Web 组件”,而是可以修改已经加载的类。请求入口类、FilterChain、Tomcat 内部类都可能被重写,这也是 Agent 型内存马比普通组件型更麻烦的地方。

Java Agent 的入口一般长这样:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
import java.lang.instrument.Instrumentation;

public class LabAgentEntry {
// premain:JVM 启动时通过 -javaagent 加载 Agent 会进入这里。
// 常见于 APM、监控、覆盖率工具。
public static void premain(String agentArgs, Instrumentation inst) {
install(agentArgs, inst);
}

// agentmain:JVM 已经运行后,通过 attach + loadAgent 加载 Agent 会进入这里。
// 冰蝎 Agent 注入链更接近这个入口。
public static void agentmain(String agentArgs, Instrumentation inst) {
install(agentArgs, inst);
}

private static void install(String agentArgs, Instrumentation inst) {
// Instrumentation 是 Agent 能修改类的关键对象。
// addTransformer 表示注册一个类字节码转换器。
// 第二个参数 true 表示支持对已加载类做 retransform。
inst.addTransformer(new LabAgentTransformer(), true);
}
}

premain 是 JVM 启动时通过 -javaagent 进来的入口,agentmain 是运行中 attach 进来的入口。冰蝎这类注入链更关注后者:

1
2
3
4
5
VirtualMachine.attach
-> loadAgent
-> agentmain
-> 获取 Instrumentation
-> addTransformer / redefineClasses

Agent Jar 里还会有一个 Manifest,指定 Agent 入口类:

1
2
3
4
Manifest-Version: 1.0
Agent-Class: LabAgentEntry
Can-Redefine-Classes: true
Can-Retransform-Classes: true

真正要盯的是这些点:

1
2
3
4
5
6
运行中 attach 到当前 JVM
loadAgent 加载临时 Jar
agentmain 被调用
Instrumentation 被保存或继续传递
addTransformer / redefineClasses / retransformClasses
目标类命中 Servlet / FilterChain / Tomcat 核心链路

为了让扫描器能识别真实危险调用,Agent 内部的 Hook 逻辑也可能包含固定命令触发点。下面这个方法本身不接收外部参数,只用来展示扫描时应该抓到的 ProcessBuilder 形态:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
public class LabAgentCommandMarker {
public static void runFixedLabCommand() {
try {
// 这个方法代表“被插入到目标类里的敏感逻辑”。
// 如果它出现在 HttpServlet.service 这种入口方法里,
// 就说明请求路径可能触发了非业务命令执行逻辑。
if (System.getProperty("os.name").toLowerCase().contains("win")) {
new ProcessBuilder("notepad.exe").start();
} else {
new ProcessBuilder("true").start();
}
} catch (Exception ignored) {
}
}
}

如果这段逻辑被插进 HttpServlet.service()ApplicationFilterChain.doFilter()ServletStubImpl.execute() 这类入口方法里,就已经不是普通 Agent 插桩了。

5.2 redefineClasses 型

防护侧可以用一个很小的示意代码理解它的落点:

redefineClasses 本来是给调试器、热修复、Agent 插桩用的能力。它允许在 JVM 运行时,把某个已经加载的类替换成新的字节码版本。

正常用途里,它可能出现在:

1
2
3
4
5
APM 方法增强
在线诊断工具
热补丁
测试覆盖率工具
运行时调试工具

它被滥用的原因也很直接:不需要新增 Filter,不需要新增 Servlet,只要把原来的入口类改掉,请求还是走原来的类名和调用链,但方法体已经变了。外面看映射表可能一切正常,真正异常藏在字节码里。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
import java.lang.instrument.ClassDefinition;
import java.lang.instrument.Instrumentation;

public class RedefineShape {
public static void redefine(
Instrumentation instrumentation,
Class<?> targetClass,
byte[] newBytes
) throws Exception {
// ClassDefinition 把“要替换哪个类”和“替换成哪些字节码”绑在一起。
// targetClass 如果是 HttpServlet / FilterChain / Tomcat 核心类,就要格外敏感。
ClassDefinition definition =
new ClassDefinition(targetClass, newBytes);

// redefineClasses 会把 JVM 中已经加载的类替换掉。
// 外部看到的类名不变,但方法体可能已经被改写。
instrumentation.redefineClasses(definition);
}
}

这个例子没有提供 Instrumentation 获取方式,也没有提供可用的替换字节码。这里只看形态:

1
2
3
4
ClassDefinition
目标 Class
新字节码
Instrumentation.redefineClasses

真正值得警惕的是目标类是谁。

如果被重定义的是业务普通类,可能是 APM、热更新或调试工具。如果被重定义的是这些入口类,就要认真看了:

1
2
3
4
javax.servlet.http.HttpServlet
jakarta.servlet.http.HttpServlet
weblogic.servlet.internal.ServletStubImpl
org.apache.catalina.core.ApplicationFilterChain

5.3 Transformer 型

Agent 也可能通过 ClassFileTransformer 在类加载或重转换时改字节码。

Transformer 原本是 Java Agent 用来“拦截类加载并改字节码”的接口。类加载时,JVM 会把原始 class 字节数组交给 Transformer,Transformer 可以返回修改后的字节码。

正常场景里,Transformer 常用于:

1
2
3
4
5
方法耗时插桩
日志增强
链路追踪
安全探针
代码覆盖率统计

它能成为注入点,是因为它正好站在“类字节码进入 JVM”这条路径上。如果 Transformer 命中了 Servlet、FilterChain、Controller、Tomcat Valve 等关键类,就可以在这些类的方法里插入额外逻辑。

结构大概是:

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
import java.lang.instrument.ClassFileTransformer;
import java.lang.instrument.IllegalClassFormatException;
import java.security.ProtectionDomain;

public class LabMarkerTransformer implements ClassFileTransformer {
@Override
public byte[] transform(
// loader:加载当前类的 ClassLoader。
// 扫描时可以通过它判断类来自 WebAppClassLoader、系统类加载器还是异常加载器。
ClassLoader loader,

// className:JVM 内部格式的类名,用 / 分隔。
// 例如 javax.servlet.http.HttpServlet 会变成 javax/servlet/http/HttpServlet。
String className,

// classBeingRedefined:如果是重转换 / 重定义已加载类,这里可能不为空。
Class<?> classBeingRedefined,

// protectionDomain:包含代码来源、权限域等信息。
// 防护工具可以结合 CodeSource 判断 class 来自哪个 jar 或目录。
ProtectionDomain protectionDomain,

// classfileBuffer:原始 class 字节码。
// Transformer 可以返回新的 byte[],JVM 会加载返回后的版本。
byte[] classfileBuffer
) throws IllegalClassFormatException {
// 这里用 HttpServlet 做例子,因为它是很多 Java Web 请求链的入口类。
// 如果 Transformer 命中了这种入口类,就需要继续看它有没有插入额外逻辑。
if ("javax/servlet/http/HttpServlet".equals(className)) {
// 返回原始字节码表示“不修改,只放行”。
// 真正的恶意 Transformer 通常会在这里返回修改后的字节码。
return classfileBuffer;
}

// 返回 null 表示这个类不处理,让 JVM 继续使用原始字节码。
return null;
}
}

这段只是展示 Transformer 的形态,没有修改字节码。

扫描时可以盯:

1
2
3
4
是否加载了 javaagent
是否出现自定义 ClassFileTransformer
是否发生 retransformClasses / redefineClasses
Transformer 是否命中 Servlet / FilterChain / Tomcat 核心类

6. 扫描工具怎么落点

内存马类型很多,但扫描工具不用一开始就全做满。可以按“越靠近请求入口,优先级越高”的思路来排。

第一层,先查 Web 组件:

1
2
3
4
5
FilterRegistration
ServletRegistration
Listener 列表
Spring HandlerMapping
Interceptor 链

第二层,查容器内部扩展点:

1
2
3
Tomcat Pipeline / Valve
Wrapper / Context 内部结构
UpgradeProtocol

第三层,查字节码和 JVM 行为:

1
2
3
4
5
关键入口类 hash
Instrumentation.redefineClasses
ClassFileTransformer
ClassLoader#defineClass
JSP 编译目录中的异常类

最后再做特征组合,而不是靠单点判断:

1
2
3
4
5
6
7
8
9
请求路径判断
只处理 POST
读取请求体
Base64 / AES
反射 setAccessible
defineClass
newInstance / equals
直接写 response
提前 return

单个点不一定有问题,组合在一起才有价值。

比如下面这种组合就很值得报警:

1
2
3
4
5
入口类被 redefine
+ 入口方法里出现路径匹配
+ 请求体解密
+ defineClass 动态加载
+ equals / 反射触发执行

7. 小结

Java 内存马可以先按挂载位置来理解:

1
2
3
4
组件型:Filter / Servlet / Listener
框架型:Controller / Interceptor / HandlerMapping
容器型:Valve / Upgrade / Pipeline
字节码型:Agent / Transformer / redefineClasses

前几类更像“多挂了一个处理节点”,Agent 型更像“把原来的节点改了”。

所以做扫描工具时,不能只看运行时注册表。注册表能抓一批,但抓不到全部。

更稳的做法是把三件事结合起来:

1
2
3
运行时组件枚举
关键入口类字节码完整性
敏感 JVM 行为监控

这样再回头看冰蝎 4.1 的 Agent 注入链,就会更清楚:它不是新增一个普通 Web 组件,而是借助 Instrumentation 把请求入口类改成了带 Hook 的版本。


Java 内存马类型基础与扫描切入点
https://pwned.icu/2026/06/10/Java内存马类型基础与扫描切入点/
作者
m0b1u3
发布于
2026年6月10日
许可协议