作者:donky16@360云安全
本文首發于安全客:https://www.anquanke.com/post/id/241265
背景介紹
傳統waf以規則匹配為主,如果只是無差別的使用規則匹配整個數據包,當規則數量逐漸變多,會造成更多性能損耗,當然還會發生誤報情況。為了能夠解決這些問題,需要對數據包進行解析,進行精準位置的規則匹配。
正常業務中上傳表單使用普遍,不僅能夠傳參,還可以進行文件的上傳,當然這也是一個很好的攻擊點,waf想要能夠精準攔截針對表單的攻擊,需要進行multipart/form-data格式數據的解析,并針對每個部分,如參數值,文件名,文件內容進行針對性的規則匹配攔截。
雖然RFC規范了multipart/form-data相關的格式與解析,但是由于不同后端程序的實現機制不同,而且RFC相關文檔也會進行增加補充,最終導致解析方式各不相同。對于waf來說,很難做到對各個后端程序進行定制化解析,尤其是云waf更加無法實現。
所以本文主要討論,利用waf和后端程序對multipart/form-data的解析差異,造成對waf的bypass。
multipart/form-data相關RFC:
- 基于表單的文件上傳: RFC1867
- multipart/form-data: RFC7578
- Multipart Media Type: RFC2046#section-5.1
解析環境
Flask/Werkzeug解析環境:docker/httpbin
Java解析環境:Windows10 pro 20H2/Tomcat9.0.35/jdk1.8.0_271/commons-fileupload
Java輸出代碼:
String result = "";
DiskFileItemFactory factoy = new DiskFileItemFactory();
ServletFileUpload sfu = new ServletFileUpload(factoy);
try {
List<FileItem> list = sfu.parseRequest(req);
for (FileItem fileItem : list) {
if (fileItem.getName() == null) {
result += fileItem.getFieldName() + ": " + fileItem.getString() + "\n";
} else {
result += "filename: " + fileItem.getName() + " " + fileItem.getFieldName() + ": " + fileItem.getString() + "\n";
}
}
} catch (FileUploadException e) {
// TODO Auto-generated catch block
e.printStackTrace();
}
PHP解析環境:Ubuntu18.04/Apache2.4.29/PHP7.2.24
PHP輸出代碼:
<?php
var_dump($_FILES);
var_dump($_POST);
基礎格式
POST /post HTTP/1.1
Host: www.example.com:8081
Accept-Encoding: gzip, deflate
Accept: */*
Accept-Language: en
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/83.0.4103.61 Safari/537.36
Connection: close
Content-Type: multipart/form-data; boundary=I_am_a_boundary
Content-Length: 303
--I_am_a_boundary
Content-Disposition: form-data; name="name"; filename="file.jsp"
Content-Type: text/plain;charset=UTF-8
This_is_file_content.
--I_am_a_boundary
Content-Disposition: form-data; name="key";
Content-Type: text/plain;charset=UTF-8
This_is_a_value.
--I_am_a_boundary--
此表單數據含有一個文件,name為name,filename為file.jsp,file_content為This_is_file_content.,還有一個非文件的參數,其name為key,value為This_is_a_value.。
httpbin解析結果
{
"args": {},
"data": "",
"files": {
"name": "This_is_file_content."
},
"form": {
"key": "This_is_a_value."
},
"headers": {
"Accept": "*/*",
"Accept-Encoding": "deflate, identity;q=0.5",
"Accept-Language": "en",
"Content-Length": "303",
"Content-Type": "multipart/form-data; boundary=I_am_a_boundary",
"Host": "www.example.com:8081",
"Route-Hop": "1",
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/83.0.4103.61 Safari/537.36"
},
"json": null,
"origin": "10.1.1.1",
"url": "http://www.example.com:8081/post"
}
詳細解析
1. Content-Type
Content-Type: multipart/form-data; boundary=I_am_a_boundary
對于上傳表單類型,Content-Type必須為multipart/form-data,并且后面要跟一個邊界參數鍵值對(boundary),在表單中分割各部分使用。
倘若multipart/form-data編寫錯誤,或者不寫boundary,那么后端將無法準確解析這個表單的每個具體內容。

2. Boundary
boundary: RFC2046
boundary需要按照以下BNF巴科斯范式

