跳转到帖子

背景

Spring 生态里 SpEL(Spring Expression Language)用得很广,常见于权限表达式、配置解析、缓存 Key 生成、规则引擎、动态模板等场景。它本身不是漏洞,问题通常出在“把外部可控输入当作表达式执行”。这类问题在代码审计中不算高频,但一旦出现在关键路径,影响面会比较大。

这篇文章整理一次比较典型的审计思路:如何从代码里识别 SpEL 注入风险,如何做安全验证,如何判断真实影响,以及如何修复。重点放在防守和工程落地,不讨论恶意利用链。

问题场景

某业务系统提供了一个“自定义规则”能力,管理员可以配置规则表达式,用于决定消息是否进入后续流程。最初设计时只考虑了内部人员使用,后来配置入口被接入到运营后台,并且部分字段可以由上游接口间接写入。

审计时看到类似代码:

ExpressionParser parser = new SpelExpressionParser();
StandardEvaluationContext context = new StandardEvaluationContext();
context.setVariable("user", user);
context.setVariable("order", order);

Expression expression = parser.parseExpression(ruleExpression);
Boolean matched = expression.getValue(context, Boolean.class);

这里的核心风险点是:ruleExpression 如果来自不可信输入,就相当于允许外部用户控制被解析执行的表达式。即使业务上认为“只有后台能配”,也需要继续确认后台权限边界、接口鉴权、配置来源、导入功能、同步任务等是否可能扩大输入面。

技术分析

SpEL 的能力比较强,除了基础的属性访问、逻辑判断、集合操作外,还支持类型引用、方法调用、构造对象等能力。对于业务规则来说,通常只需要很小一部分表达式能力,例如:

#user.level >= 3 and #order.amount > 100

但如果使用默认解析器和默认上下文,表达式能力往往超过业务所需。安全审计时,可以从以下几个角度判断风险。

一、输入是否可控

  • 表达式是否来自 HTTP 请求参数、JSON 字段、Header、Cookie。
  • 是否来自数据库配置,而配置是否可由用户、运营后台、导入任务修改。
  • 是否来自消息队列、定时任务同步、第三方回调数据。
  • 是否存在“低权限用户影响高权限规则执行”的路径。

很多漏洞不是直接把请求参数传进 parseExpression,而是经过数据库、缓存、配置中心中转后再执行。审计时不要只看 Controller,要把数据流追到最终执行点。

二、表达式执行上下文是否过宽

以下写法需要重点关注:

StandardEvaluationContext context = new StandardEvaluationContext(rootObject);

StandardEvaluationContext 功能完整,适合可信表达式场景。如果表达式来源不可信,更建议使用能力受限的上下文,例如 SimpleEvaluationContext,并显式限制可访问对象。

常见风险点包括:

  • 允许访问任意 Bean 或类型。
  • 允许调用对象上的任意 public 方法。
  • 上下文中放入了过多业务对象,例如用户、订单、环境配置、服务类实例。
  • 表达式执行前未做白名单校验。

三、是否存在危险解析入口

代码审计时可以先做关键词检索,定位 SpEL 使用点:

grep -R "parseExpression" -n src/main/java

grep -R "SpelExpressionParser" -n src/main/java

grep -R "StandardEvaluationContext" -n src/main/java

grep -R "@Value" -n src/main/java

如果项目较大,可以配合 semgrep 写一个粗略规则先扫一遍:

rules:
  - id: java-spring-spel-parse-expression
    message: Review SpEL parseExpression usage with external input
    languages: [java]
    severity: WARNING
    patterns:
      - pattern: |
          $P.parseExpression($EXPR)

这类规则只能帮助定位,不等于确认漏洞。确认时要继续分析 $EXPR 的来源、是否经过限制、上下文能力以及调用场景。

安全验证方法

验证时建议使用无副作用表达式,不要使用系统命令、网络请求、文件读写等破坏性动作。目标是证明“外部输入被当作表达式执行”,而不是追求攻击效果。

例如可以使用简单算术表达式验证:

