ARTICLE DETAIL

建站实战干货

来自一线的建站与推广经验沉淀,每一条都经过真实交付验证。

SpEL表达式注入攻防:从反射逃逸到安全加固与扩展实践

2026/9/10 18:55:36 拓冰建站 浏览量
SpEL表达式注入攻防:从反射逃逸到安全加固与扩展实践 前阵子给一个老项目做安全审计翻到一处动态规则校验的代码。大意是根据前端传入的字符串走SpEL解析再对结果做布尔判断。这行代码看起来只是取个值但当我顺着表达式注入的路径往下摸的时候冷汗差点下来——如果这段代码暴露到公网攻击者不需要任何过滤器就能直接执行任意命令等于把服务器控制台拱手让人。Spring里的EL表达式也就是SpELSpring 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) ))这行代码看似在做权限控制实际上拼接了不可信输入。攻击者传一个roleADMIN) 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 SetString 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 ListString 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(encodingutf-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带来的灵活又不至于把服务器变成别人的后花园。