ReDOS初探
其實也是在做一次Code Review專案的時候,發現了一個ReDos的問題,但是由於惰性,沒記錄一下。最近比較有空了,所以抽個時間記錄一下。
0x02 知識鋪墊
所謂的 ReDOS(Regular expression Denial of Service) 正則表示式拒絕服務攻擊 。實際上開發人員使用了正則表示式來對使用者輸入的資料進行有效性校驗, 當編寫校驗的正則表示式存在缺陷或者不嚴謹時, 攻擊者可以構造特殊的字串來大量消耗伺服器的系統資源,造成伺服器的服務中斷或停止。
正則表示式是什麼,其實大家應該都知道,或者都用到過,但是這裡需要提及一些東西就是正則表示式的引擎。這裡首先涉及到一個概念: 有限狀態自動機 。
(FSM “finite state machine” 或者FSA “finite state automaton” )是為研究有限記憶體的計算過程和某些語言類而抽象出的一種計算模型。有限狀態自動機擁有有限數量的狀態,每個狀態可以遷移到零個或多個狀態,輸入字串決定執行哪個狀態的遷移。
而有限狀態自動機還可以分成確定與非確定兩種, 非確定有限狀態自動機可以轉化為確定有限狀態自動機。
DFA(確定性有限狀態自動機)
,另一類稱為
NFA(非確定性有限狀態自動機)
。
兩類引擎要順利工作,都必須有一個正則式和一個文字串,一個捏在手裡,一個吃下去。 DFA
捏著文字串去比較正則式,看到一個子正則式,就把可能的匹配串全標註出來,然後再看正則式的下一個部分,根據新的匹配結果更新標註。而 NFA
是捏著正則式去比文字,吃掉一個字元,就把它跟正則式比較,匹配就記下來:“某年某月某日在某處匹配上了!”,然後接著往下幹。一旦不匹配,就把剛吃的這個字元吐出來,一個個的吐,直到回到上一次匹配的地方。
兩個引擎其實存在本質上的差別:
- DFA對於文字串裡的每一個字元只需掃描一次,比較快,但特性較少;NFA要翻來覆去吃字元、吐字元,速度慢,但是特性豐富,所以反而應用廣泛,當今主要的正則表示式引擎,如Perl、Ruby、Python的re模組、Java和.NET的regex庫,都是NFA的。
- 只有NFA才支援lazy和backreference等特性;
- NFA急於邀功請賞,所以最左子正則式優先匹配成功,因此偶爾會錯過最佳匹配結果;DFA則是“最長的左子正則式優先匹配成功”。
- NFA預設採用greedy量詞(見item 4);
- NFA可能會陷入遞迴呼叫的陷阱而表現得效能極差。
這裡引用別人的例子,因為我覺得舉的很好。
現在有個文字為: perlmanbook ,一個正則表示式為 /perl|perlman/ ,而這個正則表示式在兩種引擎下的匹配結果我們可以看下。
首先是NFA,它是以正則為導向,怎麼說呢,這時候我們手上第一個子正則表示式為 /perl/ ,而該引擎針對 perlmanbook 字串進行掃描,從左開始,當進度進行到 perl manbook 的時候,最開始部分的 perl 已經和第一個子正則表示式匹配而上,而當引擎進度掃描到m字元的時候,發現與第一個子正則表示式不匹配,於是把m吐出來,向上彙報說成功匹配 perl ,不再關心其他,也不嘗試第二個子正則式 /perlman/ 。
若是DFA引擎的情況下,它是以文字為導向,針對正則從左開始進行掃描,當掃描到 /p/ 的時候,發現匹配的上了,然後繼續往下匹配,當將第一個子正則 /perl/ 全部匹配上之後,這時候就會把這個正則甩開,去吃第二個子正則式的 /p/ 。這一吃好了,因為又匹配上了,於是接著往下吃。直到把正則式吃完,心滿意足往上報告說成功匹配了 ‘perlman’ 。
也就是說我們可以總結一下,NFA引擎要翻來覆去吃字元、吐字元,速度慢,且可能會陷入遞迴險境導致效能極差。因此使用NFA引擎的正則表示式就可能出現 ReDOS 的問題了。
0x03 漏洞樣例
目前正則引擎支援的語言種類:
引擎型別 | 程式 |
---|---|
DFA | awk(大多數版本)、egrep(大多數版本)、flex、lex、MySQL、Procmail |
傳統型 NFA | GNU Emacs、Java、grep(大多數版本)、less、more、.NET語言、PCRE library、Perl、PHP(所有三套正則庫)、Python、Ruby、set(大多數版本)、vi |
POSIX NFA | mawk、Mortice Lern System’s utilities、GUN Emacs(明確指定時使用) |
DFA/NFA混合 | GNU awk、 GNU grep/egrep、 Tcl |
我們來看一個demo。
簡單說明一下這個 ^(a+)+$
正則的作用是什麼:
^:匹配輸入字串的開始位置,也就是說從下面 str_list
字串中的最開頭開始匹配。
a:就是字元a,這裡不多做說明
+: “+”(懶惰) 重複一次或更多次,例如”aaaaaaaa” 匹配字串中所有的a。
$:\$會匹配行或字串的結尾。
那麼我們通過一段程式碼進行一下驗證
根據 Regular Expression Denial of Service 這個個PPT裡面,總結了一下:
- 重複分組構造
- 重複組內應出現:1.重複,2.交替重疊
會出現ReDOS的樣例正則表示式。
- (a+)+
- ([a-zA-Z]+)*
- (a|aa)+
- (a|a?)+
- (.*a){x} | for x > 10
Payload: “aaaaaaaaaaaaaaaaaaX”
當然我們也可以使用python的re模組來驗證一下是否存在ReDOS,因為Python的re模組用的就是NFA的引擎。
該PPT中提供了一些業務場景下可能存在ReDOS的正則表示式寫法
下面我們來展示一些實際業務場景中會用到的缺陷正則。
- Person Name:
^[a-zA-Z]+(([\'\,\.\-][a-zA-Z ])?[a-zA-Z]*)*$ aaaaaaaaaaaaaaaaaaaaaaaaaaaa!
- Java Classname
^(([a-z])+.)+[A-Z]([a-z])+$ aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa!
- Email Validation
^([0-9a-zA-Z]([-.\w]*[0-9a-zA-Z])*@(([0-9a-zA-Z])+([-\w]*[0-9a-zA-Z])*\.)+[a-zA-Z]{2,9})$ a@aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa!
- Multiple Email address validation
^[a-zA-Z]+(([\'\,\.\-][a-zA-Z ])?[a-zA-Z]*)*\s+<(\w[-._\w]*\w@\w[-._\w]*\w\.\w{2,3})>$|^(\w[-._\w]*\w@\w[-._\w]*\w\.\w{2,3})$ aaaaaaaaaaaaaaaaaaaaaaaa!
- Decimal validator
^\d*[0-9](|.\d*[0-9]|)*$ 1111111111111111111111111!
- Pattern Matcher
^([a-z0-9]+([\-a-z0-9]*[a-z0-9]+)?\.){0,}([a-z0-9]+([\-a-z0-9]*[a-z0-9]+)?){1,63}(\.[a-z0-9]{2,7})+$ aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa!
我們可以用下面這段python程式碼來輔助驗證。
#!/usr/bin/env python # coding: utf-8 import re import time s1 = time.time() regex = re.compile('^[a-zA-Z]+(([\'\,\.\-][a-zA-Z ])?[a-zA-Z]*)*$') str='aaaaaaaaaaaaaaaaa!' #str='aaaaaaaaaaaaaaaaaaa!' regex.match(str) s2=time.time() print(str) print(s2-s1)
分別定義
str='aaaaaaaaaaaaaaaaa!' str='aaaaaaaaaaaaaaaaaaa!'
最後結果
aaaaaaaaaaaaaaaaa! 0.0207 aaaaaaaaaaaaaaaaaaa! 0.0776
之前Ph師傅的 code-breaking 題目裡面就有一題 prefwaf ,這題實際上就涉及到這個問題,首先原始碼是這樣的。
<?php function is_php($data){ return preg_match('/<\?.*[(`;?>].*/is', $data); } if(empty($_FILES)) { die(show_source(__FILE__)); } $user_dir = 'data/' . md5($_SERVER['REMOTE_ADDR']); $data = file_get_contents($_FILES['file']['tmp_name']); if (is_php($data)) { echo "bad request"; } else { @mkdir($user_dir, 0755); $path = $user_dir . '/' . random_int(0, 10) . '.php'; move_uploaded_file($_FILES['file']['tmp_name'], $path); header("Location: $path", true, 303); }
其實這裡很明顯我們要寫入webshell,但是要繞過 is_php 函式中的 /<\?.*[(`;?>].*/is 正則表示式,假設我們輸入的字元是 <?php phpinfo();//aaaaa ,那麼這個字串匹配流程我們看一下這個動圖。
我們看到,到第4步開始由於 ?.*
是貪婪模式匹配,因此抓取到了這個字串也就是 //aaaaa 的末尾,而立實際上還有部分內容是 [(`;?>] 等特殊字元,也就是說這部分程式碼裡面如果存在這些字元就匹配成功,而該NFA引擎的正則表示式目前沒有找到,因此它從第5步開始回溯,先吐出一個 a
,輸入變成第5步顯示的 //aaaa
,但仍然匹配不上正則,繼續吐出 a
,變成 //aaa
,仍然匹配不上……。直到第12步回溯的 ; 匹配上了字元 [(`;?>] 中的 ; 。
然後 第13步 又匹配 .* ,執行貪婪匹配,通過 ; 將回溯源 <?php phpinfo(); 全部抓了出來,並返回匹配成功。
PHP為了防止正則表示式的拒絕服務攻擊(reDOS),給pcre設定了一個回溯次數上限 pcre.backtrack_limit
。我們可以通過 var_dump(ini_get('pcre.backtrack_limit'));
的方式檢視當前環境下的上限。
這裡的回溯上限是1000000,也就是說我們看看超過回溯上限的執行結果。
php > var_dump(preg_match('/<\?.*[(`;?>].*/is','<?php phpinfo();//'.str_repeat('a',1000000))); bool(false)
preg_match
返回的非1和0,而是false。 preg_match
函式返回false表示此次執行失敗了,我們可以呼叫 var_dump(preg_last_error() === PREG_BACKTRACK_LIMIT_ERROR);
,發現失敗的原因的確是回溯次數超出了限制。
這裡修復的話,php.net針對 這個函式 提及到了修復建議。
如果用 preg_match
對字串進行匹配,一定要使用 ===
全等號來判斷返回值,如:
<?php function is_php($data){ return preg_match('/<\?.*[(`;?>].*/is', $data); } if(is_php($input) === 0) { // fwrite($f, $input); ... }
這樣,即使正則執行失敗返回false,也不會進入if語句。