前回の記事は割と濃い味付けでしたが、今回は薄味です。 脆弱性自体は簡単なやつなのですが、調べている過程でRuby 3.1からYAMLのパースが安全になったことを知ったのでその共有がてら書きました。最近はあまりRubyを触る機会がなかったのでリハビリを兼ねて触っているところもあり、間違いがあれば教えて下さい。
要約
Rubyの YAML.load
(正確には Psych.load
)をユーザ入力など信頼できない値に対して実行するのは危険でした。
Do not use YAML to load untrusted data. Doing so is unsafe and could allow malicious input to execute arbitrary code inside your application. Please see doc/security.rdoc for more information.
YAMLのタグとして !ruby/object:Foo
のように指定されたクラス等を復元するため、いわゆる「安全でないデシリアライゼージョン」になり、結果として任意コード実行に繋がる可能性があります。これを防ぐために YAML.safe_load
が提供されていたのですが、Ruby 3.1からは YAML.load
のデフォルトが YAML.safe_load
に変更されました。つまりデフォルトで安全になりました。 safe_load
では任意オブジェクトの読み込みが無効化され、デフォルトで許可された複数のクラスと明示的に指定されたクラスのみが復元可能になります。
今後はRuby 3.1を使って安全にYAMLを読み込んでいきましょう。
背景
先日、Ruby on Rails(Active Record)の脆弱性(CVE-2022-32224)が公開されました。
Active RecordではDBのカラムに serialize
属性が指定できるらしく、これはDBにシリアライズしたオブジェクトを保存可能にするものですが、保存された値をデシリアライズする時に YAML.unsafe_load
が使われているので危険という脆弱性でした。
なぜオブジェクトのデシリアライズが危険かというのは以前もブログに書いたので省略します。Ruby固有の話ではなくJavaやPHPだったり他の言語でも悪用されています。
さらに言うとRailsにおけるYAMLのデシリアライゼーションによる脆弱性も何ら新しいものではなく、CVE-2013-0156などは騒がれていたような記憶があります。
ざっくり言うとデシリアライズされるオブジェクトをうまく組み立てると(Gadget Chain)、任意コード実行(RCE)に繋がります。つまり今回の脆弱性としてはDB上に細工した値を入れておくと、その値を読み出す時にデシリアライズされRCEに繋がりうるということです。細工した値をRails経由で正当にDBに入れるのは難しそうなので、SQLインジェクションと組み合わせたりが必要になりそうです。実際上のアナウンスでも攻撃者がDBを操作できる必要があると書かれています。
上記の前提条件を考慮すると緊急性が高いようには見えませんが、一応触っておこうということで試しました。
RubyのYAML.load
まずはRubyの YAML.load
を使ってデシリアライゼーションでRCEを試してみます。正確には YAML.load
はRubyのPsychモジュールによる Psych.load
です。PsychはRubyのYAMLライブラリのバックエンド実装になってます。
RubyのデシリアライゼーションのためのGadget Chainはネット上にあちこちに転がっているので調べてみてください。一応今回のブログではYAMLそのものは貼らないでおきます。
適当に持ってきたYAMLを手元に untrusted.yaml
として保存します。あとはそれを読み込むだけです。
$ ruby -e 'require "yaml"; puts YAML.load(File.read("untrusted.yaml"))' ...(略)... sh: 1: reading: not found uid=0(root) gid=0(root) groups=0(root) /usr/local/lib/ruby/3.0.0/net/protocol.rb:460:in `system': no implicit conversion of nil into String (TypeError) from /usr/local/lib/ruby/3.0.0/net/protocol.rb:460:in `write' from /usr/local/lib/ruby/3.0.0/net/protocol.rb:466:in `<<' from /usr/local/lib/ruby/3.0.0/rubygems/request_set.rb:388:in `resolve' from /usr/local/lib/ruby/3.0.0/net/protocol.rb:460:in `write' from /usr/local/lib/ruby/3.0.0/net/protocol.rb:466:in `<<' from /usr/local/lib/ruby/3.0.0/net/protocol.rb:321:in `LOG' from /usr/local/lib/ruby/3.0.0/net/protocol.rb:154:in `read' from /usr/local/lib/ruby/3.0.0/rubygems/package/tar_header.rb:101:in `from' from /usr/local/lib/ruby/3.0.0/rubygems/package/tar_reader.rb:59:in `each' from /usr/local/lib/ruby/3.0.0/rubygems/requirement.rb:189:in `map' from /usr/local/lib/ruby/3.0.0/rubygems/requirement.rb:189:in `as_list' from /usr/local/lib/ruby/3.0.0/rubygems/requirement.rb:260:in `to_s' from -e:1:in `puts' from -e:1:in `puts' from -e:1:in `<main>'
エラーは出てしまってますが、今回の例では id
が実行されているのが分かります。と普通に動いたかのように言いましたが、何も考えずにRuby 3.1.2を持ってきたら動きませんでした。
$ ruby -e 'require "yaml"; puts YAML.load(File.read("untrusted.yaml"))' /usr/local/lib/ruby/3.1.0/psych/class_loader.rb:99:in `find': Tried to load unspecified class: Gem::Installer (Psych::DisallowedClass) from /usr/local/lib/ruby/3.1.0/psych/class_loader.rb:28:in `load' from /usr/local/lib/ruby/3.1.0/psych/visitors/to_ruby.rb:424:in `resolve_class' from /usr/local/lib/ruby/3.1.0/psych/visitors/to_ruby.rb:213:in `visit_Psych_Nodes_Mapping' from /usr/local/lib/ruby/3.1.0/psych/visitors/visitor.rb:30:in `visit' from /usr/local/lib/ruby/3.1.0/psych/visitors/visitor.rb:6:in `accept' from /usr/local/lib/ruby/3.1.0/psych/visitors/to_ruby.rb:35:in `accept' from /usr/local/lib/ruby/3.1.0/psych/visitors/to_ruby.rb:338:in `block in register_empty' from /usr/local/lib/ruby/3.1.0/psych/visitors/to_ruby.rb:338:in `each' from /usr/local/lib/ruby/3.1.0/psych/visitors/to_ruby.rb:338:in `register_empty' from /usr/local/lib/ruby/3.1.0/psych/visitors/to_ruby.rb:146:in `visit_Psych_Nodes_Sequence' from /usr/local/lib/ruby/3.1.0/psych/visitors/visitor.rb:30:in `visit' from /usr/local/lib/ruby/3.1.0/psych/visitors/visitor.rb:6:in `accept' from /usr/local/lib/ruby/3.1.0/psych/visitors/to_ruby.rb:35:in `accept' from /usr/local/lib/ruby/3.1.0/psych/visitors/to_ruby.rb:318:in `visit_Psych_Nodes_Document' from /usr/local/lib/ruby/3.1.0/psych/visitors/visitor.rb:30:in `visit' from /usr/local/lib/ruby/3.1.0/psych/visitors/visitor.rb:6:in `accept' from /usr/local/lib/ruby/3.1.0/psych/visitors/to_ruby.rb:35:in `accept' from /usr/local/lib/ruby/3.1.0/psych.rb:335:in `safe_load' from /usr/local/lib/ruby/3.1.0/psych.rb:370:in `load' from -e:1:in `<main>'
エラーを見ると許可されてないクラスをロードしようとしていると怒られており、スタックトレースを見ると safe_load
が呼ばれていることが分かります。何で?!と思って調べたら以下のブログを見つけました。
Psych 4.0からは Psych.load
が safe_load
になり、以前と同じ挙動は unsafe_load
となっていました。そしてRuby 3.1からはPsych 4.0が使われるようになったため、Ruby 3.1では上記のように簡単にRCEをしようとしてもうまくいきません。デフォルトで安全になっています。この辺はあとで調べたら日本語でも説明されていました。
破壊的変更なのに大丈夫なのかな...と思ったら、やはり非互換になるので大変そうでした。
Railsでも unsafe_load
を safe_load
に変えた影響で動かなくなったというIssueが上がっていました。
あちこちが壊れるにも関わらず安全になる変更を加えるのは英断かと思います。破壊的変更を加えるのは胃が痛くなるはずです。
全然関係ないですが、Rubyはセマンティックバージョニングなのかと思っていたので、マイナーバージョンで後方互換性がなくなるのは少し驚きました。ただドキュメントに明確に
MINOR: クリスマスごとに増加する。 API レベルでの非互換がありえる。
と記載されているので何もおかしいことはなく、独自のバージョニングのようです。
脱線しましたがRailsの話に戻ります。
Railsのデシリアライゼーションを試す
ooooooo-q さんが既に検証をGitHub上に上げてくださっていたのでこちらを使います。
GitHub - ooooooo-q/cve-2022-32224-rails
準備
どうやらRailsでは明示的に unsafe_load
を呼んでいるようなのでRubyのバージョンによらず脆弱性の影響を受けるはずですが、一応Ruby 3.0を使っておきます。
$ git clone https://github.com/ooooooo-q/cve-2022-32224-rails.git $ cd cve-2022-32224-rails $ docker run --rm --name ruby -it -v $PWD:/app --workdir /app ruby:3.0 bash root@ba9b4e50ef92:/app# bundle install root@ba9b4e50ef92:/app# bin/rails db:migrate
モデルは以下のようになっています。
class User < ApplicationRecord serialize :values, Array end
serialize
をつけることでserialized objectをDBに保存可能になります。 class_name_or_coder
のところは Array
や Hash
などが選択可能です。
クラスの復元
ここも上記リポジトリの例を丸パクリさせてもらいました。まずオブジェクトを保存してみます。
root@ba9b4e50ef92:/app# bundle exec rails c Loading development environment (Rails 7.0.3) irb(main):001:0> gemspec = Gem::Specification.new("test") => Gem::Specification.new do |s| ... irb(main):002:0> user = User.create!(values: [gemspec]) (5.4ms) SELECT sqlite_version(*) TRANSACTION (0.1ms) begin transaction User Create (19.2ms) INSERT INTO "users" ("values", "created_at", "updated_at") VALUES (?, ?, ?) [["values", "---\n- !ruby/object:Gem::Specification\n name: test\n version: \n platform: ruby\n authors: []\n autorequire: \n bindir: bin\n cert_chain: []\n date: 2022-07-21 00:00:00.000000000 Z\n dependencies: []\n description: \n email: \n executables: []\n extensions: []\n extra_rdoc_files: []\n files: []\n homepage: \n licenses: []\n metadata: {}\n post_install_message: \n rdoc_options: []\n require_paths:\n - lib\n required_ruby_version: !ruby/object:Gem::Requirement\n requirements:\n - &1\n - \">=\"\n - !ruby/object:Gem::Version\n version: '0'\n required_rubygems_version: !ruby/object:Gem::Requirement\n requirements:\n - *1\n requirements: []\n rubygems_version: 3.2.33\n signing_key: \n specification_version: 4\n summary: \n test_files: []\n"], ["created_at", "2022-07-21 09:45:23.953983"], ["updated_at", "2022-07-21 09:45:23.953983"]] TRANSACTION (13.0ms) commit transaction => #<User:0x00007f4b7cc343c0 ...
このYAMLは以下のようになっています。
--- - !ruby/object:Gem::Specification name: test version: platform: ruby authors: [] autorequire: bindir: bin cert_chain: [] date: 2022-07-21 00:00:00.000000000 Z dependencies: [] description: email: executables: [] extensions: [] extra_rdoc_files: [] files: [] homepage: licenses: [] metadata: {} post_install_message: rdoc_options: [] require_paths: - lib required_ruby_version: !ruby/object:Gem::Requirement requirements: - &1 - \">=\" - !ruby/object:Gem::Version version: 0 required_rubygems_version: !ruby/object:Gem::Requirement requirements: - *1 requirements: [] rubygems_version: 3.3.7 signing_key: specification_version: 4 summary: test_files: []
最初の !ruby/object...
のところ以外は普通にkey:valueとして値が入っているだけな感じです。
ではこれを復元します。
irb(main):003:0> User.last.values User Load (5.8ms) SELECT "users".* FROM "users" ORDER BY "users"."id" DESC LIMIT ? [["LIMIT", 1]] => [Gem::Specification.new do |s| s.name = "test" s.version = nil s.installed_by_version = Gem::Version.new("0") s.date = Time.utc(2022, 7, 21) s.require_paths = ["lib"] s.rubygems_version = "3.2.33" s.specification_version = 4 s.summary = nil end]
確かにデシリアライズされ元のクラスが復元されています。
任意コード実行
SQLiteに入って細工したYAMLを入れます。 values
に上で使ったYAMLをそのまま入れるだけです。
$ sqlite3 db/development.sqlite3 sqlite> INSERT INTO users ("values", "created_at", "updated_at") VALUES ("省略", TIME(), TIME());
ではDBから値を取り出します。
irb(main):001:0> puts User.last.values User Load (4.1ms) SELECT "users".* FROM "users" ORDER BY "users"."id" DESC LIMIT ? [["LIMIT", 1]] ... /usr/local/bundle/gems/net-protocol-0.1.3/lib/net/protocol.rb:459:in `write': undefined method `call' for nil:NilClass (NoMethodError)
うまくいきませんでした。問題の切り分けのためにもう一度Ruby単体で読み込んでみます。
root@ba9b4e50ef92:/app# ruby -e 'require "yaml"; puts YAML.load(File.read("untrusted.yaml"))' #<Gem::Installer:0x0000557dfd92af38> #<Gem::SpecFetcher:0x0000557dfd9caee8> /usr/local/bundle/gems/net-protocol-0.1.3/lib/net/protocol.rb:459:in `write': undefined method `call' for nil:NilClass (NoMethodError) from /usr/local/bundle/gems/net-protocol-0.1.3/lib/net/protocol.rb:465:in `<<' from /usr/local/bundle/gems/net-protocol-0.1.3/lib/net/protocol.rb:321:in `LOG' from /usr/local/bundle/gems/net-protocol-0.1.3/lib/net/protocol.rb:154:in `read' from /usr/local/lib/ruby/3.0.0/rubygems/package/tar_header.rb:101:in `from' from /usr/local/lib/ruby/3.0.0/rubygems/package/tar_reader.rb:59:in `each' from /usr/local/lib/ruby/3.0.0/rubygems/requirement.rb:189:in `map' from /usr/local/lib/ruby/3.0.0/rubygems/requirement.rb:189:in `as_list' from /usr/local/lib/ruby/3.0.0/rubygems/requirement.rb:260:in `to_s' from -e:1:in `puts' from -e:1:in `puts' from -e:1:in `<main>'
さっきまでうまくいっていたのに急に失敗するようになりました。先程までのスタックトレースと比べると
from /usr/local/lib/ruby/3.0.0/net/protocol.rb:154:in `read'
だった部分が
from /usr/local/bundle/gems/net-protocol-0.1.3/lib/net/protocol.rb:154:in `read'
に変わっています。net-protocol(の新しいバージョン?)がインストールされているとそちらが使われるような雰囲気です。ここはちゃんと調べてません。適当にnet-protocolのバージョンを下げてみます。
root@ba9b4e50ef92:/app# cat Gemfile | grep net-protocol gem "net-protocol", "0.1.1" $ bundle install
そしてもう一度試します。
root@ba9b4e50ef92:/app# bundle exec rails c Loading development environment (Rails 7.0.3) irb(main):001:0> User.last.values User Load (7.5ms) SELECT "users".* FROM "users" ORDER BY "users"."id" DESC LIMIT ? [["LIMIT", 1]] sh: 1: reading: not found uid=0(root) gid=0(root) groups=0(root) (Object doesn't support #inspect)
今度は id
が実行されています。ということで検証終わりです。上で動かなかったのは単にGadget Chainの問題なので頑張れば動く気もしますが、本当にRCE可能であることの検証をしたかっただけなのでこれ以上は深追いしません。
まとめ
Rubyで信頼できないYAMLのYAML.load
による読み込みは危ない、YAML.safe_load
やSafeYAMLを使いましょうと長いこと言われていたかと思いますが、Ruby 3.1からデフォルトで安全になったので世界がセキュアになったねという話でした。