跳转到帖子

背景

最近在做一轮内部系统代码审计时,遇到了一类容易被低估的问题:把用户可控内容拼进表达式引擎里执行。这个问题在业务系统里并不少见,常见场景包括规则引擎、动态模板、报表筛选、工作流条件判断等。单看代码往往像是“配置灵活性”的实现,但如果边界没收紧,就会把本来只该做布尔判断或字段映射的能力,扩展成任意方法访问、对象遍历甚至更危险的行为。

本文不讨论攻击利用细节,重点放在代码审计时如何识别风险、如何缩小影响面、以及如何做可验证的修复。内容基于一次真实审计过程整理,具体类名和业务字段已做脱敏处理。

问题场景

业务中有一个“动态规则”模块,运营可以在后台配置规则,例如根据订单金额、用户等级、地区等字段判断是否命中某个策略。后端为了实现灵活判断,引入了表达式解析能力。

简化后的代码结构类似下面这样:

public boolean matchRule(Map<String, Object> context, String ruleExpression) {
    ExpressionParser parser = new SpelExpressionParser();
    StandardEvaluationContext evaluationContext = new StandardEvaluationContext();

    context.forEach(evaluationContext::setVariable);

    Boolean result = parser.parseExpression(ruleExpression).getValue(evaluationContext, Boolean.class);
    return Boolean.TRUE.equals(result);
}

从功能角度看,这段代码可以满足需求。但审计时需要关注几个关键点:

  • ruleExpression 是否来自用户可控输入或低权限后台配置;
  • 表达式引擎是否允许类型访问、方法调用、构造对象等能力;
  • 执行上下文中是否暴露了敏感对象,例如 Spring Bean、Request、Session、系统配置;
  • 规则变更是否有审批、审计日志和回滚机制;
  • 表达式执行失败时是否会泄露堆栈、类名、路径等内部信息。

技术分析

表达式注入的本质不是“用了某个表达式库就一定有漏洞”,而是把“不可信表达式”交给了“能力过大的执行器”。很多表达式引擎本身是正常组件,问题通常出现在以下几类错误组合中。

1. 表达式来源边界不清

如果规则仅由开发人员写死在代码里,风险相对可控。但如果表达式来自管理后台、接口参数、数据库配置、消息队列,就需要把它当作不可信输入处理。尤其是所谓“只有内部人员能配置”的后台,不能默认等同于可信边界。权限误配、账号共享、越权配置、供应链脚本写入数据库,都会改变风险等级。

2. EvaluationContext 权限过大

以 Spring Expression Language 为例,StandardEvaluationContext 的能力比较完整,适合可信表达式场景。如果直接用于不可信规则,可能暴露不必要的能力。对于只需要读取变量、做简单比较的场景,应优先使用能力更小的上下文或自定义解析策略。

一个常见误区是:只要不把敏感对象塞进上下文就安全。实际审计中还要看表达式引擎是否允许类型引用、方法调用、属性访问器、构造函数解析器等能力。能力面越大,误用空间越大。

3. 规则字段未做白名单

业务真正需要的表达式通常很简单,例如:

#amount > 1000 and #level == 'VIP'

如果允许配置方写任意表达式,就等于把业务规则 DSL 和通用表达式语言混在一起。更稳妥的做法是定义白名单字段和白名单操作符,比如只允许:

  • 字段:amount、level、region;
  • 操作:等于、不等于、大于、小于、包含;
  • 逻辑:AND、OR;
  • 值类型:数字、字符串、枚举、布尔。

这样即使底层使用表达式库,也应先经过一层业务 DSL 编译或校验,而不是直接把原始字符串交给表达式执行器。

审计关键步骤

这类问题适合从“入口、传播、执行点、能力面、修复验证”几个维度梳理。

步骤一:定位表达式执行点

先全局检索常见表达式执行 API。以 Java 项目为例,可以从这些关键词开始:

grep -R "parseExpression\|getValue\|StandardEvaluationContext\|SimpleEvaluationContext" -n src/main/java

grep -R "MVEL\|Aviator\|GroovyShell\|ScriptEngine\|Jexl" -n src/main/java

如果项目比较大,建议结合 IDE 调用层级和代码搜索平台,重点看表达式字符串从哪里来。只看执行点容易误判,数据来源才是定级的核心。

步骤二:追踪表达式来源

审计时我一般会把来源按风险从高到低分成几类:

来源风险判断
HTTP 请求参数高风险,通常需要立即收敛能力
数据库配置取决于写入权限和审批流程
管理后台配置不能默认可信,需要看权限模型
代码常量风险较低,但仍需避免后续改成动态配置
消息队列内容需要追踪生产者和链路完整性

在这次审计里,表达式存储在数据库中,由运营后台写入。后台虽然有登录,但规则编辑权限粒度较粗,部分非研发角色也能修改表达式。因此它不适合按“可信代码”处理。

步骤三:确认上下文暴露内容

