Java 正则表达式的灾难性回溯

原文地址:https://alphahinex.github.io/2024/10/13/java-regex-catastrophic-backtracking/


description: "Java 正则表达式的灾难性回溯问题可能导致应用程序的拒绝服务。本文介绍了如何重现、风险原因、风险评估、如何避免、修复示例等内容。"
date: 2024.10.13 10:26
categories:
- Java
tags: [Java, Regex]
keywords: regex, backtracking, catastrophic backtracking, java, security, performance, denial of service, DoS, ReDoS, OWASP, CWE, S5852


现象重现

新建一个 Backtracking.java 文件,内容如下:

public class Backtracking {
    public static void main(String[] args) {
        System.out.println(System.getProperty("java.version"));
        System.out.println("The first regex evaluation will never end in JDK <= 9:");
        System.out.println(java.util.regex.Pattern.compile("(a+)+").matcher(
"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"+
"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"+
"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"+
"aaaaaaaaaaaaaaa!").matches()); // Sensitive
        System.out.println("and the second regex evaluation will never end in any versions of the JDK:");
        System.out.println(java.util.regex.Pattern.compile("(h|h|ih(((i|a|c|c|a|i|i|j|b|a|i|b|a|a|j))+h)ahbfhba|c|i)*").matcher(
"hchcchicihcchciiicichhcichcihcchiihichiciiiihhcchi"+
"cchhcihchcihiihciichhccciccichcichiihcchcihhicchcciicchcccihiiihhihihihi"+
"chicihhcciccchihhhcchichchciihiicihciihcccciciccicciiiiiiiiicihhhiiiihchccch"+
"chhhhiiihchihcccchhhiiiiiiiicicichicihcciciihichhhhchihciiihhiccccccciciihh"+
"ichiccchhicchicihihccichicciihcichccihhiciccccccccichhhhihihhcchchihih"+
"iihhihihihicichihiiiihhhhihhhchhichiicihhiiiiihchccccchichci").matches()); // Sensitive
    }
}

编译并执行:

$ java -version
java version "1.8.0_121"
Java(TM) SE Runtime Environment (build 1.8.0_121-b13)
Java HotSpot(TM) 64-Bit Server VM (build 25.121-b13, mixed mode)
$ javac Backtracking.java
$ java Backtracking
1.8.0_121
The first regex evaluation will never end in JDK <= 9:

在 Java 8 环境下,此段程序永远不会执行结束,且该 Java 进程会持续跑满 CPU。

在 Java 11 环境下,第一个表达式能执行结束,但第二个表达式依然会持续消耗大量资源:

$ java -version
openjdk version "11.0.23" 2024-04-16 LTS
OpenJDK Runtime Environment Microsoft-9394293 (build 11.0.23+9-LTS)
OpenJDK 64-Bit Server VM Microsoft-9394293 (build 11.0.23+9-LTS, mixed mode, sharing)
$ java Backtracking
11.0.23
The first regex evaluation will never end in JDK <= 9:
false
and the second regex evaluation will never end in any versions of the JDK:

风险原因

Using slow regular expressions is security-sensitive 对这个问题进行了描述:大多数正则表达式引擎使用回溯(backtracking)来尝试正则表达式在评估输入时的所有可能执行路径,在某些情况下,这可能会导致性能问题,称为灾难性回溯(catastrophic backtracking)情况。在最坏的情况下,正则表达式的复杂度与输入大小成指数关系,这意味着一个精心构造的小输入(如20个字符)可以触发灾难性回溯并导致应用程序的拒绝服务。超线性正则表达式复杂度也可能导致相同的影响,在这种情况下,需要一个精心构造的大输入(数千个字符)。

风险评估

要确定代码中是否存在此风险,可以尝试回答如下问题:

  • 输入是用户控制的。
  • 输入大小没有限制为少量的字符。
  • 没有设置超时来限制正则表达式的评估时间。

如果任何一个问题的回答为 ,那么就可能存在风险。

如何避免

在所有下述情况中,灾难性回溯只有在正则表达式的有问题部分后面跟随一个可能失败的模式时才会发生,从而导致回溯实际发生。请注意,当执行完全匹配(例如使用 String.matches)时,正则表达式的结尾被视为一个可能失败的模式,因为它只有在到达字符串结尾时才会成功。

  • 如果正则表达式包含非占有性重复,如 r*r*?,表示可以匹配零次或多次 r,但不会占有匹配的字符(即允许回溯),如果 r 可以在相同输入上产生不同的可能匹配(可能长度不同),最坏情况下的匹配时间可能是指数级的。这种情况可能发生在 r 包含可选部分、交替或额外重复时。使用JDK 9或更高版本时,如果重复是贪婪的且整个正则表达式不包含反向引用,则运行时间会优化为多项式或线性。
  • 如果多个非占有性重复可以匹配相同内容且是连续的或仅由可选分隔符分隔,可能会导致多项式时间复杂度。例如,a*b* 没有问题,因为 a*b* 匹配不同的东西,而 a*_a* 也没有问题,因为重复部分由一个 _ 分隔,并且不能匹配该 _。然而,a*a*.*_.* 具有二次运行时间。
  • 如果你正在执行部分匹配(如使用 Matcher.findString.splitString.replaceAll 等),并且正则表达式未锚定到字符串的开头,尤其难以避免二次运行时间。例如,str.split("\\s*,") 在完全由空格组成的字符串(或至少包含大量不跟随逗号的空格序列)上将以二次时间运行。

