sqlmap 核心分析 II: 核心原理-頁面相似度演算法實踐
在上一篇文章中,我們在 checkWaf()
中戛然而止於 page ratio
這一個概念;但是在本文,筆者會詳細介紹 page ratio
對於 sqlmap 整個系統的重要意義和用法,除此之外還會指出一些 sqlmap 的核心邏輯和一些拓展性的功能。包含:
- identityWaf
- nullConnection (checkNullConnection)
0x00 PageRatio 是什麼?
要說 PageRatio 是什麼,我們可能需要先介紹另一個模組 difflib
。這個模組是在 sqlmap 中用來計算頁面的相似度的基礎模組,實際處理的時候,sqlmap 並不僅僅是直接計算頁面的相似度,而是通過首先對頁面進行一些預處理,預處理之後,根據預設的閾值來計算請求頁面和模版頁面的相似度。
對於 difflib
模組其實本身並沒有什麼非常特殊的,詳細參見官方手冊,實際在使用的過程中,sqlmap 主要使用其 SequenceMatcher
這個類。以下是關於這個類的簡單介紹:
This is a flexible class for comparing pairs of sequences of any type, so long as the sequence elements are hashable. The basic algorithm predates, and is a little fancier than, an algorithm published in the late 1980’s by Ratcliff and Obershelp under the hyperbolic name “gestalt pattern matching.” The idea is to find the longest contiguous matching subsequence that contains no “junk” elements (the Ratcliff and Obershelp algorithm doesn’t address junk). The same idea is then applied recursively to the pieces of the sequences to the left and to the right of the matching subsequence. This does not yield minimal edit sequences, but does tend to yield matches that “look right” to people.
簡單來說這個類使用了 Ratcliff 和 Obershelp 提供的演算法,匹配最長相同的字串,設定無關字元(junk)。在實際使用中,他們應用最多的方法應該就是 ratio()
。

根據文件中的描述,這個方法返回兩段文字的相似度,相似度的演算法如下:我們假設兩段文字分別為 text1
與 text2
,他們相同的部分長度總共為 M
,這兩段文字長度之和為 T
,那麼這兩段文字的相似度定義為 2.0 * M / T
,這個相似度的值在 0 到 1.0 之間。
PageRatio 的小例子

我們通過上面的介紹,知道了對於 abcdefg
和 abce123
我們計算的結果應該是 2.0 * 4 / 14
所以計算結果應該是:

到現在我們理解了 PageRatio 是什麼樣的一種演算法,我們就可以開始觀察 sqlmap 是如何使用這一個值的了~
0x01 RATIO in checkWaf
在上節的內容中,我們對於 sqlmap 的原始碼瞭解到 checkWaf
的部分,結合剛才講的 PageRatio 的例子,我們直接可以看懂這部分程式碼:

現在設定 IDS_WAF_CHECK_RATIO = 0.5
表明,只要打了檢測 IDS/WAF 的 Payload 的頁面結果與模版頁面結果文字頁面經過一定處理,最後比較出相似度相差 0.5 就可以認為觸發了 IDS/WAF。
與 checkWaf
相關的其實還有 identityWaf
, 但是這個方法太簡單了我們並不想仔細分析,有興趣的讀者可以自行了解一下,本文選擇直接跳過這一個步驟。
0x02 checkStability
這個函式其實是在檢查原始頁面是存在動態內容,並做一些處理。何為動態內容?在 sqlmap 中表示以同樣的方式訪問量次同一個頁面,訪問前後頁面內容並不是完全相同,他們相差的內容屬於動態內容。當然,sqlmap 的處理方式也並不是隨意的比較兩個頁面就沒有然後了,在比較完之後,如果存在動態頁面,還會做一部分的處理,或者提出擴充套件設定( --string/--regex
),以方便後續使用。