需要看 EvaluationContext 里放了什么对象。很多业务为了方便,会直接把整个订单对象、用户对象、环境配置对象塞进去:

evaluationContext.setVariable("order", order);
evaluationContext.setVariable("user", user);
evaluationContext.setVariable("config", systemConfig);

这会扩大表达式可访问面。即使没有明显敏感字段,也可能因为对象方法过多导致行为不可控。建议改成扁平化、最小化的数据结构,只传表达式判断必须使用的字段。

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

步骤四:限制表达式能力

如果使用 SpEL,并且需求只是变量读取和简单比较,可以优先考虑 SimpleEvaluationContext。它相比 StandardEvaluationContext 更适合受限表达式场景。

ExpressionParser parser = new SpelExpressionParser();
SimpleEvaluationContext context = SimpleEvaluationContext
        .forReadOnlyDataBinding()
        .build();

Boolean result = parser.parseExpression(ruleExpression)
        .getValue(context, safeContext, Boolean.class);

不过要注意,换成受限上下文并不等于万事大吉。仍然建议在表达式进入执行器前做语法级校验和字段白名单校验,避免业务规则被滥用。

步骤五:增加规则白名单校验

一个比较实用的做法是:不要让后台直接保存原始表达式,而是把规则拆成结构化配置。例如:

{
  "logic": "AND",
  "conditions": [
    {"field": "amount", "operator": "GT", "value": 1000},
    {"field": "level", "operator": "EQ", "value": "VIP"}
  ]
}

服务端根据白名单生成内部表达式,或者直接用代码执行条件判断。这样可以把表达式语言降级成业务 DSL,配置人员只能选择允许的字段和操作符。

private static final Set<String> ALLOWED_FIELDS = Set.of("amount", "level", "region");
private static final Set<String> ALLOWED_OPERATORS = Set.of("EQ", "NE", "GT", "GTE", "LT", "LTE", "IN");

private void validateCondition(Condition condition) {
    if (!ALLOWED_FIELDS.contains(condition.getField())) {
        throw new IllegalArgumentException("unsupported field");
    }
    if (!ALLOWED_OPERATORS.contains(condition.getOperator())) {
        throw new IllegalArgumentException("unsupported operator");
    }
}

修复建议

结合这次审计,最终给出的修复方向主要有几项:

  • 将原始表达式配置改为结构化规则配置;
  • 服务端增加字段、操作符、值类型白名单;
  • 表达式执行上下文只传最小必要字段,不传业务对象本体;
  • 将 StandardEvaluationContext 替换为更受限的上下文或自研规则执行器;
  • 规则修改增加审批、审计日志和版本回滚;
  • 表达式执行异常只返回通用错误,不向前端暴露堆栈;
  • 为规则执行增加超时、复杂度限制和监控指标。

验证方式

修复后不要只做正常功能回归,还需要验证“不该支持的能力确实被拒绝”。建议至少覆盖以下测试:

  • 合法字段和合法操作符可以正常执行;
  • 未知字段会被拒绝;
  • 未知操作符会被拒绝;
  • 复杂嵌套规则超过限制会被拒绝;
  • 表达式执行异常不会回显内部堆栈;
  • 低权限用户无法新增或修改规则;
  • 规则修改记录包含操作者、时间、变更前后内容。

可以补充一组单元测试,把安全约束固化下来,避免后续功能迭代时又把原始表达式入口放开。

@Test
void shouldRejectUnsupportedField() {
    Condition condition = new Condition("class", "EQ", "test");
    assertThrows(IllegalArgumentException.class, () -> validator.validate(condition));
}

@Test
void shouldRejectUnsupportedOperator() {
    Condition condition = new Condition("amount", "CALL", "test");
    assertThrows(IllegalArgumentException.class, () -> validator.validate(condition));
}

注意点

  • 不要把“后台功能”简单视为可信输入,尤其是权限粒度粗的运营后台;
  • 不要把安全控制完全寄托在前端表单,后端必须重新校验;
  • 不要为了兼容历史规则继续保留原始表达式执行通道,至少要加开关和迁移期限;
  • 不要在日志中记录完整敏感上下文,规则执行日志应避免落入用户隐私和业务敏感字段;
  • 如果系统里存在多个表达式引擎,建议统一治理,避免一个模块修了、另一个模块仍然裸奔。

总结

表达式注入类问题的根因通常不是单个 API 调用,而是业务灵活性和执行能力边界没有拆开。审计时可以按“表达式来源是否可信、执行上下文是否最小化、表达式能力是否受限、规则配置是否可审计”这几条线去看。

我的经验是,最稳的方案不是给通用表达式引擎不断打补丁,而是把规则抽象成业务可理解的 DSL:字段白名单、操作符白名单、值类型约束、权限审批和变更审计。这样既能保留配置灵活性,也能把风险控制在业务需要的范围内。

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

0篇意见

推荐意见

没有意见。

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