深度解析S2-045漏洞:OGNL沙箱绕过与远程代码执行实战
1. 项目概述一次对经典漏洞的深度技术复盘几年前当S2-045漏洞的预警通告在各个安全应急响应中心刷屏时整个安全圈和开发社区都为之震动。这个基于Struts2框架的远程代码执行漏洞因其利用条件简单、影响范围巨大迅速成为了当年最具威胁的漏洞之一。时至今日虽然Struts2的热度已不如从前但S2-045作为一次经典的OGNL表达式沙箱绕过案例其背后的攻击载荷构造原理、对安全机制的挑战以及防御思路依然是每一位从事应用安全研究、红蓝对抗甚至后端开发的同学值得深入学习的“活教材”。这次我们不谈泛泛的概念而是直接切入核心从OGNL表达式的沙箱机制开始一步步拆解攻击者是如何在这个看似坚固的“笼子”里找到钥匙并最终构造出能够远程执行任意代码的攻击载荷的。无论你是想理解漏洞本质的开发者还是希望提升漏洞分析能力的安全研究员这篇深度复盘都能为你提供清晰的路径和扎实的细节。2. 漏洞背景与核心攻击面定位2.1 Struts2框架与OGNL表达式的“爱恨纠葛”要理解S2-045必须先理解Struts2和OGNLObject-Graph Navigation Language的关系。Struts2作为一个经典的MVC框架其核心优势之一就是强大的数据绑定和视图渲染能力。而OGNL正是实现这一能力的“魔法引擎”。它允许开发者在JSP页面或框架配置中使用一种简洁的表达式语言来动态访问和操作Java对象栈中的属性、调用方法。例如在JSP中写s:property valueuser.name/Struts2就会通过OGNL去当前值栈ValueStack里找到user对象并取出其name属性。这种设计带来了极大的灵活性但也埋下了巨大的安全隐患。因为OGNL的功能太强大了它不仅能取属性还能执行方法调用、进行算术运算、甚至访问静态方法和构造函数。如果用户输入的数据在未经严格过滤的情况下直接被当作OGNL表达式解析并执行那就相当于给了攻击者一个在服务器端执行任意Java代码的“后门”。Struts2的发展史从某种程度上看就是一部与OGNL表达式注入漏洞不断斗争的历史。框架开发者们不断为OGNL引擎增加各种限制即沙箱而攻击者则不断寻找绕过这些限制的方法。S2-045正是这场攻防战中一个标志性的战役。2.2 S2-045漏洞的独特触发点基于Content-Type的注入Struts2历史上的很多漏洞其触发点往往在普通的HTTP参数上。但S2-045CVE-2017-5638的特别之处在于它的攻击向量是HTTP请求头中的Content-Type字段。这听起来有点反直觉一个处理文件上传类型声明的头信息怎么会导致代码执行根源在于Struts2框架中用于处理文件上传的Jakarta Multipart解析器。当框架接收到一个带有文件上传的请求multipart/form-data时它会调用这个解析器来处理请求体。如果解析过程中发生错误例如恶意构造的Content-Type值导致解析异常框架会捕获这个异常并将错误信息其中包含了原始的、未经处理的Content-Type头内容通过特定的错误处理机制进行传递。问题就出在这个错误处理机制上。在某些版本的Struts2中错误信息会被传递给一个底层方法该方法会尝试使用OGNL表达式去解析和评估错误信息中的某些部分。攻击者正是通过在Content-Type头中嵌入恶意的OGNL表达式来触发这个解析流程。由于这个传入的字符串最终被直接送入了OGNL引擎的getValue()方法而框架在将用户输入传递给OGNL引擎前未能有效清理或禁用其中的危险语法导致了远程代码执行。注意这里有一个关键点漏洞的触发需要满足“解析出错”的条件。攻击者通常会故意构造一个格式错误但包含恶意代码的Content-Type值例如Content-Type: ${(#_memberAccess[“allowStaticMethodAccess”]true).(#cmd‘whoami’).(#iswin(java.lang.SystemgetProperty(‘os.name’).toLowerCase().contains(‘win’))).(#cmds(#iswin?{‘cmd.exe’, ‘/c’, #cmd}:{‘/bin/bash’, ‘-c’, #cmd})).(org.apache.struts2.ServletActionContextgetResponse().getWriter().println(org.apache.struts2.ServletActionContextgetResponse().getWriter().println(#p)))}。注意这只是一个原理性示例实际载荷更复杂。2.3 为什么是“沙箱绕过”理解OGNL的安全防护机制在S2-045爆发之前Struts2已经因为OGNL注入问题修补过多次。为此框架引入了严格的OGNL沙箱机制主要目的是限制表达式能够执行的敏感操作例如禁止访问静态方法默认情况下不允许通过类名方法名的形式调用静态方法。禁止访问构造函数不允许使用new关键字或类似方式创建特定类的实例。限制对敏感类的访问通过黑名单或上下文限制阻止访问Runtime,ProcessBuilder,System等能够执行命令或访问系统资源的类。限制成员访问权限通过_memberAccess等内部对象控制对类成员特别是静态成员的访问。你可以把这个沙箱想象成一个Java运行时的“监狱”OGNL表达式在这个监狱里运行只能进行一些被允许的、无害的数据操作。而漏洞利用的过程就是攻击者想方设法“越狱”的过程。S2-045的载荷之所以复杂其核心目标就是为了突破这些限制重新打开访问静态方法、执行命令的通道。因此分析S2-045本质上就是分析一次完整的OGNL沙箱绕过技术。3. OGNL表达式沙箱机制深度解析3.1 OGNL上下文与安全策略的加载过程OGNL表达式并非在真空中执行它在一个特定的上下文OgnlContext中运行这个上下文包含了变量、根对象、以及至关重要的——安全访问控制策略。在Struts2中这个上下文由框架精心设置。安全策略主要通过一个名为SecurityMemberAccess的类或其类似物来实施它通常被注册到OgnlContext的_memberAccess变量中。当OGNL引擎尝试执行一个表达式比如java.lang.RuntimegetRuntime().exec(calc)时它会进行如下检查检查是否允许调用静态方法allowStaticMethodAccess。检查目标类java.lang.Runtime是否在黑名单中或者是否在允许访问的包范围内。检查目标方法exec是否可访问。在沙箱严格模式下第一步检查就会失败因为allowStaticMethodAccess默认为false。整个沙箱的有效性很大程度上依赖于这些安全标志位和访问控制列表的完整性并且假设攻击者无法在表达式执行过程中修改它们。3.2 关键防御节点_memberAccess与allowStaticMethodAccess_memberAccess是OGNL上下文中的一个关键对象它是MemberAccess接口的实现实例负责所有成员访问的逻辑判断。其中allowStaticMethodAccess是其内部的一个私有布尔字段。这个字段就像是沙箱大门的第一把锁。在安全的OGNL执行流程中流程是这样的框架初始化OgnlContext → 设置一个配置好的、安全的SecurityMemberAccess实例到_memberAccess→ 将用户输入作为表达式字符串传入 → OGNL引擎解析表达式 → 在评估表达式的每一步引擎都向_memberAccess对象咨询“这个操作允许吗”。攻击的目标因此变得非常明确必须在OGNL引擎评估我们恶意表达式的过程中找到一种方法篡改_memberAccess对象内部的allowStaticMethodAccess字段的值将其从false改为true。一旦这把锁被打开调用静态方法的大门就敞开了。3.3 沙箱规则的局限性黑名单与上下文隔离的失效场景除了控制静态方法访问沙箱还可能采用类/方法黑名单、包访问限制等。但这些机制存在固有缺陷黑名单的滞后性黑名单永远无法穷尽所有危险的类和方法。Java生态庞大总有未被列入名单但功能强大的类可供利用。上下文污染如果攻击者能够向OGNL上下文中注入新的变量或者修改已有变量的属性就可能找到绕过检查的路径。Struts2的某些特性如将请求参数自动设置为上下文变量在漏洞场景下可能成为帮凶。链式调用绕过即使直接调用Runtime.exec被禁止攻击者可能会通过一系列看似无害的调用链最终达到相同目的。例如先获取一个允许访问的类对象再通过反射间接获取Runtime。S2-045的利用过程精彩地演示了攻击者如何结合多种技术针对这些局限性进行突破。4. S2-045攻击载荷构造原理逐步拆解现在让我们进入最核心的部分一步步还原攻击者是如何构造出那个能够绕过沙箱、执行任意命令的OGNL表达式的。请注意以下分析基于公开的漏洞原理和PoC概念验证代码旨在进行技术学习请勿用于非法用途。4.1 第一步获取并篡改_memberAccess这是整个绕过过程的基石。在OGNL表达式中我们可以通过#符号来访问上下文中的变量。因此#_memberAccess就指向了那个安全控制对象。OGNL表达式支持赋值操作和链式调用。攻击者构造了这样的表达式开头(#_memberAccess[allowStaticMethodAccess]true)这行代码做了以下几件事#_memberAccess获取上下文中的_memberAccess对象。[allowStaticMethodAccess]这是一种访问对象属性的方式。由于allowStaticMethodAccess字段可能是私有private的直接使用点操作符.可能受限于Java访问控制。OGNL提供了通过键值对类似于Map或特定语法访问私有字段的能力具体实现可能因Struts2和OGNL版本而异。在某些上下文中这种方式可以绕过Java的私有访问限制。true将true值赋给这个字段。执行完这一小段表达式后_memberAccess对象内部的访问控制策略就已经被篡改了静态方法访问的锁被打开。实操心得这里的关键在于OGNL引擎在解析表达式时其访问控制检查可能发生在表达式评估的“运行时”而不是“编译时”。当引擎检查#_memberAccess[allowStaticMethodAccess]这个子表达式时它可能只是在做属性访问的权限判断这或许是被允许的而赋值操作true本身作为一个操作可能没有受到与“调用静态方法”同等级别的拦截。这就造成了逻辑上的漏洞。4.2 第二步构建命令执行环境与多平台兼容打开静态方法访问后下一步就是执行命令。但需要考虑到目标服务器的操作系统可能是Windows或Linux。一个健壮的Payload需要具备兼容性。攻击者通常会先获取系统属性来判断操作系统(#osjava.lang.SystemgetProperty(os.name).toLowerCase())然后根据操作系统选择不同的命令解释器和语法(#cmds(#os.contains(win)?{cmd.exe, /c, #command}:{/bin/bash, -c, #command}))这里用到了OGNL的三目运算符和数组字面量{}。#command是一个变量存储了要执行的命令字符串例如whoami或ipconfig。这样无论目标服务器是Windows还是LinuxPayload都能正确构造出进程启动命令参数数组。4.3 第三步反射机制与Runtime类的最终调用即使允许了静态方法访问直接调用Runtime.getRuntime().exec()可能仍然受到类黑名单的限制。更高级、更通用的技巧是使用Java反射Reflection。反射允许在运行时检查类、调用方法是绕过静态类型检查和黑名单的利器。一个典型的反射调用链如下获取Runtime类java.lang.ClassforName(java.lang.Runtime)获取getRuntime方法.getMethod(getRuntime, null)或.getDeclaredMethod(getRuntime)调用静态方法获取Runtime实例.invoke(null, null)获取exec方法.getMethod(exec, java.lang.ClassforName([Ljava.lang.String;))(注意[Ljava.lang.String;是字符串数组的类名签名)执行命令.invoke(#runtimeInstance, #cmds)将所有这些步骤用OGNL语法连接起来形成一个长长的链式表达式。由于OGNL支持使用.进行链式调用并且每一步都可能返回一个对象供下一步使用因此可以将整个反射流程写在一个复杂的表达式里。4.4 第四步输出重定向与结果回显对于攻击者来说执行命令但看不到结果是没有意义的。因此Payload还需要将命令执行的结果输出到HTTP响应中回显给攻击者。这需要获取HttpServletResponse对象在Struts2的上下文里可以通过静态方法访问ServletActionContext来获取当前请求的Response对象。例如org.apache.struts2.ServletActionContextgetResponse()。获取Writer并输出.getWriter().println(#result)。其中#result需要是命令执行的输出。获取进程输出流Process.getInputStream()并读取其内容这本身又可能涉及一系列的IO操作可以封装在一个子表达式中或者通过创建临时文件等方式实现。最终将读取到的内容通过Response写回。4.5 完整Payload的组装与编码技巧将以上所有步骤组合起来就形成了一个完整的、能够绕过沙箱、执行命令并回显的OGNL表达式。这个表达式会非常长并且包含大量特殊字符如括号、引号、分号、符号等。为了能够将其放入HTTP头Content-Type中并且避免被中间件、WAF或框架自身的简单过滤机制拦截攻击者需要对Payload进行编码。常用的技巧包括OGNL表达式嵌套使用${}将表达式嵌套起来这在某些上下文解析时是必需的。Unicode转义将关键字符如(){}转换成\u0028\u0029等形式以绕过基于字符串匹配的过滤。多重编码结合URL编码、HTML实体编码等。利用OGNL特性例如OGNL中可以使用#加数字如#a来引用表达式中的临时变量使结构更清晰虽然最终Payload为了压缩可能会去掉这些别名。最终一个高度混淆和编码后的S2-045攻击载荷在Content-Type头中可能看起来像是一串杂乱无章、包含大量%u序列的“乱码”但这正是其绕过检测的伪装。5. 漏洞复现环境搭建与调试分析5.1 靶场环境快速搭建指南要真正理解漏洞最好的方式是在受控环境中亲手复现。以下是搭建一个用于分析S2-045的本地测试环境的简要步骤准备漏洞版本Struts2下载一个受影响的Struts2版本例如2.3.32或2.5.10.1。可以从Apache官方归档站点或Maven仓库获取对应的Web应用示例struts2-blank.war或完整发行包。部署至Servlet容器使用Tomcat 8或9。将下载的WAR文件放入Tomcat的webapps目录启动Tomcat。构造恶意请求使用Burp Suite、Postman或cURL工具。关键是要构造一个POST请求设置Content-Type头部为包含恶意OGNL表达式的值。请求的URL指向一个使用了Jakarta文件上传解析器的Struts2 Action通常任何支持文件上传的端点都可能受影响。使用公开PoC进行测试互联网上有许多经过编码的S2-045 PoC。可以找一个相对简单的进行测试例如执行whoami或id命令。注意务必在完全隔离的虚拟机或容器中进行此操作。一个最简单的cURL测试命令格式如下Payload已简化编码仅作示意curl -X POST http://localhost:8080/struts2-blank/upload.action -H Content-Type: malicious_ognl_payload_here -d testvalue5.2 关键代码定位与动态调试技巧要深入分析需要阅读Struts2源码。关键类通常包括StrutsPrepareAndExecuteFilter请求入口。Dispatcher核心调度器。JakartaMultiPartRequest处理文件上传的类漏洞触发点。LocalizedTextUtil/TextProvider可能与错误信息处理和OGNL解析相关的工具类。使用IDEA或Eclipse进行远程调试是极佳的选择在Tomcat启动脚本中开启JPDA调试端口如-agentlib:jdwptransportdt_socket,servery,suspendn,address5005。在IDE中配置远程调试连接到该端口。在疑似漏洞触发点如JakartaMultiPartRequest.parse()中抛出异常的地方以及后续处理异常并调用findText()或getDefaultMessage()的方法设置断点。发送恶意请求观察调用栈跟踪用户输入的Content-Type值是如何一步步传递最终被送入OgnlUtil.getValue()或类似方法执行的。通过调试你可以清晰地看到_memberAccess对象在表达式执行前后的状态变化直观理解沙箱是如何被绕过的。5.3 漏洞触发流的可视化追踪虽然不能使用Mermaid图但我们可以用文字描述核心触发流程请求接收攻击者发送带有恶意Content-Type头的multipart/form-data请求。解析异常JakartaMultiPartRequest.parse()尝试解析畸形的Content-Type抛出异常如IllegalArgumentException。异常处理异常被框架捕获进入错误处理流程。框架试图获取本地化的错误信息。OGNL解析触发在获取错误信息的过程中框架调用了某个方法例如TextProvider.getText()该方法将包含原始Content-Type值的字符串参数直接用于构建OGNL表达式或作为表达式的一部分进行评估。沙箱绕过与代码执行OGNL引擎执行该表达式。表达式首先修改_memberAccess[allowStaticMethodAccess]然后通过反射调用Runtime.exec()执行攻击者指定的命令。结果回显命令输出被写入HttpServletResponse返回给攻击者。这个流程清晰地展示了“异常处理路径”如何成为“代码执行路径”这是许多安全漏洞的共性程序在错误处理时往往假设数据是内部可信的从而放松了安全检查。6. 从攻击视角看防御漏洞修复与防护方案6.1 官方补丁的核心思路分析Apache Struts2官方针对S2-045的修复方案其核心在于禁用OGNL表达式在错误信息中的动态评估。具体来说移除动态评估在负责处理上传错误并生成提示信息的代码段中修改了LocalizedTextUtil.findText()方法或其相关调用逻辑。确保当从资源包Resource Bundle中获取错误消息时消息中的${...}或%{...}占位符不再被当作OGNL表达式进行解析和执行而是被当作普通文本处理或者仅允许进行简单的参数替换。加固默认安全配置进一步收紧OGNL的默认安全沙箱设置例如确保allowStaticMethodAccess等关键标志在任何默认配置下都为false且难以被用户输入修改。修复的本质是切断用户输入通往OGNL解析引擎的路径或者确保即使输入到达解析引擎也处于一个无法执行危险操作的“安全模式”下。对于开发者而言最直接的教训就是永远不要将未经净化的用户输入传递给任何解释器或脚本引擎包括OGNL、EL、SpEL、JavaScript引擎等。6.2 企业级防护与应急响应建议对于仍在使用受影响版本Struts2的系统升级到安全版本是根本解决方案。如果因故无法立即升级可采取以下临时缓解措施WAFWeb应用防火墙规则部署虚拟补丁。在WAF上设置规则严格检测和拦截HTTP请求头特别是Content-Type头中包含$、#、、\u、Runtime、ProcessBuilder、getClass等关键OGNL和Java反射特征字符的请求。注意规则需要精心设计避免误杀正常业务请求。应用层过滤器编写一个Servlet Filter或Struts2 Interceptor在请求到达框架核心前对Content-Type等头信息进行严格的格式验证和内容过滤拒绝任何不符合预期格式如包含OGNL表达式特征的值。删除危险解析器如果确认应用不需要文件上传功能可以直接在struts.xml配置文件中将struts.multipart.parser设置为jakarta-stream以外的值或者直接移除相关的Jar包如struts2-core.jar中可能包含的Jakarta解析器类从根源上消除触发漏洞的组件。最小化依赖定期审查和清理项目依赖移除不必要的Struts2插件和库减少攻击面。6.3 安全开发规范避免OGNL注入的编码实践对于开发者从源头避免此类漏洞更为重要严格禁止表达式注入在任何情况下都不要使用用户可控的数据来拼接OGNL、EL或其他模板表达式。框架的标签如s:property会自动处理表达式不要手动调用Ognl.getValue()或Ognl.parseExpression()来处理用户输入。使用白名单进行输入验证对于所有用户输入包括参数、头、Cookie建立严格的白名单验证机制。例如对于Content-Type头只允许application/jsonmultipart/form-data; boundary...等有限的、预期的值。安全配置框架在struts.xml中使用最严格的安全配置。例如设置constant namestruts.ognl.disableClassExclusion valuetrue/如果版本支持来启用类排除列表并确保struts.excludedClasses等黑名单包含必要的危险类。保持依赖更新建立流程持续关注所用框架如Struts2、Spring、Fastjson等的安全公告并及时测试、更新到安全版本。S2-045并非孤例后续仍有类似漏洞如S2-046, S2-048等。7. 衍生思考OGNL沙箱绕过的通用模式与检测7.1 从S2-045看沙箱绕过的常见手法S2-045的利用并非偶然它揭示了几种OGNL沙箱绕过的通用模式上下文变量篡改这是最直接的方式。攻击者寻找OGNL上下文中那些控制安全策略的变量如_memberAccess,context等并利用OGNL的赋值能力直接修改其关键属性allowStaticMethodAccess,excludedClasses,excludedPackageNames等。防御方需要确保这些安全控制对象本身是不可篡改的或者对其的修改操作受到更严格的检查。利用黑名单遗漏不断寻找未被列入黑名单但功能强大的类。例如除了Runtime还有ProcessBuilder、ScriptEngineManager执行JS等、GroovyShell、通过ClassLoader动态加载字节码等。防御需要持续更新和维护黑名单但这是一场猫鼠游戏。反射与链式调用利用Java反射是绕过静态检查和黑名单的终极武器之一。通过Class.forName、getMethod、invoke这一套组合拳可以间接调用任何方法。更高级的利用还会结合AccessibleObject.setAccessible(true)来突破私有方法/字段的限制。防御这种攻击需要在沙箱中禁止或严格限制反射相关类的使用。属性访问语法差异OGNL提供了多种属性访问语法如点操作符.、方括号[‘key’]、#变量等。某些沙箱规则可能只拦截了其中一种语法而忽略了其他。攻击者会尝试所有可能的语法来寻找突破口。7.2 基于行为特征的漏洞检测思路对于安全工程师来说如何检测这类漏洞除了匹配已知的Payload签名更有效的是基于行为特征的检测异常HTTP头检测监控Content-Type、User-Agent、Referer等HTTP头部的长度和字符集。正常的Content-Type值通常较短且格式固定而OGNL Payload往往异常冗长且包含大量特殊字符和编码。OGNL表达式特征检测在应用层或网络层检测包含${、#_memberAccess、java.lang、getRuntime、exec(、forName(、invoke(等关键字的请求。可以结合正则表达式和语义分析提高准确率。沙箱篡改行为监控如果条件允许可以在OGNL引擎层面植入钩子Hook监控对_memberAccess等安全关键对象的写操作。任何尝试修改allowStaticMethodAccess、excludedClasses等字段的行为都应被视为高度可疑立即告警并中断表达式执行。子进程创建监控在服务器层面监控由Web应用进程如Tomcat的Java进程创建的意外子进程。一个正常的Web请求通常不会创建cmd.exe或/bin/bash的子进程。这类行为是远程命令执行的确凿证据。7.3 红队视角下的漏洞利用演进从红队攻击方视角看S2-045的利用技术也在不断进化无回显利用在无法获取命令输出的情况下如不出网攻击者会构造执行诸如“写入Webshell”、“进行DNS外带”、“延迟睡眠”等操作的Payload以证明漏洞存在并建立持久化据点。内存马注入更高级的攻击不再满足于执行单条命令而是通过OGNL表达式在目标JVM内存中直接注入一个恶意的Servlet Filter、Listener或Controller即“内存马”。这种方式无文件落地隐蔽性极强重启后失效但难以检测。绕过WAF的混淆技术为了绕过基于正则的WAF规则攻击者会使用更复杂的混淆技术如多重编码、字符串拆分拼接、利用冷门Java特性如利用${}嵌套进行表达式递归解析、甚至使用JavaScript引擎等间接方式执行代码。利用其他触发点虽然S2-045的触发点是Content-Type但OGNL注入的本质是用户输入被解析。红队会审计Struts2应用中所有可能将参数值用于OGNL解析的地方如某些标签属性、配置项等寻找新的注入点。理解这些攻击演进能帮助蓝队防御方建立更具纵深和前瞻性的防御体系。安全攻防的本质是知识的对抗深入理解像S2-045这样的经典漏洞就如同掌握了一把解剖复杂安全问题的手术刀无论是对于漏洞挖掘、应急响应还是安全开发其价值都是长远而深刻的。