为避免这些问题,可以采取以下策略:

  • 如果适用,使用有界量词(例如用 {1,5} 代替 +)限制重复次数。
  • 重构嵌套量词(nested quantifiers)以限制内部组可以被外部量词匹配的数量。例如 (ba+)+ 这种嵌套量词情况不会导致性能问题,实际上,只有存在每个组重复一次 b 字符时,内部组才能匹配。
  • 使用占有量词(possessive quantifiers)和原子分组(atomic grouping)优化正则表达式。
  • 使用否定字符类代替 . 来排除分隔符。例如,二次正则表达式 .*_.* 可以通过将其更改为 [^_]*_.* 变为线性。

如果无法重写正则表达式以避免性能问题,可以考虑以下方法:

  • 不使用正则表达式解决问题。
  • 使用非回溯的正则表达式实现,如Google的 RE2RE2/J
  • 使用多次处理,预处理或后处理字符串,或使用多个正则表达式。一个例子是将 str.split("\\s*,\\s*") 替换为 str.split(","),然后在第二步中修剪字符串中的空格。
  • 使用 Matcher.find() 时,通常可以通过使所有可能失败的部分可选来使正则表达式不可失败,这将防止回溯。当然,这意味着你将接受比预期更多的字符串,但这可以通过使用捕获组来检查可选部分是否匹配,然后在它们不匹配时忽略匹配来处理。例如,正则表达式 x*y 可以替换为 x*(y)?,然后将对 matcher.find() 的调用替换为 matcher.find() && matcher.group(1) != null

修复示例

NewBacktracking.java

public class NewBacktracking {
    public static void main(String[] args) {
        System.out.println(System.getProperty("java.version"));
        System.out.println("The first regex evaluation will end after modify:");
        System.out.println(java.util.regex.Pattern.compile("(a+)++").matcher(
"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"+
"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"+
"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"+
"aaaaaaaaaaaaaaa!").matches()); // Sensitive
        System.out.println("and the second regex evaluation will end after modify:");
        System.out.println(java.util.regex.Pattern.compile("(h|h|ih(((i|a|c|c|a|i|i|j|b|a|i|b|a|a|j))++h)ahbfhba|c|i)*").matcher(
"hchcchicihcchciiicichhcichcihcchiihichiciiiihhcchi"+
"cchhcihchcihiihciichhccciccichcichiihcchcihhicchcciicchcccihiiihhihihihi"+
"chicihhcciccchihhhcchichchciihiicihciihcccciciccicciiiiiiiiicihhhiiiihchccch"+
"chhhhiiihchihcccchhhiiiiiiiicicichicihcciciihichhhhchihciiihhiccccccciciihh"+
"ichiccchhicchicihihccichicciihcichccihhiciccccccccichhhhihihhcchchihih"+
"iihhihihihicichihiiiihhhhihhhchhichiicihhiiiiihchccccchichci").matches()); // Sensitive
    }
}
$ diff Backtracking.java NewBacktracking.java
1c1
< public class Backtracking {
---
> public class NewBacktracking {
4,5c4,5
<         System.out.println("The first regex evaluation will never end in JDK <= 9:");
<         System.out.println(java.util.regex.Pattern.compile("(a+)+").matcher(
---
>         System.out.println("The first regex evaluation will end after modify:");
>         System.out.println(java.util.regex.Pattern.compile("(a+)++").matcher(
10,11c10,11
<         System.out.println("and the second regex evaluation will never end in any versions of the JDK:");
<         System.out.println(java.util.regex.Pattern.compile("(h|h|ih(((i|a|c|c|a|i|i|j|b|a|i|b|a|a|j))+h)ahbfhba|c|i)*").matcher(
---
>         System.out.println("and the second regex evaluation will end after modify:");
>         System.out.println(java.util.regex.Pattern.compile("(h|h|ih(((i|a|c|c|a|i|i|j|b|a|i|b|a|a|j))++h)ahbfhba|c|i)*").matcher(
$ javac NewBacktracking.java
$ java NewBacktracking
1.8.0_121
The first regex evaluation will end after modify:
false
and the second regex evaluation will end after modify:
true

参考资料

©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 204,684评论 6 478
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 87,143评论 2 381
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 151,214评论 0 337
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,788评论 1 277
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 63,796评论 5 368
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,665评论 1 281
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 38,027评论 3 399
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,679评论 0 258
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 41,346评论 1 299
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,664评论 2 321
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,766评论 1 331
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,412评论 4 321
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 39,015评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,974评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,203评论 1 260
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 45,073评论 2 350
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,501评论 2 343

推荐阅读更多精彩内容