Java框架中的文件上传漏洞WAF绕过
2026-01-11 20:50:09

文件上传 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 {
// US ASCII
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) {
// ignore
}
}
else if (attribute.equals("modification-date")) {
try {
modificationDate = ZonedDateTime.parse(value, RFC_1123_DATE_TIME);
}
catch (DateTimeParseException ex) {
// ignore
}
}
else if (attribute.equals("read-date")) {
try {
readDate = ZonedDateTime.parse(value, RFC_1123_DATE_TIME);
}
catch (DateTimeParseException ex) {
// ignore
}
}
}
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 {
// US ASCII
filename = decodeFilename(value, StandardCharsets.US_ASCII);
}
}

提取''中的值作为charset,限制只允许为UTF_8/ISO_8859_1

所以可以以如下格式发包上传文件

image-20251212134405-kjbj863

如何绕过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解码

image-20251212134736-hooorlu

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

image-20251212134854-jgmaij4

双写绕 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的限制,所以可以以如下格式发包image-20251212141250-fbd1g58

一些WAF在解析Content-Disposition时,只检查第一个filename/filename*,就可以使用双写绕过

甚至也可以三写绕过

image-20251212142140-ip81hl0

经过测试,上面两种绕过方法在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);
// 用流写入真正的绝对路径,而非使用 part.write()
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来获取filenameimage-20251212171424-yzh1f15

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遇到;终止的特性,可构造如下请求包

image-20251212174032-x6fculj

一些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;
}
}

遇到\直接跳过\处理\后的下一个字符

image-20251212180737-eewejup

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

image-20251212180850-ueoj218

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

2026-01-11 20:50:09