ReDoS とは、正規表現が遅くなる仕組みとオートマトン

回 6-1 道 6 言語と処理系

ReDoS は、後戻り (バックトラック) する正規表現のエンジンに、合いそうで最後に合わない入力を渡し、照合の手間を爆発させてサービスを止める攻撃です。(a+)+$ に a を 16 個並べて最後に b を付けると、この頁の照合器は 131071 回字を見ますが、同じ字の並びを受け入れる状態の機械 (オートマトン) は 17 回で済みます。直し方は、同じ字を何通りにも分けられる書き方をやめること、原子グループや所有の量指定子、入力の長さの上限、後戻りしないエンジン RE2 です。

正規表現が状態の機械で動くことと、後戻り型のエンジンでだけ手間が爆発する理由を、照合の回数で切り分けられるようになります。

遅い正規表現で、サービスは止まるのか

止まります。正規表現 (字の並びの形を書く記法) の書き方しだいで、数十字の入力でも照合の時間が急に伸び、外から狙ってその入力を送られるとサーバーの CPU を使い切ります。これを ReDoS と呼びます。

ReDoS は Regular expression Denial of Service の略で、正規表現によるサービス妨害という意味です。脆弱性の分類 CWE では、CWE-1333「効率の悪い正規表現の計算量」として載っています。

道 6 では 4 回かけて小さな言語「ちび電卓」を作ります。この回では、式を「数」「名前」「記号」に切る決まりを正規表現で書き、それが状態の機械で動くことと、書き方しだいで後戻りが爆発する理由を、照合の回数で確かめます。

正規表現はどう動くのか

正規表現は、字を 1 つ読むたびに「今どの状態にいるか」を切り替える機械 (有限オートマトン) で表せます。ただし多くの言語のエンジンは、その機械をそのまま動かさず、分かれ道を 1 本ずつ試しては戻る「後戻り型」で動きます。遅くなるのは、この後戻り型のほうです。

ちび電卓の字句の決まり

ちび電卓の式 x + 12 * 3 は、次の 3 種類の字句 (トークン、意味を持つ最小の字の組) に切ります。

種類正規表現例
num (数)[0-9]+12、3
name (名前)[a-z]+x、total
op (記号)[-+*/()]+、*
正規表現がどこに当たるか

[0-9] は 0 から 9 のどれか 1 字、+ は「直前を 1 回以上くり返す」で、[0-9]+ は数字が 1 つ以上並んだものです。

状態の機械 (有限オートマトン)

[0-9]+ は、2 つの状態と矢印で書けます。始めの状態 S で数字を読むと NUM へ進み、NUM で数字を読むと NUM にとどまり、読み終えたとき NUM にいれば合格です。それ以外の字を読んだら、どこへも行けない DEAD に落ちます。

この機械は、1 字につき矢印を 1 本たどるだけです。入力が n 字なら、たどるのは多くても n 回で、戻ることはありません。1968 年に Ken Thompson が、正規表現をこうした機械に直し、入力の長さに比例する時間で照合するやり方を論文に書いています。

後戻り型のエンジン

Python の re、PCRE、JavaScript の V8 など、多くのエンジンは後戻り型です。+ や * で「何字取るか」の分かれ道に来ると、まず取れるだけ取り、あとが合わなければ 1 字返して試し直します。

後戻り型は、後方参照 (前に合った字をもう一度探す \1) のような、状態の機械では書けない機能も扱えます。その代わり、分かれ道の多い式では試す数が爆発します。

動き方1 字あたり最悪の手間代表
状態の機械矢印を 1 本たどる入力の長さに比例RE2、Rust の regex
後戻り型分かれ道を順に試す指数の形もあり得るPython の re、PCRE

動かして確かめる

1 本目は、ちび電卓の字句の決まりを 1 つの正規表現にまとめ、式を (種類, 値) の組に切ります。(?P<num>...) は、合った部分に num という名前を付ける書き方で、m.lastgroup でどの名前に合ったかが分かります。

動かす ちび電卓の字句を切る

