Skip to main content

Ruby gem installations can expose you to lockfile injection attacks

Escrito por:
wordpress-sync/blog-hero-ruby-gem-lockfile-injection

17 de agosto de 2022

0 minutos de leitura

In this post, we’ll look at the security blindspots of lockfile injection that a Ruby gem might expose via its Gemfile.lock. As a prelude to that, we will open up with a brief introduction to Ruby and third-party dependencies management around RubyGems and Bundler.

Web developers often work on Ruby projects, but are mostly referring to them as the popular open source web application framework Ruby on Rails. Ruby itself is the underlying open source and dynamic language that powers the project.

To make Ruby code modular and reusable, they are packaged using the RubyGems project, which is often interacted with via the command line interface as the gem executable. These packages are referred to as gems and are referenced in a package manifest file called a Gemfile.

RubyGems are now bundled with the Ruby language itself and are not required to be installed separately. While a developer could install a gem as simple as gem install <gem-name>, the overall management of transitive dependencies, pinning, and versioning is much more complex.

This is where the Bundler project comes in — the community's favorite Ruby package manager.

Ruby's Gemfile package manifest and transitive dependencies

Ruby projects manage their third-party dependencies by defining a Gemfile file. This file includes project metadata and also lists all the dependencies that are used to build the project.

Here's an example for a Ruby project with a Gemfile:

1# Gemfile
2
3source 'https://rubygems.org'
4
5gem 'rails', '~> 5.2.0'
6gem 'nokogiri', '~> 1.6.1'
7gem 'rspec'

The source definition declares where the bundler tool can find these gem libraries. The repository at https://rubygems.org is the default — and open source — Rubygems registry.

Each dependency is then declared in this file as a gem definition, which may include a version constraint, or not at all.

Using Ruby's bundler to install and manage gems

Bundler — the open source gem management project — is then tasked with installing and resolving dependencies. To get started with bundler to manage your Ruby dependencies, first, you’ll need to have both Ruby and RubyGems installed in your environment. Then, it's as easy as installing the bundler tool as follows:

1gem install bundler

Installing Ruby dependencies and Gemfile.lock

Once the bundler tool is installed, dependencies that have been declared in the Gemfile manifest can be installed by running the following command:

1bundle install

This is also equivalent to running bundle with no extra arguments, such as:

1bundle

To keep track of the dependencies that have been installed, a Gemfile.lock file is consulted in order to recreate the exact state of dependency versions across the tree of nested dependencies. If no such file exists, then bundler will proceed to install dependencies and then write the Gemfile.lock file.

Note that in some cases, you'll need to specify the --path option to the bundle install to allow for files to be created.

Once executed, a bundler install log will look as follows:

1$ bundle install --path vendor/bundle
2
3Fetching gem metadata from https://rubygems.org/...........
4Fetching gem metadata from https://rubygems.org/.
5Resolving dependencies...
6Fetching rake 13.0.6
7Installing rake 13.0.6
8Fetching concurrent-ruby 1.1.10
9Installing concurrent-ruby 1.1.10
10Fetching i18n 1.12.0
11Installing i18n 1.12.0
12Fetching minitest 5.16.2
13Installing minitest 5.16.2
14
15[... lines were removed for brevity ...]
16
17Fetching rspec 3.11.0
18Installing rspec 3.11.0
19Bundle complete! 3 Gemfile dependencies, 47 gems now installed.
20Bundled gems are installed into `./vendor/bundle`

Here are the two primary resulting outcomes from the bundler install:

  1. Third-party gem dependencies are installed into the vendor/bundle directory.

  2. The Gemfile.lock file is created to record the exact state of the dependencies across the tree of nested dependencies. Assuming this file was not previously created.

Inspecting the dependencies lockfile, Gemfile.lock, we see the following information:

1GEM
2  remote: https://rubygems.org/
3  specs:
4    actioncable (5.2.8.1)
5      actionpack (= 5.2.8.1)
6      nio4r (~> 2.0)
7      websocket-driver (>= 0.6.1)
8    actionmailer (5.2.8.1)
9      actionpack (= 5.2.8.1)
10      actionview (= 5.2.8.1)
11      activejob (= 5.2.8.1)
12      mail (~> 2.5, >= 2.5.4)
13      rails-dom-testing (~> 2.0)
14
15    [... lines were removed for brevity ...]
16
17    rails-dom-testing (2.0.3)
18      activesupport (>= 4.2.0)
19      nokogiri (>= 1.6)
20    rails-html-sanitizer (1.4.3)
21      loofah (~> 2.3)
22    railties (5.2.8.1)
23      actionpack (= 5.2.8.1)
24      activesupport (= 5.2.8.1)
25      method_source
26      rake (>= 0.8.7)
27      thor (>= 0.19.0, < 2.0)
28    rspec (3.11.0)
29      rspec-core (~> 3.11.0)
30      rspec-expectations (~> 3.11.0)
31      rspec-mocks (~> 3.11.0)
32      rspec-assertions (~> 2.9.0)
33    rspec-core (3.11.0)
34      rspec-support (~> 3.11.0)
35    rspec-expectations (3.11.0)
36      diff-lcs (>= 1.2.0, < 2.0)
37      rspec-support (~> 3.11.0)
38    rspec-mocks (3.11.1)
39      diff-lcs (>= 1.2.0, < 2.0)
40      rspec-support (~> 3.11.0)
41    rspec-support (3.11.0)
42
43PLATFORMS
44  ruby
45
46DEPENDENCIES
47  nokogiri
48  rails (~> 5.2.0)
49  rspec
50
51BUNDLED WITH
52   1.17.2