我們發現,實際的 sqlmap 原始碼確實是按照我們介紹的內容處理的,如果頁面內容是動態的話,則會提示使用者處理字串或者增加正則表示式來驗證頁面。
預設情況下sqlmap通過判斷返回頁面的不同來判斷真假,但有時候這會產生誤差,因為有的頁面在每次重新整理的時候都會返回不同的程式碼,比如頁面當中包含一個動態的廣告或者其他內容,這會導致sqlmap的誤判。此時使用者可以提供一個字串或者一段正則匹配,在原始頁面與真條件下的頁面都存在的字串,而錯誤頁面中不存在(使用--string引數新增字串,--regexp新增正則),同時使用者可以提供一段字串在原始頁面與真條件下的頁面都不存在的字串,而錯誤頁面中存在的字串(--not-string新增)。使用者也可以提供真與假條件返回的HTTP狀態碼不一樣來注入,例如,響應200的時候為真,響應401的時候為假,可以新增引數--code=200。
checkDynamicContent(firstPage, secondPage)
我們發現,如果說我們並沒指定 string / regex
那麼很多情況,我們仍然也可以正確得出結果;根據 sqlmap 原始碼,它實際上背後還是有一些處理方法的,而這些方法就在 checkDynamicContent(firstPage, secondPage)
中:

我們在這個函式中發現如果 firstPage 和 secondPage
的相似度小於 0.98 (這個相似度的概念就是前一節介紹的 PageRatio 的概念),則會重試,並且嘗試 findDynamicContent(firstPage, secondPage)
然後細化頁面究竟是 too dynamic
還是 heavily dynamic
。
如果頁面是 too dynamic
則提示啟用 --text-only
選項:
有些時候使用者知道真條件下的返回頁面與假條件下返回頁面是不同位置在哪裡可以使用--text-only(HTTP響應體中不同)--titles(HTML的title標籤中不同)。
如果頁面僅僅是顯示 heavy dynamic
的話,sqlmap 會不斷重試直到區分出到底是 too dynamic
還是普通的可以接受的動態頁面(相似度大於 0.98)。
對於 too dynamic
與可以接受的動態頁面(相似度高於 0.98),其實最根本的區別就是在於 PageRatio, 如果多次嘗試(超過 conf.retries) 設定的嘗試次數,仍然出現了相似度低於 0.98 則會認為這個頁面 too dynamic
。
findDynamicContent(firstPage, secondPage)
這個函式位於 common.py
中,這個函式作為通用函式,我們並不需要非常嚴格的去審他的原始碼,為了節省大家的時候,筆者在這裡可以描述這個函式做了一件什麼樣的事情,並舉例說明。
這個函式按函式名來解釋其實是,尋找動態的頁面內容。
實際在工作中,如果尋找到動態內容,則會將動態內容的前後內容(前: prefix
,後: suffix
,長度均在 DYNAMICITY_BOUNDARY_LENGTH
中設定,預設為 20)作為一個 tuple,存入 kb.dynamicMarkings
,在每一次頁面比較之前,會預設移除這些動態內容。
kb.dynamicMarkings.append((prefix if prefix else None, suffix if suffix else None))
例如,在實際使用中,我們按照官方給定的一個例子:
""" This function checks if the provided pages have dynamic content. If they are dynamic, proper markings will be made >>> findDynamicContent("Lorem ipsum dolor sit amet, congue tation referrentur ei sed. Ne nec legimus habemus recusabo, natum reque et per. Facer tritani reprehendunt eos id, modus constituam est te. Usu sumo indoctum ad, pri paulo molestiae complectitur no.", "Lorem ipsum dolor sit amet, congue tation referrentur ei sed. Ne nec legimus habemus recusabo, natum reque et per. <script src='ads.js'></script>Facer tritani reprehendunt eos id, modus constituam est te. Usu sumo indoctum ad, pri paulo molestiae complectitur no.") >>> kb.dynamicMarkings [('natum reque et per. ', 'Facer tritani repreh')] """
根據觀察,兩段文字差別在 script
標籤,標記的動態內容應該是 script
標籤,所以動態內容的前 20 位元組的文字座位 prefix
後 20 位元組的文字作為 suffix
,分別為:
'natum reque et per. ' 'Facer tritani repreh'
0x03 中場休息與階段性總結
我們雖然之分析了兩個大函式,但是整個判斷頁面相應內容的核心原理應該是已經非常清晰了;可能有些讀者反饋我們的進度略慢,但是其實這好比一個打基礎的過程,我們基礎越紮實對 sqlmap 越熟悉,分析後面的部分就越快。
為了更好的繼續,我們需要回顧一下之前的流程圖