簡單解釋就是,boundary不能以空格結束,但是其他位置都可以為空格,而且字符長度在1-70之間,此規定語法適用于所有multipart類型,當然并不是所有程序都按照這種規定來進行multipart的解析。
從前面介紹的multipart基礎格式可以看出來,真正作為表單各部分之間分隔邊界的不僅是Content-Type中boundary的值,真正的邊界是由--和boundary的值和末尾的CRLF組成的分隔行,當然為了能夠準確解析表單各個部分的數據,需要保證分隔行不會出現在正常的表單中的文件內容或者參數值中,所以RFC也建議使用特定的算法來生成boundary值。
flask解析結果

這里需要注意兩個點,第一,最終表單數據最后一個分隔邊界,要以--結尾。第二,RFC規定原文為
也就是說,整體的分隔邊界可以含有optional linear whitespace。
空格
注:本文使用空格的地方[\r\n\t\f\v ]都可以代替使用,文中只是介紹了使用空格的結果,大家可以測試其他的,waf或者后端程序在解析\n時,會產生很多不同結果,感興趣可自行測試。
首先使用boundary的值后面加空格進行測試,flask和php都能夠正常的解析出表單內容。
php解析結果

雖然boundary的值后面加了空格,但是在作為分隔行的時候并沒有空格也可以正常解析,但是經測試發現如果按照RFC規定那樣直接在分隔行中加入空格,效果就會不一樣。

對于flask來說是按照了RFC規定實現,無論Content-Type中boundary的值后面是不加空格還是加任意空格,在表單中非結束分隔行里都可以隨意加空格,都不影響表單數據解析,但是需要注意的就是,在最后的結束分隔行中,加空格會導致解析失敗。
很有意思的是php解析過程中,在非結束分隔行中不能增加空格,而在結束分隔行中增加空格,卻不會影響解析。

可以看到,加了空格的分隔行內的文件內容數據沒有被正確解析,而沒加空格的非文件參數被解析成功,而且結束分隔行中也添加了空格。
測試的時候偶然發現在如果在multipart/form-data和;之間加空格,如Content-Type: multipart/form-data ; boundary="I_am_a_boundary",flask會造成解析失敗,php解析正常。

正常來說,通過正則進行匹配解析的flask應該不會這樣,具體實現在werkzeug/http.py:L406。

簡單來說就是將Content-Type: multipart/form-data ; boundary="I_am_a_boundary"進行正則匹配,然后將第一組匹配結果當作mimetype,第二組作為rest,由后面處理boundary取值,看下這個正則。
_option_header_start_mime_type = re.compile(r",\s*([^;,\s]+)([;,]\s*.+)?")
為了看著美觀,使用regex101看下。
很明顯,由于第一組匹配非空字符,所以到空格處就停了,但是第二組必須是[;,]開頭,導致第二組匹配值為空,無法獲取boundary,最終解析失敗。
雙引號
boundary的值是支持用雙引號進行編寫的,就像是表單中的參數值一樣,這樣在寫分隔行的時候,就可以將雙引號內的內容作為boundary的值,php和flask都支持這種寫法。使用單引號是無法達到效果的,這也是符合上文提到的BNF巴科斯范式的bcharsnospace的。

測試一下讓重復多個雙引號,或者含有未閉合的雙引號或者雙引號前后增加其他字符會發生什么。
Content-Type: multipart/form-data; boundary=a"I_am_a_boundary"
Content-Type: multipart/form-data; boundary= "I_am_a_boundary"
Content-Type: multipart/form-data; boundary= "I_am_a_boundary"a
Content-Type: multipart/form-data; boundary=I_am_a_boundary"
Content-Type: multipart/form-data; boundary="I_am_a_boundary
Content-Type: multipart/form-data; boundary="I_am_a_boundary"aa"
Content-Type: multipart/form-data; boundary=""I_am_a_boundary"
對于php來說相對簡單,因為只要出現第一個字符不是雙引號,就算是空格,都會將之作為boundary的一部分,所以前四種解析類似,當第一個字符為雙引號時,會找與之對應的閉合的雙引號,如果找到了,那么就會忽略之后的內容直接取雙引號內內容作為boundary的值。

然而如果沒有找到閉合雙引號,就會導致boundary取值失敗,無法解析multipart/form-data。

當然對于最后一種情況,會取一個空的boundary值,我也以為會解析失敗,但是很搞笑的是,竟然boundary值為空,php也可以正常解析,當然也可以直接寫成Content-Type: multipart/form-data; boundary=。