python実行可能
出力

▶ 実行ボタンを押すと結果がここに表示されます

実行すると、次の通りに出ます

('name', 'x')
('op', '+')
('num', 12)
('op', '*')
('num', 3)
('end', None)
止まった位置 2

12 は 2 字で 1 つの num になり、値は整数の 12 です。空白は skip として捨て、最後に終わりの印 ("end", None) を足しました。決まりに無い # は、位置 2 (0 から数える) で止まります。

2 本目は、数と名前の決まりを、正規表現を使わずに状態の機械として書きます。表 MOVE が矢印で、(今の状態, 字の種類) から次の状態を引きます。

動かす 数と名前を読む状態の機械

python実行可能
出力

▶ 実行ボタンを押すと結果がここに表示されます

実行すると、次の通りに出ます

123 num
   S→NUM→NUM→NUM
abc name
   S→NAME→NAME→NAME
12a ×
   S→NUM→NUM→DEAD

123 は NUM にとどまって num に合格し、12a は数字の途中の英字で DEAD に落ちました。たどった矢印は、どれも字の数を超えません。

後戻りは、なぜ爆発するのか

(a+)+$ のように、くり返しの中にくり返しがあると、同じ字の並びを何通りにも分けられます。a が 4 つでも「4」「3 と 1」「2 と 2」「1 と 1 と 2」のように分け方がいくつもあり、最後の b で合わないと分かるまで、後戻り型のエンジンはその分け方を全部試します。

次のコードは、(a+)+$ を後戻り型で試す小さな照合器です。re と同じく先頭から合わせ、字を 1 つ見るたびに steps を 1 足します。出力は左から、a の数、合うか、見た字の数、2 の n+1 乗 − 1 の値です。

動かす 後戻りの回数を数える

python実行可能
出力

▶ 実行ボタンを押すと結果がここに表示されます

実行すると、次の通りに出ます

4 False 31 31
8 False 511 511
12 False 8191 8191
13 False 16383 16383
16 False 131071 131071
合う (True, 16)

a が 4 つで 31 回、16 個で 131071 回で、どれも 2 の n+1 乗 − 1 と同じでした。12 個から 13 個へ 1 字増やすと 8191 回から 16383 回へ、ほぼ 2 倍です。a だけの 16 字は 16 回で合い、遅いのは合わない入力だけです。

同じ並びを状態の機械で読みます。この入力では、(a+)+$ が受け入れるのは「a が 1 つ以上並んだもの」なので、矢印は S から A、A から A の 2 本で足ります。

動かす 同じ入力を状態の機械で読む

python実行可能
出力

▶ 実行ボタンを押すと結果がここに表示されます

実行すると、次の通りに出ます

4 False 5
8 False 9
12 False 13
13 False 14
16 False 17
合う (True, 16)

状態の機械は、a が 16 個と b を 17 回で読み切り、字が 1 つ増えても回数は 1 増えるだけです。同じ「合わない」という答えに、後戻り型は 131071 回、状態の機械は 17 回かかりました。爆発の原因は正規表現という考え方ではなく、分かれ道を 1 本ずつ試す動き方にあります。

本物の re でも同じことが起きます。次は手元の Python 3.14 で測った秒数で、頁の中では動かしません (ブラウザが固まるため)。

手元の Python 3.14 で測った秒数

re.match(r"(a+)+$", "a"*n + "b")
n=18  0.008 秒
n=20  0.035 秒
n=22  0.215 秒
n=24  0.742 秒

a が 2 つ増えるごとに時間が数倍に伸びました。yamory のブログも、^(([a-zA-Z0-9])+)+$ で 1 字増えるごとに約 2 倍の時間がかかったと書いています。

危ない形は、次の 2 つにまとめられます。

  • くり返しの入れ子 (a+)+、(a+)*、(x\w{1,10})+y。内と外のどちらで字を取るかを選べる
  • 重なる選択肢のくり返し (.|\s)*。空白はどちらの枝でも取れる

直し方

