ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

Spring-Interceptor内存马:原理、检测与防御实践

Spring-Interceptor内存马:原理、检测与防御实践 1. 从“内存马系列”到Spring-Interceptor为什么这个组件成了攻防焦点做Java安全这行的朋友近两年应该都有一个明显感受内存马已经从“小众炫技”变成了“必修课”。从Servlet API的Filter型、Tomcat的Valve型到Spring容器的Controller型、Bean型每一次Spring生态的普及都会带动一批新的注入思路被曝光、被修复、再被绕过循环往复。而我这次想聊的是Spring MVC体系里一个经常被忽略、但攻击价值极高的组件——Interceptor拦截器。为什么说它攻击价值高因为拦截器的执行时机天然“贴近业务”。它不像Filter那样位于Servlet容器层而是已经进入了Spring MVC的调用链对Handler的包裹更精确、更隐蔽。如果攻击者能在运行期向Spring容器注册一个恶意Interceptor就等于在每一次HTTP请求进入Controller之前插入了一段完全受控的逻辑——可以记录参数、篡改响应甚至直接短路请求返回伪造结果。这些行为落在框架层常规的RASP和Webshell查杀工具未必看得见。这篇内容我打算完全站在防御与检测的角度来拆解Spring-Interceptor内存马。我不会教你怎么打进去而是把原理讲透拦截器在Spring MVC里的生命周期是什么、内存马注入的本质是什么、为什么它比其他内存马更难清、以及我们到底能从哪里下手去发现它、处置它。适合谁来读一类是负责Java应用安全巡检的运维和蓝队同学你们需要知道除了Filter和Controller之外还有哪些隐蔽的注入点另一类是业务开发同学理解Spring容器的运行期变更风险才能在写代码时下意识避开“高危写法”。这篇文章不会太浅我默认你用过Spring Boot、看过一些内存马概念但即使你是刚接触我也会把关键链路一步步拆开讲清楚。2. 内存马的本质不是“马”是“运行期对象注入”在展开Interceptor之前我得先把一个底层认知对齐所谓内存马本质上是往运行期的进程里塞入一个“活着”的对象并让这个对象挂到某个会被反复触发的框架回调链上。2.1 为什么“内存”两个字这么关键传统Webshell是落一个文件到磁盘目录比如shell.jsp、cmd.php请求过来就能执行。而内存马不落盘只在内存里构造对象并注册。这就带来三个对防守方极其不友好的特性无文件痕迹磁盘扫描、Webshell特征扫描全部失效因为根本没有文件给你扫。进程重启即失不过这也意味着重启是终极大招只是业务连续性代价太高。隐蔽性取决于注入点公共组件里的注入点比业务代码里的更隐蔽因为它不产生业务日志甚至不走业务过滤链。理解了这个本质你就能明白防御方的思路永远只有两条要么在对象注册入口拦截比如Hook掉BeanFactory的registerSingleton方法要么在请求调用链路上做行为检测看有没有异常的handler出现在链条里。2.2 Spring容器为什么成了内存马的“温床”原因其实很朴素Spring的核心就是IoC容器而IoC容器是运行期动态的。你可以在进程跑起来之后随时向DefaultListableBeanFactory注册一个新的Bean也可以通过RequestMappingHandlerMapping注册一个新的接口映射甚至可以通过HandlerInterceptorRegistry追加一个新的Interceptor。这些能力本来是给开发同学做插件扩展、动态配置用的但攻击者只要能执行任意代码这些“合法能力”就会被拿来直接变成“持久潜伏能力”。这里有一个关键前提攻击者必须先拿到一次代码执行机会。所以内存马从来不是“入口”而是“跳板之后的落点”。常见的入口包括反序列化漏洞、表达式注入如SpEL、JSP文件上传绕过等。我们的防御重心其实是两层一层是尽量堵住入口这是另一个话题另一层就是今天这篇文章要聊的——在入口被突破后尽量发现落点、缩短驻留时间。3. Spring-Interceptor的工作原理与风险点拆解要防守Interceptor型内存马第一步就是把Spring MVC的请求处理链路搞清楚一个HTTP请求从进来到返回Interceptor到底在哪个环节、以什么顺序、被谁触发。3.1 Interceptor在Spring MVC处理链中的位置我把这条链路简化成四个环节DispatcherServlet 接收到请求 ↓ HandlerMapping 根据URL找到对应的HandlerExecutionChain ↓ HandlerExecutionChain 按注册顺序执行 Interceptor.preHandle() ↓ 进入 Controller 业务方法 ↓ 返回时逆序执行 Interceptor.postHandle() / afterCompletion()关键在第二步。DispatcherServlet拿到请求后会调用HandlerMapping.getHandler()得到一个HandlerExecutionChain这个Chain里面同时包含了目标Handler和一组Interceptor。也就是说Interceptor的执行并不由Spring容器直接管理而是被包装在HandlerExecutionChain里由DispatcherServlet统一调度。这就带来一个对内存马非常有利的点你不需要替换DispatcherServlet只需要往某个HandlerMapping的interceptors列表里追加一个对象即可。这个列表是ArrayList支持运行期动态添加而且Spring本身不会去校验这个对象是不是“注册过的Bean”只要你通过getHandler()返回了它它就会执行。3.2 三种典型注入路径全局、单映射、手工封装根据我的梳理Interceptor型内存马在真实场景里通常通过以下三条路径落地路径一通过HandlerInterceptorRegistry追加全局拦截器。这种方式最“合法”在代码里看起来和正常业务完全一致。攻击者拿到执行点后直接获取WebMvcConfigurer的registry或从WebApplicationContext里拿到RequestMappingHandlerMapping再对HandlerInterceptorRegistry进行操作。不过有个细节要留意HandlerInterceptorRegistry.addInterceptor()会做一次getInterceptor()的适配包装如果你传入的类实现了WebRequestInterceptor会被适配成WebRequestHandlerInterceptorAdapter否则作为普通HandlerInterceptor存起来。之后这个对象会被添加到所有注册的HandlerMapping里。因为涉及对象适配和跨HandlerMapping赋值这一步的动作会比直接改List更“显眼”在检测时属于高价值信号。路径二直接往HandlerMapping.adaptedInterceptors列表添加。这个就粗暴多了。AbstractHandlerMapping基类里有一个adaptedInterceptors属性是ListObject类型默认存的是MappedInterceptor或者HandlerInterceptor适配后的对象。攻击者如果能从容器里拿到RequestMappingHandlerMapping实例直接反射getAdaptedInterceptors()并add()一个构造好的Interceptor对象就完事了。不需要经过任何Registry校验、适配因为对象已经是最终的适配形态了。路径三自定义HandlerMapping并注册成Bean。这条路更隐蔽因为攻击者可以自己构造一个HandlerMapping实现类让它在getHandler()时返回一个带有恶意Interceptor的HandlerExecutionChain再把这个Bean注册进Spring容器。对Spring来说这只是一个新的HandlerMapping Bean框架层面完全合法。检测难度极高因为没有了“向现有对象添加元素”这个操作新增的是整个Bean。三种路径里路径一和路径二在检测上还有迹可循路径三几乎只能从父链路对比来发现。3.3 关键参数与回调方法preHandle才是“主战场”HandlerInterceptor接口有3个默认方法和1个旧版方法需要了解方法执行时机内存马利用价值preHandleController执行前最高可以完全控制请求是否进入业务直接返回内容postHandleController执行后、视图渲染前高可以篡改ModelAndView的内容afterCompletion整个请求结束后中适合做“事后记录类”的隐蔽操作boolean isAsyncStart/isAsyncDispatch旧版异步请求相关低一般用于适配异步场景对于内存马来说preHandle是绝对的主战场。原因很简单它执行最早、对请求的控制力最强。攻击者可以在preHandle里直接走一套“暗号校验”——比如某个请求头X-Token等于一个约定值就执行恶意逻辑、返回篡改响应否则原样放行。这样平时完全不触发流量特征极低。有人可能问postHandle也能拿到请求和响应对象为什么不选它因为preHandle返回false可以直接终止后续链路连Controller都不用进减少暴露面。同时从内存马驻留的稳定性来说preHandle不依赖业务代码执行结果请求一进来必然先经过它可靠性最高。4. 从“攻击逻辑”反推检测方案四个检测维度的落地实现前面原理部分我刻意站在攻方视角去拆解是因为防守方必须“知己知彼”。下面进入真正能落地的部分我们到底怎么发现系统里多了一个陌生的Interceptor我自己的实践经验是不要试图去“识别恶意”而是去“识别异常”。恶意Interceptor的代码可能是任何样子的但它的存在本身就是异常。所以检测思路可以拆成四个维度。4.1 维度一启动期快照比对——把“基线”建起来这是我最推荐、也最可靠的一种方式。思路就是在应用启动完成后主动记录一组“合法拦截器指纹”然后在运行期定期比对看多了什么。具体怎么做在ApplicationRunner或ApplicationListener里等Spring容器refresh完成之后遍历所有HandlerMapping取出HandlerExecutionChain里的拦截器列表对每个拦截器的Class名、所在ClassLoader、所在Jar包路径生成Hash指纹保存到一个只读的基准文件里。然后每隔一段时间或在收到敏感请求时重新采集一遍进行比对。采集的核心代码思路如下伪代码级别Component public class InterceptorBaselineCollector implements ApplicationRunner { // 注入ApplicationContext拿到所有HandlerMapping Override public void run(ApplicationArguments args) { MapString, HandlerMapping mappings applicationContext.getBeansOfType(HandlerMapping.class); for (Map.EntryString, HandlerMapping entry : mappings.entrySet()) { if (entry.getValue() instanceof AbstractHandlerMapping) { AbstractHandlerMapping mapping (AbstractHandlerMapping) entry.getValue(); // 反射获取adaptedInterceptors列表 ListObject interceptors (ListObject) ReflectionUtils.findField(AbstractHandlerMapping.class, adaptedInterceptors); // 对每个对象生成指纹 for (Object interceptor : interceptors) { String fingerprint generateFingerprint(interceptor); baseline.put(entry.getKey() : interceptor.getClass().getName(), fingerprint); } } } } }经验提示如果应用里有多个HandlerMapping尤其是用了Spring Security的时候拦截器列表会比较长。基线采集要注意过滤掉Spring自己注册的那些内部类不然误报会非常严重。我是用一个白名单前缀来过滤的比如org.springframework.security、org.springframework.web、org.springframework.boot开头的自动跳过。4.2 维度二运行期周期扫描——对“非基线”对象告警采集了基线之后运行期扫描就是水到渠成的事。这里我建议不要做成“定时任务傻傻地跑”而是结合实际情况分层高频检测每5到10分钟一次比对拦截器列表是否新增。因为内存马注入是一次性的周期不需要太短太短反而影响性能。事件驱动检测在高风险动作之后触发一次扫描。什么叫高风险动作比如出现了SpEL执行的告警日志、出现了反序列化异常的日志或者Thread.currentThread().getContextClassLoader()被异常调用这些都是入口被突破的信号此时立即做一次拦截器比对。扫描的时候除了比对新旧列表还要对每一个新出现的拦截器对象做几项“体检”第一它的ClassLoader是否属于Web应用本身。如果攻击者用自定义ClassLoader加载字节码对象的getClass().getClassLoader()往往不是LaunchedURLClassLoader或者WebappClassLoader而是一个陌生的类加载器实例这一条就能筛掉很多人。第二它的Class文件是否有对应的磁盘来源。通过ProtectionDomain.getCodeSource().getLocation()拿到的URL指向的Jar包是否存在于我们预期的Maven依赖清单里。如果指向一个临时目录、缓存目录、或者干脆是null那是高可疑信号。第三这个对象是否是Spring容器管理的Bean。正常拦截器绝大多数都是Component或者通过WebMvcConfigurer注册的在applicationContext.getBeanNamesForType()里通常能找到对应。而恶意内存马直接new出来放进去的没有BeanDefinition这一点在排查时非常管用。private boolean isSpringManaged(Object interceptor) { String[] beanNames applicationContext.getBeanNamesForType(interceptor.getClass()); return beanNames.length 0; }4.3 维度三请求链路的行为监控——从“执不执行”入手前面两种方案针对的是“列表里多了对象”但有一种情况它们会漏攻击者直接替换了某个原有HandlerMapping而不是往已有列表里加元素。这时候列表指纹可能没变化但行为变了。所以我建议在Spring MVC链路上埋一个“探针”对每个进入Controller的请求做一次拦截器命中情况记录。具体做法自己写一个HandlerInterceptor放在拦截器列表最前面的位置在WebMvcConfigurer里addInterceptor并通过order保证最优先在preHandle里遍历当前HandlerExecutionChain的所有拦截器并记录它们的类名。然后交给一个后台任务分析出现“从未见过的拦截器类名”时告警。这个方案本质上也是基线比对但维度和维度一不同它是从实际请求执行路径的“命中情况”来做避免了扫描静态列表看不到的运行时请求级差异。比如攻击者往adaptedInterceptors里塞了一个自定义包装类但包装类里的真实拦截器是在每次getHandler()时才new的静态扫描根本发现不了请求级探针却能抓到。如果你追求更低侵入性也可以不自己写拦截器而是基于HandlerExecutionChain做AOP切面切DispatcherServlet.doDispatch()方法在前置通知里拿到HandlerExecutionChain反射遍历拦截器数组。不过AOP切Spring自身Servlet的代价比加一个自定义拦截器更高稳定性上要谨慎权衡。我的建议是优先自定义拦截器方案实在不能改代码再上AOP。4.4 维度四结合Spring Security的Special Case如果你的项目用了Spring Security那情况会再复杂一层。因为Spring Security本身也大量依赖Filter和Interceptor而且它的SecurityInterceptor比如FilterSecurityInterceptor是包裹在Spring MVC的Filter链里的。攻击者有一条非常经典的进阶路径通过往Spring Security的过滤链里插入一个自定义Filter来“复用”安全上下文再把它伪装成Security内部组件。但这里我不建议深挖因为它是另一个巨大的主题。我只想强调一个点在Spring Security项目里做Interceptor基线时白名单要更加宽松并且要把Security的过滤器链和MVC的拦截器链分开比对。否则你会频繁误报最后把自己搞得“狼来了”。如果你实在想深挖关注三个地方就行了FilterChainProxy里有没有新增的FilterBean、WebSecurityConfiguration里有没有被注册的自定义SecurityFilterChain、以及DelegatingFilterProxy的targetBeanName是否指向了陌生Bean。这些和Interceptor型的检测方法论是相通的——都是基线比对加上行为观察。5. 实操一个可落地的拦截器检测/处置工具的设计要点说了这么多理论我干脆给你一个可以直接在项目里改造的小工具设计。它不需要你引入任何重量级组件只需要Spring Boot 基础反射API。5.1 采集模块定时与触发式双引擎工具整体上分三块。第一块是采集模块我设计为“定时基线事件触发”双引擎。定时引擎用Scheduled注解实现如果你对实时性要求高可以用ScheduledThreadPoolExecutor自定义频率。事件触发引擎手动提供一个scanNow()方法让运维同学在发现可疑行为时手动触发一次。两个引擎共用同一个scan()核心方法方法内部做以下几件事从ApplicationContext获取所有HandlerMappingBean。对每个AbstractHandlerMapping子类反射取出adaptedInterceptors列表。遍历列表对每个Interceptor生成对象哈希、类名、ClassLoader标识、代码源URL。和基线HashMap比对有差异立即输出告警对象。这里有一个技术细节反射读取adaptedInterceptors时Spring版本不同字段名有差异。Spring 5.0以前叫interceptorsSpring 5.0改名成adaptedInterceptors。如果你用的是旧版Spring配一个“字段名自动探测”的逻辑比较稳妥先尝试adaptedInterceptors反射找不到再试interceptors。5.2 告警模块去除“无害异常”的干扰比对之后一定会出现两类差异一类是真正的恶意注入另一类是业务代码运行期正常添加的拦截器。后者在成熟项目里并不少见比如动态配置中心下发的某个Feature Flag触发了运行时加拦截器。所以告警一定要带“置信度”概念不要一刀切。我自己在排查时用一套打分机制拦截器Class名以org.springframework、com.yourcompany等白名单开头正常以java.lang.reflect.Proxy或包含$Proxy加分。ClassLoader不是默认Web类加载器加分。代码源URL指向file:/tmp/、file:/var/tmp/或空加分。拦截器不是Spring管理的Bean前面提到用getBeanNamesForType判断加分。拦截器对象内部字段引用了类似于ProcessBuilder、ScriptEngine、Runtime等类加分。总分超过某个阈值才触发告警。这样能大大减少误报保证告警质量。5.3 处置模块三种止损手段的取舍发现内存马之后最痛苦的问题不是我发现了而是“怎么在不重启的情况下清掉它”。我按风险从高到低给你排序方案一重启应用。这是最彻底的但代价是业务中断。如果应用无状态、有多副本建议直接滚动重启。有状态应用就麻烦了得评估。方案二热删除拦截器。用反射把adaptedInterceptors里的对象移出List。这个方法理论上“无害”但有一个致命问题如果攻击者使用的是自定义HandlerMapping getHandler()时现new的拦截器你删掉列表是没有用的下次请求照样创建。所以热删除只适合路径一和路径二对于路径三必须在容器层面移除整个HandlerMapping Bean。方案三在DispatcherServlet层面加“门禁”。你可以用一个自定义的前置Filter在进入MVC链之前比对当前请求URL是否命中“异常拦截器名单”。本质上是用一个过滤器去“屏蔽”攻击者的执行路径。这个方案的好处是不改动已有拦截器列表坏处是请求还是走到了MVC只是被第三方Filter拦住了如果攻击者注入的节点在Filter之前依然会执行。我的最终建议能重启就重启不能重启就热删除加门禁双管齐下。但一定要记住内存马清除之后根本问题没有解决——攻击者的入口还在。如果不清入口清掉一个他还会再注一个。6. 排查过程中的常见坑与个人经验总结最后这部分我想写一点自己踩坑后的心得体会也算是对前面内容的一个补充。先说一个最常见的坑基线采集的时机不对。很多人在监听ApplicationReadyEvent时去采集但这个时候如果项目里有某些懒加载的Bean还没初始化对应的HandlerMapping可能还没有完全组装好拦截器列表导致基线本身就不完整。后来我改成监听ContextRefreshedEvent并延迟5秒再采集配合一次主动getHandler()触发其初始化基线才稳定下来。第二个坑Spring Security的误报率。我之前接过一个项目安全团队反馈“天天告警有内存马”我上去一看全是FilterSecurityInterceptor在不同URL pattern下被生成了多个实例各自的类名一样但Hash不同导致基线比对疯狂误报。后来我在生成指纹时取消了“实例Hash”改用“Class名代码源”作为比对键误报瞬间消失。记住这个教训比对Class维度的指纹而不是实例维度的指纹。第三个体会RASP不是万能但没有RASP真的不行。说实话纯靠日志和应用内巡检对于高级内存马几乎是“事后发现”。如果预算允许在Java应用里挂一个RASP重点HookReflectionFactory.newConstructorForSerialization、Method.invoke、ClassLoader.defineClass这几个关键点把“任意代码执行”的第一个动作扼杀在摇篮里效果要比任何事后排查都好。RASP的误报需要团队投入精力调优但对安全建设较完善的公司这是值得的。最后总结一句话Spring-Interceptor内存马的防御难点不在“发现技术”而在“发现思路”。你如果只盯着业务日志、扫描磁盘文件那你永远看不到内存马。把视角切换到容器本身、切换到运行期对象列表、切换到请求执行链路上那些藏得再深的东西也会慢慢浮出水面。排查内存马没有银弹基线、监控、门禁、重启一环扣一环做到哪一步算哪一步但至少要把“一看二排三处置”的流程跑起来才能在一次又一次的攻防对抗中积累出你的肌肉记忆。
返回列表