RubyGems.orgの脆弱性について

こんにちは、金子です。
普段はRailsを書いたりしています。

今回は2016/4/6に発表された、RubyGems.orgの脆弱性についてまとめました。

脆弱性について

RubyGems.org gem replacement vulnerability and mitigation をざっと読んでみると、

  • 特定の状況で、RubyGems.orgにupdateされているファイルの内容が不正に書き換えられる可能性があった
  • 特定の状況とは、2014-6-11から2016-4-2までの間に登録されたgemのうち、’blank-blank’のように名前に'-' (dash)が入っているもの
  • ただし2015-2-8以降に登録されたgemはRubyGems.orgがsha256 checksumを計算しており、それと実際のファイルの突合をして、書き換えられていないことを確認ずみ

つまり、2014-6-11から2015-2-7の間に登録されたgemが、今回の脆弱性の範囲にあたるということです。

自分が使っているgemのうち、影響のありそうなものを調べる

自分の使っているgemの一覧を取得する

例えばRailsでアプリケーションの開発を行っており、bundlerでgemの管理をしているようなプロジェクトでは、Gemfile.lockに現在使用しているgemの名前とversionが記録されています。

例えば以下のようなコードで、名前に'-'が入っているgemの一覧が取得できます。

lockfile = Bundler::LockfileParser.new(Bundler.read_file("path_to_Gemfile.lock"))
gems = lockfile.specs.select {|s| s.name =~ /-/}.map {|s| [s.name, s.version.to_s]}

gemの名前とversionから登録日時を調べる

RubyGems.org Data Dumps からRubyGems.orgのPostgreSQLのデータが入手できるので、こちら を参考に、自分の環境にDBを作成します。

直接DBを操作してもいいのですが、今回はRailsでラップしてみます。
rails _4.2.6_ new -d postgresql rubygems などで新規にプロジェクトを作成し、
app/models/rubygem.rbapp/models/version.rbを作成します。

# app/models/rubygem.rb
class Rubygem < ActiveRecord::Base
  has_many :versions
end

# app/models/version.rb
class Version < ActiveRecord::Base
  belongs_to :rubygem
end

またデフォルトではrubygemsという名前のdatabaseにデータが入るので、config/database.ymlも調整します。

# config/database.yml
development:
  <<: *default
  database: rubygems

準備ができたら以下のようなスクリプトを実行します。gem listの内容をもとにチェックしたいというケースもあるかと思いますので、GemVersionというクラスも用意しました。

class DangerousGemValidator
  def initialize
    refresh
  end

  def invalid_gems(gems)
    _invalid_gems(gems).map do |name, number, rubygem, version|
      [name, number, version.created_at.strftime('%Y-%m-%d %H:%M:%S')]
    end
  end

  def excepted_records
    {
      rubygems_has_no_records: @rubygems_has_no_records.sort.uniq,
      rubygems_has_multiple_records: @rubygems_has_multiple_records.sort.uniq,
      versions_has_not_just_one_records: @versions_has_not_just_one_records.sort.uniq
    }
  end

  private
  def _invalid_gems(gems)
    return @invalid_gems if @invalid_gems

    @invalid_gems = gems.each_with_object([]) do |(name, number), array|
      rubygems = Rubygem.where(name: name)
      if rubygems.count == 0
        @rubygems_has_no_records << "#{name} has no record"
        next
      end

      if rubygems.count != 1
        @rubygems_has_multiple_records << "#{name} has #{rubygems.count} record(s)"
        next
      end

      rubygem = rubygems.first
      versions = rubygem.versions.where(number: number)

      case
      when versions.count == 1
        version = versions.first
      when versions.where(platform: 'ruby').exists?
        version = versions.find_by(platform: 'ruby')
      else
        @versions_has_not_just_one_records << "#{name} has #{versions.count} versions record(s) (number: #{number}, rubygem.id: #{rubygem.id})"
        next
      end

      if (Time.utc(2014, 6, 11) <= version.created_at) && (version.created_at <= Time.utc(2015, 2, 18))
        array << [name, number, rubygem, version]
      end
    end
  end

  def refresh
    @invalid_gems = nil
    @rubygems_has_no_records = []
    @rubygems_has_multiple_records = []
    @versions_has_not_just_one_records = []
  end
end

class GemVersion
  def initialize(file_path)
    @file_path = file_path
  end

  def gems
    File.read(@file_path).each_line.each_with_object([]) do |line, array|
      line =~ /([\w-]*) \((.*)\)/
      gem_name = $1
      versions = $2.split(', ')

      next if gem_name !~ /-/
      array.concat versions.map {|v| [gem_name, v]}
    end
  end
end

gem_version = GemVersion.new("path_to_gem_list_text")

validator = DangerousGemValidator.new

# gem listをもとに検索する
validator.invalid_gems(gem_version.gems)
# or Gemfile.lockをもとに検索する
lockfile = Bundler::LockfileParser.new(Bundler.read_file("path_to_Gemfile.lock"))
gems = lockfile.specs.select {|s| s.name =~ /-/}.map {|s| [s.name, s.version.to_s]}
validator.invalid_gems(gems)

ためしに、今つくったRailsアプリ(rubygemsアプリのこと、Rails 4.2.6の標準設定)のGemfile.lockに対して実行してみます。

lockfile = Bundler::LockfileParser.new(Bundler.read_file(File.join(Rails.root, '/Gemfile.lock')))
gems = lockfile.specs.select {|s| s.name =~ /-/}.map {|s| [s.name, s.version.to_s]}
validator.invalid_gems(gems)
=> [["rack-test", "0.6.3", "2015-01-09 17:58:13"], ["rails-deprecated_sanitizer", "1.0.3", "2014-09-25 16:33:44"]]

おや、rails-deprecated_sanitizerがひっかかりました。rubygems.org をみてみると、確かに。。。

gemの内容を確認する

RubyGems.org gem replacement vulnerability and mitigation の”WHAT SHOULD I DO?”をもとに、rails-deprecated_sanitizer1.0.3を確認してみましょう。
まずはgemをインストールして、unpackします。

$ gem install rails-deprecated_sanitizer -v 1.0.3
Successfully installed rails-deprecated_sanitizer-1.0.3
1 gem installed
$ gem unpack rails-deprecated_sanitizer -v 1.0.3
Unpacked gem: '/.../rails-deprecated_sanitizer-1.0.3'

次に、コードをダウンロードし、リリース時のコミットをチェックアウトします。

$ git clone git@github.com:rails/rails-deprecated_sanitizer.git
$ cd rails-deprecated_sanitizer
$ git checkout v1.0.3

diffを取ります。

$ diff -r rails-deprecated_sanitizer-1.0.3/ rails-deprecated_sanitizer
Only in rails-deprecated_sanitizer: .git
Only in rails-deprecated_sanitizer: .gitignore
Only in rails-deprecated_sanitizer: .travis.yml
Only in rails-deprecated_sanitizer: Gemfile
Only in rails-deprecated_sanitizer: LICENSE.txt
Only in rails-deprecated_sanitizer: Rakefile
Only in rails-deprecated_sanitizer: rails-deprecated_sanitizer.gemspec

rails-deprecated_sanitizerにしかないファイルが複数ありますが、これはrails-deprecated_sanitizer.gemspecの設定で、パッケージに含めていないものなので、問題なさそうです。

まとめ

マネーフォワードの本番環境で使用している全gemを対象に上記の絞り込みを行い、差分の確認をし、全gemに問題がないことを確かめました。

最後に

マネーフォワードでは、RubyやRailsをいじり倒すのが大好きというエンジニアを募集しています。
ご応募お待ちしています。

【採用サイト】
マネーフォワード採用サイト
Wantedly | マネーフォワード

【プロダクト一覧】
家計簿アプリ・クラウド家計簿ソフト『マネーフォワード』
家計簿アプリ・クラウド家計簿ソフト『マネーフォワード』 iPhone,iPad
家計簿アプリ・クラウド家計簿ソフト『マネーフォワード』 Android
クラウド型会計ソフト『MFクラウド会計』
クラウド型請求書管理ソフト『MFクラウド請求書』
クラウド型給与計算ソフト『MFクラウド給与』
経費精算システム『MFクラウド経費』
消込ソフト・システム『MFクラウド消込』
マイナンバー対応『MFクラウドマイナンバー』
創業支援トータルサービス『MFクラウド創業支援サービス』
お金に関する正しい知識やお得な情報を発信するウェブメディア『マネトク!』

Pocket