commons-fileupload组件解析特性

在去年某次红队评估项目中遇到一个Java的fckeditor上传接口,测试过程中存在WAF基于数据包的拦截。在通过审计上传组件commons-fileupload的源码绕过WAF后,才想起来fckeditor java版本是有白名单的,虽然没能从这个点GetShell,但这个过程还是比较有意思。于是记录下commons-fileupload组件中可能可以用来绕过WAF的点。在22年看过Java文件上传大杀器-绕waf(针对commons-fileupload组件)这篇文章,不过就记得一个MIME编码了,而且当时没能成功绕过,误打误撞又自己分析了一遍。最近又看了下,当时绕过的方法反而是文章中一个比较简单的废弃的方法,想着再详细梳理下commons-fileupload的解析过程,于是有了这篇文章。

整体过程

首先看org.apache.commons.fileupload.FileUploadBase#parseRequest(org.apache.commons.fileupload.RequestContext)函数,先解析当前文件列表

1
FileItemIterator iter = this.getItemIterator(ctx);

然后遍历文件列表,根据解析好的文件信息以及文件流等进行复制

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
while(iter.hasNext()) {
if ((long)items.size() == this.fileCountMax) {
throw new FileCountLimitExceededException("attachment", this.getFileCountMax());
}

FileItemStream item = iter.next();
String fileName = ((FileItemIteratorImpl.FileItemStreamImpl)item).name;
fileItem = fac.createItem(item.getFieldName(), item.getContentType(), item.isFormField(), fileName);
items.add(fileItem);

try {
Streams.copy(item.openStream(), fileItem.getOutputStream(), true, buffer);
} catch (FileUploadIOException var25) {
throw (FileUploadException)var25.getCause();
} catch (IOException var26) {
throw new IOFileUploadException(String.format("Processing of %s request failed. %s", "multipart/form-data", var26.getMessage()), var26);
}

FileItemHeaders fih = item.getHeaders();
fileItem.setHeaders(fih);
}

所以解析的逻辑主要在org.apache.commons.fileupload.FileUploadBase.FileItemIteratorImpl#FileItemIteratorImpl函数中

可能用于绕过的点

multipart/前缀绕过

在解析之前通过Servlet上下文获取Content-Type头,然后通过判断其内容是否为multipart/开头,如果是则继续后续的逻辑image-20241001151415600

所以我们可以通过添加多余空格的方式绕过:Content-Type: multipart/ form-data; 之前的WAF绕过用的正是这个技巧image-20241001152038079

大小写混合绕过

同时可以看到,在进行比较的时候,组件对Content-Type的值统一转换成了小写,所以可以进行大小写的变形:Content-Type: MulTIPaRt/form-data; image-20241001152223517

boundary解析过程中的分割符绕过

在bounndary相关解析逻辑中:

1
2
3
4
org.apache.commons.fileupload.FileUploadBase#getBoundary
org.apache.commons.fileupload.ParameterParser#parse(java.lang.String, char[])
org.apache.commons.fileupload.ParameterParser#parse(java.lang.String, char)
org.apache.commons.fileupload.ParameterParser#parse(char[], int, int, char)

为了将Content-Type的内容解析成key-value参数,在这个过程中会扫描其内容,并以;,以及=作为分割符号从而解析出key和value

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
protected byte[] getBoundary(String contentType) {
ParameterParser parser = new ParameterParser();
parser.setLowerCaseNames(true);
Map<String, String> params = parser.parse(contentType, new char[]{';', ','});
String boundaryStr = (String)params.get("boundary");
if (boundaryStr == null) {
return null;
} else {
byte[] boundary;
try {
boundary = boundaryStr.getBytes("ISO-8859-1");
} catch (UnsupportedEncodingException var7) {
boundary = boundaryStr.getBytes();
}

return boundary;
}
}

根据org.apache.commons.fileupload.ParameterParser#parse(java.lang.String, char[])的逻辑中,选择;,中第一个出现的字符作为分割符,并且由于后续的代码逻辑,如果添加多个分割符的话需要需要保证第一个和最后一个均为相同的分割符号

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
public Map<String, String> parse(String str, char[] separators) {
if (separators != null && separators.length != 0) {
char separator = separators[0];
if (str != null) {
int idx = str.length();
char[] var5 = separators;
int var6 = separators.length;

for(int var7 = 0; var7 < var6; ++var7) {
char separator2 = var5[var7];
int tmp = str.indexOf(separator2);
if (tmp != -1 && tmp < idx) {
idx = tmp;
separator = separator2;
}
}
}

return this.parse(str, separator);
} else {
return new HashMap();
}
}

比如,我选择多个,作作为分割符,可以这样

Content-Type: multipart/form-data,,,;;;;,hello-world,,;;;;;,,,,boundary=------------------------7jqxUg1tWaweeuyRNJxBrB

