起きたこと
問い合わせフォームのメールアドレス・電話番号の確かめに、[A-Za-z0-9_+-]+(\.[A-Za-z0-9_+-]+)* や [0-9]+(-[0-9]+)* のような正規表現を使っていた。a.a.a.… と「.」で区切った部分を4500個並べた入力を送ると、10言語のうち Java と Kotlin のプログラムだけが StackOverflowError で止まった。Kotlin の Regex は Java の正規表現を使うので、同じように止まる。ほかの8言語(PHP・Python・Go・JavaScript・C#・Rust・Ruby・Swift)は、同じ入力で止まらなかった。
正規表現が遅くなる話(ReDoS)とは違い、時間はかからずにすぐ止まる。
再現の手順
public class Re {
public static void main(String[] args) {
int n = Integer.parseInt(args[0]);
String s = "a" + "-a".repeat(n);
System.out.println(n + " 回: " + s.matches("[a-z]+(-[a-z]+)*"));
}
}$ for n in 1000 2000 3000 5000; do java Re.java $n 2>&1 | head -2; done 1000 回: true 2000 回: true Exception in thread "main" java.lang.StackOverflowError at java.base/java.util.regex.Pattern$BmpCharProperty.match(Pattern.java:4133) Exception in thread "main" java.lang.StackOverflowError at java.base/java.util.regex.Pattern$GroupTail.match(Pattern.java:5000)
Python では、同じ形の正規表現に10万回の繰り返しを渡しても止まらなかった。
$ python3 -c "import re;print(bool(re.fullmatch(r'[a-z]+(-[a-z]+)*', 'a'+'-a'*100000)))" True
原因
Java の正規表現(java.util.regex)は、( ) を * で繰り返す部分を、繰り返し1回ごとに再帰で調べる。繰り返しが数千回になると、スレッドのスタックの深さの上限を超える。
直し方
区切りの文字で先に分けてから、部分ごとに、( ) の繰り返しを含まない正規表現で確かめる。split の2つ目の引数に -1 を渡して、空の部分(「--」や先頭・最後の「-」)も残し、空なら合わないとする。
boolean ok = true;
for (String part : s.split("-", -1)) ok = ok && part.matches("[a-z]+");$ java Fix.java 5000 5000 回: true $ java Fix.java 100000 100000 回: true
java -Xss16m でスタックを大きくすると、5000回は通った。ただし20万回ではまた止まったので、上限が上がるだけで直しにはならない。
$ java -Xss16m Re.java 5000 5000 回: true $ java -Xss16m Re.java 200000 Exception in thread "main" java.lang.StackOverflowError
確かめたこと
- この Mac(macOS 26.6.2・arm64・OpenJDK 21.0.12)で、上の出力のとおり。止まり始めたのは2000回と3000回のあいだ
- 入門の第16回・第17回の Java と Kotlin を、この直し方に書き換えた。長い入力のケースを正解表に足し、この Mac と Linux のコンテナ(毎朝の検証と同じもの)の両方で、10言語とも全件が正解と一致した