pockestrap

Programmer's memo

OpenStructに信頼できない値を渡してはいけない

新しいOpenStructに信頼できない値を渡すと、GCされないシンボルが作成されメモリが使いつくされる可能性があります。

対象のバージョン

対象となるostruct gemのバージョンは、0.3.0かそれ以上です。

Ruby 3.0にはostructのバージョン0.3.1が添付されているため、この対象となります。

Ruby 2.7とそれ以前のRubyのバージョンでは、これよりも古いバージョンのostructが添付されているためデフォルトでは対象になりません。 しかし、Ruby 2.7でもgem install ostructしてバージョン0.3.1をインストールでき、その場合は対象となります。

Problem

ostruct gem v0.3.0以上では、OpenStruct.newに渡したHashのキーに対応するメソッドを、OpenStruct#initializeが呼ばれたタイミングで定義するようになりました。

これはこのHashのキーのシンボルがGCされなくなることを意味します。 なぜならば、Rubyではメソッド名として使われたシンボルはGCされないためです。 この問題については https://fiveteesixone.lackland.io/2015/01/21/symbol-gc-ruby-2-2/ などが詳しいでしょう。

これは次のようなプログラムで確かめることができます。

# test.rb

require 'ostruct'
require 'objspace'

puts RUBY_DESCRIPTION
puts OpenStruct::VERSION

10000.times do |i|
  OpenStruct.new(:"#{'x' * 10000}_#{i}" => i)
end

GC.start

puts "#{ObjectSpace.memsize_of_all * 0.001 * 0.001} MB"

このプログラムではそれぞれ異なる10001文字のキーを持つハッシュを10000回作成し、それをOpenStruct.newに渡しています。 その後GC.startGCを実行した後に、Rubyプロセス全体のメモリ使用量を計測しています。1

まずはこれをRuby 2.7.2と、それに標準で添付されているostruct gemで検証します。この場合のメモリ使用量は7MBほどでした。

$ ruby test.rb
ruby 2.7.2p137 (2020-10-01 revision 5445e04352) [x86_64-linux]
0.2.0
7.028303 MB

次に、このコードをRuby 3.0.0と、それに標準で添付されているostruct gemで検証します。するとメモリ使用量は204MBまで増加しました。2

$ ruby test.rb
ruby 3.0.0dev (2020-12-22T00:22:38Z master 843fd1e8cf) [x86_64-linux]
0.3.1
204.02889000000002 MB

Ruby 2.7.2でgem install ostructした場合にも、同様の結果が得られます。

$ gem install ostruct
Fetching ostruct-0.3.1.gem
Successfully installed ostruct-0.3.1

$ ruby test.rb
ruby 2.7.2p137 (2020-10-01 revision 5445e04352) [x86_64-linux]
0.3.1
203.90093900000002 MB

これは先にも説明したとおり、大量に作成されたシンボルがGCの対象ならず、GC.startの後も残り続けているのが原因です。

この問題はostructソースコード中のコメントにも明記されています。

This is a potential security issue; building OpenStruct from untrusted user data (e.g. JSON web request) may be susceptible to a "symbol denial of service" attack since the keys create methods and names of methods are never garbage collected.

https://github.com/ruby/ostruct/blob/v0.3.1/lib/ostruct.rb#L79-L81

なぜメソッドが定義されるようになったのか

これはOpenStructの挙動の変更が原因です。

Ruby 2.7.2までのOpenStructの実装では、OpenStruct#initialize時にメソッドを定義するのではなく、method_missingを通して実際にそのメソッドが呼ばれたときに初めてメソッドを定義していました。 メソッドの定義が呼び出されるまで遅延されていれば、OpenStruct.newがどんな入力を受け取っていてもそれを呼び出さない限りGCできないシンボルは生まれません。

ところが、この実装では問題がありました。 https://bugs.ruby-lang.org/issues/15409 詳しくチケットを見ていないのですが、Kernelに生えているのと同名のキーを持つHashをOpenStructに渡した場合、Kernelに生えているメソッドが優先されてしまっていました。

そのため、OpenStruct#initialize時にKernelで定義されたメソッドを上書きして定義する必要がありました。

この変更は https://github.com/ruby/ostruct/pull/15 で行われています。

回避策

まず、Ruby 3未満を使っていて、ostruct gemをインストールしていない場合、影響はありません。 ただしRuby 3にアップグレードすることを考えると、対応を考えたほうが良いでしょう。

また、OpenStructが固定のキーしか受け付けない場合にも影響はありません。

該当のバージョンのostruct gemを使っていて、かつOpenStructがユーザー入力を直接受け付けるような場合には対応が必要です。

まず、ostruct gemのバージョンを古いものに固定するとこの問題は解決します。 Gemfilegem 'ostruct', '< 0.3'のように書くと良いでしょう。 ostruct gemはバージョン0.1.0がリリースされているため、この場合にはバージョン0.1.0がインストールされます。

また、OpenStructをそもそも使わないことを検討しても良いでしょう。移行先にはただのHashやStructなどが考えられます。

OpenStructには今回紹介した以外にもパフォーマンスの問題などがあり、ドキュメントでも別の手段を使うことを検討するよう書かれています。

(略) For all these reasons, consider not using OpenStruct at all.

https://github.com/ruby/ostruct/blob/v0.3.1/lib/ostruct.rb#L107

まとめ

Ruby 3.0.0のOpenStructの変更にセキュリティ上気になる点があったので記事にしました。

今まで信頼できない値を渡しても安全だったOpenStructがそうでなくなったのは気をつける必要があると思います。 その反面この情報があまり知られていない(私は今日まで知りませんでした)と感じたため、今回記事にしました。

この問題に気がついたのは、Junichi Itoさんが書かれたRuby 3の変更点を紹介する記事を読んでいる途中でした。 https://zenn.dev/jnchito/articles/24e0bd7fd1045d#%E6%A8%99%E6%BA%96%E3%83%A9%E3%82%A4%E3%83%96%E3%83%A9%E3%83%AA%E3%81%AE%E4%B8%BB%E3%81%AA%E5%A4%89%E6%9B%B4%E7%82%B9 分かりやすい記事をありがとうございます。

また、当初この問題がostruct gemのドキュメントに明記されている(問題として認識され広く公開されている)ことに気が付かず、セキュリティ上の問題としてHackerOneから報告をしてしまいました。 Ruby 3のリリース直前という忙しい時期にお手数をおかけしました…。

この記事によって問題を未然に防ぐ手助けができましたら望外の喜びです。


  1. メモリ使用量の計測は https://blog.freedom-man.com/measure-ruby-memory-usage のコードを参考にしました。

  2. Ruby 3.0.0.rc1でテストするのが筋ですが、手元にインストールされていなかったのでmasterブランチをビルドしたものを使いました。