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的设计图:
一个简单的Demo
这里我们需要四个部件:Action(操作类)、Interceptor(拦截器)、View(视图,jsp文件等)、Configuration Files(配置文件,xml格式)
我这里使用Idea的Struts2模板进行创建,使用的Struts版本为2.5.16,其他版本下载地址
实现一个简单的页面跳转的功能,项目的结构如下:
MessageHello.java属于Model模块,其中会储存一段明文信息,当Action模块中的HelloAction.java被调用时会进行赋值,最终回显在HelloWorld.jsp页面上。默认的入口页面是index.jsp(也可以在web.xml中修改)
配置文件主要有两个:struts.xml
和web.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 | //HelloAction.java |
1 | //HelloWorld.jsp |
1 | //index.jsp |
1 | //struts.xml |
1 | //web.xml |
运行结果:


简单分析各个组件作用
上面这个简单的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>
,如果返回的结果是字符串"success",就跳转到HelloWorld.jsp页面去。 - 最终跳转到了HelloWorld.jsp页面后,他显示的内容是HelloAction中的messageHello这个对象的message属性(通过值栈的方式进行取值) ,即调用了这个对象的getMessage()方法(定义在MessageHello类中)。
啰嗦说了这么多,下面这张图就可以解释清楚了:
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里的例子,打开后是一个上传的页面:
正常上传的响应头部是正常的,该漏洞的利用点是修改请求包头中的Content-Type
字段来增加请求内容。
比如下面这个例子,将会返回传入的计算式子的内容:
可以看到在返回的头部中增加了一条vulhub
名称的字段,值为请求报头中的Content-Type中的计算式3*4
。修改后:
1 | Content-Type:%{#context['com.opensymphony.xwork2.dispatcher.HttpServletResponse'].addHeader('vulhub',3*4)}.multipart/form-data; |
原理分析
版本是2.3.20,下面根据官方的设计图进行排查(这里再放一张方便查看):
这个漏洞成因是因为对信息处理的不当导致执行了OGNL表达式。首先查看过滤器的入口:StrutsPrepareAndExecuteFilter
,这个类是Struts2默认配置的入口过滤器,位置:org\apache\struts2\dispatcher\ng\filter
,查看对请求的处理函数doFilter
。关键代码:
1 | prepare.setEncodingAndLocale(request, response); |
跟进一下,查看wrapRequest()
方法:
1 | /** |
内部又调用了Dispatcher类的wrapRequest()
方法,跟进一下:
1 | /** |
在我注释的地方可以看到,他首先从request中获取了我们的Content-Type
字段的值,并在if判断中对content_type这个字段进行了判断:content_type.contains("multipart/form-data")
,仅仅使用了String类的contains方法(contains方法如下)
1 | public boolean contains(CharSequence s) { |
换言之,只要请求的Conten-Type
字段中包含这个multipart/form-data
字符串就符合条件了,根据上面的复现可以看到,这里开始就出现污染了,conten-type错误的包含了ognl表达式却没有加以限制。之后使用了getMultiPartRequest()
方法进行一些清理工作,跟进MultiPartRequestWrapper
构造方法:
1 | /** |
在进行了一些初始化的工作后,调用了MultiPartRequest.parse()
接口方法,跟进实现JakartaMultiPartRequest.parse()
:
1 | /** |
跟进catch体的buildErrorMessage
方法:
1 | protected String buildErrorMessage(Throwable e, Object[] args) { |
我们输入的恶意Conten-Type会被抛出到这里的e
,之后调用了LocalizedTextUtil.findtext()
方法,其中e.getMessage()方法这个变量的值就是我们输入的恶意Content-Type。跟进findText方法:
1 | public static String findText(Class aClass, String aTextName, Locale locale, String defaultMessage, Object[] args) { |
先是获取上下文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()
方法处下断点:
Burpsuite发一个含恶意Content-Type的POST包:
捕捉到异常:
可以查看到异常的类型为org.apache.commons.fileupload.FileUploadBase$InvalidContentTypeException
,后面跟的就是我们的Content-Type。跟进,进入buildErrorMessage函数中:
得到errorKey的值为:struts.messages.upload.error.InvalidContentTypeException
,步过if判断,继续跟进到LocalizedTextUtil.findtext()
函数:
获得值栈对象后,继续调用下一个重载的findText()方法,可以看到参数defaultMessage的值就是恶意的Content-Type的值:
跟进findText()到getDefaultMessage()
方法:
在这里message的值也被污染了,值为恶意的Content-Type值。步入buildMessageFormat()
函数:
可以看到传入的参数中会先进行TextParseUtil.translateVariables()
方法,重点就在这里了,这个方法的描述是这样的:Converts all instances of ${...}, and %{...} in <code>expression</code> to the value returned
。显然这是一个计算OGNL表达式的函数,步入进行查看:
最终调用的函数是这个:
1 | /** |
最终返回的值就是计算的OGNL表达式的值,可以在最后构造出的response中看到这个结果:
总的来说就是很单纯的使用了一个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的页面:
填写表单,注入点在Gangster Name这个字段
可以看到在页面的回显中有结果:
原理分析
这个洞主要是因为上述版本中开启了struts 1 plugin
导致任意代码执行漏洞,这个插件对应类为org.apache.struts2.s1.Struts1Action
,起一个封装类的作用,主要是让Struts1中的Action类可以再Struts2中正常使用。位置:
官方给出的2.3.20版本的showcase里就有这个漏洞(上述复现),查看对应jsp文件得到其对应动作为saveGangster
,查看struts-intergration.xml文件得到其对应的java文件为Struts1Action.java文件
:
<action name="saveGangster" class="org.apache.struts2.s1.Struts1Action">
。
查看他的execute()
方法,其中有这么一段代码:
1 | try { |
跟进这个execute()方法,位于上述的SaveGangsterAction.java
中:
1 |
|
这个漏洞的核心问题就在注释的地方了,在新建ActionMessage类时,将注入点的Name的原始消息直接拼接了起来,没有任何的处理,导致了最后的问题。这样参数就成了我们带有OGNL表达式的参数了。
继续往下看
1 | HttpServletRequest request = ServletActionContext.getRequest(); //获取request体对象 |
在我标注注意的else那部分,msg.getKey()
方法返回的就是最终回显的消息(此时还未解析OGNL表达式),之后调用了getText()方法:
1 | public String getText(String aTextName) { |
跟进TextProvider.getText()
函数:
1 | /** |
可以看到这里就和045重合了,调用findText,接下来会获得值栈对象、重载findText、调用LocalizedTextUtil.getDefaultMessage()
方法,最终调用TextParseUtil.translateVariables()
方法导致最后的RCE。这个漏洞和045还是很相像的。
动态调试
在org.apache.struts2.s1.Struts1Action.execute()
方法里下断点,burp发包:
可以在表单数据actionForm
变量中看到输入:
拿到请求:
可以在请求的参数中看到输入:
跟进action.execute()
方法,首先拿到用户的输入表单,变量gform中包含我们的输入:
添加返回信息到message变量,message变量被污染:
跟进addMessage()
方法,requestMessages变量将返回的信息加入,最后设置request变量的属性:
execute()函数结束,步入request.getAttribute()
函数:
首先获取上下文,然后用一个Object类变量attribute来接受request的属性,可以看到这里获得了最终的回显信息:
将这个信息返回到上述的messages变量中,这样messages变量也被污染了:
进入label36,跟进addActionMessage()
函数:
msg.getKey()
函数返回信息,跟进getText()
函数:
跟进TextProvider.getText()
函数:
跟进重载函数getText(),已经可以看到要调用LocalizedTextUtil.findText()
方法了,:
跟进findText()方法,这里的key和defaultValue参数的值都是那条消息:
和S2-045的分析到这里就重合了,接下来会获取值栈对象,然后调用重载的findText()方法:
来到default的分支,跟进getDefaultMessage()方法:
到这里就清晰了,紧接着会调用关键的解析OGNL表达式的函数TextParseUtil.translateVariables()
方法,传入的参数中就有包含我们的输入:
可以看到计算的结果:
参考链接
- 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,打开后页面如下:
构造以下url(后面写错了,应该是${1+1}/actionChain1.action):
可以得到回显:
根据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()))} |
可以看到成功执行了id命令,达到了RCE的效果
原理分析
版本是2.5.16,漏洞点在struts2的core依赖包中org\apache\struts2\dispatcher\mapper\DefaultActionMapper.java
中:
这个漏洞主要是因为配置文件中缺少了namespace的属性导致的问题,上面复现的showcase中的配置文件是这样的:
1 |
|
查看DefaultActionMapper.java中的parseNameAndNamespace函数(用于解析namespace和name):
1 | protected void parseNameAndNamespace(String uri, ActionMapping mapping, ConfigurationManager configManager) { |
当alwaysSelectFullNamespace
的值设为true时,namespace的值就可以通过传入的uri的值来进行控制。Action执行结束时,调用ServletActionRedirectResult.execute()进行重定向Result的解析:
1 | public void execute(ActionInvocation invocation) throws Exception { |
通过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 | /** |
而TextParseUtil.translateVariables()方法最终调用了translateVariables()方法,其中的TextParser.evalure()执行了url中的OGNL表达式,导致了最后的代码执行:
1 | public static Object translateVariables(char[] openChars, String expression, final ValueStack stack, final Class asType, final ParsedValueEvaluator evaluator, int maxLoopCount) { |
动态调试
ps: 官方给的2.3.16版本的showcase里,上述复现的漏洞是没有的,vulhub的那个是因为他把struts-actionchaining.xml这个文件里package属性里的namespace去掉了所以满足了条件。。就说走了好几遍没问题啊,最后发现压根没问题。。所以根据vulhub那个dockerfile:
1 | version: '2' |
先把这个xml进行修改(修改后):
1 | //struts-actionchaining.xml |
即去掉了package的namespace属性,并对后续操作做了修改:将重定向页面(302)改为了一个register2的操作,这样就可以在response中看到回显了,这里的逻辑修改主要是为了方便查看调试的回显。
在org.apache.struts2.dispatcher.mapper.DefaultActionMapper
中下断点:
burp发包,可以看到uri的信息就是请求的url值:
这里可以看到条件以满足:alwaysSelectFullNamespace
这个变量值的值为true。
也可以看到namespace
变量已经被污染了:
设置命名空间和名称:
在ServletActionRedirectResult.execute()
方法中下断点:
跟进到这里,返回action的映射,里面的request参数中就包含请求的url:
放行至下一个断点ServletActionRedirectResult.execute()
方法,也就是出问题的地方:
跟进ServletRedirectResult.getUriFromActionMapping()
方法生成暂时的路径tmpLocation,可以看到传入的参数中命名空间为含我们输入的OGNL表达式的字符串,动作action为register2,就是我们之前在xml中修改的那个逻辑,method方法为空:
首先创建了一个新类ActionMapping:
之后调用上述方法,继续跟进,可以看到主要操作是拼接字符串,这样的话最终的uri也就被污染了,接收函数返回值的tmpLocation也就被污染了:
最终返回的uri是包含OGNL表达式的,但到这里还没有执行:
继续跟进父类的StrutsResultSupport.setLocation()
函数:
函数说明也说得很清楚了,这里的Location的作用是用来跳转的,所以我们才会在响应的状态码中看到302,继续跟进,执行父类的execute()方法:
重载,继续跟进:
到这里就恍然大悟了,他会将这个跳转的路径进行OGNL表达式的解析(不懂为啥要这么设计...可能另有他用),继续跟进conditionParse()
方法:
又是熟悉的函数,熟悉的场景。这个函数的主要作用是根据值栈来解析OGNL表达式的,继续跟进:
一样的重载,最终调用的当然还是这个函数了:
到此分析完毕,传入的Location字符串被这个函数解析了,顺便就解析了我们的OGNL字符串。怎么解析我没有深入看,大概是匹配了%
或者$
以及花括号然后再解析的(比如下图已经得出了表达式):
参考链接
- https://mp.weixin.qq.com/s/iBLrrXHvs7agPywVW7TZrg
小结
通过对上面这三个漏洞的分析可以看出来,对于Struts2的漏洞,很多都是针对他的OGNL表达式,S2-045、S2-057,这两个漏洞出问题调用的最终函数都是TextParser
这个类的evaluate
方法,导致执行了恶意的OGNL表达式。