引言
前面分享了通过利用Accept的解析异常来触发log4j2漏洞,传送门:https://sec-in.com/article/1431。继续前面的思路,结合异常日志寻找其他的触发点。
SpringBoot常见异常
如果对Springboot有一定使用经验的话,会经常看到以下异常:
text
Servlet.service() for servlet [dispatcherServlet] in context with path [] threw exception ......
一般情况下,当Controller存在Exception的话,就会抛出对应的异常,例如如下demo:
java
@PostMapping(path = "/test")
public String test(@RequestParam String content) {
int i=1/0;
return content;
}
很明显当访问test接口时,会因为1/0抛出除0异常,查看console台的输出,跟之前的想法是一致的(返回了除0错误):
```text
ERROR 74453 --- [nio-8080-exec-2] o.a.c.c.C.[.[.[.[dispatcherServlet] : Servlet.service() for servlet [dispatcherServlet] in context with path [] threw exception [Request processing failed; nested exception is java.lang.ArithmeticException: / by zero] with root cause
java.lang.ArithmeticException: / by zero
```
查阅相关的资料,当Spring没有解决异常时(一般会在DispatcherServlet统一处理)才会显示这个日志错误,所以会传播到tomcat的StandardWrapperValve.java日志中记录错误。关键的代码如下:
org.apache.catalina.core.StandardWrapperValve的invoke方法,跟进sm.getString方法,可以看到具体的value跟我们的常见异常前面的描述是一致的:
而DispatcherServlet主要用作职责调度工作,本身主要用于控制流程。例如如果请求类型是 multipart 的话,将通过 MultipartResolver 进行文件上传解析。基于前面异常处理的逻辑,如果在MultipartResolver解析时产生异常,如果Spring没有解决,那么应该也会以日志的形式打印出来。这里尝试找到一个异常来印证猜想。
SpringBoot在解析multipart请求时,默认使用的是StandardServletMultipartResolver解析器,考虑到跨路径上传的问题(可以参考https://sec-in.com/article/836),还可以使用commonsMultipartResolver
解析器,其具体实现使用的是commons fileupload
组件解析。这里以commonsMultipartResolver为例。
commonsMultipartResolver异常
查看CommonsMultipartResolver的解析流程,主要在parseRequest方法解析multipart请求,通过调用父类 CommonsFileUploadSupport 的 parseFileItems(List
java
/**
* Parse the given servlet request, resolving its multipart elements.
* @param request the request to parse
* @return the parsing result
* @throws MultipartException if multipart resolution failed.
*/
protected MultipartParsingResult parseRequest(HttpServletRequest request) throws MultipartException {
String encoding = determineEncoding(request);
FileUpload fileUpload = prepareFileUpload(encoding);
try {
List<FileItem> fileItems = ((ServletFileUpload) fileUpload).parseRequest(request);
//将这些流数据转换成 MultipartParsingResult,包含 CommonsMultipartFile、参数信息、Content-type
return parseFileItems(fileItems, encoding);
}
catch (FileUploadBase.SizeLimitExceededException ex) {
throw new MaxUploadSizeExceededException(fileUpload.getSizeMax(), ex);
}
catch (FileUploadBase.FileSizeLimitExceededException ex) {
throw new MaxUploadSizeExceededException(fileUpload.getFileSizeMax(), ex);
}
catch (FileUploadException ex) {
throw new MultipartException("Failed to parse multipart servlet request", ex);
}
}
查看parseFileItems方法的具体实现:
```java
protected CommonsFileUploadSupport.MultipartParsingResult parseFileItems(List
MultiValueMap
Map
Map
Iterator var6 = fileItems.iterator();
while(var6.hasNext()) {
FileItem fileItem = (FileItem)var6.next();
if (fileItem.isFormField()) {
String partEncoding = this.determineEncoding(fileItem.getContentType(), encoding);
String value;
try {
value = fileItem.getString(partEncoding);
} catch (UnsupportedEncodingException var12) {
if (this.logger.isWarnEnabled()) {
this.logger.warn("Could not decode multipart item '" + fileItem.getFieldName() + "' with encoding '" + partEncoding + "': using platform default");
}
value = fileItem.getString();
}
String[] curParam = (String[])multipartParameters.get(fileItem.getFieldName());
if (curParam == null) {
multipartParameters.put(fileItem.getFieldName(), new String[]{value});
} else {
String[] newParam = StringUtils.addStringToArray(curParam, value);
multipartParameters.put(fileItem.getFieldName(), newParam);
}
multipartParameterContentTypes.put(fileItem.getFieldName(), fileItem.getContentType());
} else {
CommonsMultipartFile file = this.createMultipartFile(fileItem);
multipartFiles.add(file.getName(), file);
LogFormatUtils.traceDebug(this.logger, (traceOn) -> {
return "Part '" + file.getName() + "', size " + file.getSize() + " bytes, filename='" + file.getOriginalFilename() + "'" + (traceOn ? ", storage=" + file.getStorageDescription() : "");
});
}
}
return new CommonsFileUploadSupport.MultipartParsingResult(multipartFiles, multipartParameters, multipartParameterContentTypes);
}
```
首先是fileItem.isFormField()的判断逻辑,这里主要是判断FileItem对象里面封装的数据是一个普通文本表单字段,还是一个文件表单字段,例如下面的request是普通文本表单的,会进入对应的逻辑:
关键代码如下:
```java
String partEncoding = this.determineEncoding(fileItem.getContentType(), encoding);
String value;
try {
value = fileItem.getString(partEncoding);
} catch (UnsupportedEncodingException var12) {
if (this.logger.isWarnEnabled()) {
this.logger.warn("Could not decode multipart item '" + fileItem.getFieldName() + "' with encoding '" + partEncoding + "': using platform default");
}
value = fileItem.getString();
}
String[] curParam = (String[])multipartParameters.get(fileItem.getFieldName());
if (curParam == null) {
multipartParameters.put(fileItem.getFieldName(), new String[]{value});
} else {
String[] newParam = StringUtils.addStringToArray(curParam, value);
multipartParameters.put(fileItem.getFieldName(), newParam);
}
multipartParameterContentTypes.put(fileItem.getFieldName(), fileItem.getContentType());
```
首先会调用determineEncoding来获取对应的编码,也就是Content-Type头部的这个部分:
查看具体的实现,如果没有获取到对应的header的话,会设置默认的编码(默认是UTF-8),否则调用MediaType.parseMediaType方法解析:
java
private String determineEncoding(String contentTypeHeader, String defaultEncoding) {
if (!StringUtils.hasText(contentTypeHeader)) {
return defaultEncoding;
} else {
MediaType contentType = MediaType.parseMediaType(contentTypeHeader);
Charset charset = contentType.getCharset();
return charset != null ? charset.name() : defaultEncoding;
}
}
继续跟进,无异常的话会return一个MediaType对象:
```java
public static MediaType parseMediaType(String mediaType) {
MimeType type;
try {
type = MimeTypeUtils.parseMimeType(mediaType);
} catch (InvalidMimeTypeException var4) {
throw new InvalidMediaTypeException(var4);
}
try {
return new MediaType(type);
} catch (IllegalArgumentException var3) {
throw new InvalidMediaTypeException(mediaType, var3.getMessage());
}
}
```
查看parseMimeType的具体实现,主要是进行了一些长度还有是否以multipart开头等检查:
java
public static MimeType parseMimeType(String mimeType) {
if (!StringUtils.hasLength(mimeType)) {
throw new InvalidMimeTypeException(mimeType, "'mimeType' must not be empty");
} else {
return mimeType.startsWith("multipart") ? parseMimeTypeInternal(mimeType) : (MimeType)cachedMimeTypes.get(mimeType);
}
}
parseMimeTypeInternal方法主要是进行一系列的字符串处理,最终返回MimeType对象:
```Java
private static MimeType parseMimeTypeInternal(String mimeType) {
int index = mimeType.indexOf(59);
String fullType = (index >= 0 ? mimeType.substring(0, index) : mimeType).trim();
if (fullType.isEmpty()) {
throw new InvalidMimeTypeException(mimeType, "'mimeType' must not be empty");
} else {
if ("".equals(fullType)) {
fullType = "/*";
}
int subIndex = fullType.indexOf(47);
if (subIndex == -1) {
throw new InvalidMimeTypeException(mimeType, "does not contain '/'");
} else if (subIndex == fullType.length() - 1) {
throw new InvalidMimeTypeException(mimeType, "does not contain subtype after '/'");
} else {
String type = fullType.substring(0, subIndex);
String subtype = fullType.substring(subIndex + 1);
if ("*".equals(type) && !"*".equals(subtype)) {
throw new InvalidMimeTypeException(mimeType, "wildcard type is legal only in '*/*' (all mime types)");
} else {
LinkedHashMap parameters = null;
int nextIndex;
do {
nextIndex = index + 1;
for(boolean quoted = false; nextIndex < mimeType.length(); ++nextIndex) {
char ch = mimeType.charAt(nextIndex);
if (ch == ';') {
if (!quoted) {
break;
}
} else if (ch == '"') {
quoted = !quoted;
}
}
String parameter = mimeType.substring(index + 1, nextIndex).trim();
if (parameter.length() > 0) {
if (parameters == null) {
parameters = new LinkedHashMap(4);
}
int eqIndex = parameter.indexOf(61);
if (eqIndex >= 0) {
String attribute = parameter.substring(0, eqIndex).trim();
String value = parameter.substring(eqIndex + 1).trim();
parameters.put(attribute, value);
}
}
index = nextIndex;
} while(nextIndex < mimeType.length());
try {
return new MimeType(type, subtype, parameters);
} catch (UnsupportedCharsetException var13) {
throw new InvalidMimeTypeException(mimeType, "unsupported charset '" + var13.getCharsetName() + "'");
} catch (IllegalArgumentException var14) {
throw new InvalidMimeTypeException(mimeType, var14.getMessage());
}
}
}
}
}
```
查看MimeType的构造函数,通过设置断点的方式可以大概知道对应参数的内容:
```java
public MimeType(String type, String subtype, @Nullable Map
Assert.hasLength(type, "'type' must not be empty");
Assert.hasLength(subtype, "'subtype' must not be empty");
this.checkToken(type);
this.checkToken(subtype);
this.type = type.toLowerCase(Locale.ENGLISH);
this.subtype = subtype.toLowerCase(Locale.ENGLISH);
if (!CollectionUtils.isEmpty(parameters)) {
Map
parameters.forEach((parameter, value) -> {
this.checkParameters(parameter, value);
map.put(parameter, value);
});
this.parameters = Collections.unmodifiableMap(map);
} else {
this.parameters = Collections.emptyMap();
}
}
```
这里在checkParameters方法对传入的参数进行了处理,如果参数是charset(设置编码)的话,会调用forName方法进行处理:
```java
protected void checkParameters(String parameter, String value) {
Assert.hasLength(parameter, "'parameter' must not be empty");
Assert.hasLength(value, "'value' must not be empty");
this.checkToken(parameter);
if ("charset".equals(parameter)) {
if (this.resolvedCharset == null) {
this.resolvedCharset = Charset.forName(this.unquote(value));
}
} else if (!this.isQuotedString(value)) {
this.checkToken(value);
}
}
```
查看Charset.forName()方法,这里将前面charset参数的值传入,经过lookup方法处理后,如果返回为null,抛出UnsupportedCharsetException异常,因为在Controller解析过程中触发,所以会被DispatcherServlet统一处理,r若没处理的话大概率会抛给tomcat,打印ERROR级别的日志:
java
public static Charset forName(String charsetName) {
Charset cs = lookup(charsetName);
if (cs != null)
return cs;
throw new UnsupportedCharsetException(charsetName);
}
查看lookup方法的实现,如果找不到常规的charset的话会调用checkName方法然后返回null,从而抛出异常:
```java
private static Charset lookup(String charsetName) {
if (charsetName == null)
throw new IllegalArgumentException("Null charset name");
Object[] a;
if ((a = cache1) != null && charsetName.equals(a[0]))
return (Charset)a[1];
// We expect most programs to use one Charset repeatedly.
// We convey a hint to this effect to the VM by putting the
// level 1 cache miss code in a separate method.
return lookup2(charsetName);
}
private static Charset lookup2(String charsetName) {
Object[] a;
if ((a = cache2) != null && charsetName.equals(a[0])) {
cache2 = cache1;
cache1 = a;
return (Charset)a[1];
}
Charset cs;
if ((cs = standardProvider.charsetForName(charsetName)) != null ||
(cs = lookupExtendedCharset(charsetName)) != null ||
(cs = lookupViaProviders(charsetName)) != null)
{
cache(charsetName, cs);
return cs;
}
/* Only need to check the name if we didn't find a charset for it */
checkName(charsetName);
return null;
}
```
那么这里的Exception到底DispatcherServlet会不会处理呢,验证上面的猜想,将Content-type的charset字段设置为sec-in进行请求:
可以看到console台确实打印了对应的异常信息,同时包含了用户可控的内容(sec-in):
text
Servlet.service() for servlet [dispatcherServlet] in context with path [] threw exception [Request processing failed; nested exception is org.springframework.http.InvalidMediaTypeException: Invalid mime type "text/html; charset=sec-in": unsupported charset 'sec-in'] with root cause
与log4j2的关联
上面的过程将异常以日志的形式打印出来了,很自然就联系到了log4j2的问题。
根据前面的猜想,当使用了漏洞版本的log4j2时,如果在multipart请求中,将Content-type头部的charset字段替换成对应的poc,会不会能成功利用呢?
这里结合dnslog进行验证,成功接收到请求:
可以深入看一下具体的日志解析逻辑,本质上也是slf4j桥接。
其他
除此之外,该方法还可以简单的用于判断当前springboot应用使用的multipartResolver是哪个。默认的StandardServletMultipartResolver遇到不合法的charset是不会抛出异常的:
因为CommonsMultipartResolver针对linux和windows的情况对multipart请求中的原始文件名进行了截断处理,防止了../../带来的目录穿越风险。所以可以通过该方法简单的区分当前使用的解析器,进行更深入的漏洞利用。
参考资料
- 左青龙
- 微信扫一扫
-
- 右白虎
- 微信扫一扫
-
评论