RubyGems lockfile injection of Gemfile.lock

Lockfile injection is an attack vector in which a malicious user modifies the Gemfile.lock file to contain a different set of dependencies than the Gemfile manifest. Due to the nature of the machine-generated format of lockfiles, such as Gemfile.lock, they are not often reviewed by human eyes during code reviews. To further add to the problem at hand, a pull request view of code changes relating to the file will result in a collapsed view of the lockfile, when large amounts of code changes have been made.

To prove the severity and commonality of this attack, the above-provided code snippet of a Gemfile.lock file is modified to contain a different set of dependencies. Can you find which malicious packages were added? Not as easy to spot as you'd expect! I planted the made-up Ruby gem called rspec-assertions (~> 2.9.0) as a dependency in the rspec tree. This dependency doesn't exist at all, but it could if I created a malicious ruby gem that I wanted to slip in as part of the dependencies you install.

The attack is carried out by modifying the Gemfile.lock file and adding a new nested dependency that is in the tree of another one. For example, consider the following lockfile contents (showing just the beginning of it):

1GEM
2  remote: https://rubygems.org/
3  specs:
4    actioncable (5.2.8.1)
5      actionpack (= 5.2.8.1)
6      nio4r (~> 2.0)
7      websocket-driver (>= 0.6.1)

The above Gemfile.lock is a valid dependency tree resolution. However, a malicious user can update this bundler-generated dependency tree so that it adds the legitimate gem called digest as follows:

1GEM
2  remote: https://rubygems.org/
3  specs:
4    actioncable (5.2.8.1)
5      actionpack (= 5.2.8.1)
6      digest (3.1.0)
7      nio4r (~> 2.0)
8      websocket-driver (>= 0.6.1)

If you'd now run bundle in the command line prompt in order to keep up to date with package updates, bundler would install the digest gem. We can take a further look at the vendor/ directory to verify the new gem:

1ls -alh vendor/bundle/ruby/2.6.0/gems/digest-3.1.0            
2total 16
3drwxr-xr-x   6 lirantal  staff   192B Jul 18 01:24 .
4drwxr-xr-x  49 lirantal  staff   1.5K Jul 18 01:24 ..
5-rw-r--r--   1 lirantal  staff   1.3K Jul 18 01:24 LICENSE.txt
6-rw-r--r--   1 lirantal  staff   3.0K Jul 18 01:24 README.md
7drwxr-xr-x   3 lirantal  staff    96B Jul 18 01:24 ext
8drwxr-xr-x   5 lirantal  staff   160B Jul 18 01:24 lib

The attack surface in this case allows unsuspecting developers, who may receive package update contributions from the community or other unvetted sources, to inject new malicious gem dependencies into the Gemfile.lock, hence poison the lockfile, and thereby target project maintainers.

The attack surface of Ruby gems lockfile injection

So what’s the impact of having arbitrary ruby gems that are controlled by an attacker, as part of a Ruby application's dependency tree?

Since the newly added gem dependency is handcrafted for malicious purposes, it isn't likely to be required by source code and called at runtime by another dependency. But, we could exploit a similar attack vector that exists in the JavaScript npm package manager which allows for arbitrary commands to be run during the install time of dependencies.

Dating all the way back to 2011, the Ruby ecosystem has made a smart decision in which it doesn't allow package maintainers to hook into the installation process of their dependencies, in order to execute arbitrary commands. References to that discussion are available in a GitHub issue as they iterate why an npm-like postinstall script is a bad idea, and rightfully so.

That said, Ruby gems allow bundling native C code that would get compiled during install time. This is the opening we are looking for in order to leverage the lockfile injection method towards arbitrary code execution. In fact, this method has been used in the wild since 2008 and is still a functional way to hook into Ruby's gem installation process.

Let’s build a Ruby gem for malicious purposes

