Techouse Developers Blog

テックハウス開発者ブログ|マルチプロダクト型スタートアップ|エンジニアによる技術情報を発信|SaaS、求人プラットフォーム、DX推進

RubyKaigi 2025 - Improvement of REXML and speed up using StringScanner (Day2)

ogp

はじめに

こんにちは、Techouse に新卒で入社し、クラウドハウスに所属している Higashiji です。

本記事では、RubyKaigi 2025 2 日目の NAITOH Jun さんの講演 Improvement of REXML and speed up using StringScanner の中で触れられていた、REXML が v3.2.7 で遅くなった件について、その原因と解決までの流れを調査した結果をご紹介します。

個人のメモのような内容になってしまいますが、同じ疑問を抱いた人の理解の一助になれば幸いです。

想定読者

RubyKaigi 2025 でテーマとなった講演を聞いたり、資料を読んで、同じ疑問を抱いた方。

講演の概要

講演では、v3.2.7 での脆弱性対応 (CVE-2024-35176) に伴う一時的なパフォーマンス低下の後、XML パースのパフォーマンスを以前と同等以上に改善するまでの道のりについて紹介されました。

講演資料には、これらの改善内容について具体的なコードやベンチマークの比較を交えて詳しく説明されています。

講演資料: Improvement of REXML and speed up using StringScanner

v3.2.7 のパフォーマンス悪化

講演の終了後、v3.2.7 でパフォーマンスが悪化したという点に興味を惹かれたため、内藤さんに質問させていただきました。
すると、「StringScanner は使っているものの、脆弱性への対応部分が原因で遅くなっていた」と教えていただきました。

StringScanner を使っているのにもかかわらず遅くなる理由が気になったため、調べてみます。

問題の脆弱性

問題の脆弱性は、CVE-2024-35176 として公開されています。

この脆弱性が問題となるケースは以下のように、属性の値として大量の > が含まれる XML をパースしようとした時です。

<foo bar=">>>>>>>>>>...>>>>>>>>>>>" />

REXML は、XML のタグの終了を示す > を基本的な行の区切りとして処理を行っています。

ただし、XML 1.0 では、> は The right angle bracket (>) may be represented using the string '>'" とあるように、実体参照ではなくリテラルとして属性値に設定してもよいとされています。
Extensible Markup Language (XML) 1.0 (Fifth Edition)

