はじめに
こんにちは、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 の内容は別の問題かと考えました。しかし、
- 修正と同時に追加されたテストで
>
が大量に含まれている場合に計算量が線形であることをチェックしていること - CVE-2024-35176 を報告した方のフォーラムの投稿では
>
と記載されていること
から、単なる 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 の文字列マッチも利用することで更なる高速化を図っています。
プロファイリング
今回取り上げたリリースごとにベンチマークおよびプロファイリングを実施し、変更がどのように影響しているか調べました。
測定概要
対象リリース
リリース | 説明 |
---|---|
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
v3.2.7
v3.2.9
まとめ
問題となった脆弱性は、REXML が
>
を区切り文字として特別に扱っているため、属性の値として用いられた場合にその箇所を繰り返し読んでしまうことが原因で発生。v3.2.7 では、属性値を表す引用符が閉じられるまで一括で読むような変更が加えられたものの、それにより属性ごとに 1 つの正規表現オブジェクトが生成されてしまい、メモリ効率の悪化につながっていた。
v3.2.9 では、属性値の読み出しに用いられる正規表現を定数化しておくことでオブジェクトの生成および GC の実行時間にかかる時間を削減し、全体的な処理速度の向上に成功。
感想
RubyKaigi をはじめとするカンファレンスでは、パフォーマンスの向上や新機能といった華々しい成果に焦点が当たりがちです。
しかし、その陰で不具合や脆弱性修正を日夜行ってくださっている仕事の数々に、自分の日々の業務や活動が支えられています。
Pull requests や Issue を追いかける中でこれを改めて実感し、自分もコミュニティに対して還元したいという思いをより強くしました。
また、CVE の記載内容に誤りがあるなんて想像もしていなかったので、その点でもいい経験になりました。
最後になりますが、発表者の NAITOH Jun 様、REXML のメンテナの皆様、RubyKaigi 運営の皆様にこの場を借りて感謝を申し上げます。
Techouse では、社会課題の解決に一緒に取り組むエンジニアを募集しております。 ご応募お待ちしております。