image-20241008110332390

选择;作为分割符,可以这样:Content-Type: multipart/form-data;;;;;,,,,,,====,,;;;;;boundary=------------------------7jqxUg1tWaweeuyRNJxBrB

image-20241008111734809

空白字符绕过

1
2
3
4
5
6
org.apache.commons.fileupload.FileUploadBase#getBoundary
org.apache.commons.fileupload.ParameterParser#parse(java.lang.String, char[])
org.apache.commons.fileupload.ParameterParser#parse(char[], char)
org.apache.commons.fileupload.ParameterParser#parse(char[], int, int, char)
org.apache.commons.fileupload.ParameterParser#parseToken
org.apache.commons.fileupload.ParameterParser#getToken

org.apache.commons.fileupload.ParameterParser#getToken函数扫描Content-Type内容

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
private String getToken(boolean quoted) {
// 从左到右扫描遇到空白字符就跳过,最终确定左边界
while(this.i1 < this.i2 && Character.isWhitespace(this.chars[this.i1])) {
++this.i1;
}

// 从右到右左描遇到空白字符就跳过,最终确定右边界
while(this.i2 > this.i1 && Character.isWhitespace(this.chars[this.i2 - 1])) {
--this.i2;
}

if (quoted && this.i2 - this.i1 >= 2 && this.chars[this.i1] == '"' && this.chars[this.i2 - 1] == '"') {
++this.i1;
--this.i2;
}

String result = null;
if (this.i2 > this.i1) {
result = new String(this.chars, this.i1, this.i2 - this.i1);
}

return result;
}

所以理论上multipar/formdata可以添加任意空白字符串,但由于大部分会破坏http数据包结构只能添加空格

image-20241001162124816

最终payload:Content-Type: MulTIPaRt/form-data ;image-20241001162228208

multipart属性解析——form-data前缀绕过

org.apache.commons.fileupload.FileUploadBase#getFieldName(java.lang.String),同样支持大小写

image-20241008114250213

multipart属性解析——MIME编码绕过

接下来就是解析header头参数,这部分key-value的解析逻辑和前面一致

1
2
3
4
5
6
7
8
9
10
11
12
13
while(this.hasChar()) {
paramName = this.parseToken(new char[]{'=', separator});
paramValue = null;
if (this.hasChar() && charArray[this.pos] == '=') {
++this.pos;
paramValue = this.parseQuotedToken(new char[]{separator});
if (paramValue != null) {
try {
paramValue = MimeUtility.decodeText(paramValue);
} catch (UnsupportedEncodingException var9) {
}
}
}

不过由于这里是multipart属性的部分了,所以需要关注value的解析,这里的重点就是Java文件上传大杀器-绕waf(针对commons-fileupload组件)里写的MIME编码了,最终payload如下:

1
2
Content-Disposition: form-data; name="file"; filename="=?utf-8?B?dGVzdC50eHQ=?="
Content-Type: image/png

总结

根据commons-fileupload的解析特性,总结可能可以用于绕过WAF的方式:

  1. 格式前缀绕过

    Content-Type以multipart/开头,Content-Disposition以form-data开头,后面可以添加额外的内容进行绕过,比如

    • Content-Type: multipart/helloform-data;boundary=------------------------7jqxUg1tWaweeuyRNJxBrB
    • Content-Disposition:form-datahello; name="file"; filename="test.txt"
  2. Key的大小写绕过以及空白字符

    数据包中作为Key被解析的部分,比如multipart/form-databoundary、以及name、filename这些属性值本身,在解析时会统一转成小写,所以可以利用大小写混用绕过;以及扫描key、value过程中会跳过空白字符,比如:

    Content-Disposition: form-data; name="file"; FILENAME = "test.txt"

  3. 分割符绕过

    key,value的解析是依据,以及=等符号,所以可以利用多个分割符进行绕过,需要注意的是,http header头中第一个和最后一个分割符必须统一,比如均为;或者,

    • Content-Type: multipart/form-data;;;====,,,,,===;;;;boundary=------------------------7jqxUg1tWaweeuyRNJxBrB

    而multipart属性中分隔符只能用;

    • Content-Disposition:form-data;NAME="file";---;FILENAME="test.txt"
  4. value中的MIME编码

    • 例如:Content-Disposition: form-data; name="file"; filename="=?utf-8?B?dGVzdC50eHQ=?="

思考

那次遇到的WAF是基于黑名单的校验,只要是multipart数据包,就禁止上传JSP相关的后缀;问题产生的原因在于WAF对multipart的识别和后端组件解析方式不一致导致的绕过;我不清楚WAF具体的解析方式,所以想到的成本比较低的修复方式是在进行Content-Type识别时,对这一行内容非boundary部分统一转成大写或者小写,然后去掉其中的空白字符,再识别是否为multipart/form-data类型数据包;

参考