Spring Security作为Java生态中最强大的安全框架,几乎成了企业级应用权限管理的标配。但很多人不知道,即使集成了Spring Security,你的应用依然可能因为表达式注入而沦陷。表达式注入不是SQL注入那种老生常谈的漏洞,它更隐蔽,危害却一点不小——攻击者可以通过精心构造的输入,绕过权限校验、窃取敏感数据,甚至直接执行服务器命令。问题的根源在于Spring Security中大量使用的Spring Expression Language,也就是SpEL。当你把用户输入直接拼接到SpEL表达式中,等于把服务器的大门钥匙递给了攻击者。
表达式注入到底是怎么发生的先看一个典型场景。假设你有一个权限校验方法,根据用户角色动态判断是否有权限访问某个资源。很多开发者会这样写:
String expression = "hasRole('" + userInput + "')";
Boolean hasPermission = expressionEvaluator.evaluate(expression);
这段代码看起来人畜无害,但如果userInput的值是“') or hasRole('ADMIN') or ('”,整个表达式就会变成:
hasRole('') or hasRole('ADMIN') or ('')
结果就是权限校验被绕过,攻击者以普通用户身份获得了ADMIN权限。这还只是最基础的注入手法。SpEL的功能远比SQL强大,它可以调用方法、访问对象属性、实例化类。攻击者一旦控制了表达式,就能做的事情远超你想象。比如通过反射加载Runtime类执行系统命令:
T(java.lang.Runtime).getRuntime().exec('whoami')
或者读取服务器上的敏感文件:
new java.util.Scanner(T(java.io.File).new('etc/passwd')).useDelimiter('\\A').next()
这些表达式一旦被注入到你的权限校验逻辑中,后果不堪设想。Spring Security内部大量使用SpEL来做方法级别的权限控制,@PreAuthorize、@PostAuthorize、@PreFilter、@PostFilter这些注解,本质上都是在执行SpEL表达式。如果你在这些注解的参数中拼接了用户输入,就等于埋下了一颗定时炸弹。
漏洞根源:SpEL的执行上下文太强大了为什么SpEL注入比SQL注入更危险?因为SpEL的执行上下文默认拥有非常高的权限。在Spring Security环境中,SpEL表达式可以访问Spring容器中的Bean,可以调用静态方法,可以反射创建对象。这意味着攻击者一旦注入成功,基本上就拿到了服务器的控制权。很多开发者误以为只要做了输入校验就万事大吉,但黑名单过滤在SpEL面前形同虚设——攻击者可以用字符串拼接、编码转换、反射调用等方式绕过过滤规则。真正有效的防御必须从架构层面入手,而不是靠正则表达式堵窟窿。
防御策略一:永远不要拼接用户输入到表达式中这是最根本的解决方案,也是最容易被忽视的。Spring Security提供了参数化传递变量的机制,你应该使用#开头的变量引用,而不是直接把用户输入拼进去。正确做法如下:
// 错误做法:拼接字符串
@PreAuthorize("hasRole('" + role + "')")
// 正确做法:使用变量引用
@PreAuthorize("hasRole(#role)")
public void sensitiveMethod(@Param("role") String role) {
// 业务逻辑
}
当你在注解中使用#role时,Spring Security会从方法参数中提取值,然后安全地绑定到表达式中,而不是进行字符串拼接。这个机制从根本上杜绝了注入的可能性,因为用户输入被当作数据而不是代码来处理。同样的原则适用于所有Spring Security注解,包括@PostAuthorize、@PreFilter、@PostFilter,以及自定义的权限校验逻辑。如果你的业务场景确实需要动态构建表达式,那就必须引入更严格的安全措施。
防御策略二:使用SimpleEvaluationContext替代默认上下文如果你确实需要在代码中动态执行SpEL表达式,那就必须限制表达式的执行能力。Spring 5之后引入了SimpleEvaluationContext,它专门为不需要完整SpEL功能的场景设计,默认禁用了类型引用、构造器调用、静态方法调用等危险操作。对比一下两种上下文的差异:
// 危险的默认上下文 ExpressionParser parser = new SpelExpressionParser(); StandardEvaluationContext context = new StandardEvaluationContext(); // 攻击者可以执行任意代码 // 安全的受限上下文 ExpressionParser parser = new SpelExpressionParser(); EvaluationContext context = SimpleEvaluationContext.forReadOnlyDataBinding().build(); // 攻击者只能访问显式设置的变量
SimpleEvaluationContext提供了几个构建选项:forReadOnlyDataBinding只允许读取数据,forReadWriteDataBinding允许读写但不能调用方法,你也可以通过withInstanceMethods等方式精确控制哪些方法可以被调用。这种白名单机制比黑名单过滤可靠得多。如果你的业务只需要在表达式中做简单的逻辑判断和变量比较,SimpleEvaluationContext完全够用,而且安全性大幅提升。
防御策略三:自定义PermissionEvaluator实现细粒度控制Spring Security允许你通过实现PermissionEvaluator接口来定制权限校验逻辑,这是处理复杂权限场景的最佳实践。与其在@PreAuthorize中写复杂的SpEL表达式,不如把权限判断逻辑封装到Java代码中:
@Component
public class CustomPermissionEvaluator implements PermissionEvaluator {
@Override
public boolean hasPermission(Authentication auth, Object targetDomainObject, Object permission) {
// 在这里实现你的权限逻辑
// 所有参数都是强类型的,不存在注入风险
if (targetDomainObject instanceof Document) {
Document doc = (Document) targetDomainObject;
return doc.getOwner().equals(auth.getName());
}
return false;
}
@Override
public boolean hasPermission(Authentication auth, Serializable targetId, String targetType, Object permission) {
// 根据ID和类型进行权限判断
return false;
}
}
然后在配置类中注册这个PermissionEvaluator:
@Configuration
@EnableGlobalMethodSecurity(prePostEnabled = true)
public class SecurityConfig extends GlobalMethodSecurityConfiguration {
@Autowired
private CustomPermissionEvaluator permissionEvaluator;
@Override
protected MethodSecurityExpressionHandler createExpressionHandler() {
DefaultMethodSecurityExpressionHandler handler = new DefaultMethodSecurityExpressionHandler();
handler.setPermissionEvaluator(permissionEvaluator);
return handler;
}
}
使用时,在注解中调用hasPermission方法:
@PreAuthorize("hasPermission(#document, 'read')")
public Document getDocument(Document document) {
return document;
}
这种方式把权限逻辑从表达式移到了Java代码中,不仅消除了注入风险,还让代码更容易测试和维护。你可以对CustomPermissionEvaluator编写单元测试,确保权限逻辑正确无误。
防御策略四:输入校验与白名单过滤虽然前面强调不要依赖输入过滤来防御注入,但在某些无法避免动态表达式拼接的场景下,严格的输入校验仍然是必要的防线。关键在于使用白名单而非黑名单。如果你知道用户输入只能是有限的几个值,比如角色名称只能是“ADMIN”、“USER”、“MANAGER”之一,那就严格校验输入是否在这个集合内:
private static final SetALLOWED_ROLES = Set.of("ADMIN", "USER", "MANAGER"); public boolean validateRole(String role) { if (role == null || !ALLOWED_ROLES.contains(role)) { throw new IllegalArgumentException("Invalid role: " + role); } return true; }
对于更复杂的输入场景,可以考虑使用正则表达式限制字符集。比如只允许字母数字和下划线:
private static final Pattern SAFE_PATTERN = Pattern.compile("^[a-zA-Z0-9_]+$");
public boolean validateInput(String input) {
if (input == null || !SAFE_PATTERN.matcher(input).matches()) {
throw new SecurityException("Potentially malicious input detected");
}
return true;
}
但要清醒认识到,这种过滤只能作为纵深防御的一层,不能作为唯一的防护手段。SpEL的语法非常灵活,攻击者可能用你意想不到的方式绕过正则表达式。
纵深防御:多层安全体系构建真正的安全不能依赖单一措施。针对表达式注入,你应该构建多层防御体系。第一层是架构设计层面,确保用户输入永远不会直接拼接到表达式中。第二层是执行环境限制,使用SimpleEvaluationContext或自定义的受限上下文。第三层是输入校验,对所有用户输入进行白名单过滤。第四层是运行时监控,记录所有表达式执行日志,对异常表达式行为进行告警。第五层是依赖管理,及时更新Spring Security版本,因为官方会修复已知的安全漏洞。
还有一个容易被忽略的点:第三方库也可能引入表达式注入风险。如果你的项目使用了任何支持表达式语言的组件,比如模板引擎、规则引擎,同样需要检查它们是否安全地处理用户输入。安全是一个整体,任何一个薄弱的环节都可能成为攻击者的突破口。
最后要强调的是安全意识。很多表达式注入漏洞的根源不是技术问题,而是开发者对SpEL的危险性认识不足。当你看到一段代码把用户输入拼接到表达式中时,应该本能地感到警觉,就像看到SQL拼接一样。代码审查时,任何涉及SpEL表达式的地方都应该被重点关注。安全不是某个团队的责任,而是每个开发者写每一行代码时都要考虑的事情。
