一個由正則表示式引發的血案(解決版)
正則表示式一直以來是廣大碼農處理字串的福音,但與此同時,也引發過血案。我們發表在ASE'18的論文“ReScue: Crafting Regular Expression DoS Attacks”[1]大幅改進了這類時間複雜性攻擊的檢測工具,並因此獲得了ACM SIGSOFT Distinguished Paper Award。
正則表示式引發的血案
正則表示式
正則表示式(Regular Expressions)可以說是世界上最流行的字串處理工具之一,它用一個字串來表示一個字串的 集合 ,例如 /ab+a/
表示{ aba
, abba
, ...},再加上各種語法特性和API,是處理字串的神器之一。
比如程式員經常使用的字串搜尋工具 grep
(最初作者是天才程式設計師、圖靈獎獲得者、UNIX的發明人之一Ken Thompson),其實就是 ed
命令

的別名(globally search regex and print),現在在vim中輸入 :g/re/p
依然可以實現同樣的功能。網際網路上還流傳著很多正則表示式的傳說,例如以下正則表示式能判定一個字串是否恰好由非素數個 1
組成:
/^1?$|^(11+)\1+$/
厲害了,上過《編譯原理》、《形式語言與自動機》課的我竟然完全……看不懂?嗯,需要花點時間閱讀一下經典教材《精通正則表示式》[2]。人生苦短,正則表示式(還有Python)能顯著減少程式的長度、提高開發效率,有效延長了程式設計師的生命,也許還可以拯救你的髮際線,呃,或者也許你能看懂這個正則表示式的時候已經是資深程式設計師了(逃

血案(denny版)
當然,正則表示式也不是那麼容易駕馭的。8102年的有一天,還在公司當弱弱的實習生、剛學會正則表示式的denny決定使用一個正則表示式來完成老大交待的Email地址驗證需求:
- 老大的需求:驗證
.com
結尾的Email地址 - denny的解讀:一個字串, 中間有個
@
,前面可以有字母、數字、下劃線、和點,後面可以有多個字尾,最後一個是.com
- denny的實現:
^[a-zA-Z0-9._]+@([a-zA-Z0-9]+.)+com$
(其實有bug哦) - denny覺得: 彷彿哪裡不對 ,denny進行了測試:
PASS -- [email protected] (true) PASS -- [email protected](false) ... 此處省略一百個通過的弱智測試用例 ... PASS -- [email protected] (false) PASS -- test_163.163@test_163.test.com (false) # OK,很對,上線
- denny在小霸王伺服器上進行了版本更新並部署:
commit 4040404040404 (origin/master, origin/HEAD) Author: denny <[email protected]> Date:Mon Sep 25 17:00:00 2018 +0800 加入正則表示式Email地址校驗
- denny下班之後,小霸王伺服器收到了一些奇怪的請求
power.overwhelming@aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa power.overwhelming@aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa power.overwhelming@aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa power.overwhelming@aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa power.overwhelming@aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa ...
- 小霸王伺服器感受到了巨大壓力: CPU 100%

- 小霸王伺服器在重壓之下選擇舉手投降(Denial of Service)
正則表示式複雜度攻擊
正如另一篇文章裡指出的,寫得不好的正則表示式可能會導致正則表示式引擎耗費大量的時間在 回溯 上,達到輸入長度的 指數級 !一個不太長的字串(幾十或幾百),就能讓正則引擎這輩子都跑不出匹配結果,從而導致拒絕服務攻擊(Denial of Service),因為是正則表示式導致的,縮寫成ReDoS。
而剛才denny寫的正則表示式正是這樣一個有 指數級最壞情況 例子:

而這種出問題的正則表示式,很可能就被不知不覺部署到了生產環境中!
- 正則表示式可能已經通過了嚴格的測試。
- 在非惡意條件下構造的輸入中,可能表現得非常好,例如匹配時間是線性的。

這些正則表示式流入了生產環境,自然就成了Denial of Service攻擊的把柄,要是沒有log,也許小霸王伺服器被壓垮都不知道是為什麼呢!
正則表示式複雜度攻擊:原理
為了支援現代正則表示式的各種神奇語法特性(例如 \1
、 (?:...)
等,剛才已經在判斷素數的例子裡見過部分特性了),《編譯原理》課本上那種構造DFA,或者直接對NFA做dp的匹配方式已經不管用了[3]。概括地說,今天的正則引擎匹配正則表示式的演算法就是 搜尋 :
- 將正則表示式編譯成一個有向圖,和自動機類似,不妨稱為NFA;
- 在匹配時,維護一個NFA節點和匹配位置的 棧 ,經過一個NFA節點將引起一系列的入棧和出棧操作。
也許你已經反應過來了,棧即對應了回溯搜尋的過程。例如在某一時刻面臨兩種選擇(例如 expr1|expr2
),那就在棧上先存兩個節點,一個對應 expr1
,另一個對應 expr2
,等一條路徑匹配失敗返回出棧,就自動開始另一條路徑的搜尋。
嗯,有沒有想起什麼?你寫過的各種回溯搜尋(比如漢諾塔非遞迴版本)都是這樣的套路嘛,這當然有一個指數級的最壞情況了。
正則表示式匹配:例子
以上面denny寫的正則表示式 ^[a-zA-Z0-9._]+@([a-zA-Z0-9]+.)+com$
為例子,當遇到輸入 power.overwhelming@aaaa
時:
- 首先前段的
power.overwhelming@
會順利匹配正則表示式的前段^[a-zA-Z0-9._]+@
; - 然後括號內的
[a-zA-Z0-9]+
會匹配後段所有的字元aaaa
; - 然後發現
.
匹配字串結尾失敗,回溯一位,讓[a-zA-Z0-9]+
匹配aaa
,.
匹配a
; - 注意,其實denny在這裡就已經寫錯了, 他應該將
.
轉義\.
,這個未轉義的.
也是導致後續回溯的原因之一。 - 最外層的
+
被成功匹配1次,接著com
的c
匹配字串結尾失敗,回溯一位,[a-zA-Z0-9]+
匹配aa
,.
匹配a
; - 最外層的
+
嘗試匹配第2次,[a-zA-Z0-9]+
匹配a
,.
匹配字串結尾失敗,匹配第2次失敗,於是讓c
嘗試匹配倒數第二個a
,匹配失敗,回溯; -
[a-zA-Z0-9]+
匹配a
成功,.
匹配第2個a
成功,最外層的+
嘗試重複第2次,經過一次回溯,順利匹配第3個和第4個a
成功,然後c
匹配字串結尾,匹配失敗,再次回溯,[a-zA-Z0-9]+
無法匹配空字串,匹配失敗,由於^
的存在,不需要從頭開始推進,直接返回False
。
太長不看版
對於 @
後面的每一個 a
,既可以出現在最外層的 +
的匹配中,也可以出現在內層的 +
的匹配中,也就是說,每一個 a
都存在2種不同的匹配可能,所以當匹配失敗需要列舉所有匹配可能時,需要列舉 種可能(其中
n
代表 a
的個數)。
- 即假設每一種可能都能在常數時間裡輸出結果,那麼總的匹配時間將跟
a
的個數(字串長度)呈指數級關係。
我不識字版
ReDoS匹配視訊模擬版,總之你看到它在不停的回溯(在狀態機裡繞圈圈)就對了!警告:洗腦背景音樂。
ofollow,noindex">
自動構造正則表示式複雜度攻擊字串
現在進入我們工具的廣告部分。說來我們論文裡解決的問題也很簡單:
給定一個正則 引擎 和一個 正則表示式 ,為這個正則表示式找到一個攻擊 字串 ,它可以最大化正則引擎的匹配時間。
如果這個問題得到解決,我們搞出這麼個工具,程式設計師在寫完正則表示式以後,直接把正則表示式拿到工具裡跑跑看,如果工具返回一個匹配巨慢的ReDoS字串,就不應該把它拿到線上去工作,真是省去了很多麻煩。實際上,我們的研究組有相當多此類工作,研究自動化的測試工具。
問題分析
既然問題的輸入和輸出都明確了,沒啥搞不定的,不就是個最優化問題嘛!只要把所有長度為 的字串都拿來試一試,找一個最慢的就好了。窮舉是萬能的,但也是萬萬不能的——如果碰上一個要匹配幾百年的正則表示式,再這麼搜尋 的空間,真是麻煩大了。
另一方面,其實我們也已經有能搞定的演算法了:拿點啟發式搜尋來瞎搞搞,湊個數,十有八九沒問題——你猜對了!就是這麼簡單,我們就拿遺傳演算法發了篇論文(還不快來讀博士?),還得了獎!而且這玩意還有一個高大上的名字:Search-Based Software Engineering [4](SBSE,基於搜尋的軟體工程)!
那麼怎麼才能報考南京大學計算機軟體研究所呢?歡迎騷擾軟體所的各位老師和同學。

簡單,不簡單
當然了,要做一個好的搜尋演算法也不是那麼容易的。我們的確可以直接搬來一個遺傳演算法,讓fitness function是字串匹配的“價效比”:
然後讓遺傳演算法幫我們找到所有字串中價效比最高的那個,自然就是能夠造成ReDoS攻擊的字串。很不幸的是——實際的正則表示式沒那麼簡單。例如剛才讓小霸王伺服器垮掉的例子,它匹配的是一個Email地址。因此,如果不生成一個 @
字元,匹配壓根不會進行到後半部分,也不會觸發超慢的回溯過程了。而如果剛好有一個字串,它會引發複雜度問題但卻又有很不錯的“價效比”,整個種群很快就會充斥類似的字串,從而導致整個遺傳演算法陷入區域性最優解,錯失找到真正問題的機會。

三階段的檢測方法(論文方法概括版)
到這裡已經比較技術細節了,我們就上個圖,具體的辦法還請閱讀我們的論文,大體思想是說,我們不僅要再遺傳演算法裡考慮字串的“價效比”,還需要考慮對正則引擎編譯出來的NFA的狀態覆蓋;最後為了使演算法在找到有潛力的攻擊字串後迅速找到實際的複雜度攻擊,還利用Pumping Lemma [6]設計了一個快速得到有實際攻擊價值字串的方法。

以危險的正則表示式 (0|[0-1]){2,15}(hello)\2([0-9]+)+#
為例,首先經歷的是“Seeding”階段,生成若干種子字串,完全不管價效比,只為了覆蓋更多正則引擎的NFA的狀態:例如 {"0", "00", "00hello", "00hellohello", "00hellohello0", "oohellohello0#", ...}
;有了這些好的種子,我們再做以“價效比”為導向的遺傳演算法(“Incubating”階段),同時保持種群中狀態覆蓋不降低,構造匹配較慢的字串,例如 00hellohello00000000
。最後,在“Pumping”階段將匹配較慢的字串強化為效果拔群的ReDoS字串,例如 00hellohello0000000000000000000000000000
。
程式碼實現參考傳送門。我們的遺傳演算法需要理解正則表示式匹配的過程,因此我們對Java的正則引擎稍做了一些profiling的修改,能夠在正則表示式匹配的同時生成matching trace。也正是因為用了這樣白盒的演算法,實驗結果才能比已有的一些技術好那麼一丟丟。
附上一些我們的工具在GitHub開源專案中發現的ReDoS問題:
- This regex may be stucked by input · Issue #141 · nhnent/tui.editor
- This regex may be stucked by input · Issue #3638 · ajaxorg/ace
- This regex may be stucked by input strings. · Issue #9731 · meteor/meteor
- Regex timeout · Issue #2020 · openstates/openstates
後記
我們關注到複雜度攻擊這個問題,來自機緣巧合在Tim Roughgarden的Coursera課上提到了Crosby和Wallach在2003年USENIX Security上的論文 [7],結果一句話湊出了一篇論文。當時正好桔子同學入學,就接了這個鍋。我們一度想研究Hash Tables,但發現Java 8裡的HashMap已經一勞永逸地解決了複雜度攻擊問題(你讀到這裡幾乎就可以猜到解決方案:在Hash Bucket超過某個大小時改用紅黑樹儲存),幾乎無路可做,又恰好發現正則表示式也是這麼一個導致複雜度飆升的型別,順水推舟就做下去了。匆忙之中完成了投稿,趕上ReDoS火起來(看到CCS'17的SlowFuzz [5]的時候我們都崩潰了),結果卻是非常意外的得到了ACM SIGSOFT Distinguished Paper Award。

總之,歡迎大家加入我們的大家庭,一起做有趣的軟體工程研究!
參考文獻
[1] Shen, Yuju, Yanyan Jiang, Chang Xu, Ping Yu, Xiaoxing Ma, and Jian Lu. ReScue: crafting regular expression DoS attacks. ASE'18.
[2] Jeffrey E F Friedl. Mastering Regular Expressions: Understand Your Data and Be More Productive (3th ed.), 2006.
[3] Ken Thompson. 1968. Programming techniques: Regular expression search algorithm. Communications of the ACM 11(6), 419-422, 1968.
[4] Mark Harman and Bryan F Jones. Search-based software engineering. Information and Software Technology 43(14): 833-839, 2001.
[5] Theofilos Petsios, Jason Zhao, Angelos D Keromytis, and Suman Jana. Slowfuzz: Automated domain-independent detection of algorithmic complexity vulnerabilities. CCS'17.
[6] James Kirrage, Asiri Rathnayake, and Hayo Thielecke. Static analysis for regular expression denial-of-service attacks. NSS'13.
[7] Scott A Crosby and Dan S Wallach. Denial of service via algorithmic complexity attacks. USENIX Security'03.
作者簡介 :本文作者包括南京大學的碩士生沈宇桔、蔣炎巖博士、許暢教授、餘萍副教授、馬曉星教授和呂建教授。