1 + 2

如果业务期望表达式返回布尔值,可以用:

1 + 2 == 3

如果上下文中存在变量,可以验证变量访问能力:

#user.level >= 1

验证时记录三类信息即可:

  • 输入点:表达式从哪里进入系统。
  • 执行点:代码中哪个位置执行了 parseExpression 和 getValue。
  • 影响范围:表达式能访问哪些变量、对象和方法。
建议在测试环境完成验证,并使用最小权限账号。生产环境只做代码路径确认和日志侧佐证,不建议直接投递测试表达式。

关键步骤

1. 梳理数据流

从执行点向上追踪 ruleExpression 的来源。如果表达式来自数据库,需要继续找写入表字段的接口、后台页面、导入功能和同步任务。

// 执行点
Expression expression = parser.parseExpression(ruleExpression);
Boolean result = expression.getValue(context, Boolean.class);

实际审计中经常会遇到这种情况:开发认为字段只由管理员配置,但后台接口缺少细粒度权限,或者存在批量导入接口可以由普通运营角色调用。此时漏洞影响就不再局限于管理员误操作。

2. 判断表达式能力边界

重点看是否使用 StandardEvaluationContext,以及是否注册了额外能力:

context.setBeanResolver(beanResolver);
context.setTypeLocator(typeLocator);
context.setMethodResolvers(methodResolvers);
context.setPropertyAccessors(propertyAccessors);

这些扩展点本身是正常功能,但在不可信表达式场景下会显著扩大表达式能力。一般来说,业务规则只需要读取少量字段并做逻辑比较,不需要 Bean 访问、类型定位和任意方法调用。

3. 使用白名单表达式模型

修复时不要依赖黑名单过滤特殊字符串。黑名单很容易漏掉语法变体,也不利于长期维护。更稳妥的做法是收敛表达式语法和上下文能力。

示例:仅允许读取指定属性并做简单比较。

SimpleEvaluationContext context = SimpleEvaluationContext
        .forReadOnlyDataBinding()
        .build();

Map<String, Object> root = new HashMap<>();
root.put("level", user.getLevel());
root.put("amount", order.getAmount());

ExpressionParser parser = new SpelExpressionParser();
Expression expression = parser.parseExpression(ruleExpression);
Boolean matched = expression.getValue(context, root, Boolean.class);

这里仍然需要限制 ruleExpression 的来源和语法。对于高风险业务,更建议不要直接暴露 SpEL,而是设计 DSL 或固定条件组合,例如字段、操作符、值三元组:

{
  "field": "amount",
  "operator": "GT",
  "value": 100
}

后端将其转换为受控判断逻辑,而不是执行用户输入的表达式。

4. 增加表达式预校验

如果历史原因必须继续兼容 SpEL,可以在解析前加入白名单校验,例如限制允许的变量名、操作符和字面量类型。示例思路:

  • 只允许 #user.level、#order.amount 等固定字段。
  • 只允许比较运算、逻辑与或、括号。
  • 禁止类型引用、构造对象、方法调用、Bean 引用。
  • 限制表达式长度和嵌套深度。

工程上可以将表达式解析为 AST 后检查节点类型,而不是简单正则。正则适合做第一层粗过滤,不适合作为唯一防线。

5. 加日志和审计

规则表达式属于高风险配置,建议记录完整变更审计:

  • 谁修改了表达式。
  • 修改前后内容。
  • 来源 IP 和操作入口。
  • 审批单或变更原因。
  • 规则命中后的关键业务动作。

同时对异常表达式解析失败、频繁变更、非常规字符等行为做告警。很多时候,日志能帮助区分误配置、测试行为和真实风险。

常见误区

误区一:只有管理员能配,所以没风险

后台权限也需要分层。管理员账号可能被钓鱼、共享、误授权,后台接口也可能存在越权。只要表达式能力超过业务需要,就应该收敛。

误区二:过滤几个关键字就够了

表达式语言通常存在多种等价写法,黑名单很难覆盖完整。正确方向是白名单、最小权限上下文、减少暴露对象。

