绕过后缀安全检查进行文件上传

  • A+

引言

  文件上传是web中比较常见的业务,例如上传头像、简历等。当前也有很多开源工具包进行辅助实现,例如Apache Commons FileUpload、Jfinal cos等。但是若在开发过程中没有做好相关的安全防护,攻击者可以通过上传恶意脚本来获取系统权限。
  对上传的文件后缀名进行白名单限制也是比较简单有效的方式。以下是实际业务中发现的一处绕过后缀安全检查进行文件上传的实例,当前漏洞已经修复完毕。

漏洞分析过程

  相关业务为营业执照证明的上传,该接口可以进行多文件同时上传,具体的业务实现如下:
```java
......
String path = config.get_UPLOAD_FILE_PATH();
int max_size_byte = config.get_UPLOAD_MAX_SIZE_BYTE();
MultipartRequest multiReq = null;
try {
multiReq = new MultipartRequest(req, path, max_size_byte, "UTF-8",new MyRename());

} catch (Exception e) {
model.addAttribute("ERROR_MSG", "您上传的文件超出系统规定的大小(" + config.get_UPLOAD_MAX_SIZE_KB() + " KB)");
}
......
//对上传的文件进行检索,如果不是白名单内的后缀则进行删除处理
......
if(illegal){
......
......
model.addAttribute("ERROR_MSG", "您上传的文件不合法");
}

......

/*
* 重命名策略,
/
class MyRename implements FileRenamePolicy{

@Override
public File rename(File file) {
String fileName = file.getName();
String extName = fileName.substring(fileName.lastIndexOf("."));
String uuid = UUID.randomUUID().toString().replace("-","");
String newName = uuid+extName;//abc.jpg
file = new File(file.getParent(),newName);
return file;
}

}
  这里引用了cos组件进行辅助实现,其是O'Rrilly公司开发的一款用于HTTP上传文件的OpenSource组件,使用方法比Apache Commons FileUpload简单,只需要调用`com.oreilly.servlet.MultipartRequest`类的相关方法即可:
![image.png](/img/sin/M00/00/48/wKg0C1-ZnCiABqgYAABSupeqmOg529.png)
  cos组件对于multipart请求的解析进行了封装,这里任意后缀文件都可以直接上传到给定的目录。为了保证安全性上传后进行安全检查,删除白名单以外的后缀文件。
  具体效果如下:
  正常情况下上传png图片成功:
![fileUpload_bypass_java_23.png](/img/sin/M00/00/48/wKg0C1-ZnHCAbK5PAADGqE8wyek613.png)
  尝试上传jsp恶意文件,提示上传失败:
![fileUpload_bypass_java_24.png](/img/sin/M00/00/48/wKg0C1-ZnIKASCsnAADN-T0-Rqc362.png)
    在处理多文件上传业务时,轮询上传时传的每个参数,然后直接上传到给定的目录。上传后再进行安全检查,例如检查文件后缀文件内容等,若发现为jsp/jspx等恶意文件直接删除。其实主要是处理顺序的问题,该方式存在条件竞争缺陷,尝试写一个中转写木马文件的jsp,在上传的同时并发访问该上传路径,这样就有可能在删除文件之前访问到jsp文件,通过中转的方法完成新jsp木马的写入。但是由于通过uuid重命名了上传的jsp,存在一定的利用难度。
  通过代码上下文分析,发现可以想办法让程序执行到安全检测之前报错,这样上传成功后就会停止执行,在成功上传的同时并未删除对应的jsp文件。这里看看具体`com.oreilly.servlet.MultipartRequest`的实现,看看有没有利用的思路,如下是具体的方法:
java
public MultipartRequest(HttpServletRequest request, String saveDirectory, int maxPostSize, String encoding, FileRenamePolicy policy)
throws IOException
{
if (request == null) {
throw new IllegalArgumentException("request cannot be null");
}
if (saveDirectory == null) {
throw new IllegalArgumentException("saveDirectory cannot be null");
}
if (maxPostSize <= 0) {
throw new IllegalArgumentException("maxPostSize must be positive");
}
File dir = new File(saveDirectory);
if (!dir.isDirectory()) {
throw new IllegalArgumentException("Not a directory: " + saveDirectory);
}
if (!dir.canWrite()) {
throw new IllegalArgumentException("Not writable: " + saveDirectory);
}
MultipartParser parser = new MultipartParser(request, maxPostSize, true, true, encoding);
Hashtable queryParameters;
if (request.getQueryString() != null)
{
queryParameters = HttpUtils.parseQueryString(request.getQueryString());

  Enumeration queryParameterNames = queryParameters.keys();
  while (queryParameterNames.hasMoreElements())
  {
    Object paramName = queryParameterNames.nextElement();
    String[] values = (String[])queryParameters.get(paramName);
    Vector newValues = new Vector();
    for (int i = 0; i < values.length; i++) {
      newValues.add(values[i]);
    }
    this.parameters.put(paramName, newValues);
  }
}
Part part;
while ((part = parser.readNextPart()) != null)
{
  String name = queryParameters.getName();
  if (queryParameters.isParam())
  {
    ParamPart paramPart = (ParamPart)queryParameters;
    String value = paramPart.getStringValue();
    Vector existingValues = (Vector)this.parameters.get(name);
    if (existingValues == null)
    {
      existingValues = new Vector();
      this.parameters.put(name, existingValues);
    }
    existingValues.addElement(value);
  }
  else if (queryParameters.isFile())
  {
    FilePart filePart = (FilePart)queryParameters;
    String fileName = filePart.getFileName();
    if (fileName != null)
    {
      filePart.setRenamePolicy(policy);

      filePart.writeTo(dir);
      this.files.put(name, new UploadedFile(dir.toString(), filePart.getFileName(), fileName, filePart.getContentType()));
    }
    else
    {
      this.files.put(name, new UploadedFile(null, null, null, null));
    }
  }
}

}
```
  这里主要传递了5个参数:

  • 文件上传的request请求
  • 实际保存的目录地址
  • 上传文件的大小限制,具体是从request请求中获取ContentLength的大小,若超出传入的限定值maxPostSize则抛出IO异常:

