#7 Ruby 3.2.0 のReDoSに対する正規表現の改善を試してみる

Ruby 3.2.0 リリース

昨年末12月25日にRubyの3.2.0がリリースされました。 今回の主な改善はWebAssemblyサポートと正規表現の改善でしたが本記事では正規表現に関してどのような改善が行われたかまとめてみました。 リリースノートによると改善内容は

の二つになりますが、どちらもReDoSに対する対策になります。

ReDoSとは

そもそもReDoSとは、何だぁ~~?ということですが、 Regular expression Denial of Serviceの省略で、こちらのページによると

The Regular expression Denial of Service (ReDoS) is a Denial of Service attack, that exploits the fact that most Regular Expression implementations may reach extreme situations that cause them to work very slowly

とあり、極端な文字列のマッチングで計算リーソースが過剰に消費されることを悪用した攻撃のことを指します。

Regexpのマッチングアルゴリズムの改善

起票されたチケットはこちら。 内容を深堀するとオートマトンなどを説明しないといけないのですがここでは割愛(というか説明できない)。 具体的にどのように動きが変わったか実際のコードで見ていきます。 実行コードはこちらのQiitaの記事を参考にしています。 ReDoSの説明もあるのでもし興味があれば読んでみてください。

以下、実行時の引数のマッチングをテストするスクリプトで実行時間を見ていきます。

> cat regex01.rb 
re = /^(([a-zA-Z0-9])+)+$/

str = ARGV[0]
puts "INPUT: #{str}"

start_time = Time.now

str.match(re) { |match| puts "MATCHED" if match }
puts "RUNNING TIME: #{Time.now - start_time}[s]"

Rubyのバージョンが3.1.3の場合

Rubyのバージョンを確認。

> ruby -v
ruby 3.1.3p185 (2022-11-24 revision 1a6b16756e) [arm64-darwin21]

マッチする文字列をテストする。 実行時間は約4秒。

> ruby regex01.rb abcdef                           
INPUT: abcdef
MATCHED
RUNNING TIME: 4.0e-06[s]

次にマッチングしない文字列をテストする。 実行時間は約93秒。 このような文字列が極端に長くなるとリソースを消耗してしまう。

> ruby regex01.rb AbCdEfGh1JkLmN0pQR5TuAbCdEfGh1Jk@
INPUT: AbCdEfGh1JkLmN0pQR5TuAbCdEfGh1Jk@
RUNNING TIME: 93.541195[s]

Rubyのバージョンが3.2.0の場合

次に3.2.0で同じパターンを試す。

> ruby -v
ruby 3.2.0 (2022-12-25 revision a528908271) [arm64-darwin21]
> ruby regex01.rb abcdef  
INPUT: abcdef
MATCHED
RUNNING TIME: 4.0e-06[s]
> ruby regex01.rb AbCdEfGh1JkLmN0pQR5TuAbCdEfGh1Jk@
INPUT: AbCdEfGh1JkLmN0pQR5TuAbCdEfGh1Jk@
RUNNING TIME: 5.0e-06[s]

同じ文字列でもパターンの解析時間が大幅に短縮している。 このように実行時間の短縮=リソースの消耗を防ぐことでReDoS攻撃の対策を行っている。

Regexpタイムアウトの導入

次に正規表現タイムアウトに関して確認する。 起票されたチケットはこちら

先ずはタイムアウト無しで検証

上記スクリプトを改良してマッチする文字列をテストする。

> cat regex02.rb       
TIMES=50000000.freeze # Integer

re = /^(([a-zA-Z0-9])+)+$/

str = ARGV[0]  # Command line argument
puts "INPUT: #{str} * #{TIMES}"

start_time = Time.now

target = str * TIMES

begin
  target.match(re) { |match| puts "MATCHED" if match }
ensure
  puts "RUNNING TIME: #{Time.now - start_time}[s]"
end

実行時間は約4秒。

projects/ruby-test  > ruby regex02.rb abcdef
INPUT: abcdef * 50000000
MATCHED
RUNNING TIME: 4.065616[s]

Regexp.timeout を設定して検証。

> cat regex02.rb        
TIMES=50000000.freeze # Integer
TIMEOUT=1.0 # Float

Regexp.timeout = TIMEOUT

re = /^(([a-zA-Z0-9])+)+$/

str = ARGV[0]  # Command line argument
puts "INPUT: #{str} * #{TIMES}"

start_time = Time.now
target = str * TIMES

begin
  target.match(re) { |match| puts "MATCHED" if match }
ensure
  puts "RUNNING TIME: #{Time.now - start_time}[s]"
end

処理時間約1秒で Regexp::TimeoutError が発生する。

projects/ruby-test  > ruby regex02.rb abcdef
INPUT: abcdef * 50000000
RUNNING TIME: 1.023424[s]
regex02.rb:17:in `match': regexp match timeout (Regexp::TimeoutError)
    from regex02.rb:17:in `match'
    from regex02.rb:17:in `<main>'

正規表現オブジェクトに個別でタイムアウトを設定して検証。

> cat regex02.rb 
TIMES=50000000.freeze # Integer
TIMEOUT=1.0 # Float

Regexp.timeout = TIMEOUT

re = Regexp.new('^(([a-zA-Z0-9])+)+$', timeout: 2.0)

str = ARGV[0]  # Command line argument
puts "INPUT: #{str} * #{TIMES}"

start_time = Time.now

target = str * TIMES

begin
  target.match(re) { |match| puts "MATCHED" if match }
ensure
  puts "RUNNING TIME: #{Time.now - start_time}[s]"
end

全体のタイムアウトは1秒で設定されているが、 処理時間約2秒で Regexp::TimeoutError が発生する。

projects/ruby-test  > ruby regex02.rb abcdef
INPUT: abcdef * 50000000
RUNNING TIME: 2.023538[s]
regex02.rb:17:in `match': regexp match timeout (Regexp::TimeoutError)
    from regex02.rb:17:in `match'
    from regex02.rb:17:in `<main>'