文件上传 bypass waf
Spring Boot 2.6.13
以IDEA自带的Spring 2.6.13配合Java8u65环境进行分析
Spring Boot 如何处理 Content-Disposition
Spring使用org.springframework.http.ContentDisposition#parse处理Multipart
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85
| public static ContentDisposition parse(String contentDisposition) { List<String> parts = tokenize(contentDisposition); String type = parts.get(0); String name = null; String filename = null; Charset charset = null; Long size = null; ZonedDateTime creationDate = null; ZonedDateTime modificationDate = null; ZonedDateTime readDate = null; for (int i = 1; i < parts.size(); i++) { String part = parts.get(i); int eqIndex = part.indexOf('='); if (eqIndex != -1) { String attribute = part.substring(0, eqIndex); String value = (part.startsWith("\"", eqIndex + 1) && part.endsWith("\"") ? part.substring(eqIndex + 2, part.length() - 1) : part.substring(eqIndex + 1)); if (attribute.equals("name") ) { name = value; } else if (attribute.equals("filename*") ) { int idx1 = value.indexOf('\''); int idx2 = value.indexOf('\'', idx1 + 1); if (idx1 != -1 && idx2 != -1) { charset = Charset.forName(value.substring(0, idx1).trim()); Assert.isTrue(UTF_8.equals(charset) || ISO_8859_1.equals(charset), "Charset should be UTF-8 or ISO-8859-1"); filename = decodeFilename(value.substring(idx2 + 1), charset); } else { filename = decodeFilename(value, StandardCharsets.US_ASCII); } } else if (attribute.equals("filename") && (filename == null)) { if (value.startsWith("=?") ) { Matcher matcher = BASE64_ENCODED_PATTERN.matcher(value); if (matcher.find()) { String match1 = matcher.group(1); String match2 = matcher.group(2); filename = new String(Base64.getDecoder().decode(match2), Charset.forName(match1)); } else { filename = value; } } else { filename = value; } } else if (attribute.equals("size") ) { size = Long.parseLong(value); } else if (attribute.equals("creation-date")) { try { creationDate = ZonedDateTime.parse(value, RFC_1123_DATE_TIME); } catch (DateTimeParseException ex) { } } else if (attribute.equals("modification-date")) { try { modificationDate = ZonedDateTime.parse(value, RFC_1123_DATE_TIME); } catch (DateTimeParseException ex) { } } else if (attribute.equals("read-date")) { try { readDate = ZonedDateTime.parse(value, RFC_1123_DATE_TIME); } catch (DateTimeParseException ex) { } } } else { throw new IllegalArgumentException("Invalid content disposition format"); } } return new ContentDisposition(type, name, filename, charset, size, creationDate, modificationDate, readDate); }
|
流量层面的WAF会对filename即上传文件名进行检测,跟进Spring对文件名的处理逻辑
filename*
1 2 3 4 5 6 7 8 9 10 11 12 13 14
| else if (attribute.equals("filename*") ) { int idx1 = value.indexOf('\''); int idx2 = value.indexOf('\'', idx1 + 1); if (idx1 != -1 && idx2 != -1) { charset = Charset.forName(value.substring(0, idx1).trim()); Assert.isTrue(UTF_8.equals(charset) || ISO_8859_1.equals(charset), "Charset should be UTF-8 or ISO-8859-1"); filename = decodeFilename(value.substring(idx2 + 1), charset); } else { filename = decodeFilename(value, StandardCharsets.US_ASCII); } }
|
提取''中的值作为charset,限制只允许为UTF_8/ISO_8859_1
所以可以以如下格式发包上传文件