これに対応するため、REXML は属性値を読んでいる状態で > に遭遇すると、次の > までを検索範囲として、再度属性値を抜き出そうとします。 (ソースコード

そのため、上記のように > が連続した属性値のパースを行うためにはREXML::Parsers::BaseParser#parse_attributes メソッド内で以下のようにループを繰り返すことになります。

1回目の検索対象: bar=">
2回目の検索対象: bar=">>
3回目の検索対象: bar=">>>
4回目の検索対象: bar=">>>>
...

ループごとに正規表現を用いた scan が行われるため、属性値の > が増えるごとに scan の対象が指数関数的に増加し、処理に時間がかかるようになります。

修正内容

Read quoted attributes in chunks #126

CVE-2024-35176 はこのプルリクエストによって修正され、v3.2.7 でリリースされました。

修正内容としては、属性値の読み出し方を従来の方式から変更し、属性値の開始のクォートから、終端のクォートまで一気に読み出すようにしました。
これにより、属性値にどんな文字が含まれていても計算量は O(n) で済むようになります。

余談: CVE の間違いについて

なお、 CVE の Description には 「属性値に < が大量に出現する場合 ( when it parses an XML that has many <s in an attribute value ) に DoS が成立する脆弱性がある」と記載がありますが、
正しくは、上述の通り> が大量に出現する場合に発生する問題のようです。

私は当初、CVE-2024-35176 とこの PR の内容は別の問題かと考えました。しかし、

から、単なる Typo と考えて良さそうです。

CVE の記載に Typo があることは想定していませんでしたが、調べてみると脆弱性情報が誤っていることは時々あるようで、気をつけなければと思いました。
参考: Blog | NVD の脆弱性情報を活用する上で気をつけたいこと | yamory | 脆弱性管理クラウド | SBOM 対応

パフォーマンス劣化

上記の修正により、バグは修正されました。
しかし、引き換えにパフォーマンスが悪化してしまいます。

def read_until(term)
  pattern = Regexp.union(term)
  data = []
  begin
    until str = @scanner.scan_until(pattern)
      @scanner << readline(term)
    end
  rescue EOFError
    @scanner.rest
  else
    read if @scanner.eos? and !@source.eof?
    str
  end
end

問題は、属性値を読み出すために追加された read_until メソッドです。
ここで term"' の両方があり得るため動的に生成するコードになっています。
しかし、パース対象の XML に属性が登場するごとに RegExp オブジェクトが生成されることになり、メモリ効率が悪化してしまったのです。

Optimize Source#read_until method #135

このパフォーマンス問題を修正した PR がこちらで、v3.2.9 でリリースされました。

この改善では、属性値の読み出しの際に用いる RegExp(') , RegExp(") については事前に定数化しておくことで、毎回正規表現オブジェクトを生成しなくて済むようになっています。

なお、講演でも述べられている通り、執筆時点(2025/04/20)で最新の v3.4.1 では StringScanner の文字列マッチも利用することで更なる高速化を図っています。

https://github.com/ruby/rexml/blob/bfb37e9ca4cb974c9bb2dc2f06e1202719d1bc4d/lib/rexml/source.rb#L67-L81

プロファイリング

今回取り上げたリリースごとにベンチマークおよびプロファイリングを実施し、変更がどのように影響しているか調べました。

測定概要

対象リリース

リリース 説明
v3.2.6 StringScanner 導入前
v3.2.7 StringScanner 導入、脆弱性対応で read_until 導入
v3.2.9 正規表現の定数化実施

実行環境

Ruby : ruby 3.4.3 (2025-04-14 revision d0b7e5b6a0) +PRISM [arm64-darwin24] (YJIT 無効)
OS: macOS 15.1.1
CPU: Apple M3
Memory: 24GB

測定に利用したコード

$LOAD_PATH.unshift(File.expand_path("lib"))
require 'rexml/document'
require 'rexml/parsers/pullparser'
require 'stackprof'
require 'benchmark'

def build_xml(n_elements, n_attributes)
  xml = '<?xml version="1.0"?><root>'
  n_elements.times do |i|
    xml << '<child '
    n_attributes.times {|j| xml << "id#{j}=\"#{i}\" " }
    xml << '/>'
  end
  xml << '</root>'
end
xml = build_xml(5000, 2)

Benchmark.bm do |x|
  x.report("PullParser") do
    StackProf.run(mode: :cpu,raw: true, out: 'tmp/stackprof-pull.dump') do
      100.times do
        parser = REXML::Parsers::PullParser.new(xml)
        while parser.has_next?
          parser.pull
        end
      end
    end
  end
end

バージョンを切り替えつつ、2 つの属性を持つ 5000 要素をパースする処理を、100 回実行します。
パース処理のパフォーマンスのみを測定対象としたいため、Parser は、DOM を構築しない PullParser を採用しました。

測定結果

処理速度

講演資料にも示されている通り、v3.2.6, v3.2.7 の差はほとんどなく、v3.2.9 では大きく処理速度が向上していることがわかります。

バージョン user system total real
v3.2.6 2.716144 0.019482 2.735626 2.738030
v3.2.7 2.782422 0.019432 2.801854 2.802911
v3.2.9 2.344010 0.018151 2.362161 2.364245

プロファイラ

サンプルのうち GC の占める割合が 82.78% → 85.50% → 77.40% と変化しています。
割合の絶対値はパース対象の XML によって大きく変わるものの、v3.2.9 で大幅に省メモリ化を果たしたと言えそうです。

v3.2.6
==================================
  Mode: cpu(1000)
  Samples: 2419 (0.00% miss rate)
  GC: 2002 (82.76%)
==================================
     TOTAL    (pct)     SAMPLES    (pct)     FRAME
      1157  (47.8%)        1157  (47.8%)     (sweeping)
      2002  (82.8%)         840  (34.7%)     (garbage collection)
        86   (3.6%)          86   (3.6%)     StringScanner#scan
        65   (2.7%)          65   (2.7%)     Regexp#match
       235   (9.7%)          56   (2.3%)     REXML::Parsers::BaseParser#parse_attributes
       356  (14.7%)          48   (2.0%)     REXML::Parsers::BaseParser#pull_event
        54   (2.2%)          29   (1.2%)     Class#new
        84   (3.5%)          18   (0.7%)     REXML::IOSource#match
        15   (0.6%)          15   (0.6%)     Kernel#require
        15   (0.6%)          15   (0.6%)     StringScanner#[]
        19   (0.8%)          11   (0.5%)     REXML::IOSource#empty?
        20   (0.8%)           7   (0.3%)     REXML::Parsers::PullParser#has_next?
         7   (0.3%)           7   (0.3%)     REXML::Source#empty?
         7   (0.3%)           7   (0.3%)     MatchData#[]
       378  (15.6%)           6   (0.2%)     REXML::Parsers::PullParser#pull
         5   (0.2%)           5   (0.2%)     (marking)
       362  (15.0%)           5   (0.2%)     REXML::Parsers::BaseParser#pull
        14   (0.6%)           5   (0.2%)     REXML::IOSource#read
         5   (0.2%)           5   (0.2%)     StringScanner#initialize
         4   (0.2%)           4   (0.2%)     StringIO#gets
         4   (0.2%)           4   (0.2%)     String#[]
       417  (17.2%)           4   (0.2%)     block (4 levels) in <main>
         3   (0.1%)           3   (0.1%)     Hash#initialize
         7   (0.3%)           3   (0.1%)     IO::generic_readable#readline
        11   (0.5%)           2   (0.1%)     Set#initialize
        10   (0.4%)           2   (0.1%)     REXML::Parsers::BaseParser#empty?
         9   (0.4%)           2   (0.1%)     REXML::IOSource#readline
        13   (0.5%)           2   (0.1%)     REXML::Parsers::BaseParser#has_next?
         3   (0.1%)           1   (0.0%)     Kernel#tap
         1   (0.0%)           1   (0.0%)     Set#each
v3.2.7
==================================
  Mode: cpu(1000)
  Samples: 2573 (0.00% miss rate)
  GC: 2200 (85.50%)
==================================
     TOTAL    (pct)     SAMPLES    (pct)     FRAME
      1131  (44.0%)        1131  (44.0%)     (sweeping)
      2200  (85.5%)        1032  (40.1%)     (garbage collection)
        64   (2.5%)          64   (2.5%)     StringScanner#<<
        57   (2.2%)          57   (2.2%)     StringScanner#scan
       102   (4.0%)          43   (1.7%)     REXML::IOSource#match
        41   (1.6%)          41   (1.6%)     Regexp.union
        37   (1.4%)          37   (1.4%)     (marking)
       183   (7.1%)          33   (1.3%)     REXML::Parsers::BaseParser#parse_attributes
       322  (12.5%)          19   (0.7%)     REXML::Parsers::BaseParser#pull_event
        15   (0.6%)          15   (0.6%)     Kernel#require
        38   (1.5%)          15   (0.6%)     Class#new
        64   (2.5%)          12   (0.5%)     REXML::IOSource#read_until
        11   (0.4%)          11   (0.4%)     StringScanner#scan_until
        14   (0.5%)           8   (0.3%)     REXML::Parsers::PullParser#has_next?
         6   (0.2%)           6   (0.2%)     StringScanner#[]
       337  (13.1%)           6   (0.2%)     REXML::Parsers::PullParser#pull
         5   (0.2%)           5   (0.2%)     REXML::Source#empty?
         5   (0.2%)           5   (0.2%)     StringIO#gets
       373  (14.5%)           5   (0.2%)     block (4 levels) in <main>
        10   (0.4%)           4   (0.2%)     Set#initialize
       325  (12.6%)           3   (0.1%)     REXML::Parsers::BaseParser#pull
         8   (0.3%)           3   (0.1%)     REXML::IOSource#empty?
         7   (0.3%)           2   (0.1%)     IO::generic_readable#readline
         2   (0.1%)           2   (0.1%)     Kernel#block_given?
         2   (0.1%)           2   (0.1%)     Kernel#is_a?
         2   (0.1%)           2   (0.1%)     String#chomp!
         8   (0.3%)           1   (0.0%)     REXML::IOSource#readline
         1   (0.0%)           1   (0.0%)     REXML::Parsers::PullEvent#initialize
         6   (0.2%)           1   (0.0%)     REXML::Parsers::BaseParser#has_next?
         1   (0.0%)           1   (0.0%)     StringScanner#pos
v3.2.9
==================================
  Mode: cpu(1000)
  Samples: 2044 (0.00% miss rate)
  GC: 1582 (77.40%)
==================================
     TOTAL    (pct)     SAMPLES    (pct)     FRAME
      1582  (77.4%)         872  (42.7%)     (garbage collection)
       644  (31.5%)         644  (31.5%)     (sweeping)
        81   (4.0%)          81   (4.0%)     StringScanner#<<
        66   (3.2%)          66   (3.2%)     (marking)
        65   (3.2%)          65   (3.2%)     StringScanner#scan
       126   (6.2%)          60   (2.9%)     REXML::IOSource#match
       196   (9.6%)          55   (2.7%)     REXML::Parsers::BaseParser#parse_attributes
       397  (19.4%)          26   (1.3%)     REXML::Parsers::BaseParser#pull_event
        59   (2.9%)          21   (1.0%)     Class#new
        19   (0.9%)          19   (0.9%)     StringScanner#[]
        16   (0.8%)          16   (0.8%)     Kernel#require
        30   (1.5%)          16   (0.8%)     REXML::IOSource#read_until
        19   (0.9%)          13   (0.6%)     REXML::IOSource#empty?
        24   (1.2%)          10   (0.5%)     Set#initialize
       462  (22.6%)           8   (0.4%)     block (4 levels) in <main>
         8   (0.4%)           8   (0.4%)     REXML::Parsers::PullEvent#initialize
         7   (0.3%)           7   (0.3%)     String#encode
       419  (20.5%)           7   (0.3%)     REXML::Parsers::PullParser#pull
         6   (0.3%)           6   (0.3%)     StringScanner#scan_until
        18   (0.9%)           5   (0.2%)     REXML::Parsers::PullParser#has_next?
         8   (0.4%)           5   (0.2%)     REXML::IOSource#readline
        96   (4.7%)           5   (0.2%)     REXML::IOSource#ensure_buffer
         5   (0.2%)           4   (0.2%)     REXML::Source#empty?
       402  (19.7%)           4   (0.2%)     REXML::Parsers::BaseParser#pull
         3   (0.1%)           3   (0.1%)     StringIO#gets
         3   (0.1%)           3   (0.1%)     Hash#initialize
        91   (4.5%)           2   (0.1%)     REXML::IOSource#read
         3   (0.1%)           2   (0.1%)     REXML::Source#position
         2   (0.1%)           2   (0.1%)     Set#each
         1   (0.0%)           1   (0.0%)     Kernel#is_a?

フレームグラフ

最後に、stackprof --d3-flamegraph を用いて、プロファイラの出力を可視化してみます。
ここでは GC にかかった時間は省いています。

v3.2.6 と v3.2.7 では内部実装が大きく変わったため単純な比較が難しいです。
一方、3.2.7 から v3.2.9 にかけての変化は比較的わかりやすく、正規表現オブジェクトを生成する RegExp.union の占めている CPU 時間がすっかり消えていることから、最適化が狙い通りに働いていることがわかります。

v3.2.6

REXML v3.2.6 におけるプロファイラの出力結果について、メソッドごとのCPU時間を可視化した図。

v3.2.7

REXML v3.2.7 におけるプロファイラの出力結果について、メソッドごとのCPU時間を可視化した図。RegExp.union が1/5程度を占めている。

v3.2.9

REXML v3.2.9 におけるプロファイラの出力結果について、メソッドごとのCPU時間を可視化した図。v3.2.7 において RegExp.union が占めていた領域が消えている。

まとめ

  • 問題となった脆弱性は、REXML が > を区切り文字として特別に扱っているため、属性の値として用いられた場合にその箇所を繰り返し読んでしまうことが原因で発生。

  • v3.2.7 では、属性値を表す引用符が閉じられるまで一括で読むような変更が加えられたものの、それにより属性ごとに 1 つの正規表現オブジェクトが生成されてしまい、メモリ効率の悪化につながっていた。

  • v3.2.9 では、属性値の読み出しに用いられる正規表現を定数化しておくことでオブジェクトの生成および GC の実行時間にかかる時間を削減し、全体的な処理速度の向上に成功。

感想

RubyKaigi をはじめとするカンファレンスでは、パフォーマンスの向上や新機能といった華々しい成果に焦点が当たりがちです。
しかし、その陰で不具合や脆弱性修正を日夜行ってくださっている仕事の数々に、自分の日々の業務や活動が支えられています。
Pull requests や Issue を追いかける中でこれを改めて実感し、自分もコミュニティに対して還元したいという思いをより強くしました。

また、CVE の記載内容に誤りがあるなんて想像もしていなかったので、その点でもいい経験になりました。

最後になりますが、発表者の NAITOH Jun 様、REXML のメンテナの皆様、RubyKaigi 運営の皆様にこの場を借りて感謝を申し上げます。

Techouse では、社会課題の解決に一緒に取り組むエンジニアを募集しております。 ご応募お待ちしております。

jp.techouse.com