前阵子给一个老项目做安全审计,翻到一处动态规则校验的代码。大意是根据前端传入的字符串走SpEL解析,再对结果做布尔判断。这行代码看起来只是"取个值",但当我顺着表达式注入的路径往下摸的时候,冷汗差点下来——如果这段代码暴露到公网,攻击者不需要任何过滤器就能直接执行任意命令,等于把服务器控制台拱手让人。
Spring里的EL表达式,也就是SpEL(Spring Expression Language),是Spring生态里被用得最多、同时又最容易被低估的组件之一。你可能在@Value注解里见过它,在@PreAuthorize权限注解里见过它,在规则引擎、配置中心、动态查询里也见过它。但绝大多数人只把它当成"字符串模板渲染器"在用,压根没想过这个表达式本身是个运算环境,甚至是一个风险极高的运行时入口。今天这篇就把SpEL的安全问题和扩展机制一次性讲透,适合正在用Spring Boot做业务系统、或者正在做安全加固的同学参考。
1. SpEL表达式注入到底是怎么发生的:一个上下文类引发的反射逃逸
先说结论:SpEL表达式注入的本质是表达式解析时使用了过于宽泛的EvaluationContext。只要上下文给了表达式足够的权限,攻击者就能在表达式里构造完整的反射调用链,最终把"读一个值"变成"执行一段命令"。
1.1 从两个Context的区别说起
SpEL的求值核心是EvaluationContext接口,Spring默认提供两个实现:
| Context实现 | 安全边界 | 典型使用场景 |
|---|---|---|
StandardEvaluationContext | 支持引用类、调用方法、反射访问任意对象,相当于完全信任表达式 | 框架内部、脚本引擎、动态规则等可信来源 |
SimpleEvaluationContext | 只支持指定范围内的属性访问和方法调用,禁止类引用、禁止危险类型 | 解析外部输入、用户自定义校验逻辑 |
问题就出在,很多业务代码图省事,直接用了StandardEvaluationContext。这个上下文里内置了ReflectivePropertyAccessor,它会通过反射自动解析表达式里的属性或方法调用。这意味着表达式里可以写T(java.lang.Runtime)这种类引用语法,直接拿到java.lang.Runtime这个Class对象,然后正常调用getRuntime()、exec()。
我见过一个很典型的例子,写在Spring Boot的Controller里:
@PostMapping("/validate") public boolean validate(@RequestBody String expression) { ExpressionParser parser = new SpelExpressionParser(); // 危险:直接把前端输入的表达式交给StandardEvaluationContext执行 EvaluationContext context = new StandardEvaluationContext(); return Boolean.TRUE.equals(parser.parseExpression(expression).getValue(context)); }这段代码里,前端随便传一个T(java.lang.Runtime).getRuntime().exec("touch /tmp/pwned"),后端就会帮你把命令执行一遍。整个链路没有任何过滤,因为SpEL的类引用机制就是这么设计的——它本身是给"可信表达式"用的,不是给你当字符串解析器用的。
1.2 一条完整的反射逃逸链是怎么走通的
为了让你彻底理解攻击路径,我把这条链拆开看:
- 表达式解析器把字符串解析成语法树。
- 求值时,
StandardEvaluationContext允许通过T()操作符引用任意类的Class对象。 - 反射调用该类的方法时,
ReflectivePropertyAccessor和ReflectiveMethodExecutor会尽力找到匹配的方法并执行。 - 由于
Runtime.exec()是公开方法,反射可以直接调用,于是任意命令执行成立。
如果只想探活,可以传这样的表达式:
T(java.lang.Runtime).getRuntime().exec("id")更深入的攻击者会把这个封装成反弹Shell、写计划任务、下载木马等。只要表达式能被用户控制,服务器就是砧板上的肉。
在审计中,我常跟团队说的一句话是:凡是把用户输入拼进parseExpression()的地方,都必须默认它已经失守,然后按失守的假设去设计防御。
1.3 为什么SimpleEvaluationContext能挡住大多数攻击
SimpleEvaluationContext之所以安全,是因为它主动砍掉了T()类引用、构造函数解析、静态方法调用这些能力,并且只允许通过setRootObject()或者注册的PropertyAccessor访问预先指定的对象。同样的攻击表达式丢进去,解析阶段就会直接抛出SpelEvaluationException。
EvaluationContext context = SimpleEvaluationContext .forReadOnlyDataBinding() .withInstanceMethods() .build();这样你可以在表达式里调用root对象上的公开方法,但不能T(...)随便引用类。相当于给表达式一个"沙箱",沙箱里只有你放进去的东西。
但这里要提醒一句:SimpleEvaluationContext也不是万能安全柜。它依然允许通过root对象的方法间接访问其他对象,所以root对象本身要干净,不能随便把ApplicationContext、Runtime这类对象塞进去。
2. 从审计视角排查:项目里哪些入口最容易把SpEL暴露给攻击者
SpEL注入不会自己冒出来,它一定依附在某个业务入口上。我在做安全测试的时候总结了一套排查思路,按优先级列出来,你可以对照自己的项目检查一遍。
2.1 高危入口:动态规则、策略配置、表达式校验
这种入口最常见,也最致命。典型场景包括优惠券规则、审批流条件、工作流表达式、数据权限字段等。业务上为了"灵活",会把一段表达式存在数据库或者配置中心,运行时用SpEL解析。如果这些规则能通过管理后台编辑,而后台权限控制不严,攻击者一旦拿到低权限账号,就能通过修改规则实现远程代码执行。
更隐蔽的是把前端参数直接传给SpEL解析的接口。我之前在某个项目里看到过这样的代码:
public boolean checkRule(String rule) { return Boolean.TRUE.equals( parser.parseExpression(rule).getValue(new StandardEvaluationContext(user)) ); }这个方法的调用链路里,rule来自请求体。这就是标准的表达式注入漏洞。修复方式很简单,第一件事就是把StandardEvaluationContext换成受限上下文,第二件事是把rule的来源限制为服务端可信配置,绝不能来自前端。
2.2 中危入口:@Value注解与配置中心属性
@Value("#{...}")里写SpEL,本身不会直接导致注入,因为正常项目的配置项是运维维护的。但如果配置中心暴露了编辑接口,或者配置存放在Git仓库且权限管理混乱,攻击者就能通过篡改配置,塞进去一个恶意表达式。比如:
# 原本是正常公式 discount.rate=#{T(java.lang.Math).min(0.8, 0.9)}被篡改后变成:
discount.rate=#{T(java.lang.Runtime).getRuntime().exec("calc")}下次Bean初始化或者配置刷新的时候就会执行。这个风险点在攻防演练中常被利用,尤其是配置中心没有做内容校验、没有做敏感操作审计时。
这里我的建议是:凡是配置文件里出现的SpEL表达式,尽量用简单的占位符(${})代替。确实需要计算逻辑的,用Java代码显式实现,不要让SpEL成为配置项的标准语言。除非你能确保配置中心权限绝对可靠,否则就尽量缩小暴露面。
2.3 低危但需要关注:Spring Security注解表达式
@PreAuthorize("hasRole('ADMIN')")这类注解依赖SpEL解析方法权限表达式。正常来说,表达式字符串是写死在代码里的,攻击者一般改不了。但有一种情况例外:如果你在代码里动态组装表达式字符串,比如把用户输入的参数拼进表达式里,就会引入注入面:
@PreAuthorize("hasRole('" + request.getParameter("role") + "')")这行代码看似在做权限控制,实际上拼接了不可信输入。攻击者传一个role=ADMIN') or hasRole('USER之类的字符串,轻则绕过鉴权,重则结合上下文搞出更严重的问题。权限表达式一定要用常量,绝不允许动态拼接。
2.4 特殊入口:Spring AI 与模板渲染
最近Spring AI很火,它在PromptTemplate、结构化输出等场景里也用了类似模板渲染的表达式机制。虽然大部分模板变量只是普通占位符,但一旦某个模板里允许嵌入表达式语法,并且模板内容由外部输入拼接,就值得按SpEL注入的视角去评估。我建议团队在引入Spring AI相关功能时,把"表达式渲染"和"不可信输入"隔离成两条链路,不要在一条流程里既渲染模板又执行用户可控表达式。
3. 加固方案实操:从上下文隔离到依赖升级,一个都不能少
定位完风险点,接下来就是动手加固。我按实施难度从低到高给出一套方案,你可以照着操作。
3.1 第一道防线:上下文隔离
这是成本最低、见效最快的一步。规则很简单:可信表达式用StandardEvaluationContext,不可信表达式一律用SimpleEvaluationContext,并且在构建时按需开启能力。
public class SpelSafetyUtils { /** * 只读场景:解析外部输入时使用只读上下文, * 不允许写数据,不允许调用任意方法。 */ public static EvaluationContext readOnlyContext(Object root) { return SimpleEvaluationContext .forReadOnlyDataBinding() .withRootObject(root) .build(); } /** * 需要调用root对象有限公开方法时,显式指定方法白名单。 */ public static EvaluationContext limitedMethodContext(Object root) { return SimpleEvaluationContext .forReadWriteDataBinding() .withInstanceMethods() .withRootObject(root) .build(); } }使用的时候:
ExpressionParser parser = new SpelExpressionParser(); EvaluationContext context = SpelSafetyUtils.readOnlyContext(user); Boolean result = parser.parseExpression(expression).getValue(context, Boolean.class);如果表达式来源不可信,务必在构建上下文前把表达式长度和字符集也限制住,防止超长表达式导致解析器资源耗尽。
3.2 第二道防线:自定义白名单策略
有些场景下,SimpleEvaluationContext的能力实在不够用,需要让表达式调用一些自定义方法。这时候不要放开StandardEvaluationContext,而是通过自定义方法实现白名单调用。
@Component public class CustomFunctions { public boolean checkAge(User user, int limit) { return user.getAge() >= limit; } } // 注册为SpEL自定义函数 StandardEvaluationContext context = new StandardEvaluationContext(); context.registerFunction("checkAge", CustomFunctions.class.getMethod("checkAge", User.class, int.class));表达式里只允许写#checkAge(#root, 18),无法直接引用T(...)或者Runtime。这就在"灵活"和"安全"之间取了平衡点。
如果你用的是Spring Security的@PreAuthorize,也可以通过自定义SecurityExpressionRoot或PermissionEvaluator来实现白名单式的方法授权,而不是让用户在表达式里随意拼逻辑。
3.3 第三道防线:类过滤器与方法过滤器
如果确实有部分可信场景必须用StandardEvaluationContext,那也千万别裸奔。SpEL提供了ReflectivePropertyAccessor和ReflectiveMethodExecutor的拦截机制,可以限制可以被反射访问的类和被执行的方法。
public class SafeMethodExecutor implements MethodExecutor { private static final Set<String> ALLOWED_METHODS = Set.of("getName", "getId"); @Override public TypedValue execute(EvaluationContext context, Object target, Object... arguments) throws AccessException { return null; } }更彻底的做法是结合ClassFilter,只允许解析指定包下的类:
public class SafeClassFilter implements ClassFilter { @Override public boolean matches(Class<?> clazz) { return clazz.getName().startsWith("com.example.domain."); } }这个方案实现成本偏高,对于大部分业务项目来说,简单地把上下文换成SimpleEvaluationContext已经能解决90%的问题。过滤器方案更适合那些必须开放部分表达式能力的产品化平台。
3.4 第四道防线:依赖版本与扫描策略
SpEL解析器本身也是历史CVE重灾区。Spring Framework历年来有过多个与SpEL相关的漏洞,尤其是某些版本里StandardEvaluationContext的行为变化或者绕过尝试。安全测试时,第一步就是看项目使用的spring-core/spring-expression版本,确认是否低于已修复版本。
排查命令很简单:
mvn dependency:tree | grep spring-expression建议直接升到当前最新稳定版,同时定期关注Spring官方安全公告。另外,在CI流水线里接入代码扫描,规则可以自定义为:出现new StandardEvaluationContext时给予高风险告警,出现getValue(context)且变量来源于外部输入时强制人工复核。
3.5 一个比较容易忽略的点:表达式来源本身要防篡改
很多人做了上下文隔离,却忘了防篡改。比如把SpEL表达式存数据库,那么"谁可以写数据库"就是安全边界。针对配置中心的变更,至少要做到三点:变更审批、变更审计、敏感表达式预警。我见过有人在配置中心写了一条非常复杂的表达式,连他自己都看不懂,过了半年才发现被人塞了后门。这类问题靠技术手段难防,必须靠流程兜底。
4. 扩展SpEL的正确姿势:自定义函数、属性访问器与动态能力落地
聊完安全,再说扩展。SpEL不是只能读Bean属性,它的扩展能力其实非常强。用好这些扩展,能把很多"原本要写一堆if-else"的业务简化成一行表达式。
4.1 自定义函数:把Java方法变成表达式可调用的能力
注册自定义函数是最高频的扩展方式。通过registerFunction,可以把任意静态方法注册进上下文,表达式里直接调函数名:
ExpressionParser parser = new SpelExpressionParser(); StandardEvaluationContext context = new StandardEvaluationContext(); context.registerFunction("upper", StringUtils.class.getDeclaredMethod("toUpperCase", String.class)); String result = parser.parseExpression("#upper('hello')").getValue(context, String.class);这样的好处是业务逻辑还是写在Java里,表达式只是"引用"你的方法,而不是在表达式里东拼西凑。对安全也有帮助,因为你能控制的暴露面变成了"我注册了哪些函数",而不是"表达式能访问全量Class"。
4.2 自定义属性访问器:让表达式像读属性一样读任意对象
如果想在表达式里像访问Bean属性一样访问Map、HttpSession、数据库查询结果,可以自定义PropertyAccessor。
public class MapAccessor implements PropertyAccessor { @Override public boolean canRead(EvaluationContext context, Object target, String name) { return target instanceof Map; } @Override public TypedValue read(EvaluationContext context, Object target, String name) { Map<?, ?> map = (Map<?, ?>) target; return new TypedValue(map.get(name)); } @Override public boolean canWrite(EvaluationContext context, Object target, String name) { return false; } @Override public void write(EvaluationContext context, Object target, String name, Object newValue) { throw new UnsupportedOperationException(); } }注册后,表达式可以直接写#config['timeout']。用这个方案做动态配置解析非常顺手,而且属性访问器天然是白名单机制,比直接在上下文里塞大对象更可控。
4.3 自定义构造器与解析配置:按需开启编译模式
SpEL还支持通过ConstructorResolver扩展构造对象的能力,适合在规则引擎里动态构建DTO。不过,随意开构造器会显著扩大攻击面,所以除非明确需要,否则不要注册自定义构造器解析器。
另一个实用配置是SpelParserConfiguration,它支持开启SpEL编译模式,把表达式编译成字节码,提升频繁解析场景的性能:
SpelParserConfiguration config = new SpelParserConfiguration( SpelCompilerMode.IMMEDIATE, ExpressionClassLoaderFactory.class.getClassLoader()); SpelExpressionParser parser = new SpelExpressionParser(config);但要注意:编译模式只适合表达式固定且可信的场景。如果表达式来源不可信,编译模式反而会让反射逃逸链执行得更流畅,所以这个开关要慎用。
4.4 业务场景延伸:动态规则、价格计算、灰度策略
我实际落地过的场景包括:
- 会员积分规则:
#consumeAmount gt 1000 ? #consumeAmount * 0.2 : 0 - 灰度发布策略:
#userId % 100 lt 10 - 促销优惠计算:
#price * #discount - #coupon - 数据权限过滤:
#deptId in #allowedDeptIds
这些场景的共同点是规则变化频繁,又不想每次改需求都发版。把规则存配置中心,用SpEL自定义函数解析,业务上非常灵活。但每一处都必须配上第3节讲的安全加固,缺一不可。
4.5 Spring AI 场景里的表达式扩展注意点
Spring AI的PromptTemplate同样支持表达式变量渲染。如果你在AI应用里让用户自定义Prompt模板,要特别注意模板引擎的取值边界。我的实践是把"模板变量"和"可执行表达式"区分开:模板里只允许{{变量}}这种占位符,内核渲染时用SpEL把变量从预定义Map中取出,绝不把用户输入作为表达式本身来解析。这样既保留了模板扩展能力,又不会把表达式解析器暴露给终端用户。
5. 回归测试与日常防御:怎么保证安全边界不会被后续改动冲垮
加固做完不算完,还得保证下个迭代不会把这些安全边界又冲垮。我在项目里总结了一个组合拳,覆盖从开发到上线的闭环。
5.1 把攻击性表达式写进单元测试
这是最直接有效的手段。构造一批恶意表达式,跑单元测试断言它们被拦截或者抛出异常:
class SpelSafetyTest { private static final List<String> MALICIOUS_EXPRESSIONS = List.of( "T(java.lang.Runtime).getRuntime().exec('id')", "new java.lang.ProcessBuilder('id').start()", "T(java.lang.System).setProperty('x','y')", "T(java.lang.Thread).sleep(10000)" ); @Test void givenMaliciousExpression_whenParseInSimpleContext_shouldThrow() { ExpressionParser parser = new SpelExpressionParser(); EvaluationContext context = SimpleEvaluationContext .forReadOnlyDataBinding() .build(); for (String expression : MALICIOUS_EXPRESSIONS) { assertThatThrownBy(() -> parser.parseExpression(expression).getValue(context)) .isInstanceOf(SpelEvaluationException.class); } } @Test void givenMaliciousExpression_whenParseInCustomContext_shouldBeBlocked() { // 自定义白名单上下文,未注册任何类访问能力 EvaluationContext context = SpelSafetyUtils.readOnlyContext(user); Expression expression = new SpelExpressionParser().parseExpression( "T(java.lang.Runtime).getRuntime().exec('id')" ); assertThatThrownBy(() -> expression.getValue(context)) .isInstanceOf(Exception.class); } }这套测试跑在CI里,任何人在代码里动了上下文配置,只要把攻击面放开,测试立刻红。
5.2 写一个SpEL使用审计脚本,约束团队规范
我用Python写过一个小脚本,扫描项目里所有new StandardEvaluationContext()和parseExpression的调用点,输出风险清单:
import re, pathlib for path in pathlib.Path('src').rglob('*.java'): content = path.read_text(encoding='utf-8') if 'StandardEvaluationContext' in content and ('parseExpression' in content or '@Value' in content): print(f'[高风险] {path}')日常开发中这个脚本可以帮新人快速找到需要重点关注的地方。深入一点可以在Checkstyle或SonarQube里配自定义规则,把SpEL相关的风险提示自动挂到MR上。
5.3 安全自检清单:每次迭代上线前过一遍
我习惯把SpEL相关检查项列成清单,上线前逐项打勾:
- 是否存在用户输入直接传入
parseExpression()的路径?存在则必须改为受限上下文。 - 是否用了
StandardEvaluationContext?用在哪里?表达式来源是否绝对可信? - 配置中心里是否存在SpEL表达式?表达式携带哪些能力?是否有多人审批?
- 依赖的spring-expression版本是否为最新修复版本?
- 新增的自定义函数是否都符合"最小暴露"原则?
- 恶意表达式回归测试是否全部通过?
只要有一项存疑,就暂缓上线,先把风险处理掉。
5.4 一个容易被忽略的教训:防御要跟着"表达式来源"走
最后说个我踩过的坑。有一回我把上下文已经换成了SimpleEvaluationContext,觉得万事大吉。后来发现业务方为了解决某个需求,直接把ApplicationContext对象注册成了root object。这样一来,表达式虽然不能再T(Runtime)了,却能通过#applicationContext.getBean(...)拿到任意Bean,再通过Bean的方法间接触达危险能力。这个教训告诉我:上下文隔离只是手段,真正的安全边界是"表达式到底能触达哪些对象和方法"。每加一个root对象、每注册一个函数,都要问一句:这东西被恶意调用会怎样。
回头再看,Spring里SpEL的安全问题,本质上是个信任边界问题。你把表达式当成纯文本,它也许就是纯文本;你把它当成可执行代码,它就有了代码的全部力量。做扩展的时候也要带着这层意识——能用注册函数解决的,就不要开放类引用;能用只读上下文解决的,就不要给写权限。这样既能享受到SpEL带来的灵活,又不至于把服务器变成别人的后花园。