直し方の第一は、同じ字を何通りにも分けられない式に書き直すことです。(a+)+$ が言いたいのは「a が 1 つ以上で終わる」なので、a+$ で足ります。

書き直せないときは、後戻りを禁じる書き方を使います。原子グループ (?>...) は、いったん抜けたら中へ戻らない組で、所有の量指定子 a++ は、取った字を返さない + です。Python の re では、どちらも 3.11 から使えます。

動かす 直した式を確かめる

python実行可能
出力

▶ 実行ボタンを押すと結果がここに表示されます

実行すると、次の通りに出ます

aaaa oooo
aaab xxxx
b xxxx
a+$ None
(?>a+)+$ None
(a++)+$ None

短い入力では、元の式と直した 3 つの式の答えがそろいました。直した式は、a が 30 個と b でもすぐに None (合わない) を返します。元の式には、頁が固まるのでこの入力を渡していません。

式の外でも守ります。

守り効くこと限り
入力の長さの上限爆発の上限を抑える指数の形では短くても重い。軽減にとどまる
先頭を ^ で縛る探し始める位置を 1 つにする入れ子のくり返しは直らない
後戻りしないエンジン (RE2)入力の長さに比例する時間を約束する後方参照と先読みが使えない
式を書き直す爆発の元を消す式ごとに人が見る必要がある

RE2 は、信頼できない利用者の式でも危なくないことを目標に作られ、照合の時間が入力の長さに比例することを約束しています。そのために、後戻りでしか作れない後方参照などの機能を持ちません。利用者が式そのものを渡せるサービスでは、式の書き直しを頼めないので、こうした後戻りしないエンジンを選ぶのが根本の守りです。

^ と $ で全体を縛る

正規表現は仕事でどう確かめるのか

仕事では、外から届く字に当てる正規表現を探し、合わない長い入力で時間を見る試験を足します。

  1. 外から届く字 (フォーム、URL、ヘッダー、ログ) に当てる式を洗い出す
  2. くり返しの入れ子と、重なる選択肢のくり返しが無いかを見る
  3. 「合いそうで最後だけ合わない」長い入力 (a を数十字と b など) で試験を足し、時間の上限を置く
  4. 入力の長さに上限を置き、式に渡す前に切る
  5. 使うライブラリの ReDoS の告知を追う。yamory のブログは、Lodash の CVE-2019-1010266 を例に挙げています

safe-regex や ReDoS checker のような道具で、式を機械で調べることもできます。ただし yamory のブログが書く通り、道具の確かめは簡易な物で、全部の危ない式を見つけられるわけではありません。生成 AI が書いた式も、そのまま使わずに同じ試験を通します。

次の 6-2 では、この頁の正規表現で式を字句に切り、木に組み立てて計算します。

関わる実例

1 本の正規表現が配信網の CPU を使い切った (2019年7月2日)

2019年7月2日、Cloudflare は守りの規則 (WAF、Web の攻撃を止める仕組み) に新しい規則を出しました。その規則の正規表現が後戻りを大量に起こし、世界中の HTTP と HTTPS を受け持つ CPU を使い切りました。利用者には 502 の誤りの頁が出て、サービスは 27 分止まりました。

事後報告によると、問題の芯は .*(?:.*=.*) で、.*.*=.* と同じ形です。「何でも」を表す .* が 2 つ並ぶと、どちらがどの字を取るかを全部試します。規則は 13 時 42 分に自動で出され、14 時 07 分に WAF を全体で止めて、14 時 09 分に CPU が戻りました。

報告は、数週間前の作り直しで CPU の使いすぎを防ぐ守りが誤って外されていたことも原因に挙げ、その守りを戻すこと、3,868 個の規則を見直すこと、実行の時間を約束する re2 か Rust の regex エンジンへ移ることを直す手にしました。

崩れた前提は「規則の試験が通れば、本番で重くならない」です。試験は合う・合わないを見ていましたが、CPU の使いすぎを見る手立てがありませんでした。