Let's get started with building a Ruby gem in which we will add instructions to work around the built-in mechanism of the Gemfile spec that doesn't natively allow for code execution during install.

Naming things is hard in software, but not for us. We'll go with naming our Ruby gem woof. The following is our Ruby gem's gemspec file called woof.gemspec:

1Gem::Specification.new do |s|
2    s.name        = "woof"
3    s.version     = "1.0.0"
4    s.summary     = "Woof"
5    s.description = "A hello world gem, used for testing"
6    s.authors     = ["Liran Tal"]
7    s.email       = "liran@snyk.io"
8    s.files       = ["lib/woof.rb"]
9    s.homepage    =
10      "https://rubygems.org/gems/woof"
11    s.license       = "MIT"
12    s.extensions << 'ext/woof/extconf.rb'
13  end
14

Then create the primary source file for our Ruby gem, lib/woof.rb:

1class Woof
2    def self.hi
3      puts "woof woof"
4    end
5  end

Then, we can create Ruby's native extconf.rb extension file which allows us to declare information needed to create makefiles, provide instructions related to extending Ruby with native C extensions, and generally execute any Ruby code.

Create the file ext/woof/extconf.rb with the following contents:

1# Use the Ruby languague API to create an empty file during install /tmp/woof
2File.write("/tmp/woof", "")
3
4require 'mkmf'
5create_makefile('hello_c')

The extension code snippet above is a simple example of how to create a native C extension for Ruby. The create_makefile method is a Ruby method that is used to create a makefile for a native C extension. The create_makefile method takes a single argument, the name of the extension. The name of the extension should be the same as the name of the Ruby gem.

The very first line of the extension code is a call to the File.write method, which is a Ruby method that writes a file to the file system. The first argument to File.write is the path to the file to write to, and the second argument is the content to write to the file.

In our effort to simulate a (benign) malicious package, we see that upon installing our woof Ruby gem, a new file will be created in the file system. The file will be named /tmp/woof and will be empty.

For the Ruby gem to successfully install, we do need to provide a valid hello.c file. It can be a simple *hello world* program from Ruby's stock docs as follows:

1#include <stdio.h>
2#include "ruby.h"
3
4VALUE world(VALUE self) {
5  printf("Hello World!\n");
6  return Qnil;
7}
8
9// The initialization method for this module
10void Init_hello_c() {
11  printf("Hello World!\n");
12  VALUE HelloC = rb_define_module("HelloC");
13  rb_define_singleton_method(HelloC, "world", world, 0);
14}

Finally, we can build this Ruby gem. In the root directory of this project, run the following command:

1gem build woof.gemspec

Once successful, a new gem will be created in the root directory of this project, named woof-1.0.0.gem.

Now, to test that this Ruby gem indeed works as expected, we can install it and observe that a new file /tmp/woof has been created (you can verify that it doesn't exist prior to installing the package). Proceed as follows to install the Ruby gem to a local folder:

  1. Create a new temporary directory, such as mkdir ~/ruby-gem-test.

  2. In this new directory, create a new directory named vendor.

  3. Instruct Ruby to use the new vendor directory as the gem directory and install woof gem into it as follows: gem install --local /path/to/woof-1.0.0.gem --install-dir 'vendor'

To confirm that our arbitrary code execution attack has been employed successfully as a Ruby gem installation method, ensure that a new file /tmp/woof has been created.

Ruby Gem lockfile injection prevention

As shown throughout this article, the Ruby gem installation process allows package maintainers to execute arbitrary code during the install time of their dependencies. This is a powerful attack surface that can be used in conjunction with the lockfile injection attack of a Ruby application, and also more generally in the space of supply chain security in the Ruby ecosystem.

Unfortunately, unlike the npm package manager, which includes a --ignore-script command line flag, Rub's own gem CLI does not allow for opting out of the native C extension compilation process during install time, leaving users vulnerable to third-party package maintainers ability to execute arbitrary code.

To help mitigate against this type of attack we recommend the following:

  1. Only manage your Ruby gem dependencies and updates to them, along with changes to the lockfile Gemfile.lock using an automated bot to upgrade your Ruby gem versions as needed and provide you with security fixes where possible.

  2. If you are accepting contributions from developers to your Gemfile and Gemfile.lock files, ensure you are carefully reviewing these code changes and scrutinizing every line of change.

  3. Favor installing dependencies with bundle install --deployment which calculates the complete dependency tree of all Ruby gems and will halt the install process when the Gemfile.lock differs from the exact gems that should be installed by the Gemfile.

This research is based upon prior original work by me (Liran Tal) in 2019 for disclosing risks of lockfile injection in the JavaScript ecosystem.

More Ruby security resources: