knqyf263's blog

自分のためのメモとして残しておくためのブログ。

CVE-2022-32224(Railsの脆弱性)を試す

前回の記事は割と濃い味付けでしたが、今回は薄味です。 脆弱性自体は簡単なやつなのですが、調べている過程でRuby 3.1からYAMLのパースが安全になったことを知ったのでその共有がてら書きました。最近はあまりRubyを触る機会がなかったのでリハビリを兼ねて触っているところもあり、間違いがあれば教えて下さい。

要約

RubyYAML.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.

Module: YAML (Ruby 3.1.0)

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)が公開されました。

discuss.rubyonrails.org

Active RecordではDBのカラムに serialize 属性が指定できるらしく、これはDBにシリアライズしたオブジェクトを保存可能にするものですが、保存された値をデシリアライズする時に YAML.unsafe_load が使われているので危険という脆弱性でした。

なぜオブジェクトのデシリアライズが危険かというのは以前もブログに書いたので省略します。Ruby固有の話ではなくJavaPHPだったり他の言語でも悪用されています。

knqyf263.hatenablog.com

さらに言うとRailsにおけるYAMLのデシリアライゼーションによる脆弱性も何ら新しいものではなく、CVE-2013-0156などは騒がれていたような記憶があります。

www.youtube.com

ざっくり言うとデシリアライズされるオブジェクトをうまく組み立てると(Gadget Chain)、任意コード実行(RCE)に繋がります。つまり今回の脆弱性としてはDB上に細工した値を入れておくと、その値を読み出す時にデシリアライズされRCEに繋がりうるということです。細工した値をRails経由で正当にDBに入れるのは難しそうなので、SQLインジェクションと組み合わせたりが必要になりそうです。実際上のアナウンスでも攻撃者がDBを操作できる必要があると書かれています。

上記の前提条件を考慮すると緊急性が高いようには見えませんが、一応触っておこうということで試しました。

RubyYAML.load

まずはRubyYAML.load を使ってデシリアライゼーションでRCEを試してみます。正確には YAML.loadRubyのPsychモジュールによる Psych.load です。PsychはRubyYAMLライブラリのバックエンド実装になってます。

RubyのデシリアライゼーションのためのGadget Chainはネット上にあちこちに転がっているので調べてみてください。一応今回のブログではYAMLそのものは貼らないでおきます。

www.elttam.com

staaldraad.github.io

staaldraad.github.io

適当に持ってきた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 が呼ばれていることが分かります。何で?!と思って調べたら以下のブログを見つけました。

www.ctrl.blog

Psych 4.0からは Psych.loadsafe_load になり、以前と同じ挙動は unsafe_load となっていました。そしてRuby 3.1からはPsych 4.0が使われるようになったため、Ruby 3.1では上記のように簡単にRCEをしようとしてもうまくいきません。デフォルトで安全になっています。この辺はあとで調べたら日本語でも説明されていました。

techlife.cookpad.com

破壊的変更なのに大丈夫なのかな...と思ったら、やはり非互換になるので大変そうでした。

secret-garden.hatenablog.com

Railsでも unsafe_loadsafe_load に変えた影響で動かなくなったというIssueが上がっていました。

github.com

あちこちが壊れるにも関わらず安全になる変更を加えるのは英断かと思います。破壊的変更を加えるのは胃が痛くなるはずです。

全然関係ないですが、Rubyはセマンティックバージョニングなのかと思っていたので、マイナーバージョンで後方互換性がなくなるのは少し驚きました。ただドキュメントに明確に

MINOR: クリスマスごとに増加する。 API レベルでの非互換がありえる。

と記載されているので何もおかしいことはなく、独自のバージョニングのようです。

www.ruby-lang.org

脱線しましたが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 のところは ArrayHash などが選択可能です。

api.rubyonrails.org

クラスの復元

ここも上記リポジトリの例を丸パクリさせてもらいました。まずオブジェクトを保存してみます。

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で信頼できないYAMLYAML.load による読み込みは危ない、YAML.safe_loadSafeYAMLを使いましょうと長いこと言われていたかと思いますが、Ruby 3.1からデフォルトで安全になったので世界がセキュアになったねという話でした。