好的,接下來我們的目標就是圖中描述的部分“過濾重複以及不需要檢查的引數,然後檢查引數是為動態引數”,在下一篇文章中,我們將會詳細介紹 sqlmap 其他的核心函式,諸如啟發式檢測,和 sql 注入檢測核心函式。
0x04 引數預處理以及動態引數檢查
引數預處理
引數預處理包含如下步驟:
- 引數排序
# Order of testing list (first to last)
orderList = (PLACE.CUSTOM_POST, PLACE.CUSTOM_HEADER, PLACE.URI, PLACE.POST, PLACE.GET)
for place in orderList[::-1]:
if place in parameters:
parameters.remove(place)
parameters.insert(0, place) - 引數分級檢查
for place in parameters:
# Test User-Agent and Referer headers only if
# --level >= 3
skip = (place == PLACE.USER_AGENT and conf.level < 3)
skip |= (place == PLACE.REFERER and conf.level < 3)
# Test Host header only if
# --level >= 5
skip |= (place == PLACE.HOST and conf.level < 5)
# Test Cookie header only if --level >= 2
skip |= (place == PLACE.COOKIE and conf.level < 2)
skip |= (place == PLACE.USER_AGENT and intersect(USER_AGENT_ALIASES, conf.skip, True) not in ([], None))
skip |= (place == PLACE.REFERER and intersect(REFERER_ALIASES, conf.skip, True) not in ([], None))
skip |= (place == PLACE.COOKIE and intersect(PLACE.COOKIE, conf.skip, True) not in ([], None))
skip |= (place == PLACE.HOST and intersect(PLACE.HOST, conf.skip, True) not in ([], None))
skip &= not (place == PLACE.USER_AGENT and intersect(USER_AGENT_ALIASES, conf.testParameter, True))
skip &= not (place == PLACE.REFERER and intersect(REFERER_ALIASES, conf.testParameter, True))
skip &= not (place == PLACE.HOST and intersect(HOST_ALIASES, conf.testParameter, True))
skip &= not (place == PLACE.COOKIE and intersect((PLACE.COOKIE,), conf.testParameter, True))
if skip:
continue
if kb.testOnlyCustom and place not in (PLACE.URI, PLACE.CUSTOM_POST, PLACE.CUSTOM_HEADER):
continue
if place not in conf.paramDict:
continue
paramDict = conf.paramDict[place]
paramType = conf.method if conf.method not in (None, HTTPMETHOD.GET, HTTPMETHOD.POST) else place - 引數過濾

checkDynParam(place, parameter, value)
我們進入 checkDynParam
函式發現,整個函式其實看起來非常簡單,但是實際上我們發現 agent.queryPage
這個函式現在又返回了一個好像是 Bool 值的返回值作為 dynResult
這令我們非常困惑,我們上一次見這個函式返回的是 (page, headers, code)
。

我們發現實際上的頁面比較邏輯也並不是在 checkDynParam
,所以表面上,我們這一節的內容是在 checkDynParam
這個函式,但是實際上我們仍然需要跟進到 agent.queryPage
。
那麼,等什麼呢?繼續吧!
agent.queryPage 與 comparison
跟進 agent.queryPage
我相信一定是痛苦的,這其實算是 sqlmap 的核心基礎函式之一,裡面包含了接近三四百行的請求前預處理,包含 tamper
的處理邏輯以及隨機化引數和 CSRF 引數的處理檢測邏輯。同時如果涉及到了 timeBasedCompare
還包含著時間盲注的處理邏輯;除此之外,一般情況下 agent.queryPage
中還存在著針對頁面比較的核心呼叫,頁面對比對應函式為 comparison
。為了簡化大家的負擔,筆者只擷取最後返回值的部分 agent.queryPage
。