大多數waf應該會認為這是一個不符合規范的boundary,從而導致解析multipart/form-data失敗,所以這種繞過waf的方式顯得更加粗暴。
對于flask來說,可以看下解析boundary的正則werkzeug/http.py:L79。
_option_header_piece_re = re.compile(
r"""
;\s*,?\s* # newlines were replaced with commas
(?P<key>
"[^"\\]*(?:\\.[^"\\]*)*" # quoted string
|
[^\s;,=*]+ # token
)
(?:\*(?P<count>\d+))? # *1, optional continuation index
\s*
(?: # optionally followed by =value
(?: # equals sign, possibly with encoding
\*\s*=\s* # * indicates extended notation
(?: # optional encoding
(?P<encoding>[^\s]+?)
'(?P<language>[^\s]*?)'
)?
|
=\s* # basic notation
)
(?P<value>
"[^"\\]*(?:\\.[^"\\]*)*" # quoted string
|
[^;,]+ # token
)?
)?
\s*
""",
這個正則可以解釋本文的大多數flask解析結果產生的原因,這里看到flask對于boundary兩邊的空格是做了處理的,對于雙引號的處理,都會取第一對雙引號內的內容作為boundary的值,對于非閉合的雙引號,會處理成token形式,將雙引號作為boundary的一部分,并不會像php一樣解析boundary失敗。

從上面正則也能看出,對于最后一種Content-Type的情況,flask也會取空值作為boundary的值,但是這不會同過flask對boundary的正則驗證,導致boundary取值失敗,無法解析,下文會提及到。
轉義符號
以flask的正則中quoted string和token作為區分是否boundary為雙引號內取值,測試兩種轉義符的位置會怎樣影響解析。
\在token中
Content-Type: multipart/form-data; boundary=I_am_a\"_boundary
這種形式的boundary,flask和php都會將\認定為一個字符,并不具有轉義作用,并將整體的I_am_a\"_boundary內容做作為boundary的值。

\在quoted string中
Content-Type: multipart/form-data; boundary="I_am_a\"_boundary"
對于flask來說,在雙引號的問題上,werkzeug/http.py:L431中調用一個處理函數,就是取雙引號之間的內容作為boundary的值。

可以看到,在取完boundary值之后還做了一個value.replace("\\\\", "\\").replace('\\"', '"')的操作,將轉義符認定為具有轉義的作用,而不是單單一個字符,所以最終boundary的值是I_am_a"_boundary。

對于php來說,依舊和token類型的boundary處理機制一樣,認定\只是一個字符,不具有轉義作用,所以按照上文雙引號中提到的,由于遇到第二個雙引號就會直接閉合雙引號,忽略后面內容,最終php會取I_am_a\作為boundary的值。

空格 & 雙引號
上文提到使用空格對解析的影響,既然可以使用雙引號來指定boundary的值,那么如果在雙引號外或者內加入空格,后端會如何解析呢?
- 雙引號外
對于flask來說,依舊和普通不加雙引號的解析一致,會忽略雙引號外(兩邊)的空格,直接取雙引號內的內容作為boundary的值,php對于雙引號后面有空格時,處理機制和flask一致,但是當雙引號前面有空格時,會無法正常解析表單數據內容。

解析會和不帶雙引號的實現一致,此時php會將前面的空格和后面的雙引號和雙引號的內容作為一個整體,將之作為boundary的值,當然這雖然符合RFC規定的boundary可以以空格開頭,但是把雙引號當作boundary的一部分并不符合。

- 雙引號內
此時php會取雙引號內的所有內容(非雙引號)作為boundary的值,無論是以任意空格開頭還是結束,其分隔行中boundary前后的空格數,要與Content-Type中雙引號內boundary前后的空格個數一致,否則解析失敗。

值得注意的是,flask解析的時候,如果雙引號內的boundary值以空格開始,那么在分隔行中類似php只要空格個數一致,就可以成功解析,但是如果雙引號內的boundary的值以空格結束,無論空格個數是否一致,都無法正常解析。
想知道為什么出現這種狀況,只能看下werkzeug是如何實現的,flask對boundary的驗證可以在werkzeug/formparser.py:L46看到。
#: a regular expression for multipart boundaries
_multipart_boundary_re = re.compile("^[ -~]{0,200}[!-~]$")
這個正則是來驗證boundary有效性的,比較符合RFC規定的,只不過在長度上限制更小,可以是空格開頭,不能以空格結尾,但是用的不是全匹配,所以以空格結尾也會通過驗證。
上圖使用boundary= " I_am_a_boundary ",所以boundary的值為" I_am_a_boundary "雙引號內的內容,而且這個值也會通過boundary正則的驗證,最終還是解析失敗了,很是是奇怪。上文空格中提到,對于flask來說,在分隔行中boundary后可以加任意空格不影響最終的解析的。

原因是解析multipart/form-data具體內容時,為了尋找分割行,將每一行數據都進行了一個line.strip()操作,這樣會把CRLF去除,當然會把結尾的所有空格也給strip掉,所以當boundary不以空格結尾時,在分隔行中可以隨意在結尾加空格。但是這也會導致一個問題,當不按照RFC規定,用空格結尾作為boundary值,雖然過了flask的boundary正則驗證,但是在解析body時,卻將結尾的空格都strip掉,導致在body中分隔行經過處理之后變為了-- I_am_a_boundary,這與Content-Type中獲取的boundary值(結尾含有空格)并不一致,導致找不到分隔行,解析全部失敗。
結束分隔行
在上文空格內容中提到,php在結束分割行中的boundary后面加空格并不會影響最終的解析,其實并不是空格的問題,經測試發現,其實php根本就沒把結束分隔行當回事。

可以看到,沒有結束分隔行,php會根據每一分隔行來分隔各個表單部分,并根據Content-Length來進行取表單最后一部分的內容的值,然而這是極不尊重RFC規定的,一般waf會將這種沒有結束分隔行的視為錯誤的multipart/form-data格式,從而導致整體body解析失敗,那么waf可以被繞過。
上文提到flask會對multipart/form-data的每一行內容進行strip操作,但是由于結束分隔行需要以--結尾,所以在strip的過程中只會將CRLFstrip掉,但是在解析boundary的時候,boundary是不能以空格為結尾的,最終會導致結束分隔行是嚴謹的--BOUNDARY--CRLF,當然如果使用雙引號使boundary以空格結尾,那么結束分隔行是可以正確解析的,但是非結束分隔行無法解析還是會導致整體解析失敗。
其他
從flask的代碼能夠看出來,支持參數名的quoted string形式,就是參數名在雙引號內。

而對于Java來說,支持參數名的大小寫不敏感的寫法。

3. Content-Disposition
對于multipart/form-data類型的數據,通過分隔行分隔的每一部分都必須含有Content-Dispostion,其類型為form-data,并且必須含有一個name參數,形如Content-Disposition: form-data; name="name",如果這部分是文件類型,可以在后面加一個filename參數,當然filename參數是可選的。
空格
經常和waf打交道的都知道,隨便一個空格,可能就會發生奇效。對于Content-Disposition參數,測試在四個位置加任意的空格。
- 原本有空格的位置
Content-Disposition: form-data; name="key1"; filename="file.php"
Content-Disposition: form-data; name="key1" ; filename="file.php"
Content-Disposition: form-data; name="key1" ; filename="file.php"
Content-Disposition: form-data ; name="key1" ; filename="file.php"
前三種類型,php和flask解析都是準確的。

但是第四種對于Content-Disposition: form-data ;來說,php解析準確,認為其是正常的multipart/form-data數據,然而flask解析失敗了,并且直接返回了500(:

這里flask處理Content-Disposition的方式是和request_header中Content-Type是一致的,經過了r",\s*([^;,\s]+)([;,]\s*.+)?"匹配,由于空格導致后面的name和filename無法解析,只不過這種情況會返回500。對于后續的name和filename得解析也是和request_header中Content-Type一致,后面匹配中的group作為rest進行后續的正則匹配,匹配用到的正則,是上文第2部分(Boundary)雙引號中的_option_header_piece_re。
- 參數名和等于號之間
Content-Disposition: form-data; name ="key1"; filename="file.php"
Content-Disposition: form-data; name="key1"; filename ="file.php"
flask正常解析

php解析失敗,不僅第一部分數據無法解析,第二部分非文件參數也解析失敗,可見php解析會將name=/filename=作為關鍵字匹配,當發現name=和filename=都不存在時,直接不再解析了,這與boundary的解析是不一樣的,使用Content-Type: multipart/form-data; boundary =I_am_a_boundary一樣可以正常解析處boundary的值。

如果我們不在name和等于號之間加空格,只在filename和等于號之間加空格,形如Content-Disposition: form-data; name="key1"; filename ="file.txt",那么php會將這種解析會非文件參數。

如果waf支持這種多余空格形式的寫法,那么將會把這種解析為文件類型,造成解析上的差異,waf錯把非文件參數當作文件,那么可能繞過waf的部分規則。
- 參數值和等于號之間
Content-Disposition: form-data; name= "key1"; filename= "file_name"
php和flask解析正常。
- 參數值中
這個沒啥注意的,flask會按照準確的name解析。

php會忽略開頭的空格,并把非開頭空格轉化為_,具體原因可以看php-variables。

重復參數
- 重復name/filename參數名
php和flask都會取最后一個name/filename,從flask代碼來看,存儲參數使用了字典,由于具有相同的key=name,所以最后在解析的時候,遇到相同key的參數,會進行參數值的覆蓋。

這種重復參數名的方式,在下文中將結合其他方式進行繞過waf。
- 重復name/filename參數名和參數值
接著嘗試重復整個form-data的一部分,構造這樣一個數據包進行測試。
--I_am_a_boundary
Content-Disposition: form-data; name="key3"; filename="file_name.asp"
Content-Type: text/plain;charset=UTF-8
This_is_file_content.
--I_am_a_boundary
Content-Disposition: form-data; name="key3"; filename="file_name.jsp"
Content-Type: text/plain;charset=UTF-8
This_is_file2_content.
--I_am_a_boundary
Content-Disposition: form-data; name="key5";
Content-Type: text/plain;charset=UTF-8
aaaaaaaaaaaa
--I_am_a_boundary
Content-Disposition: form-data; name="key5";
Content-Type: text/plain;charset=UTF-8
bbbbbbbbbbbb
--I_am_a_boundary--
對于php來說,和在同一個Content-Disposition中重復name/filename一致,會選取相同name部分中最后一部分。

對于flask來說,帶有filename的,會取第一部分,而且相同name的非文件參數,會將兩個取值作為一個列表解析。

其實這里是httpbin處理后的結果,為了準確看到flask解析結果,需要直接查看request.form/request.files。

使用的是ImmutableMultiDict,在werkzeug/datastructures.py中定義,可以看到,最終form和files都是把所有multipart數據都獲取了,即使具有相同的key。如果我們使用常用的keys()/values()/item()函數,都會因為相同key,而只能取到第一個key的值,想獲取相同key的所有取值,需要使用ImmutableMultiDict.to_dict()方法,并設置參數flat=True。

httpbin就是在處理request.form時,多加了這種處理,導致最后看到兩個取值的列表,但是在request.files處理時沒有進行to_dict。

由此可見,不同的后端程序,實現起來可能會不一樣,如果waf在實現時,并沒有將所有key重復的數據都解析出來,并且進入waf規則匹配,那么使用重復的key,也會成為很好的繞過waf的方式。
引號
上文提到,_option_header_piece_re這個正則在flask中也會用來解析Content-Disposition,所以對于name/filename的取值,和boundary取值機制是一樣的,加了雙引號是quoted string,沒有雙引號的是token。
所以主要分析php是如何處理的,首先php在處理boundary時,如果空格開頭,那么空格將作為boundary的一部分即使空格后存在正常的雙引號閉合的boundary。但是在Content-Disposition中,雙引號外的空格是可以被忽略的,當然不使用雙引號,參數值兩邊的空格也會被忽略。

此小段標題引號,并沒有像上一大段一樣使用雙引號,是因為php不僅支持雙引號取值,也支持單引號取值,這很php。

flask肯定是不支持單引號的,上面的正則能看出來,單引號會被當作參數值的一部分,這里看了下Java的commons-fileuploadv1.2的實現org.apache.commons.fileupload.ParameterParser.java:L76,在解析參數值的時候也是不支持單引號的。

所以如果waf在multipart解析中是不支持參數值用單引號取值的,對于php而言,出現這種payload就可以導致waf解析錯誤。
Content-Disposition: form-data; name='key3; filename='file_name.txt; name='key3'
支持單引號的會將之解析為{"name": "key3"},并沒有filename參數,視為非文件參數
不支持單引號的會將之解析為{"name": "'key3'", "filename": "'file_name.txt"},視為文件參數,將之后參數值視為文件內容。
這種waf和后端處理程序解析的不一致可能會導致waf被繞過。
此時,還有一個引號的問題沒有解決,就是如果出現多余的引號會發生什么,形如Content-Disposition: form-data; name="key3"a"; filename="file_name;txt",上文在boundary的解析中已經看到了結果,name會取key3,并忽略之后的內容,即使含有雙引號,那么后面的filename內容還能正確解析嗎?正好看看flask使用正則和Java/php使用字符解析帶來的一些差異。
看一下flask的具體實現werkzeug/http.py:L402。
result = []
value = "," + value.replace("\n", ",") # ',form-data; name="key3"aaaa"; filename="file_name.txt"'
while value:
match = _option_header_start_mime_type.match(value)
if not match:
break
result.append(match.group(1)) # mimetype
options = {}
# Parse options
rest = match.group(2) # '; name="key3"aaaa"; filename="file_name.txt"'
continued_encoding = None
while rest:
optmatch = _option_header_piece_re.match(rest)
if not optmatch:
break
option, count, encoding, language, option_value = optmatch.groups() # option_value: "key3"
...
...
... # 省略
rest = rest[optmatch.end() :]
result.append(options)
使用_option_header_piece_re匹配到之后,會繼續從下一個字符開始繼續進入正則匹配,所以第二次進入正則時,rest為aaaa"; filename="file_name.txt",以a開頭就無法匹配中正則了,直接退出,導致filename解析失敗,并且name取key3。

Java的代碼在上面已經貼出,其中的terminators=";",也就是說當出現雙引號時,會忽略;,但是當找到閉合雙引號時,取值沒有結束,會繼續尋找;,這就導致會一直取到閉合雙引號外的;才會停止,這和php是不一致的,php雖然后面多余的雙引號會影響后續filename取值,但是會在第一次出現閉合雙引號時取值結束。

對于flask/php來說,如果waf解析方式和后端不相同,也可能會錯誤判斷文件和非文件參數,但是Java后端很難使用,因為對于name的取值會導致后端無法正確獲取。但是這個取值特性依舊有用,下文文件擴展名將進行介紹。
轉義符號
php和flask都支持參數值中含有轉移符號,從上面的_option_header_piece_re正則可以看出,和boundary取值一致,flask在quoted string類型的參數值中的轉義符具有轉義作用,在token類型中只是一個字符\,不具有轉義作用。

php雖然在token類型中,解析和對boundary解析一致,轉義符號具有轉義作用,但是在解析quoted string類型時解析方式和boundary竟然不一樣了,解析boundary時,轉義符為一個\字符不具有轉義作用,所以boundary="aa\"bbb"會被解析為aa\,而在Content-Disposition中,轉義符號具有轉義作用。

和上文提到的php解析單引號的方式一樣,存在這么一種payload
Content-Disposition: form-data; name="key3\"; filename="file_name.txt; name="key3"

flask/php將之解析為非文件參數,并且根據多個重復的name/filename解析機制,最終解析結果{"name": "key3"}
如果waf并不支持轉義符號的解析,只是簡單的字符匹配雙引號閉合,那么解析結果為{"name": "key3\\", "filename": "\"file_name.txt"},視為文件參數,將之后參數值視為文件內容,造成解析差異,導致waf可能被繞過。
上文提到php可以使用單引號取值,在單引號中增加轉義符的解析方式會和雙引號不同,具體可參考php單引號和雙引號的區別與用法。
文件擴展名
前文主要提出一些mutlipart整體上的waf繞過,在源站后端解析正常的情況下讓waf解析失敗不進入規則匹配,或者waf解析與后端有差異,判斷是否為文件失敗,導致規則無法匹配,或者filename參數根本沒有進入waf的規則匹配。無論是在CTF比賽中還是在實際滲透測試中,如何繞過文件擴展名是大家很關注的一個點,所以這一段內容主要介紹,在waf解析到filename參數的情況下,從協議和后端解析的層面如何繞過文件擴展名。
其實這種繞過就一個思路,舉個簡單的例子filename="file_name.php",對于一個正常的waf來說取到file_name.php,發現擴展名為php,接著進行攔截,此處并不討論waf規則中不含有php關鍵字等等waf規則本身不完善的情況,我們只有一個目標,那就是waf解析出的filename不出現php關鍵字,并且后端程序在驗證擴展名的時候會認為這是一個php文件。
從各種程序解析的代碼來看,為了讓waf解析出現問題,干擾的字符除了上文說的引號,空格,轉義符,還有:;,這里還是要分為兩種形式的測試。
token形式
Content-Disposition: form-data; name=key3; filename=file_name:.php
Content-Disposition: form-data; name=key3; filename=file_name'.php
Content-Disposition: form-data; name=key3; filename=file_name".php
Content-Disposition: form-data; name=key3; filename=file_name\".php
Content-Disposition: form-data; name=key3; filename=file_name .php
Content-Disposition: form-data; name=key3; filename=file_name;.php
前五種情況flask/Java解析結果都是一致的,會取整體作為filename的值,都是含有php關鍵字的,這也說明如果waf解析存在差異,將特殊字符直接截斷取值,會導致waf被繞過。
最后一種情況,flask/Java/php解析都會直接截斷,filename=file_name,這樣后端獲取不了,無論waf解析方式如何,無法繞過。
對于php而言,前三種會如flask以一樣,將整體作為filename的值,第五種空格類型,php會截斷,最終取filename=file_name,這種容易理解,當沒出現引號時,出現空格,即認為參數值結束。

然后再測試轉義符號的時候,出現了從\開始截斷,并去\后面的值最為filename的值,這種解析方式和boundary解析也不相同,當然雙引號和單引號相同效果。

看代碼才發現,php并沒有把\當作轉義符號,而是貼心地將filename看做一個路徑,并取路徑中文件的名稱,畢竟參數名是filename啊:)

所以這個解析方式和引號跟本沒關系,只是php在解析filename時,會取最后的\或者/后面的值作為文件名。

quoted string形式
Content-Disposition: form-data; name=key3; filename="file_name:.php"
Content-Disposition: form-data; name=key3; filename="file_name'.php"
Content-Disposition: form-data; name=key3; filename="file_name".php"
Content-Disposition: form-data; name=key3; filename="file_name\".php"
Content-Disposition: form-data; name=key3; filename="file_name .php"
Content-Disposition: form-data; name=key3; filename="file_name;.php"
flask解析結果還是依照_option_header_piece_re正則,除第三種filename取file_name之外,其他都會取雙引號內整體的值作為filename,轉義符具有轉義作用。php第三種也會解析出file_name,但是在第四種轉義符是具有轉義作用的,所以進入上文的*php_ap_basename函數時,是沒有\的,所以其解析結果也會是file_name".php,使用單引號的情況和上文引號部分分析一致。

對于Java來說,除第三種情況外,都是會取引號內整體作為filename值,但是第三種情況就非常有趣,上文引號部分已經分析,Java會繼續取值,那么最后filename取值為"file_name".php"。

所以對于Java這個異常的特性來說,通常waf會像php/flask那樣在第一次出現閉合雙引號時,直接取雙引號內內容作為filename的取值,這樣就可以繞過文件擴展名的檢測。
4. Content-Type(Body)
對于一些不具有編碼解析功能的waf,可以通過對參數值的編碼繞過waf。
Charset
對于Java,可以使用UTF-16編碼。

flask可以使用UTF-7編碼。

由于Java代碼中,會把文件和非文件參數都用org.apache.commons.fileupload.FileItem來存儲,所以都會進行解碼操作,而flask將兩者分成了form和files,而且files并沒用使用Content-Type中的charset進行解碼werkzeug/formparser.py:L564。

其他
RFC7578中寫了一些其他form-data的解析方式,可以通過_charset_參數指定charset,或者使用encoded-word,但是測試的三種程序都沒有做相關的解析,很多只是在郵件中用到。
5. Content-Transfer-Encoding
RFC7578明確寫出只有三種參數類型可以出現在multipart/form-data中,其他類型MUST被忽略,這里的第三種Content-Transfer-Encoding其實也被廢棄。
然而在flask代碼中發現werkzeug實現了此部分。

也可以使用QUOTED-PRINTABLE編碼方式。
參考鏈接
https://github.com/postmanlabs/httpbin
https://www.ietf.org/rfc/rfc1867.txt
https://tools.ietf.org/html/rfc7578
https://tools.ietf.org/html/rfc2046#section-5.1
https://www.php.net/manual/zh/language.variables.external.php
https://www.cnblogs.com/youxin/archive/2012/02/13/2348551.html
本文由 Seebug Paper 發布,如需轉載請注明來源。本文地址:http://www.bjnorthway.com/1631/
暫無評論