如何绕过WAF,跟进decodeFilename方法
URL 编码绕 WAF
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28
| private static String decodeFilename(String filename, Charset charset) { Assert.notNull(filename, "'input' String` should not be null"); Assert.notNull(charset, "'charset' should not be null"); byte[] value = filename.getBytes(charset); ByteArrayOutputStream baos = new ByteArrayOutputStream(); int index = 0; while (index < value.length) { byte b = value[index]; if (isRFC5987AttrChar(b)) { baos.write((char) b); index++; } else if (b == '%' && index < value.length - 2) { char[] array = new char[]{(char) value[index + 1], (char) value[index + 2]}; try { baos.write(Integer.parseInt(String.valueOf(array), 16)); } catch (NumberFormatException ex) { throw new IllegalArgumentException(INVALID_HEADER_FIELD_PARAMETER_FORMAT, ex); } index+=3; } else { throw new IllegalArgumentException(INVALID_HEADER_FIELD_PARAMETER_FORMAT); } } return StreamUtils.copyToString(baos, charset); }
|
若检测到%则会先进行URL解码

所以可以将后缀名进行一次URL编码,也可以成功上传文件

双写绕 WAF
处理filename代码如下
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16
| else if (attribute.equals("filename") && (filename == null)) { if (value.startsWith("=?") ) { Matcher matcher = BASE64_ENCODED_PATTERN.matcher(value); if (matcher.find()) { String match1 = matcher.group(1); String match2 = matcher.group(2); filename = new String(Base64.getDecoder().decode(match2), Charset.forName(match1)); } else { filename = value; } } else { filename = value; } }
|
除了字段名为filename,另一个条件是filename == null
但是处理filename*的逻辑中就没有filename == null的限制,所以可以以如下格式发包
一些WAF在解析Content-Disposition时,只检查第一个filename/filename*,就可以使用双写绕过
甚至也可以三写绕过

