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 Set ALLOWED_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表达式的地方都应该被重点关注。安全不是某个团队的责任,而是每个开发者写每一行代码时都要考虑的事情。