在標註中,我們發現了我們之前的疑問,為什麼 agent.queryPage
時而返回頁面內容,時而返回頁面與模版頁面的比較結果。其實在於如果 content/response
被設定為 True
的時候,則會返回頁面具體內容,headers,以及響應碼;如果 timeBasedCompare
被設定的時候,返回是否發生了延遲;預設情況返回與模版頁面的比較結果。
我們發現這一個 comparison
函式很奇怪,他沒有輸入兩個頁面的內容,而是僅僅輸入當前頁面的相關資訊,但是為什麼筆者要明確說是與“模版頁面”的比較結果呢?我們馬上就跟入 comparison
一探究竟。

進去之後根據圖中的呼叫關係,我們主要需要觀察一下 _comparison
這個函式的行為。當開啟這個函式的時候,我們發現也是一段接近一百行的函式,仍然是一個需要硬著頭皮看下去的一段程式碼。

根據圖中的使用紅色方框框住的程式碼,我們很容易就能發現,這其實是在禁用 PageRatio
的頁面相似度演算法,而是因為使用者設定了 --string/--not-string/--regex/--code
從而可以明確從其他方面區分出頁面為什麼不同。當然,我們的重點並不是他,而是計算 ratio
並且使用 ratio
得出頁面相似的具體邏輯。

我相信令大家困惑的可能是這兩段關於 nullConnection
的程式碼,在前面的部分中,我們沒有詳細說明 nullConnection
究竟意味著什麼:
Optimization: These options can be used to optimize the performance of sqlmap -oTurn on all optimization switches --predict-outputPredict common queries output --keep-aliveUse persistent HTTP(s) connections --null-connectionRetrieve page length without actual HTTP response body --threads=THREADSMax number of concurrent HTTP(s) requests (default 1)
根據官方手冊的描述, nullConnection
是一種不用獲取頁面內容就可以知道頁面大小的方法,這種方法在布林盲注中有非常好的效果,可以很好的節省頻寬。具體的原理詳見這一片古老的文章。
明白這一點,上面的程式碼就變得異常好懂了,如果沒有啟用 --null-connection
優化,兩次比較的頁面分別為 page
與 kb.pageTemplate
。其實 kb.pageTemplate
也並不陌生,其實就是第一次正式訪問頁面的時候,存下的那個頁面的內容。
conf.originalPage = kb.pageTemplate = page
如果啟用 --null-connection
,計算 ratio 就只是很簡單的通過頁面的長度來計算,計算公式為
ratio = 1. * pageLength / len(kv.pageTemplate) if ratio > 1.: ratio = 1. / ratio
接下來我們再順著他的邏輯往下走:

根據上面對原始碼的標註,我們很容易理解這個 ratio
是怎麼算出來的,同樣我們也很清楚,其實並不只是簡單無腦的使用 ratio
就可以起到很好的效果,配合各種各樣的選項或者預處理:比如移除頁面的動態內容,只比較 title
,只比較文字,不比較 html
標籤。

上面原始碼為最終使用 ratio
對頁面的相似度作出判斷的邏輯,其中
UPPER_RATIO_BOUND = 0.98 LOWER_RATIO_BOUND = 0.02 DIFF_TOLERANCE = 0.05
0x05 結束語
閱讀完本文,我相信讀者對 sqlmap 中處理各種問題的細節都會有自己的理解,當然這是最好的。
在下一篇文章,筆者將會帶大家進入更深層的 sqlmap 的邏輯,敬請期待。