浅谈Log4j2在Springboot的检测-2

admin 2022年4月15日03:03:20浅谈Log4j2在Springboot的检测-2已关闭评论22 views字数 10961阅读36分32秒阅读模式

引言

  前面分享了通过利用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日志中记录错误。关键的代码如下:

浅谈Log4j2在Springboot的检测-2

  org.apache.catalina.core.StandardWrapperValve的invoke方法,跟进sm.getString方法,可以看到具体的value跟我们的常见异常前面的描述是一致的:

浅谈Log4j2在Springboot的检测-2

浅谈Log4j2在Springboot的检测-2

  而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 fileItems, String encoding) 方法,将这些流数据转换成 MultipartParsingResult 对象:

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 fileItems, String encoding) {
MultiValueMap multipartFiles = new LinkedMultiValueMap();
Map multipartParameters = new HashMap();
Map multipartParameterContentTypes = new HashMap();
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是普通文本表单的,会进入对应的逻辑:

浅谈Log4j2在Springboot的检测-2

  关键代码如下:

```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头部的这个部分:

浅谈Log4j2在Springboot的检测-2

  查看具体的实现,如果没有获取到对应的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的构造函数,通过设置断点的方式可以大概知道对应参数的内容:

浅谈Log4j2在Springboot的检测-2

```java
public MimeType(String type, String subtype, @Nullable Map parameters) {
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 map = new LinkedCaseInsensitiveMap(parameters.size(), Locale.ENGLISH);
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进行请求:

浅谈Log4j2在Springboot的检测-2

  可以看到console台确实打印了对应的异常信息,同时包含了用户可控的内容(sec-in):

浅谈Log4j2在Springboot的检测-2

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进行验证,成功接收到请求:

浅谈Log4j2在Springboot的检测-2

浅谈Log4j2在Springboot的检测-2

  可以深入看一下具体的日志解析逻辑,本质上也是slf4j桥接。

其他

  除此之外,该方法还可以简单的用于判断当前springboot应用使用的multipartResolver是哪个。默认的StandardServletMultipartResolver遇到不合法的charset是不会抛出异常的:

浅谈Log4j2在Springboot的检测-2

  因为CommonsMultipartResolver针对linux和windows的情况对multipart请求中的原始文件名进行了截断处理,防止了../../带来的目录穿越风险。所以可以通过该方法简单的区分当前使用的解析器,进行更深入的漏洞利用。

参考资料

  • 左青龙
  • 微信扫一扫
  • weinxin
  • 右白虎
  • 微信扫一扫
  • weinxin
admin
  • 本文由 发表于 2022年4月15日03:03:20
  • 转载请保留本文链接(CN-SEC中文网:感谢原作者辛苦付出):
                   浅谈Log4j2在Springboot的检测-2http://cn-sec.com/archives/913083.html