出典は Details of the Cloudflare outage on July 2, 2019 (Cloudflare Blog) です。

空白 2 万字の投稿で Stack Overflow が止まった (2016年7月20日)

2016年7月20日、Stack Overflow は 34 分止まりました。行の頭と終わりの空白を削る正規表現 ^[\s\u200c]+|[\s\u200c]+$ に、約 20,000 字の空白が続く投稿が当たり、Web サーバーの CPU を使いました。投稿がトップ頁に載って頁が遅くなり、ロードバランサーの死活の確かめがトップ頁を見ていたため、サイト全体が外されました。

事後報告は、これは指数の形の爆発ではなく、長さの 2 乗の形 (O(n²)) だったと書いています。空白の並びの途中の位置ごとに \s+$ を試し直すためです。正規表現は、文字列の関数に置き換えられました。

崩れた前提は「行の終わりの空白を探すのは軽い」です。出典は Outage Postmortem - July 20, 2016 (Stack Exchange、Internet Archive の保存版) です。元の stackstatus の住所は、2026年10月27日には開けませんでした。

よくある問い

合う入力でも ReDoS は起きますか

この頁の (a+)+$ では起きません。a を 16 個渡すと、照合器は 16 回で「合う」と答えました。爆発するのは、合いそうで最後に合わない入力で、全部の分け方を試し終えるまで「合わない」と言えないからです。Node.js の公式の案内も、危ない式でも合うと答えるのは速く、合わないときに指数の形の手間が出ると書いています。

Node.js では ReDoS がなぜ特に問題になるのですか

Node.js は JavaScript を 1 本のイベントループ (順番に仕事を回す流れ) で動かすので、重い照合が 1 つ走ると、ほかの利用者の要求も待たされるからです。公式の案内は、入れ子のくり返し (a+)* や後方参照を避けること、RE2 を使う node-re2 に替える手を挙げています。

RE2 にすれば正規表現は全部そのまま動きますか

動かない式があります。RE2 は後戻りでしか作れない機能を持たないので、後方参照、先読み、原子グループ、所有の量指定子が使えません。Node.js の案内も、RE2 は V8 の正規表現と 100% 同じではないので、入れ替えたら試験で確かめるよう書いています。

確認クイズ

答えだと思う選択肢を押すと、その場で正誤と理由が開きます。4 問すべてに正しく答えると、この回は修了で、石が緑に変わります (この端末のブラウザに残ります)。間違えた問いは、あとから解き直せます。

1 問目

後戻り型のエンジンで (a+)+$ が遅くなるのは、どんな入力のときですか。

2 問目

このコードを動かすと何が出ますか。

ちび電卓の字句を正規表現で拾う

import re
p = r"[0-9]+|[a-z]+|[-+*/]"
print(re.findall(p, "x+12*y3"))

3 問目

状態の機械 (有限オートマトン) で照合するとき、入力が n 字なら、たどる矢印の数はどうなりますか。

4 問目

(a+)+$ を直す書き方として、Python の re で正しい物はどれですか。

説明課題

遅くなる正規表現を 1 つ書き、なぜ遅くなるのかを、「分け方」「後戻り」「状態の機械」という言葉を使って 3〜4 文で説明してください。直し方も 1 つ添えます。書いたら、下の模範解答を開いて比べてください。

模範解答

自分の説明を書いてから開いて、比べてください。

この回の先の一歩

入力の確かめ、ログの検索、守りの規則など、正規表現は外から届く字を最初に受け止める所に置かれます。後戻りが爆発する形を見分けられると、その入り口で止まるサービスを防げます。

一次資料

どれも 2026年10月27日に開いて、題と中身を確かめました。

次へ

道 6 言語と処理系の回

  1. 6-1正規表現とオートマトン後戻りが増える式この回
  2. 6-2字句解析と構文解析電卓の式を木にする頁で読む
  3. 6-3小さなインタプリタ変数と if を持つ言語頁で読む
  4. 6-4型と型検査型が間違いを先に止める頁で読む

道 6 の頁へ戻るほかの道を見る