经过测试,上面两种绕过方法在Spring Boot 3.0.2 + Java 17环境中也可用
Tomcat
使用Tomcat 9.0.104进行测试,文件上传demo代码如下
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36
| import javax.servlet.ServletException; import javax.servlet.annotation.MultipartConfig; import javax.servlet.http.HttpServlet; import javax.servlet.http.HttpServletRequest; import javax.servlet.http.HttpServletResponse; import javax.servlet.http.Part; import java.io.*;
@MultipartConfig public class UploadServlet extends HttpServlet { private static final String UPLOAD_DIR = "/tmp";
@Override protected void doPost(HttpServletRequest req, HttpServletResponse resp) throws IOException, ServletException {
File dir = new File(UPLOAD_DIR);
for (Part part : req.getParts()) { String fileName = part.getSubmittedFileName(); if (fileName == null || fileName.isEmpty()) continue; File outFile = new File(dir, fileName); try (InputStream is = part.getInputStream(); OutputStream os = new FileOutputStream(outFile)) { byte[] buf = new byte[8192]; int len; while ((len = is.read(buf)) != -1) { os.write(buf, 0, len); } } resp.setCharacterEncoding("UTF-8"); resp.getWriter().println("Upload successful: " + fileName); } } }
|
getSubmittedFileName 如何处理 Content-Disposition
Tomcat Servlet 3.1起支持使用getSubmittedFileName方法处理Content-Disposition来获取filename
org.apache.catalina.core.ApplicationPart#getSubmittedFileName代码
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26
| public String getSubmittedFileName() { String fileName = null; String cd = this.getHeader("Content-Disposition"); if (cd != null) { String cdl = cd.toLowerCase(Locale.ENGLISH); if (cdl.startsWith("form-data") || cdl.startsWith("attachment")) { ParameterParser paramParser = new ParameterParser(); paramParser.setLowerCaseNames(true); Map<String, String> params = paramParser.parse(cd, ';'); if (params.containsKey("filename")) { fileName = (String)params.get("filename"); if (fileName != null) { if (fileName.indexOf(92) > -1) { fileName = HttpParser.unquote(fileName.trim()); } else { fileName = fileName.trim(); } } else { fileName = ""; } } } }
return fileName; }
|
跟进ParaParser.parse
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40
| public Map<String, String> parse(char[] charArray, int offset, int length, char separator) { if (charArray == null) { return new HashMap(); } else { HashMap<String, String> params = new HashMap(); this.chars = (char[])(([C)charArray).clone(); this.pos = offset; this.len = length;
while(this.hasChar()) { String paramName = this.parseToken(new char[]{'=', separator}); String paramValue = null; if (this.hasChar() && charArray[this.pos] == '=') { ++this.pos; paramValue = this.parseQuotedToken(new char[]{separator}); if (paramValue != null) { try { paramValue = RFC2231Utility.hasEncodedValue(paramName) ? RFC2231Utility.decodeText(paramValue) : MimeUtility.decodeText(paramValue); } catch (UnsupportedEncodingException var9) { } } }
if (this.hasChar() && charArray[this.pos] == separator) { ++this.pos; }
if (paramName != null && !paramName.isEmpty()) { paramName = RFC2231Utility.stripDelimiter(paramName); if (this.lowerCaseNames) { paramName = paramName.toLowerCase(Locale.ENGLISH); }
params.put(paramName, paramValue); } }
return params; } }
|
while循环中,走到paramName=filename时,通过this.parseQuotedToken方法获取filename的值,跟进org.apache.tomcat.util.http.fileupload.ParameterParser#parseQuotedToken
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21
| private String parseQuotedToken(char[] terminators) { this.i1 = this.pos; this.i2 = this.pos; boolean quoted = false;
for(boolean charEscaped = false; this.hasChar(); ++this.pos) { char ch = this.chars[this.pos]; if (!quoted && this.isOneOf(ch, terminators)) { break; }
if (!charEscaped && ch == '"') { quoted = !quoted; }
charEscaped = !charEscaped && ch == '\\'; ++this.i2; }
return this.getToken(true); }
|
parseQuotedToken会在走到最后一个字符/遇到;后终止,
分号截断绕 WAF
根据parseQuotedToken遇到;终止的特性,可构造如下请求包

一些WAF识别最后的后缀名,这里最后的后缀为.txt,实际上传却是.jsp
反斜杠绕 WAF
getSubmittedFileName中有这样一段逻辑
1 2 3 4 5 6 7 8 9 10 11 12 13
| Map<String, String> params = paramParser.parse(cd, ';'); if (params.containsKey("filename")) { fileName = (String)params.get("filename"); if (fileName != null) { if (fileName.indexOf(92) > -1) { fileName = HttpParser.unquote(fileName.trim()); } else { fileName = fileName.trim(); } } else { fileName = ""; } }
|
获取到filename的值后,检查值中是否有反斜杠\,即fileName.indexOf(92) > -1
若有,则使用HttpParser.unquote再进行一次处理,跟进
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33
| public static String unquote(String input) { if (input != null && input.length() >= 2) { int start; int end; if (input.charAt(0) == '"') { start = 1; end = input.length() - 1; } else { start = 0; end = input.length(); }
StringBuilder result = new StringBuilder();
for(int i = start; i < end; ++i) { char c = input.charAt(i); if (input.charAt(i) == '\\') { ++i; if (i == end) { return null; }
result.append(input.charAt(i)); } else { result.append(c); } }
return result.toString(); } else { return input; } }
|
遇到\直接跳过\处理\后的下一个字符

根据这个特性可构造请求包

若WAF检测最近的两个"中的文件名的值,则可以利用这种方法绕过
https://y4tacker.github.io/2022/06/19/year/2022/6/%E6%8E%A2%E5%AF%BBTomcat%E6%96%87%E4%BB%B6%E4%B8%8A%E4%BC%A0%E6%B5%81%E9%87%8F%E5%B1%82%E9%9D%A2%E7%BB%95waf%E6%96%B0%E5%A7%BF%E5%8A%BF/#%E6%9B%B4%E6%96%B0Spring-2022-06-20
https://y4tacker.github.io/2022/06/21/year/2022/6/%E6%8E%A2%E5%AF%BBJava%E6%96%87%E4%BB%B6%E4%B8%8A%E4%BC%A0%E6%B5%81%E9%87%8F%E5%B1%82%E9%9D%A2waf%E7%BB%95%E8%BF%87%E5%A7%BF%E5%8A%BF%E7%B3%BB%E5%88%97%E4%BA%8C/#Spring4
https://forum.butian.net/share/4069
https://xz.aliyun.com/news/9726
https://www.freebuf.com/articles/web/336869.html