java
public MultipartParser(HttpServletRequest req, int maxSize, boolean buffer, boolean limitLength, String encoding)
throws IOException
{
......
......
int length = req.getContentLength();
if (length > maxSize) {
throw new IOException("Posted content length of " + length + " exceeds limit of " + maxSize);
}

  然后在进行multipart request解析时,先通过content-length获取文件的总大小,然后读Stream根据对应的boundary进行切割,进行写入操作:

java
public class PartInputStream extends FilterInputStream
{
......
int read = 0;
int boundaryLength = this.boundary.length();
int maxRead = this.buf.length - boundaryLength - 2;
while (this.count < maxRead)
{
read = ((ServletInputStream)this.in).readLine(this.buf, this.count, this.buf.length - this.count);
if (read == -1) {
throw new IOException("unexpected end of part");
}
}

  通过解析上传请求,若不超过上传大小限制的话就将对应的文件保存在对应的目录中。

  • 编码方式
  • 重命名规则
      大致的思路应该还是围绕multipart请求内容进行“动手”:

修改Content-Length长度

  在进行multipart request解析时,会先通过content-length获取文件的总大小,然后读Stream,那么可以修改Content-Length触发throw new IOException("unexpected end of part")错误:

fileUpload_bypass_java_29.png
  可以看到上传异常,通过log查看上传的文件名,访问上传的文件,成功解析对应的jsp内容:

fileUpload_bypass_java_25.png

构造畸形multipart请求数据包

  除此之外,还可以尝试构造畸形的数据包触发multipart解析器报错,例如下面的方式:

  例如删减boundary触发报错:

fileUpload_bypass_java_26.png
  同时cos依赖在进行multipart request解析时,会通过filename获取当前上传的文件名,那么可以修改上传的属性使得解析器获取不了相关的属性触发报错:
  第一个上传的jsp文件格式不修改,将第二个上传的png文件的filename属性名称修改为filename1:

fileUpload_bypass_java_27.png
  同样的通过log查看上传的文件名,访问上传的文件,成功解析对应的jsp内容:

fileUpload_bypass_java_28.png
  综上成功绕过了相关后缀检测,上传了webshell。比较幸运的是response并未直接返回相关的文件路径,同时前端页面也没有相应的信息,在一定程度上为漏洞利用增加了。

总结

  反观上述过程,主要是处理的顺序问题,相关安全检测逻辑在上传之后,从而在某些场景下导致了绕过。一方面的确是第三方组件依赖对上传实现进行了封装,在开发时候考虑的风险场景可能有遗漏。包括jfinal也出现过类似的安全问题,其CVE-2019-17352有点类似上述的过程,具体描述如下:

image.png
  同样也是先后顺序导致的绕过。其次在进行黑盒测试时,通过报错的方式尝试绕过后缀安全检查进行文件上传也是一种不错的思路。