原创 | 浅谈Log4j2在Springboot的检测-2

admin 2022年4月13日20:08:35评论60 views字数 11168阅读37分13秒阅读模式

点击蓝字




关注我们


引言



Part 1

原创 | 浅谈Log4j2在Springboot的检测-2
前面分享了通过利用Accept的解析异常来触发log4j2漏洞,传送门:https://sec-in.com/article/1431。继续前面的思路,结合异常日志寻找其他的触发点。

SpringBoot常见异常



Part 2

原创 | 浅谈Log4j2在Springboot的检测-2
如果对Springboot有一定使用经验的话,会经常看到以下异常:
Servlet.service() for servlet [dispatcherServlet] in context with path [] threw exception ......
一般情况下,当Controller存在Exception的话,就会抛出对应的异常,例如如下demo:
@PostMapping(path = "/test")public String test(@RequestParam String content) {    int i=1/0;    return content;}
很明显当访问test接口时,会因为1/0抛出除0异常,查看console台的输出,跟之前的想法是一致的(返回了除0错误):
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 对象:
/**     * 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方法的具体实现:
protected CommonsFileUploadSupport.MultipartParsingResult parseFileItems(List<FileItem> fileItems, String encoding) {    MultiValueMap<String, MultipartFile> multipartFiles = new LinkedMultiValueMap();    Map<String, String[]> multipartParameters = new HashMap();    Map<String, String> 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

关键代码如下:
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方法解析:
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对象:
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开头等检查:
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对象:
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

public MimeType(String type, String subtype, @Nullable Map<String, String> 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<String, String> 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方法进行处理:
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级别的日志:
public static Charset forName(String charsetName) {    Charset cs = lookup(charsetName);    if (cs != null)        return cs;    throw new UnsupportedCharsetException(charsetName);}
查看lookup方法的实现,如果找不到常规的charset的话会调用checkName方法然后返回null,从而抛出异常:
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

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桥接。

其他



Part 3

原创 | 浅谈Log4j2在Springboot的检测-2
除此之外,该方法还可以简单的用于判断当前springboot应用使用的multipartResolver是哪个。默认的StandardServletMultipartResolver遇到不合法的charset是不会抛出异常的:

原创 | 浅谈Log4j2在Springboot的检测-2

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

参考资料



Part 4

原创 | 浅谈Log4j2在Springboot的检测-2
https://leokongwq.github.io/2017/03/25/java-web-exception.html

往期推荐



原创 | 我在人间凑数的日子---网恋篇(一)
原创 | java安全-java反序列化之URLDNS
原创 | CVE-2022-21999

原文始发于微信公众号(SecIN技术平台):原创 | 浅谈Log4j2在Springboot的检测-2

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

发表评论

匿名网友 填写信息