误区三:测试没弹计算器,所以不是漏洞

验证漏洞不应以破坏性效果为目标。只要能证明不可信输入被作为表达式执行,并且表达式能力超出预期,就应按风险处理。

误区四:只修入口,不修执行点

入口可能不止一个。修复应优先放在执行点附近,形成统一安全封装,避免后续新入口绕过。

修复建议

措施说明
替换为 DSL优先推荐。将规则表达为字段、操作符和值,由后端受控执行。
使用 SimpleEvaluationContext减少表达式能力,避免默认开放过多访问能力。
白名单字段只暴露业务必需字段,不传入完整对象或服务实例。
限制语法禁止方法调用、类型引用、构造对象、Bean 访问等非必要能力。
配置审计记录表达式变更、审批、执行异常和命中情况。
统一封装不要在业务代码里到处直接 new SpelExpressionParser,应封装为统一安全组件。

一个相对稳妥的封装思路

可以将表达式执行集中到一个组件中,统一做长度限制、变量白名单、上下文收敛和异常处理。

public class SafeRuleEvaluator {

    private static final int MAX_EXPR_LENGTH = 200;
    private final ExpressionParser parser = new SpelExpressionParser();
    private final SimpleEvaluationContext context = SimpleEvaluationContext
            .forReadOnlyDataBinding()
            .build();

    public boolean evaluate(String expression, Map<String, Object> variables) {
        if (expression == null || expression.length() > MAX_EXPR_LENGTH) {
            throw new IllegalArgumentException("invalid expression length");
        }
        if (!isAllowedExpression(expression)) {
            throw new IllegalArgumentException("expression is not allowed");
        }
        Map<String, Object> safeVariables = filterVariables(variables);
        Expression exp = parser.parseExpression(expression);
        Boolean result = exp.getValue(context, safeVariables, Boolean.class);
        return Boolean.TRUE.equals(result);
    }

    private boolean isAllowedExpression(String expression) {
        // 示例:实际项目中建议基于 AST 做白名单校验
        return !expression.contains("T(")
                && !expression.contains("new ")
                && !expression.contains("@")
                && !expression.contains(".class");
    }

    private Map<String, Object> filterVariables(Map<String, Object> variables) {
        Map<String, Object> safe = new HashMap<>();
        safe.put("level", variables.get("level"));
        safe.put("amount", variables.get("amount"));
        safe.put("region", variables.get("region"));
        return safe;
    }
}

上面只是示例,不建议把字符串过滤当作最终方案。更可靠的方式是从产品形态上减少表达式自由度,例如改成固定条件配置。

排查清单

  • 项目中是否存在 SpelExpressionParser、parseExpression、getValue。
  • 表达式参数是否可能来自用户输入、后台配置、导入文件、消息队列。
  • 是否使用 StandardEvaluationContext。
  • 上下文中是否放入完整业务对象、服务对象、环境对象。
  • 是否注册 BeanResolver、TypeLocator、MethodResolver。
  • 是否有表达式长度、语法、字段白名单限制。
  • 是否有配置变更审计和异常告警。
  • 是否存在多个入口写入同一表达式字段。

总结

SpEL 注入的本质不是 Spring 有问题,而是把不可信输入交给了能力过强的表达式引擎。审计这类问题时,不要只盯着单个接口,要把“输入来源、存储中转、执行上下文、表达式能力、业务影响”串起来看。

修复上优先选择 DSL 或固定规则模型,其次才是受限 SpEL。无论采用哪种方案,都应遵循最小能力原则:只暴露必要字段,只允许必要语法,只在必要场景执行,并留下可追溯的配置审计。这样即使后续业务扩展,也不容易把一个规则功能演变成高风险执行入口。

代码审计与漏洞定位示意图
代码审计、调用链与关键函数定位示意

0篇意见

推荐意见

没有意见。

游客
抱歉,你的帖子内容包括我们不允许的字词。请编辑你的帖子,删除下面高亮的屏蔽字。
添加意见…