作者: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:

解析環境

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,那么后端將無法準確解析這個表單的每個具體內容。

image-20210506165651391

2. Boundary

boundary: RFC2046

boundary需要按照以下BNF巴科斯范式

image-20210507104034573

簡單解釋就是,boundary不能以空格結束,但是其他位置都可以為空格,而且字符長度在1-70之間,此規定語法適用于所有multipart類型,當然并不是所有程序都按照這種規定來進行multipart的解析。

從前面介紹的multipart基礎格式可以看出來,真正作為表單各部分之間分隔邊界的不僅是Content-Type中boundary的值,真正的邊界是由--boundary的值和末尾的CRLF組成的分隔行,當然為了能夠準確解析表單各個部分的數據,需要保證分隔行不會出現在正常的表單中的文件內容或者參數值中,所以RFC也建議使用特定的算法來生成boundary值。

flask解析結果

image-20210506171605402

這里需要注意兩個點,第一,最終表單數據最后一個分隔邊界,要以--結尾。第二,RFC規定原文為image-20210506171821029

也就是說,整體的分隔邊界可以含有optional linear whitespace

空格

注:本文使用空格的地方[\r\n\t\f\v ]都可以代替使用,文中只是介紹了使用空格的結果,大家可以測試其他的,waf或者后端程序在解析\n時,會產生很多不同結果,感興趣可自行測試。

首先使用boundary的值后面加空格進行測試,flask和php都能夠正常的解析出表單內容。

php解析結果

image-20210506174911588

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

image-20210506175509684

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

很有意思的是php解析過程中,在非結束分隔行中不能增加空格,而在結束分隔行中增加空格,卻不會影響解析。

image-20210506180311779

可以看到,加了空格的分隔行內的文件內容數據沒有被正確解析,而沒加空格的非文件參數被解析成功,而且結束分隔行中也添加了空格。

測試的時候偶然發現在如果在multipart/form-data;之間加空格,如Content-Type: multipart/form-data ; boundary="I_am_a_boundary",flask會造成解析失敗,php解析正常。

image-20210510154200362

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

image-20210510154644069

簡單來說就是將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看下。

image-20210510155439959

很明顯,由于第一組匹配非空字符,所以到空格處就停了,但是第二組必須是[;,]開頭,導致第二組匹配值為空,無法獲取boundary,最終解析失敗。

雙引號

boundary的值是支持用雙引號進行編寫的,就像是表單中的參數值一樣,這樣在寫分隔行的時候,就可以將雙引號內的內容作為boundary的值,php和flask都支持這種寫法。使用單引號是無法達到效果的,這也是符合上文提到的BNF巴科斯范式的bcharsnospace的。

image-20210506181540998

測試一下讓重復多個雙引號,或者含有未閉合的雙引號或者雙引號前后增加其他字符會發生什么。

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的值。

image-20210508182041201

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

image-20210508182245495

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

image-20210508183337555

大多數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失敗。

image-20210510111459753

從上面正則也能看出,對于最后一種Content-Type的情況,flask也會取空值作為boundary的值,但是這不會同過flask對boundary的正則驗證,導致boundary取值失敗,無法解析,下文會提及到。

轉義符號

以flask的正則中quoted stringtoken作為區分是否boundary為雙引號內取值,測試兩種轉義符的位置會怎樣影響解析。

  • \token

Content-Type: multipart/form-data; boundary=I_am_a\"_boundary

這種形式的boundary,flask和php都會將\認定為一個字符,并不具有轉義作用,并將整體的I_am_a\"_boundary內容做作為boundary的值。

image-20210510150739879

  • \quoted string

Content-Type: multipart/form-data; boundary="I_am_a\"_boundary"

對于flask來說,在雙引號的問題上,werkzeug/http.py:L431中調用一個處理函數,就是取雙引號之間的內容作為boundary的值。

image-20210508171436532

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

image-20210510151806991

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

image-20210510152443179

空格 & 雙引號

上文提到使用空格對解析的影響,既然可以使用雙引號來指定boundary的值,那么如果在雙引號外或者內加入空格,后端會如何解析呢?

  • 雙引號外

對于flask來說,依舊和普通不加雙引號的解析一致,會忽略雙引號外(兩邊)的空格,直接取雙引號內的內容作為boundary的值,php對于雙引號后面有空格時,處理機制和flask一致,但是當雙引號前面有空格時,會無法正常解析表單數據內容。

image-20210507181336131

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

image-20210508110704762

  • 雙引號內

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

image-20210508111929617

值得注意的是,flask解析的時候,如果雙引號內的boundary值以空格開始,那么在分隔行中類似php只要空格個數一致,就可以成功解析,但是如果雙引號內的boundary的值以空格結束,無論空格個數是否一致,都無法正常解析。

image-20210508172504215想知道為什么出現這種狀況,只能看下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后可以加任意空格不影響最終的解析的。

image-20210508173420372

原因是解析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根本就沒把結束分隔行當回事。

image-20210507174147586

可以看到,沒有結束分隔行,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形式,就是參數名在雙引號內。

image-20210518145852473

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

image-20210518150202423

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解析都是準確的。

image-20210507155029466

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

image-20210507155526630

這里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正常解析

image-20210507160650959

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

image-20210507160904453

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

image-20210507162858974

如果waf支持這種多余空格形式的寫法,那么將會把這種解析為文件類型,造成解析上的差異,waf錯把非文件參數當作文件,那么可能繞過waf的部分規則。

  • 參數值和等于號之間

Content-Disposition: form-data; name= "key1"; filename= "file_name"

php和flask解析正常。

  • 參數值中

這個沒啥注意的,flask會按照準確的name解析。

image-20210510164649320

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

image-20210510165012773

重復參數

  • 重復name/filename參數名

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

image-20210510180352849

這種重復參數名的方式,在下文中將結合其他方式進行繞過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部分中最后一部分。

image-20210511103159148

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

image-20210511103709556

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

image-20210511142758330

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

image-20210511143702801

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

image-20210511144017535

由此可見,不同的后端程序,實現起來可能會不一樣,如果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中,雙引號外的空格是可以被忽略的,當然不使用雙引號,參數值兩邊的空格也會被忽略。

image-20210511160143841

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

image-20210512100843378

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

image-20210512101808226

所以如果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。

image-20210513150802159

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

image-20210513151043853

對于flask/php來說,如果waf解析方式和后端不相同,也可能會錯誤判斷文件和非文件參數,但是Java后端很難使用,因為對于name的取值會導致后端無法正確獲取。但是這個取值特性依舊有用,下文文件擴展名將進行介紹。

轉義符號

php和flask都支持參數值中含有轉移符號,從上面的_option_header_piece_re正則可以看出,和boundary取值一致,flask在quoted string類型的參數值中的轉義符具有轉義作用,在token類型中只是一個字符\,不具有轉義作用。

image-20210512143842725

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

image-20210512144554468

和上文提到的php解析單引號的方式一樣,存在這么一種payload

Content-Disposition: form-data; name="key3\"; filename="file_name.txt; name="key3"

image-20210512145853322

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,這種容易理解,當沒出現引號時,出現空格,即認為參數值結束。

image-20210512165247797

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

image-20210512165617827

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

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

image-20210512174853186

  • 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,使用單引號的情況和上文引號部分分析一致。

image-20210513111409102

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

image-20210513152431882

所以對于Java這個異常的特性來說,通常waf會像php/flask那樣在第一次出現閉合雙引號時,直接取雙引號內內容作為filename的取值,這樣就可以繞過文件擴展名的檢測。

4. Content-Type(Body)

image-20210513153208424

對于一些不具有編碼解析功能的waf,可以通過對參數值的編碼繞過waf。

Charset

對于Java,可以使用UTF-16編碼。

image-20210517153729144

flask可以使用UTF-7編碼。

image-20210517160156654

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

image-20210517161242711

其他

image-20210517164903160

RFC7578中寫了一些其他form-data的解析方式,可以通過_charset_參數指定charset,或者使用encoded-word,但是測試的三種程序都沒有做相關的解析,很多只是在郵件中用到。

5. Content-Transfer-Encoding

image-20210517165215792

RFC7578明確寫出只有三種參數類型可以出現在multipart/form-data中,其他類型MUST被忽略,這里的第三種Content-Transfer-Encoding其實也被廢棄。

image-20210517165451682

然而在flask代碼中發現werkzeug實現了此部分。

image-20210517170536557

也可以使用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

https://xz.aliyun.com/t/9432


Paper 本文由 Seebug Paper 發布,如需轉載請注明來源。本文地址:http://www.bjnorthway.com/1631/